refactor(test): migrate gsd/tests i-n from custom harness to node:test (#2399)

This commit is contained in:
Tom Boucher 2026-03-24 23:33:01 -04:00 committed by GitHub
parent b1782a8678
commit 4498dcea32
17 changed files with 1468 additions and 1741 deletions

View file

@ -8,9 +8,9 @@ import {
verifyExpectedArtifact,
buildLoopRemediationSteps,
} from "../auto.ts";
import { createTestContext } from './test-helpers.ts';
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
const { assertEq, assertTrue, report } = createTestContext();
function createFixtureBase(): string {
const base = mkdtempSync(join(tmpdir(), "gsd-idle-recovery-test-"));
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
@ -23,99 +23,91 @@ function cleanup(base: string): void {
// ═══ resolveExpectedArtifactPath ═════════════════════════════════════════════
{
console.log("\n=== resolveExpectedArtifactPath: research-milestone ===");
test('resolveExpectedArtifactPath: research-milestone', () => {
const base = createFixtureBase();
try {
const result = resolveExpectedArtifactPath("research-milestone", "M001", base);
assertTrue(result !== null, "should resolve a path");
assertTrue(result!.endsWith("M001-RESEARCH.md"), `path should end with M001-RESEARCH.md, got ${result}`);
assert.ok(result !== null, "should resolve a path");
assert.ok(result!.endsWith("M001-RESEARCH.md"), `path should end with M001-RESEARCH.md, got ${result}`);
} finally {
cleanup(base);
}
}
});
{
console.log("\n=== resolveExpectedArtifactPath: plan-milestone ===");
test('resolveExpectedArtifactPath: plan-milestone', () => {
const base = createFixtureBase();
try {
const result = resolveExpectedArtifactPath("plan-milestone", "M001", base);
assertTrue(result !== null, "should resolve a path");
assertTrue(result!.endsWith("M001-ROADMAP.md"), `path should end with M001-ROADMAP.md, got ${result}`);
assert.ok(result !== null, "should resolve a path");
assert.ok(result!.endsWith("M001-ROADMAP.md"), `path should end with M001-ROADMAP.md, got ${result}`);
} finally {
cleanup(base);
}
}
});
{
console.log("\n=== resolveExpectedArtifactPath: research-slice ===");
test('resolveExpectedArtifactPath: research-slice', () => {
const base = createFixtureBase();
try {
const result = resolveExpectedArtifactPath("research-slice", "M001/S01", base);
assertTrue(result !== null, "should resolve a path");
assertTrue(result!.endsWith("S01-RESEARCH.md"), `path should end with S01-RESEARCH.md, got ${result}`);
assert.ok(result !== null, "should resolve a path");
assert.ok(result!.endsWith("S01-RESEARCH.md"), `path should end with S01-RESEARCH.md, got ${result}`);
} finally {
cleanup(base);
}
}
});
{
console.log("\n=== resolveExpectedArtifactPath: plan-slice ===");
test('resolveExpectedArtifactPath: plan-slice', () => {
const base = createFixtureBase();
try {
const result = resolveExpectedArtifactPath("plan-slice", "M001/S01", base);
assertTrue(result !== null, "should resolve a path");
assertTrue(result!.endsWith("S01-PLAN.md"), `path should end with S01-PLAN.md, got ${result}`);
assert.ok(result !== null, "should resolve a path");
assert.ok(result!.endsWith("S01-PLAN.md"), `path should end with S01-PLAN.md, got ${result}`);
} finally {
cleanup(base);
}
}
});
{
console.log("\n=== resolveExpectedArtifactPath: complete-milestone ===");
test('resolveExpectedArtifactPath: complete-milestone', () => {
const base = createFixtureBase();
try {
const result = resolveExpectedArtifactPath("complete-milestone", "M001", base);
assertTrue(result !== null, "should resolve a path");
assertTrue(result!.endsWith("M001-SUMMARY.md"), `path should end with M001-SUMMARY.md, got ${result}`);
assert.ok(result !== null, "should resolve a path");
assert.ok(result!.endsWith("M001-SUMMARY.md"), `path should end with M001-SUMMARY.md, got ${result}`);
} finally {
cleanup(base);
}
}
});
{
console.log("\n=== resolveExpectedArtifactPath: unknown unit type → null ===");
test('resolveExpectedArtifactPath: unknown unit type → null', () => {
const base = createFixtureBase();
try {
const result = resolveExpectedArtifactPath("unknown-type", "M001/S01", base);
assertEq(result, null, "unknown type returns null");
assert.deepStrictEqual(result, null, "unknown type returns null");
} finally {
cleanup(base);
}
}
});
// ═══ writeBlockerPlaceholder ═════════════════════════════════════════════════
{
console.log("\n=== writeBlockerPlaceholder: writes file for research-slice ===");
test('writeBlockerPlaceholder: writes file for research-slice', () => {
const base = createFixtureBase();
try {
const result = writeBlockerPlaceholder("research-slice", "M001/S01", base, "idle recovery exhausted 2 attempts");
assertTrue(result !== null, "should return relative path");
assert.ok(result !== null, "should return relative path");
const absPath = resolveExpectedArtifactPath("research-slice", "M001/S01", base)!;
assertTrue(existsSync(absPath), "file should exist on disk");
assert.ok(existsSync(absPath), "file should exist on disk");
const content = readFileSync(absPath, "utf-8");
assertTrue(content.includes("BLOCKER"), "should contain BLOCKER heading");
assertTrue(content.includes("idle recovery exhausted 2 attempts"), "should contain the reason");
assertTrue(content.includes("research-slice"), "should mention the unit type");
assertTrue(content.includes("M001/S01"), "should mention the unit ID");
assert.ok(content.includes("BLOCKER"), "should contain BLOCKER heading");
assert.ok(content.includes("idle recovery exhausted 2 attempts"), "should contain the reason");
assert.ok(content.includes("research-slice"), "should mention the unit type");
assert.ok(content.includes("M001/S01"), "should mention the unit ID");
} finally {
cleanup(base);
}
}
});
{
console.log("\n=== writeBlockerPlaceholder: creates directory if missing ===");
test('writeBlockerPlaceholder: creates directory if missing', () => {
const base = mkdtempSync(join(tmpdir(), "gsd-idle-recovery-test-"));
try {
// Only create milestone dir, not slice dir
@ -123,38 +115,36 @@ function cleanup(base: string): void {
// resolveSlicePath needs the slice dir to exist to resolve, so this should return null
const result = writeBlockerPlaceholder("research-slice", "M001/S01", base, "test reason");
// Since the slice dir doesn't exist, resolveExpectedArtifactPath returns null
assertEq(result, null, "returns null when directory structure doesn't exist");
assert.deepStrictEqual(result, null, "returns null when directory structure doesn't exist");
} finally {
cleanup(base);
}
}
});
{
console.log("\n=== writeBlockerPlaceholder: writes file for research-milestone ===");
test('writeBlockerPlaceholder: writes file for research-milestone', () => {
const base = createFixtureBase();
try {
const result = writeBlockerPlaceholder("research-milestone", "M001", base, "hard timeout");
assertTrue(result !== null, "should return relative path");
assert.ok(result !== null, "should return relative path");
const absPath = resolveExpectedArtifactPath("research-milestone", "M001", base)!;
assertTrue(existsSync(absPath), "file should exist on disk");
assert.ok(existsSync(absPath), "file should exist on disk");
const content = readFileSync(absPath, "utf-8");
assertTrue(content.includes("BLOCKER"), "should contain BLOCKER heading");
assertTrue(content.includes("hard timeout"), "should contain the reason");
assert.ok(content.includes("BLOCKER"), "should contain BLOCKER heading");
assert.ok(content.includes("hard timeout"), "should contain the reason");
} finally {
cleanup(base);
}
}
});
{
console.log("\n=== writeBlockerPlaceholder: unknown type → null ===");
test('writeBlockerPlaceholder: unknown type → null', () => {
const base = createFixtureBase();
try {
const result = writeBlockerPlaceholder("unknown-type", "M001/S01", base, "test");
assertEq(result, null, "unknown type returns null");
assert.deepStrictEqual(result, null, "unknown type returns null");
} finally {
cleanup(base);
}
}
});
// ═══ verifyExpectedArtifact: complete-slice roadmap check ════════════════════
// Regression for #indefinite-hang: complete-slice must verify roadmap [x] or
@ -177,8 +167,7 @@ const ROADMAP_COMPLETE = `# M001: Test Milestone
> After this: something works
`;
{
console.log("\n=== verifyExpectedArtifact: complete-slice — all artifacts present + roadmap marked [x] returns true ===");
test('verifyExpectedArtifact: complete-slice — all artifacts present + roadmap marked [x] returns true', () => {
const base = createFixtureBase();
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
@ -186,14 +175,13 @@ const ROADMAP_COMPLETE = `# M001: Test Milestone
writeFileSync(join(sliceDir, "S01-UAT.md"), "# UAT\n", "utf-8");
writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), ROADMAP_COMPLETE, "utf-8");
const result = verifyExpectedArtifact("complete-slice", "M001/S01", base);
assertTrue(result === true, "SUMMARY + UAT + roadmap [x] should verify as true");
assert.ok(result === true, "SUMMARY + UAT + roadmap [x] should verify as true");
} finally {
cleanup(base);
}
}
});
{
console.log("\n=== verifyExpectedArtifact: complete-slice — SUMMARY + UAT present but roadmap NOT marked [x] returns false ===");
test('verifyExpectedArtifact: complete-slice — SUMMARY + UAT present but roadmap NOT marked [x] returns false', () => {
const base = createFixtureBase();
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
@ -201,14 +189,13 @@ const ROADMAP_COMPLETE = `# M001: Test Milestone
writeFileSync(join(sliceDir, "S01-UAT.md"), "# UAT\n", "utf-8");
writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), ROADMAP_INCOMPLETE, "utf-8");
const result = verifyExpectedArtifact("complete-slice", "M001/S01", base);
assertTrue(result === false, "roadmap not marked [x] should return false (crash recovery scenario)");
assert.ok(result === false, "roadmap not marked [x] should return false (crash recovery scenario)");
} finally {
cleanup(base);
}
}
});
{
console.log("\n=== verifyExpectedArtifact: complete-slice — SUMMARY present but UAT missing returns false ===");
test('verifyExpectedArtifact: complete-slice — SUMMARY present but UAT missing returns false', () => {
const base = createFixtureBase();
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
@ -216,14 +203,13 @@ const ROADMAP_COMPLETE = `# M001: Test Milestone
// no UAT file
writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), ROADMAP_COMPLETE, "utf-8");
const result = verifyExpectedArtifact("complete-slice", "M001/S01", base);
assertTrue(result === false, "missing UAT should return false");
assert.ok(result === false, "missing UAT should return false");
} finally {
cleanup(base);
}
}
});
{
console.log("\n=== verifyExpectedArtifact: complete-slice — no roadmap file present is lenient (returns true) ===");
test('verifyExpectedArtifact: complete-slice — no roadmap file present is lenient (returns true)', () => {
const base = createFixtureBase();
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
@ -231,87 +217,80 @@ const ROADMAP_COMPLETE = `# M001: Test Milestone
writeFileSync(join(sliceDir, "S01-UAT.md"), "# UAT\n", "utf-8");
// no roadmap file
const result = verifyExpectedArtifact("complete-slice", "M001/S01", base);
assertTrue(result === true, "missing roadmap file should be lenient and return true");
assert.ok(result === true, "missing roadmap file should be lenient and return true");
} finally {
cleanup(base);
}
}
});
// ═══ buildLoopRemediationSteps ═══════════════════════════════════════════════
{
console.log("\n=== buildLoopRemediationSteps: execute-task returns concrete steps ===");
test('buildLoopRemediationSteps: execute-task returns concrete steps', () => {
const base = mkdtempSync(join(tmpdir(), "gsd-loop-remediation-test-"));
try {
mkdirSync(join(base, ".gsd", "milestones", "M002", "slices", "S03", "tasks"), { recursive: true });
const result = buildLoopRemediationSteps("execute-task", "M002/S03/T01", base);
assertTrue(result !== null, "should return remediation steps");
assertTrue(result!.includes("gsd undo-task"), "steps include undo-task command");
assertTrue(result!.includes("T01"), "steps mention the task ID");
assertTrue(result!.includes("gsd undo-task"), "steps include gsd undo-task command");
assert.ok(result !== null, "should return remediation steps");
assert.ok(result!.includes("gsd undo-task"), "steps include undo-task command");
assert.ok(result!.includes("T01"), "steps mention the task ID");
assert.ok(result!.includes("gsd undo-task"), "steps include gsd undo-task command");
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});
{
console.log("\n=== buildLoopRemediationSteps: plan-slice returns concrete steps ===");
test('buildLoopRemediationSteps: plan-slice returns concrete steps', () => {
const base = mkdtempSync(join(tmpdir(), "gsd-loop-remediation-test-"));
try {
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01"), { recursive: true });
const result = buildLoopRemediationSteps("plan-slice", "M001/S01", base);
assertTrue(result !== null, "should return remediation steps for plan-slice");
assertTrue(result!.includes("S01-PLAN.md"), "steps mention the slice plan file");
assertTrue(result!.includes("gsd recover"), "steps include gsd recover command");
assert.ok(result !== null, "should return remediation steps for plan-slice");
assert.ok(result!.includes("S01-PLAN.md"), "steps mention the slice plan file");
assert.ok(result!.includes("gsd recover"), "steps include gsd recover command");
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});
{
console.log("\n=== buildLoopRemediationSteps: research-slice returns concrete steps ===");
test('buildLoopRemediationSteps: research-slice returns concrete steps', () => {
const base = mkdtempSync(join(tmpdir(), "gsd-loop-remediation-test-"));
try {
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01"), { recursive: true });
const result = buildLoopRemediationSteps("research-slice", "M001/S01", base);
assertTrue(result !== null, "should return remediation steps for research-slice");
assertTrue(result!.includes("S01-RESEARCH.md"), "steps mention the slice research file");
assertTrue(result!.includes("gsd recover"), "steps include gsd recover command");
assert.ok(result !== null, "should return remediation steps for research-slice");
assert.ok(result!.includes("S01-RESEARCH.md"), "steps mention the slice research file");
assert.ok(result!.includes("gsd recover"), "steps include gsd recover command");
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});
{
console.log("\n=== buildLoopRemediationSteps: unknown type returns null ===");
test('buildLoopRemediationSteps: unknown type returns null', () => {
const base = mkdtempSync(join(tmpdir(), "gsd-loop-remediation-test-"));
try {
const result = buildLoopRemediationSteps("unknown-type", "M001/S01", base);
assertEq(result, null, "unknown type returns null");
assert.deepStrictEqual(result, null, "unknown type returns null");
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});
// ═══ verifyExpectedArtifact: hook unit types ═════════════════════════════════
console.log("\n=== verifyExpectedArtifact: hook types always return true ===");
{
test('verifyExpectedArtifact: hook types always return true', () => {
const base = createFixtureBase();
try {
// Hook units don't have standard artifacts — they should always pass
const result1 = verifyExpectedArtifact("hook/code-review", "M001/S01/T01", base);
assertTrue(result1, "hook/code-review should always return true");
assert.ok(result1, "hook/code-review should always return true");
const result2 = verifyExpectedArtifact("hook/simplify", "M001/S01/T02", base);
assertTrue(result2, "hook/simplify should always return true");
assert.ok(result2, "hook/simplify should always return true");
const result3 = verifyExpectedArtifact("hook/custom-hook", "M001/S01", base);
assertTrue(result3, "hook/custom-hook at slice level should return true");
assert.ok(result3, "hook/custom-hook at slice level should return true");
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});
report();

View file

@ -19,9 +19,8 @@ import {
formatDecisionsForPrompt,
formatRequirementsForPrompt,
} from '../context-store.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
// ─── Fixture Helper ────────────────────────────────────────────────────────
@ -48,8 +47,7 @@ function generateDecisionsMarkdown(count: number): string {
// Edge Case 1: Empty Project
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== integration-edge: empty project ===');
{
test('integration-edge: empty project', () => {
const base = mkdtempSync(join(tmpdir(), 'gsd-int-edge-empty-'));
const gsdDir = join(base, '.gsd');
mkdirSync(gsdDir, { recursive: true });
@ -59,55 +57,54 @@ console.log('\n=== integration-edge: empty project ===');
try {
// Open DB first so migrateFromMarkdown doesn't auto-create at default path
openDatabase(dbPath);
assertTrue(isDbAvailable(), 'empty: DB available after open');
assert.ok(isDbAvailable(), 'empty: DB available after open');
// Migrate with no markdown files on disk
const result = migrateFromMarkdown(base);
assertEq(result.decisions, 0, 'empty: 0 decisions imported');
assertEq(result.requirements, 0, 'empty: 0 requirements imported');
assertEq(result.artifacts, 0, 'empty: 0 artifacts imported');
assert.deepStrictEqual(result.decisions, 0, 'empty: 0 decisions imported');
assert.deepStrictEqual(result.requirements, 0, 'empty: 0 requirements imported');
assert.deepStrictEqual(result.artifacts, 0, 'empty: 0 artifacts imported');
// Query decisions → empty array
const decisions = queryDecisions();
assertEq(decisions.length, 0, 'empty: queryDecisions returns empty array');
assert.deepStrictEqual(decisions.length, 0, 'empty: queryDecisions returns empty array');
// Query requirements → empty array
const requirements = queryRequirements();
assertEq(requirements.length, 0, 'empty: queryRequirements returns empty array');
assert.deepStrictEqual(requirements.length, 0, 'empty: queryRequirements returns empty array');
// Query with scope filters → still empty, no crash
const scopedDecisions = queryDecisions({ milestoneId: 'M001' });
assertEq(scopedDecisions.length, 0, 'empty: scoped queryDecisions returns empty');
assert.deepStrictEqual(scopedDecisions.length, 0, 'empty: scoped queryDecisions returns empty');
const scopedRequirements = queryRequirements({ sliceId: 'S01' });
assertEq(scopedRequirements.length, 0, 'empty: scoped queryRequirements returns empty');
assert.deepStrictEqual(scopedRequirements.length, 0, 'empty: scoped queryRequirements returns empty');
// Format empty results → empty strings
const formattedD = formatDecisionsForPrompt([]);
const formattedR = formatRequirementsForPrompt([]);
assertEq(formattedD, '', 'empty: formatDecisionsForPrompt returns empty string');
assertEq(formattedR, '', 'empty: formatRequirementsForPrompt returns empty string');
assert.deepStrictEqual(formattedD, '', 'empty: formatDecisionsForPrompt returns empty string');
assert.deepStrictEqual(formattedR, '', 'empty: formatRequirementsForPrompt returns empty string');
// Format with actual empty query results
const formattedD2 = formatDecisionsForPrompt(decisions);
const formattedR2 = formatRequirementsForPrompt(requirements);
assertEq(formattedD2, '', 'empty: format of empty query decisions is empty string');
assertEq(formattedR2, '', 'empty: format of empty query requirements is empty string');
assert.deepStrictEqual(formattedD2, '', 'empty: format of empty query decisions is empty string');
assert.deepStrictEqual(formattedR2, '', 'empty: format of empty query requirements is empty string');
closeDatabase();
} finally {
closeDatabase();
rmSync(base, { recursive: true, force: true });
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Edge Case 2: Partial Migration (decisions only, no requirements)
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== integration-edge: partial migration ===');
{
test('integration-edge: partial migration', () => {
const base = mkdtempSync(join(tmpdir(), 'gsd-int-edge-partial-'));
const gsdDir = join(base, '.gsd');
mkdirSync(gsdDir, { recursive: true });
@ -120,49 +117,48 @@ console.log('\n=== integration-edge: partial migration ===');
try {
openDatabase(dbPath);
assertTrue(isDbAvailable(), 'partial: DB available after open');
assert.ok(isDbAvailable(), 'partial: DB available after open');
const result = migrateFromMarkdown(base);
// Decisions imported, requirements skipped gracefully
assertTrue(result.decisions === 6, `partial: imported ${result.decisions} decisions, expected 6`);
assertEq(result.requirements, 0, 'partial: 0 requirements imported (no file)');
assert.ok(result.decisions === 6, `partial: imported ${result.decisions} decisions, expected 6`);
assert.deepStrictEqual(result.requirements, 0, 'partial: 0 requirements imported (no file)');
// Decisions queryable
const decisions = queryDecisions();
assertTrue(decisions.length === 6, `partial: queryDecisions returns 6 (got ${decisions.length})`);
assert.ok(decisions.length === 6, `partial: queryDecisions returns 6 (got ${decisions.length})`);
const m001Decisions = queryDecisions({ milestoneId: 'M001' });
assertTrue(m001Decisions.length > 0, 'partial: M001 decisions non-empty');
assertTrue(m001Decisions.length < decisions.length, 'partial: M001 scope filters correctly');
assert.ok(m001Decisions.length > 0, 'partial: M001 decisions non-empty');
assert.ok(m001Decisions.length < decisions.length, 'partial: M001 scope filters correctly');
// Requirements return empty — no crash
const requirements = queryRequirements();
assertEq(requirements.length, 0, 'partial: queryRequirements returns empty');
assert.deepStrictEqual(requirements.length, 0, 'partial: queryRequirements returns empty');
const scopedReqs = queryRequirements({ sliceId: 'S01' });
assertEq(scopedReqs.length, 0, 'partial: scoped queryRequirements returns empty');
assert.deepStrictEqual(scopedReqs.length, 0, 'partial: scoped queryRequirements returns empty');
// Format works on partial data
const formattedD = formatDecisionsForPrompt(m001Decisions);
assertTrue(formattedD.length > 0, 'partial: formatted decisions non-empty');
assert.ok(formattedD.length > 0, 'partial: formatted decisions non-empty');
const formattedR = formatRequirementsForPrompt(requirements);
assertEq(formattedR, '', 'partial: formatted empty requirements is empty string');
assert.deepStrictEqual(formattedR, '', 'partial: formatted empty requirements is empty string');
closeDatabase();
} finally {
closeDatabase();
rmSync(base, { recursive: true, force: true });
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Edge Case 3: Fallback Mode (_resetProvider)
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== integration-edge: fallback mode ===');
{
test('integration-edge: fallback mode', () => {
const base = mkdtempSync(join(tmpdir(), 'gsd-int-edge-fallback-'));
const gsdDir = join(base, '.gsd');
mkdirSync(gsdDir, { recursive: true });
@ -175,54 +171,53 @@ console.log('\n=== integration-edge: fallback mode ===');
try {
// Step 1: Open DB normally and verify it works
openDatabase(dbPath);
assertTrue(isDbAvailable(), 'fallback: DB available after open');
assert.ok(isDbAvailable(), 'fallback: DB available after open');
migrateFromMarkdown(base);
const before = queryDecisions();
assertTrue(before.length === 4, `fallback: 4 decisions before reset (got ${before.length})`);
assert.ok(before.length === 4, `fallback: 4 decisions before reset (got ${before.length})`);
// Step 2: Close and reset provider → DB unavailable
closeDatabase();
_resetProvider();
assertTrue(!isDbAvailable(), 'fallback: DB unavailable after _resetProvider');
assert.ok(!isDbAvailable(), 'fallback: DB unavailable after _resetProvider');
// Step 3: Queries degrade gracefully (return empty, don't throw)
const degradedDecisions = queryDecisions();
assertEq(degradedDecisions.length, 0, 'fallback: queryDecisions returns empty when unavailable');
assert.deepStrictEqual(degradedDecisions.length, 0, 'fallback: queryDecisions returns empty when unavailable');
const degradedRequirements = queryRequirements();
assertEq(degradedRequirements.length, 0, 'fallback: queryRequirements returns empty when unavailable');
assert.deepStrictEqual(degradedRequirements.length, 0, 'fallback: queryRequirements returns empty when unavailable');
const degradedScopedD = queryDecisions({ milestoneId: 'M001' });
assertEq(degradedScopedD.length, 0, 'fallback: scoped queryDecisions returns empty when unavailable');
assert.deepStrictEqual(degradedScopedD.length, 0, 'fallback: scoped queryDecisions returns empty when unavailable');
const degradedScopedR = queryRequirements({ sliceId: 'S01' });
assertEq(degradedScopedR.length, 0, 'fallback: scoped queryRequirements returns empty when unavailable');
assert.deepStrictEqual(degradedScopedR.length, 0, 'fallback: scoped queryRequirements returns empty when unavailable');
// Format functions work on empty arrays (no crash)
const formattedD = formatDecisionsForPrompt(degradedDecisions);
assertEq(formattedD, '', 'fallback: format degraded decisions is empty');
assert.deepStrictEqual(formattedD, '', 'fallback: format degraded decisions is empty');
const formattedR = formatRequirementsForPrompt(degradedRequirements);
assertEq(formattedR, '', 'fallback: format degraded requirements is empty');
assert.deepStrictEqual(formattedR, '', 'fallback: format degraded requirements is empty');
// Step 4: Re-open DB → restores availability
openDatabase(dbPath);
assertTrue(isDbAvailable(), 'fallback: DB available after re-open');
assert.ok(isDbAvailable(), 'fallback: DB available after re-open');
// Data should be there from the file-backed DB (persisted by first open)
// But rows may need re-import since the DB was freshly opened from the file
migrateFromMarkdown(base);
const restored = queryDecisions();
assertTrue(restored.length === 4, `fallback: 4 decisions after re-open (got ${restored.length})`);
assert.ok(restored.length === 4, `fallback: 4 decisions after re-open (got ${restored.length})`);
closeDatabase();
} finally {
closeDatabase();
rmSync(base, { recursive: true, force: true });
}
}
});
// ─── Report ────────────────────────────────────────────────────────────────
report();

View file

@ -21,9 +21,8 @@ import {
formatRequirementsForPrompt,
} from '../context-store.ts';
import { saveDecisionToDb, generateDecisionsMd } from '../db-writer.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
// ─── Fixture Generators (duplicated from token-savings.test.ts — file-scoped) ──
@ -119,10 +118,7 @@ const ROADMAP_CONTENT = `# M001: Test Milestone\n\n**Vision:** Integration test
// Full Lifecycle Integration Test
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
console.log('\n=== integration-lifecycle: full pipeline ===');
{
test('integration-lifecycle: full pipeline', async () => {
// ── Step 1: Set up temp dir with realistic .gsd/ structure ──────────
const base = mkdtempSync(join(tmpdir(), 'gsd-int-lifecycle-'));
const gsdDir = join(base, '.gsd');
@ -142,37 +138,37 @@ async function main(): Promise<void> {
try {
// ── Step 2: Open file-backed DB + migrateFromMarkdown ──────────────
openDatabase(dbPath);
assertTrue(isDbAvailable(), 'lifecycle: DB is available after open');
assert.ok(isDbAvailable(), 'lifecycle: DB is available after open');
const result = migrateFromMarkdown(base);
assertTrue(result.decisions === DECISIONS_COUNT, `lifecycle: imported ${result.decisions} decisions, expected ${DECISIONS_COUNT}`);
assertTrue(result.requirements === REQUIREMENTS_COUNT, `lifecycle: imported ${result.requirements} requirements, expected ${REQUIREMENTS_COUNT}`);
assertTrue(result.artifacts >= 1, `lifecycle: imported at least 1 artifact (got ${result.artifacts})`);
assert.ok(result.decisions === DECISIONS_COUNT, `lifecycle: imported ${result.decisions} decisions, expected ${DECISIONS_COUNT}`);
assert.ok(result.requirements === REQUIREMENTS_COUNT, `lifecycle: imported ${result.requirements} requirements, expected ${REQUIREMENTS_COUNT}`);
assert.ok(result.artifacts >= 1, `lifecycle: imported at least 1 artifact (got ${result.artifacts})`);
// Verify file-backed DB uses WAL
const adapter = _getAdapter()!;
const mode = adapter.prepare('PRAGMA journal_mode').get();
assertEq(mode?.['journal_mode'], 'wal', 'lifecycle: file-backed DB uses WAL mode');
assert.deepStrictEqual(mode?.['journal_mode'], 'wal', 'lifecycle: file-backed DB uses WAL mode');
// ── Step 3: Scoped queries — decisions by milestone ────────────────
const allDecisions = queryDecisions();
const m001Decisions = queryDecisions({ milestoneId: 'M001' });
const m002Decisions = queryDecisions({ milestoneId: 'M002' });
assertTrue(allDecisions.length === DECISIONS_COUNT, `lifecycle: all decisions count = ${DECISIONS_COUNT} (got ${allDecisions.length})`);
assertTrue(m001Decisions.length > 0, 'lifecycle: M001 decisions non-empty');
assertTrue(m002Decisions.length > 0, 'lifecycle: M002 decisions non-empty');
assertTrue(m001Decisions.length < allDecisions.length, 'lifecycle: M001 filtered count < total count');
assertTrue(m002Decisions.length < allDecisions.length, 'lifecycle: M002 filtered count < total count');
assertEq(m001Decisions.length + m002Decisions.length, allDecisions.length, 'lifecycle: M001 + M002 = total decisions');
assert.ok(allDecisions.length === DECISIONS_COUNT, `lifecycle: all decisions count = ${DECISIONS_COUNT} (got ${allDecisions.length})`);
assert.ok(m001Decisions.length > 0, 'lifecycle: M001 decisions non-empty');
assert.ok(m002Decisions.length > 0, 'lifecycle: M002 decisions non-empty');
assert.ok(m001Decisions.length < allDecisions.length, 'lifecycle: M001 filtered count < total count');
assert.ok(m002Decisions.length < allDecisions.length, 'lifecycle: M002 filtered count < total count');
assert.deepStrictEqual(m001Decisions.length + m002Decisions.length, allDecisions.length, 'lifecycle: M001 + M002 = total decisions');
// Verify scoping correctness
for (const d of m001Decisions) {
assertTrue(d.when_context.includes('M001'), `lifecycle: M001 decision ${d.id} has M001 in when_context`);
assert.ok(d.when_context.includes('M001'), `lifecycle: M001 decision ${d.id} has M001 in when_context`);
}
for (const d of m002Decisions) {
assertTrue(d.when_context.includes('M002'), `lifecycle: M002 decision ${d.id} has M002 in when_context`);
assert.ok(d.when_context.includes('M002'), `lifecycle: M002 decision ${d.id} has M002 in when_context`);
}
// ── Step 4: Scoped queries — requirements by slice ─────────────────
@ -180,19 +176,19 @@ async function main(): Promise<void> {
const s01Requirements = queryRequirements({ sliceId: 'S01' });
const s04Requirements = queryRequirements({ sliceId: 'S04' });
assertTrue(allRequirements.length === REQUIREMENTS_COUNT, `lifecycle: all requirements count = ${REQUIREMENTS_COUNT} (got ${allRequirements.length})`);
assertTrue(s01Requirements.length > 0, 'lifecycle: S01 requirements non-empty');
assertTrue(s04Requirements.length > 0, 'lifecycle: S04 requirements non-empty');
assertTrue(s01Requirements.length < allRequirements.length, 'lifecycle: S01 filtered count < total count');
assert.ok(allRequirements.length === REQUIREMENTS_COUNT, `lifecycle: all requirements count = ${REQUIREMENTS_COUNT} (got ${allRequirements.length})`);
assert.ok(s01Requirements.length > 0, 'lifecycle: S01 requirements non-empty');
assert.ok(s04Requirements.length > 0, 'lifecycle: S04 requirements non-empty');
assert.ok(s01Requirements.length < allRequirements.length, 'lifecycle: S01 filtered count < total count');
// ── Step 5: Format + token savings validation ──────────────────────
const formattedDecisions = formatDecisionsForPrompt(m001Decisions);
const formattedRequirements = formatRequirementsForPrompt(s01Requirements);
assertTrue(formattedDecisions.length > 0, 'lifecycle: formatted M001 decisions non-empty');
assertTrue(formattedRequirements.length > 0, 'lifecycle: formatted S01 requirements non-empty');
assertMatch(formattedDecisions, /\| D/, 'lifecycle: formatted decisions contains decision rows');
assertMatch(formattedRequirements, /### R\d+/, 'lifecycle: formatted requirements has headings');
assert.ok(formattedDecisions.length > 0, 'lifecycle: formatted M001 decisions non-empty');
assert.ok(formattedRequirements.length > 0, 'lifecycle: formatted S01 requirements non-empty');
assert.match(formattedDecisions, /\| D/, 'lifecycle: formatted decisions contains decision rows');
assert.match(formattedRequirements, /### R\d+/, 'lifecycle: formatted requirements has headings');
// Token savings: scoped output vs full file content
const fullDecisionsContent = readFileSync(join(gsdDir, 'DECISIONS.md'), 'utf-8');
@ -203,24 +199,24 @@ async function main(): Promise<void> {
console.log(` Token savings: ${savingsPercent.toFixed(1)}% (scoped: ${dbScopedTotal}, full: ${fullTotal})`);
assertTrue(dbScopedTotal > 0, 'lifecycle: scoped content non-empty');
assertTrue(dbScopedTotal < fullTotal, 'lifecycle: scoped content smaller than full content');
assertTrue(savingsPercent >= 30, `lifecycle: savings ≥30% (actual: ${savingsPercent.toFixed(1)}%)`);
assert.ok(dbScopedTotal > 0, 'lifecycle: scoped content non-empty');
assert.ok(dbScopedTotal < fullTotal, 'lifecycle: scoped content smaller than full content');
assert.ok(savingsPercent >= 30, `lifecycle: savings ≥30% (actual: ${savingsPercent.toFixed(1)}%)`);
// ── Step 6: Simulate content change → re-import ────────────────────
const newDecisionRow = `| D${DECISIONS_COUNT + 1} | M001/S01 | testing | new decision added after initial import | choice X | rationale Y | yes |`;
appendFileSync(join(gsdDir, 'DECISIONS.md'), '\n' + newDecisionRow + '\n');
const result2 = migrateFromMarkdown(base);
assertTrue(result2.decisions === DECISIONS_COUNT + 1, `lifecycle: re-import got ${result2.decisions} decisions, expected ${DECISIONS_COUNT + 1}`);
assert.ok(result2.decisions === DECISIONS_COUNT + 1, `lifecycle: re-import got ${result2.decisions} decisions, expected ${DECISIONS_COUNT + 1}`);
const afterReimport = queryDecisions();
assertTrue(afterReimport.length === DECISIONS_COUNT + 1, `lifecycle: DB has ${DECISIONS_COUNT + 1} decisions after re-import (got ${afterReimport.length})`);
assert.ok(afterReimport.length === DECISIONS_COUNT + 1, `lifecycle: DB has ${DECISIONS_COUNT + 1} decisions after re-import (got ${afterReimport.length})`);
// Verify the new decision is queryable
const newM001 = queryDecisions({ milestoneId: 'M001' });
const foundNew = newM001.some(d => d.id === `D${DECISIONS_COUNT + 1}`);
assertTrue(foundNew, `lifecycle: newly imported D${DECISIONS_COUNT + 1} found in M001 scope`);
assert.ok(foundNew, `lifecycle: newly imported D${DECISIONS_COUNT + 1} found in M001 scope`);
// ── Step 7: saveDecisionToDb write-back + round-trip ───────────────
const saved = await saveDecisionToDb(
@ -234,44 +230,37 @@ async function main(): Promise<void> {
base,
);
assertTrue(typeof saved.id === 'string', 'lifecycle: saveDecisionToDb returned an id');
assertMatch(saved.id, /^D\d+$/, 'lifecycle: saved ID matches D### pattern');
assert.ok(typeof saved.id === 'string', 'lifecycle: saveDecisionToDb returned an id');
assert.match(saved.id, /^D\d+$/, 'lifecycle: saved ID matches D### pattern');
// Query back from DB
const allAfterSave = queryDecisions();
const savedDecision = allAfterSave.find(d => d.id === saved.id);
assertTrue(savedDecision !== null && savedDecision !== undefined, `lifecycle: saved decision ${saved.id} found in DB`);
assertEq(savedDecision?.decision, 'integration test write-back decision', 'lifecycle: saved decision text matches');
assertEq(savedDecision?.choice, 'option Z', 'lifecycle: saved choice matches');
assert.ok(savedDecision !== null && savedDecision !== undefined, `lifecycle: saved decision ${saved.id} found in DB`);
assert.deepStrictEqual(savedDecision?.decision, 'integration test write-back decision', 'lifecycle: saved decision text matches');
assert.deepStrictEqual(savedDecision?.choice, 'option Z', 'lifecycle: saved choice matches');
// Verify DECISIONS.md was regenerated with the new decision
const regeneratedMd = readFileSync(join(gsdDir, 'DECISIONS.md'), 'utf-8');
assertTrue(regeneratedMd.includes(saved.id), `lifecycle: regenerated DECISIONS.md contains ${saved.id}`);
assertTrue(regeneratedMd.includes('integration test write-back decision'), 'lifecycle: regenerated md contains write-back text');
assert.ok(regeneratedMd.includes(saved.id), `lifecycle: regenerated DECISIONS.md contains ${saved.id}`);
assert.ok(regeneratedMd.includes('integration test write-back decision'), 'lifecycle: regenerated md contains write-back text');
// Round-trip: parse regenerated markdown back → verify field fidelity
const reparsed = parseDecisionsTable(regeneratedMd);
const reparsedSaved = reparsed.find(d => d.id === saved.id);
assertTrue(reparsedSaved !== undefined, `lifecycle: reparsed markdown contains ${saved.id}`);
assertEq(reparsedSaved?.choice, 'option Z', 'lifecycle: round-trip choice preserved');
assertEq(reparsedSaved?.rationale, 'proves round-trip fidelity', 'lifecycle: round-trip rationale preserved');
assert.ok(reparsedSaved !== undefined, `lifecycle: reparsed markdown contains ${saved.id}`);
assert.deepStrictEqual(reparsedSaved?.choice, 'option Z', 'lifecycle: round-trip choice preserved');
assert.deepStrictEqual(reparsedSaved?.rationale, 'proves round-trip fidelity', 'lifecycle: round-trip rationale preserved');
// ── Step 8: DB consistency — total count sanity ─────────────────────
const finalCount = queryDecisions().length;
// Original 14 + 1 re-import + 1 saveDecisionToDb = 16
assertTrue(finalCount === DECISIONS_COUNT + 2, `lifecycle: final DB count = ${DECISIONS_COUNT + 2} (got ${finalCount})`);
assert.ok(finalCount === DECISIONS_COUNT + 2, `lifecycle: final DB count = ${DECISIONS_COUNT + 2} (got ${finalCount})`);
closeDatabase();
} finally {
closeDatabase();
rmSync(base, { recursive: true, force: true });
}
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -20,11 +20,11 @@ import {
parseSliceBranch,
} from '../worktree.ts';
import { clearPathCache } from '../paths.ts';
import { createTestContext } from './test-helpers.ts';
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
// ─── Assertion Helpers ────────────────────────────────────────────────────
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
// ─── Fixture Helpers ──────────────────────────────────────────────────────
function createFixtureBase(): string {
@ -79,11 +79,9 @@ function createGitRepo(): string {
// Test Groups
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
// ─── Group 1: deriveState with new-format-only milestones ─────────────
console.log('\n=== Group 1: deriveState with new-format-only milestones ===');
{
test('Group 1: deriveState with new-format-only milestones', async () => {
const base = createFixtureBase();
try {
// Create M001-abc123 with roadmap + 2 slices (S01 complete, S02 in-progress)
@ -125,32 +123,32 @@ async function main(): Promise<void> {
const state = await deriveState(base);
// Phase should be executing (active milestone with incomplete slice + plan + tasks)
assertEq(state.phase, 'executing', 'G1: phase is executing');
assertTrue(state.activeMilestone !== null, 'G1: activeMilestone is not null');
assertEq(state.activeMilestone?.id, 'M001-abc123', 'G1: activeMilestone id is M001-abc123');
assertEq(state.activeMilestone?.title, 'Test Feature', 'G1: title stripped to Test Feature');
assert.deepStrictEqual(state.phase, 'executing', 'G1: phase is executing');
assert.ok(state.activeMilestone !== null, 'G1: activeMilestone is not null');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001-abc123', 'G1: activeMilestone id is M001-abc123');
assert.deepStrictEqual(state.activeMilestone?.title, 'Test Feature', 'G1: title stripped to Test Feature');
// Registry
assertEq(state.registry.length, 1, 'G1: registry has 1 entry');
assertEq(state.registry[0]?.id, 'M001-abc123', 'G1: registry entry id');
assertEq(state.registry[0]?.status, 'active', 'G1: registry entry status is active');
assertEq(state.registry[0]?.title, 'Test Feature', 'G1: registry title stripped');
assert.deepStrictEqual(state.registry.length, 1, 'G1: registry has 1 entry');
assert.deepStrictEqual(state.registry[0]?.id, 'M001-abc123', 'G1: registry entry id');
assert.deepStrictEqual(state.registry[0]?.status, 'active', 'G1: registry entry status is active');
assert.deepStrictEqual(state.registry[0]?.title, 'Test Feature', 'G1: registry title stripped');
// Active slice
assertTrue(state.activeSlice !== null, 'G1: activeSlice is not null');
assertEq(state.activeSlice?.id, 'S02', 'G1: activeSlice is S02');
assert.ok(state.activeSlice !== null, 'G1: activeSlice is not null');
assert.deepStrictEqual(state.activeSlice?.id, 'S02', 'G1: activeSlice is S02');
// Progress
assertEq(state.progress?.milestones?.done, 0, 'G1: milestones done = 0');
assertEq(state.progress?.milestones?.total, 1, 'G1: milestones total = 1');
assert.deepStrictEqual(state.progress?.milestones?.done, 0, 'G1: milestones done = 0');
assert.deepStrictEqual(state.progress?.milestones?.total, 1, 'G1: milestones total = 1');
} finally {
cleanup(base);
}
}
});
// ─── Group 2: deriveState with mixed-format milestones ────────────────
console.log('\n=== Group 2: deriveState with mixed old+new format milestones ===');
{
test('Group 2: deriveState with mixed old+new format milestones', async () => {
const base = createFixtureBase();
try {
// M001 — complete milestone (all slices done + summary)
@ -217,40 +215,40 @@ Everything worked.
const state = await deriveState(base);
// Registry — should have 2 entries sorted by seq number
assertEq(state.registry.length, 2, 'G2: registry has 2 entries');
assertEq(state.registry[0]?.id, 'M001', 'G2: registry[0] is M001 (sorted first)');
assertEq(state.registry[1]?.id, 'M002-abc123', 'G2: registry[1] is M002-abc123 (sorted second)');
assert.deepStrictEqual(state.registry.length, 2, 'G2: registry has 2 entries');
assert.deepStrictEqual(state.registry[0]?.id, 'M001', 'G2: registry[0] is M001 (sorted first)');
assert.deepStrictEqual(state.registry[1]?.id, 'M002-abc123', 'G2: registry[1] is M002-abc123 (sorted second)');
// M001 is complete
assertEq(state.registry[0]?.status, 'complete', 'G2: M001 status is complete');
assertEq(state.registry[0]?.title, 'Legacy Feature', 'G2: M001 title stripped');
assert.deepStrictEqual(state.registry[0]?.status, 'complete', 'G2: M001 status is complete');
assert.deepStrictEqual(state.registry[0]?.title, 'Legacy Feature', 'G2: M001 title stripped');
// M002-abc123 is active
assertEq(state.registry[1]?.status, 'active', 'G2: M002-abc123 status is active');
assertEq(state.registry[1]?.title, 'New Feature', 'G2: M002-abc123 title stripped');
assert.deepStrictEqual(state.registry[1]?.status, 'active', 'G2: M002-abc123 status is active');
assert.deepStrictEqual(state.registry[1]?.title, 'New Feature', 'G2: M002-abc123 title stripped');
// Active milestone
assertTrue(state.activeMilestone !== null, 'G2: activeMilestone is not null');
assertEq(state.activeMilestone?.id, 'M002-abc123', 'G2: activeMilestone is M002-abc123');
assertEq(state.activeMilestone?.title, 'New Feature', 'G2: activeMilestone title stripped');
assert.ok(state.activeMilestone !== null, 'G2: activeMilestone is not null');
assert.deepStrictEqual(state.activeMilestone?.id, 'M002-abc123', 'G2: activeMilestone is M002-abc123');
assert.deepStrictEqual(state.activeMilestone?.title, 'New Feature', 'G2: activeMilestone title stripped');
// Phase
assertEq(state.phase, 'executing', 'G2: phase is executing');
assert.deepStrictEqual(state.phase, 'executing', 'G2: phase is executing');
// Active slice
assertEq(state.activeSlice?.id, 'S02', 'G2: activeSlice is S02');
assert.deepStrictEqual(state.activeSlice?.id, 'S02', 'G2: activeSlice is S02');
// Progress
assertEq(state.progress?.milestones?.done, 1, 'G2: milestones done = 1');
assertEq(state.progress?.milestones?.total, 2, 'G2: milestones total = 2');
assert.deepStrictEqual(state.progress?.milestones?.done, 1, 'G2: milestones done = 1');
assert.deepStrictEqual(state.progress?.milestones?.total, 2, 'G2: milestones total = 2');
} finally {
cleanup(base);
}
}
});
// ─── Group 3: indexWorkspace with mixed-format milestones ─────────────
console.log('\n=== Group 3: indexWorkspace with mixed-format milestones ===');
{
test('Group 3: indexWorkspace with mixed-format milestones', async () => {
const base = createFixtureBase();
try {
// Same fixture as Group 2: M001 (complete) + M002-abc123 (active)
@ -304,39 +302,39 @@ Everything worked.
const index = await indexWorkspace(base);
// Both milestones indexed
assertEq(index.milestones.length, 2, 'G3: 2 milestones in index');
assertEq(index.milestones[0]?.id, 'M001', 'G3: index[0] is M001');
assertEq(index.milestones[1]?.id, 'M002-abc123', 'G3: index[1] is M002-abc123');
assert.deepStrictEqual(index.milestones.length, 2, 'G3: 2 milestones in index');
assert.deepStrictEqual(index.milestones[0]?.id, 'M001', 'G3: index[0] is M001');
assert.deepStrictEqual(index.milestones[1]?.id, 'M002-abc123', 'G3: index[1] is M002-abc123');
// Titles stripped from both formats
assertEq(index.milestones[0]?.title, 'Legacy Feature', 'G3: M001 title stripped');
assertEq(index.milestones[1]?.title, 'New Feature', 'G3: M002-abc123 title stripped');
assert.deepStrictEqual(index.milestones[0]?.title, 'Legacy Feature', 'G3: M001 title stripped');
assert.deepStrictEqual(index.milestones[1]?.title, 'New Feature', 'G3: M002-abc123 title stripped');
// Active state
assertEq(index.active.milestoneId, 'M002-abc123', 'G3: active milestone is M002-abc123');
assertEq(index.active.sliceId, 'S01', 'G3: active slice is S01');
assert.deepStrictEqual(index.active.milestoneId, 'M002-abc123', 'G3: active milestone is M002-abc123');
assert.deepStrictEqual(index.active.sliceId, 'S01', 'G3: active slice is S01');
// Scopes include new-format paths
assertTrue(
assert.ok(
index.scopes.some(s => s.scope === 'M002-abc123'),
'G3: scope includes M002-abc123 milestone',
);
assertTrue(
assert.ok(
index.scopes.some(s => s.scope === 'M002-abc123/S01'),
'G3: scope includes M002-abc123/S01 slice',
);
assertTrue(
assert.ok(
index.scopes.some(s => s.scope === 'M002-abc123/S01/T01'),
'G3: scope includes M002-abc123/S01/T01 task',
);
} finally {
cleanup(base);
}
}
});
// ─── Group 4: inlinePriorMilestoneSummary with mixed formats ──────────
console.log('\n=== Group 4: inlinePriorMilestoneSummary with mixed formats ===');
{
test('Group 4: inlinePriorMilestoneSummary with mixed formats', async () => {
const base = createFixtureBase();
try {
// M001 — completed with summary
@ -358,21 +356,21 @@ Built the legacy feature successfully.
const result = await inlinePriorMilestoneSummary('M002-abc123', base);
// Result should be non-null (M001 is before M002-abc123)
assertTrue(result !== null, 'G4: result is non-null');
assertTrue(typeof result === 'string', 'G4: result is a string');
assert.ok(result !== null, 'G4: result is non-null');
assert.ok(typeof result === 'string', 'G4: result is a string');
// Should contain the M001 summary content
assertTrue(result!.includes('Prior Milestone Summary'), 'G4: contains Prior Milestone Summary header');
assertTrue(result!.includes('Built the legacy feature successfully'), 'G4: contains M001 summary content');
assertTrue(result!.includes('Used old format for milestone IDs'), 'G4: contains M001 key decisions');
assert.ok(result!.includes('Prior Milestone Summary'), 'G4: contains Prior Milestone Summary header');
assert.ok(result!.includes('Built the legacy feature successfully'), 'G4: contains M001 summary content');
assert.ok(result!.includes('Used old format for milestone IDs'), 'G4: contains M001 key decisions');
} finally {
cleanup(base);
}
}
});
// ─── Group 5: dispatch-guard with new-format milestones ──────────────
console.log('\n=== Group 5: dispatch-guard with new-format milestones ===');
{
test('Group 5: dispatch-guard with new-format milestones', () => {
const base = createGitRepo();
try {
// M001-abc123: all slices complete
@ -403,28 +401,28 @@ Built the legacy feature successfully.
run('git commit -m init', base);
// No blocker: M001-abc123 is complete, dispatching M002-abc123/S01
assertEq(
assert.deepStrictEqual(
getPriorSliceCompletionBlocker(base, 'main', 'plan-slice', 'M002-abc123/S01'),
null,
'G5: no blocker for M002-abc123/S01 when M001-abc123 all complete',
);
// No blocker for first slice of first milestone
assertEq(
assert.deepStrictEqual(
getPriorSliceCompletionBlocker(base, 'main', 'execute-task', 'M001-abc123/S01/T01'),
null,
'G5: no blocker for M001-abc123/S01/T01 (first milestone first slice)',
);
// Blocker: trying to dispatch M002-abc123/S02 when S01 is incomplete
assertMatch(
assert.match(
getPriorSliceCompletionBlocker(base, 'main', 'execute-task', 'M002-abc123/S02/T01') ?? '',
/M002-abc123\/S01 is not complete/,
'G5: blocks M002-abc123/S02 when S01 incomplete',
);
// Non-slice dispatch type should not be blocked
assertEq(
assert.deepStrictEqual(
getPriorSliceCompletionBlocker(base, 'main', 'plan-milestone', 'M002-abc123'),
null,
'G5: non-slice dispatch type not blocked',
@ -447,7 +445,7 @@ Built the legacy feature successfully.
// M001 (seq=1) < M001-abc123 (seq=1) — but M001 has incomplete S02
// Since M001 seq=1 and M002-abc123 seq=2, blocker should reference M001/S02
assertMatch(
assert.match(
getPriorSliceCompletionBlocker(base, 'main', 'plan-slice', 'M002-abc123/S01') ?? '',
/earlier slice M001\/S02 is not complete/,
'G5: mixed-format blocker references M001/S02',
@ -468,7 +466,7 @@ Built the legacy feature successfully.
run('git commit -m complete-m001', base);
clearPathCache();
assertEq(
assert.deepStrictEqual(
getPriorSliceCompletionBlocker(base, 'main', 'plan-slice', 'M002-abc123/S01'),
null,
'G5: no blocker after M001 completed (mixed format)',
@ -476,7 +474,7 @@ Built the legacy feature successfully.
// M001-abc123 still has all complete, M002-abc123/S01 still incomplete
// Check that S02 of M002-abc123 is still blocked by its own S01
assertMatch(
assert.match(
getPriorSliceCompletionBlocker(base, 'main', 'execute-task', 'M002-abc123/S02/T01') ?? '',
/M002-abc123\/S01 is not complete/,
'G5: intra-milestone blocker still works in mixed-format context',
@ -508,7 +506,7 @@ Built the legacy feature successfully.
run('git commit -m add-m003', base);
clearPathCache();
assertMatch(
assert.match(
getPriorSliceCompletionBlocker(base, 'main', 'execute-task', 'M003-xyz789/S02/T01') ?? '',
/earlier slice M003-xyz789\/S01 is not complete/,
'G5: positional path produces "earlier slice" message with new-format milestone ID',
@ -516,13 +514,13 @@ Built the legacy feature successfully.
} finally {
cleanup(base);
}
}
});
// ─── Group 6: Branch name helpers with new-format IDs ───────────────
console.log('\n=== Group 6: Branch name helpers with new-format IDs ===');
{
test('Group 6: Branch name helpers with new-format IDs', () => {
// Test getSliceBranchName with new-format ID
assertEq(
assert.deepStrictEqual(
getSliceBranchName('M001-abc123', 'S01'),
'gsd/M001-abc123/S01',
'G6: getSliceBranchName returns gsd/M001-abc123/S01',
@ -530,26 +528,12 @@ Built the legacy feature successfully.
// Test parseSliceBranch with new-format branch name
const parsed = parseSliceBranch('gsd/M001-abc123/S01');
assertTrue(parsed !== null, 'G6: parseSliceBranch returns non-null for new-format');
assertEq(parsed?.milestoneId, 'M001-abc123', 'G6: parsed milestoneId is M001-abc123');
assertEq(parsed?.sliceId, 'S01', 'G6: parsed sliceId is S01');
assertEq(parsed?.worktreeName, null, 'G6: parsed worktreeName is null (no worktree)');
}
assert.ok(parsed !== null, 'G6: parseSliceBranch returns non-null for new-format');
assert.deepStrictEqual(parsed?.milestoneId, 'M001-abc123', 'G6: parsed milestoneId is M001-abc123');
assert.deepStrictEqual(parsed?.sliceId, 'S01', 'G6: parsed sliceId is S01');
assert.deepStrictEqual(parsed?.worktreeName, null, 'G6: parsed worktreeName is null (no worktree)');
});
// ─── Summary ──────────────────────────────────────────────────────────
report();
}
// When run via vitest, wrap in test(); when run via tsx, call directly.
const isVitest = typeof globalThis !== 'undefined' && (globalThis as any).__vitest_worker__?.config?.defines != null && 'vitest' in (globalThis as any).__vitest_worker__.config.defines || process.env.VITEST;
if (isVitest) {
const { test } = await import('node:test');
test('integration-mixed-milestones: all groups pass', async () => {
await main();
});
} else {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
// When run via vitest, wrap in test(); when run via tsx, call directly.

View file

@ -1,4 +1,3 @@
import { createTestContext } from './test-helpers.ts';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs';
@ -38,8 +37,8 @@ import {
} from '../files.ts';
import { clearPathCache, _clearGsdRootCache } from '../paths.ts';
import { invalidateStateCache } from '../state.ts';
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
@ -174,29 +173,27 @@ function makeTaskSummaryContent(taskId: string): string {
// DB Accessor Tests
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: DB accessor basics ──');
{
test('── markdown-renderer: DB accessor basics ──', () => {
openDatabase(':memory:');
// getAllMilestones — empty
const empty = getAllMilestones();
assertEq(empty.length, 0, 'getAllMilestones returns empty when no milestones');
assert.deepStrictEqual(empty.length, 0, 'getAllMilestones returns empty when no milestones');
// Insert and retrieve
insertMilestone({ id: 'M001', title: 'Test MS', status: 'active' });
insertMilestone({ id: 'M002', title: 'Second MS', status: 'active' });
const all = getAllMilestones();
assertEq(all.length, 2, 'getAllMilestones returns 2 milestones');
assertEq(all[0].id, 'M001', 'first milestone is M001');
assertEq(all[1].id, 'M002', 'second milestone is M002');
assertEq(all[0].title, 'Test MS', 'milestone title correct');
assertEq(all[0].status, 'active', 'milestone status correct');
assert.deepStrictEqual(all.length, 2, 'getAllMilestones returns 2 milestones');
assert.deepStrictEqual(all[0].id, 'M001', 'first milestone is M001');
assert.deepStrictEqual(all[1].id, 'M002', 'second milestone is M002');
assert.deepStrictEqual(all[0].title, 'Test MS', 'milestone title correct');
assert.deepStrictEqual(all[0].status, 'active', 'milestone status correct');
// getMilestoneSlices — empty
const noSlices = getMilestoneSlices('M001');
assertEq(noSlices.length, 0, 'getMilestoneSlices returns empty when no slices');
assert.deepStrictEqual(noSlices.length, 0, 'getMilestoneSlices returns empty when no slices');
// Insert slices and retrieve
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Slice 1', status: 'complete' });
@ -204,26 +201,24 @@ console.log('\n── markdown-renderer: DB accessor basics ──');
insertSlice({ id: 'S01', milestoneId: 'M002', title: 'M2 Slice', status: 'pending' });
const m1Slices = getMilestoneSlices('M001');
assertEq(m1Slices.length, 2, 'M001 has 2 slices');
assertEq(m1Slices[0].id, 'S01', 'first slice is S01');
assertEq(m1Slices[0].status, 'complete', 'S01 status is complete');
assertEq(m1Slices[1].id, 'S02', 'second slice is S02');
assertEq(m1Slices[1].status, 'pending', 'S02 status is pending');
assert.deepStrictEqual(m1Slices.length, 2, 'M001 has 2 slices');
assert.deepStrictEqual(m1Slices[0].id, 'S01', 'first slice is S01');
assert.deepStrictEqual(m1Slices[0].status, 'complete', 'S01 status is complete');
assert.deepStrictEqual(m1Slices[1].id, 'S02', 'second slice is S02');
assert.deepStrictEqual(m1Slices[1].status, 'pending', 'S02 status is pending');
const m2Slices = getMilestoneSlices('M002');
assertEq(m2Slices.length, 1, 'M002 has 1 slice');
assert.deepStrictEqual(m2Slices.length, 1, 'M002 has 1 slice');
closeDatabase();
}
});
console.log('\n── markdown-renderer: getArtifact accessor ──');
{
test('── markdown-renderer: getArtifact accessor ──', () => {
openDatabase(':memory:');
// Not found
const missing = getArtifact('nonexistent/path');
assertEq(missing, null, 'getArtifact returns null for missing path');
assert.deepStrictEqual(missing, null, 'getArtifact returns null for missing path');
// Insert and retrieve
insertArtifact({
@ -236,21 +231,19 @@ console.log('\n── markdown-renderer: getArtifact accessor ──');
});
const found = getArtifact('milestones/M001/M001-ROADMAP.md');
assertTrue(found !== null, 'getArtifact returns non-null for existing path');
assertEq(found!.artifact_type, 'ROADMAP', 'artifact type correct');
assertEq(found!.milestone_id, 'M001', 'milestone_id correct');
assertEq(found!.full_content, '# Roadmap content', 'content correct');
assert.ok(found !== null, 'getArtifact returns non-null for existing path');
assert.deepStrictEqual(found!.artifact_type, 'ROADMAP', 'artifact type correct');
assert.deepStrictEqual(found!.milestone_id, 'M001', 'milestone_id correct');
assert.deepStrictEqual(found!.full_content, '# Roadmap content', 'content correct');
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Roadmap Checkbox Round-Trip
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: renderRoadmapCheckboxes round-trip ──');
{
test('── markdown-renderer: renderRoadmapCheckboxes round-trip ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -275,36 +268,34 @@ console.log('\n── markdown-renderer: renderRoadmapCheckboxes round-trip ─
// Render — should set S01 [x] and leave S02 [ ]
const ok = await renderRoadmapCheckboxes(tmpDir, 'M001');
assertTrue(ok, 'renderRoadmapCheckboxes returns true');
assert.ok(ok, 'renderRoadmapCheckboxes returns true');
// Read rendered file and parse
const rendered = fs.readFileSync(roadmapPath, 'utf-8');
clearAllCaches();
const parsed = parseRoadmap(rendered);
assertEq(parsed.slices.length, 2, 'roadmap has 2 slices after render');
assert.deepStrictEqual(parsed.slices.length, 2, 'roadmap has 2 slices after render');
const s01 = parsed.slices.find(s => s.id === 'S01');
const s02 = parsed.slices.find(s => s.id === 'S02');
assertTrue(!!s01, 'S01 found in parsed roadmap');
assertTrue(!!s02, 'S02 found in parsed roadmap');
assertTrue(s01!.done, 'S01 is checked (done) after render');
assertTrue(!s02!.done, 'S02 is unchecked (pending) after render');
assert.ok(!!s01, 'S01 found in parsed roadmap');
assert.ok(!!s02, 'S02 found in parsed roadmap');
assert.ok(s01!.done, 'S01 is checked (done) after render');
assert.ok(!s02!.done, 'S02 is unchecked (pending) after render');
// Verify artifact stored in DB
const artifact = getArtifact('milestones/M001/M001-ROADMAP.md');
assertTrue(artifact !== null, 'roadmap artifact stored in DB after render');
assertTrue(artifact!.full_content.includes('[x] **S01:'), 'DB artifact has S01 checked');
assertTrue(artifact!.full_content.includes('[ ] **S02:'), 'DB artifact has S02 unchecked');
assert.ok(artifact !== null, 'roadmap artifact stored in DB after render');
assert.ok(artifact!.full_content.includes('[x] **S01:'), 'DB artifact has S01 checked');
assert.ok(artifact!.full_content.includes('[ ] **S02:'), 'DB artifact has S02 unchecked');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
console.log('\n── markdown-renderer: renderRoadmapCheckboxes bidirectional ──');
{
test('── markdown-renderer: renderRoadmapCheckboxes bidirectional ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -328,7 +319,7 @@ console.log('\n── markdown-renderer: renderRoadmapCheckboxes bidirectional
clearAllCaches();
const ok = await renderRoadmapCheckboxes(tmpDir, 'M001');
assertTrue(ok, 'bidirectional render returns true');
assert.ok(ok, 'bidirectional render returns true');
const rendered = fs.readFileSync(roadmapPath, 'utf-8');
clearAllCaches();
@ -336,21 +327,19 @@ console.log('\n── markdown-renderer: renderRoadmapCheckboxes bidirectional
const s01 = parsed.slices.find(s => s.id === 'S01');
const s02 = parsed.slices.find(s => s.id === 'S02');
assertTrue(!s01!.done, 'S01 unchecked (DB says pending, was checked on disk)');
assertTrue(s02!.done, 'S02 checked (DB says complete, was unchecked on disk)');
assert.ok(!s01!.done, 'S01 unchecked (DB says pending, was checked on disk)');
assert.ok(s02!.done, 'S02 checked (DB says complete, was unchecked on disk)');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Plan Checkbox Round-Trip
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: renderPlanCheckboxes round-trip ──');
{
test('── markdown-renderer: renderPlanCheckboxes round-trip ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -376,29 +365,27 @@ console.log('\n── markdown-renderer: renderPlanCheckboxes round-trip ──'
clearAllCaches();
const ok = await renderPlanCheckboxes(tmpDir, 'M001', 'S01');
assertTrue(ok, 'renderPlanCheckboxes returns true');
assert.ok(ok, 'renderPlanCheckboxes returns true');
const rendered = fs.readFileSync(planPath, 'utf-8');
clearAllCaches();
const parsed = parsePlan(rendered);
assertEq(parsed.tasks.length, 3, 'plan has 3 tasks after render');
assert.deepStrictEqual(parsed.tasks.length, 3, 'plan has 3 tasks after render');
const t01 = parsed.tasks.find(t => t.id === 'T01');
const t02 = parsed.tasks.find(t => t.id === 'T02');
const t03 = parsed.tasks.find(t => t.id === 'T03');
assertTrue(t01!.done, 'T01 checked (done in DB)');
assertTrue(t02!.done, 'T02 checked (done in DB)');
assertTrue(!t03!.done, 'T03 unchecked (pending in DB)');
assert.ok(t01!.done, 'T01 checked (done in DB)');
assert.ok(t02!.done, 'T02 checked (done in DB)');
assert.ok(!t03!.done, 'T03 unchecked (pending in DB)');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
console.log('\n── markdown-renderer: renderPlanCheckboxes bidirectional ──');
{
test('── markdown-renderer: renderPlanCheckboxes bidirectional ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -422,7 +409,7 @@ console.log('\n── markdown-renderer: renderPlanCheckboxes bidirectional ─
clearAllCaches();
const ok = await renderPlanCheckboxes(tmpDir, 'M001', 'S01');
assertTrue(ok, 'bidirectional plan render returns true');
assert.ok(ok, 'bidirectional plan render returns true');
const rendered = fs.readFileSync(planPath, 'utf-8');
clearAllCaches();
@ -430,17 +417,15 @@ console.log('\n── markdown-renderer: renderPlanCheckboxes bidirectional ─
const t01 = parsed.tasks.find(t => t.id === 'T01');
const t02 = parsed.tasks.find(t => t.id === 'T02');
assertTrue(!t01!.done, 'T01 unchecked (DB says pending, was checked)');
assertTrue(t02!.done, 'T02 checked (DB says done, was unchecked)');
assert.ok(!t01!.done, 'T01 unchecked (DB says pending, was checked)');
assert.ok(t02!.done, 'T02 checked (DB says done, was unchecked)');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
console.log('\n── markdown-renderer: renderPlanFromDb creates parse-compatible slice plan + task plan files ──');
{
test('── markdown-renderer: renderPlanFromDb creates parse-compatible slice plan + task plan files ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -498,50 +483,48 @@ console.log('\n── markdown-renderer: renderPlanFromDb creates parse-compatib
});
const rendered = await renderPlanFromDb(tmpDir, 'M001', 'S02');
assertTrue(fs.existsSync(rendered.planPath), 'slice plan written to disk');
assertEq(rendered.taskPlanPaths.length, 2, 'task plan paths returned for each task');
assertTrue(rendered.taskPlanPaths.every((p) => fs.existsSync(p)), 'all task plan files written to disk');
assert.ok(fs.existsSync(rendered.planPath), 'slice plan written to disk');
assert.strictEqual(rendered.taskPlanPaths.length, 2, 'task plan paths returned for each task');
assert.ok(rendered.taskPlanPaths.every((p) => fs.existsSync(p)), 'all task plan files written to disk');
const planContent = fs.readFileSync(rendered.planPath, 'utf-8');
clearAllCaches();
const parsedPlan = parsePlan(planContent);
assertEq(parsedPlan.id, 'S02', 'rendered slice plan parses with correct slice id');
assertEq(parsedPlan.goal, 'Render slice plans from DB state.', 'rendered slice plan preserves goal');
assertEq(parsedPlan.demo, 'Rendered plans exist on disk.', 'rendered slice plan preserves demo');
assertEq(parsedPlan.mustHaves.length, 2, 'rendered slice plan exposes must-haves');
assertEq(parsedPlan.tasks.length, 2, 'rendered slice plan exposes all tasks');
assertEq(parsedPlan.tasks[0].id, 'T01', 'first task parses correctly');
assertTrue(parsedPlan.tasks[0].description.includes('DB-backed slice plan renderer'), 'task description preserved in slice plan');
assertEq(parsedPlan.tasks[0].files?.[0], 'src/resources/extensions/gsd/markdown-renderer.ts', 'files list preserved in slice plan');
assertEq(parsedPlan.tasks[0].verify, 'node --test markdown-renderer.test.ts', 'verify line preserved in slice plan');
assert.strictEqual(parsedPlan.id, 'S02', 'rendered slice plan parses with correct slice id');
assert.strictEqual(parsedPlan.goal, 'Render slice plans from DB state.', 'rendered slice plan preserves goal');
assert.strictEqual(parsedPlan.demo, 'Rendered plans exist on disk.', 'rendered slice plan preserves demo');
assert.strictEqual(parsedPlan.mustHaves.length, 2, 'rendered slice plan exposes must-haves');
assert.strictEqual(parsedPlan.tasks.length, 2, 'rendered slice plan exposes all tasks');
assert.strictEqual(parsedPlan.tasks[0].id, 'T01', 'first task parses correctly');
assert.ok(parsedPlan.tasks[0].description.includes('DB-backed slice plan renderer'), 'task description preserved in slice plan');
assert.strictEqual(parsedPlan.tasks[0].files?.[0], 'src/resources/extensions/gsd/markdown-renderer.ts', 'files list preserved in slice plan');
assert.strictEqual(parsedPlan.tasks[0].verify, 'node --test markdown-renderer.test.ts', 'verify line preserved in slice plan');
const planArtifact = getArtifact('milestones/M001/slices/S02/S02-PLAN.md');
assertTrue(planArtifact !== null, 'slice plan artifact stored in DB');
assertTrue(planArtifact!.full_content.includes('## Tasks'), 'stored plan artifact contains task section');
assert.ok(planArtifact !== null, 'slice plan artifact stored in DB');
assert.ok(planArtifact!.full_content.includes('## Tasks'), 'stored plan artifact contains task section');
const taskPlanPath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks', 'T01-PLAN.md');
const taskPlanContent = fs.readFileSync(taskPlanPath, 'utf-8');
const taskPlanFile = parseTaskPlanFile(taskPlanContent);
assertEq(taskPlanFile.frontmatter.estimated_steps, 1, 'task plan frontmatter exposes estimated_steps');
assertEq(taskPlanFile.frontmatter.estimated_files, 1, 'task plan frontmatter exposes estimated_files');
assertEq(taskPlanFile.frontmatter.skills_used.length, 0, 'task plan frontmatter uses conservative empty skills list');
assertMatch(taskPlanContent, /^# T01: Render slice plan/m, 'task plan renders task heading');
assertMatch(taskPlanContent, /^## Inputs$/m, 'task plan renders Inputs section');
assertMatch(taskPlanContent, /^## Expected Output$/m, 'task plan renders Expected Output section');
assertMatch(taskPlanContent, /^## Verification$/m, 'task plan renders Verification section');
assert.strictEqual(taskPlanFile.frontmatter.estimated_steps, 1, 'task plan frontmatter exposes estimated_steps');
assert.strictEqual(taskPlanFile.frontmatter.estimated_files, 1, 'task plan frontmatter exposes estimated_files');
assert.strictEqual(taskPlanFile.frontmatter.skills_used.length, 0, 'task plan frontmatter uses conservative empty skills list');
assert.match(taskPlanContent, /^# T01: Render slice plan/m, 'task plan renders task heading');
assert.match(taskPlanContent, /^## Inputs$/m, 'task plan renders Inputs section');
assert.match(taskPlanContent, /^## Expected Output$/m, 'task plan renders Expected Output section');
assert.match(taskPlanContent, /^## Verification$/m, 'task plan renders Verification section');
const taskArtifact = getArtifact('milestones/M001/slices/S02/tasks/T01-PLAN.md');
assertTrue(taskArtifact !== null, 'task plan artifact stored in DB');
assertTrue(taskArtifact!.full_content.includes('skills_used: []'), 'stored task plan artifact preserves conservative skills_used');
assert.ok(taskArtifact !== null, 'task plan artifact stored in DB');
assert.ok(taskArtifact!.full_content.includes('skills_used: []'), 'stored task plan artifact preserves conservative skills_used');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
console.log('\n── markdown-renderer: renderTaskPlanFromDb throws for missing task ──');
{
test('── markdown-renderer: renderTaskPlanFromDb throws for missing task ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -557,23 +540,21 @@ console.log('\n── markdown-renderer: renderTaskPlanFromDb throws for missing
await renderTaskPlanFromDb(tmpDir, 'M001', 'S02', 'T99');
} catch (error) {
threw = true;
assertMatch(String((error as Error).message), /task M001\/S02\/T99 not found/, 'renderTaskPlanFromDb should fail clearly when task row is missing');
assert.match(String((error as Error).message), /task M001\/S02\/T99 not found/, 'renderTaskPlanFromDb should fail clearly when task row is missing');
}
assertTrue(threw, 'renderTaskPlanFromDb throws when the task row is missing');
assert.ok(threw, 'renderTaskPlanFromDb throws when the task row is missing');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Task Summary Rendering
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: renderTaskSummary round-trip ──');
{
test('── markdown-renderer: renderTaskSummary round-trip ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -596,33 +577,31 @@ console.log('\n── markdown-renderer: renderTaskSummary round-trip ──');
});
const ok = await renderTaskSummary(tmpDir, 'M001', 'S01', 'T01');
assertTrue(ok, 'renderTaskSummary returns true');
assert.ok(ok, 'renderTaskSummary returns true');
// Verify file exists on disk
const summaryPath = path.join(
tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks', 'T01-SUMMARY.md',
);
assertTrue(fs.existsSync(summaryPath), 'T01-SUMMARY.md written to disk');
assert.ok(fs.existsSync(summaryPath), 'T01-SUMMARY.md written to disk');
// Parse and verify
const rendered = fs.readFileSync(summaryPath, 'utf-8');
clearAllCaches();
const parsed = parseSummary(rendered);
assertEq(parsed.frontmatter.id, 'T01', 'parsed summary has correct id');
assertEq(parsed.frontmatter.parent, 'S01', 'parsed summary has correct parent');
assertEq(parsed.frontmatter.milestone, 'M001', 'parsed summary has correct milestone');
assertEq(parsed.frontmatter.duration, '45m', 'parsed summary has correct duration');
assertTrue(parsed.title.includes('T01'), 'parsed summary title contains task ID');
assertTrue(parsed.whatHappened.includes('Built the test feature'), 'whatHappened content preserved');
assert.deepStrictEqual(parsed.frontmatter.id, 'T01', 'parsed summary has correct id');
assert.deepStrictEqual(parsed.frontmatter.parent, 'S01', 'parsed summary has correct parent');
assert.deepStrictEqual(parsed.frontmatter.milestone, 'M001', 'parsed summary has correct milestone');
assert.deepStrictEqual(parsed.frontmatter.duration, '45m', 'parsed summary has correct duration');
assert.ok(parsed.title.includes('T01'), 'parsed summary title contains task ID');
assert.ok(parsed.whatHappened.includes('Built the test feature'), 'whatHappened content preserved');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
console.log('\n── markdown-renderer: renderTaskSummary skips empty ──');
{
test('── markdown-renderer: renderTaskSummary skips empty ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -643,20 +622,18 @@ console.log('\n── markdown-renderer: renderTaskSummary skips empty ──');
});
const ok = await renderTaskSummary(tmpDir, 'M001', 'S01', 'T01');
assertTrue(!ok, 'renderTaskSummary returns false for empty summary');
assert.ok(!ok, 'renderTaskSummary returns false for empty summary');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Slice Summary Rendering
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: renderSliceSummary round-trip ──');
{
test('── markdown-renderer: renderSliceSummary round-trip ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -680,38 +657,36 @@ console.log('\n── markdown-renderer: renderSliceSummary round-trip ──');
});
const ok = await renderSliceSummary(tmpDir, 'M001', 'S01');
assertTrue(ok, 'renderSliceSummary returns true');
assert.ok(ok, 'renderSliceSummary returns true');
// Verify SUMMARY file
const summaryPath = path.join(
tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'S01-SUMMARY.md',
);
assertTrue(fs.existsSync(summaryPath), 'S01-SUMMARY.md written to disk');
assert.ok(fs.existsSync(summaryPath), 'S01-SUMMARY.md written to disk');
const summaryContent = fs.readFileSync(summaryPath, 'utf-8');
assertTrue(summaryContent.includes('Test Slice Summary'), 'summary content correct');
assert.ok(summaryContent.includes('Test Slice Summary'), 'summary content correct');
// Verify UAT file
const uatPath = path.join(
tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'S01-UAT.md',
);
assertTrue(fs.existsSync(uatPath), 'S01-UAT.md written to disk');
assert.ok(fs.existsSync(uatPath), 'S01-UAT.md written to disk');
const uatContent = fs.readFileSync(uatPath, 'utf-8');
assertTrue(uatContent.includes('artifact-driven'), 'UAT content correct');
assert.ok(uatContent.includes('artifact-driven'), 'UAT content correct');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// renderAllFromDb
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: renderAllFromDb produces all files ──');
{
test('── markdown-renderer: renderAllFromDb produces all files ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -779,8 +754,8 @@ console.log('\n── markdown-renderer: renderAllFromDb produces all files ─
const result = await renderAllFromDb(tmpDir);
assertTrue(result.rendered > 0, 'renderAllFromDb rendered some files');
assertEq(result.errors.length, 0, 'renderAllFromDb had no errors');
assert.ok(result.rendered > 0, 'renderAllFromDb rendered some files');
assert.deepStrictEqual(result.errors.length, 0, 'renderAllFromDb had no errors');
// Verify M001 roadmap has S01 checked
const m1Roadmap = fs.readFileSync(
@ -789,7 +764,7 @@ console.log('\n── markdown-renderer: renderAllFromDb produces all files ─
clearAllCaches();
const parsed1 = parseRoadmap(m1Roadmap);
const s01 = parsed1.slices.find(s => s.id === 'S01');
assertTrue(s01!.done, 'M001 S01 checked after renderAll');
assert.ok(s01!.done, 'M001 S01 checked after renderAll');
// Verify M001/S01 plan has T01 checked
const m1s1Plan = fs.readFileSync(
@ -797,26 +772,24 @@ console.log('\n── markdown-renderer: renderAllFromDb produces all files ─
);
clearAllCaches();
const parsedPlan = parsePlan(m1s1Plan);
assertTrue(parsedPlan.tasks[0].done, 'M001/S01 T01 checked after renderAll');
assert.ok(parsedPlan.tasks[0].done, 'M001/S01 T01 checked after renderAll');
// Verify task summary written
const taskSummaryPath = path.join(
tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks', 'T01-SUMMARY.md',
);
assertTrue(fs.existsSync(taskSummaryPath), 'T01 summary written by renderAll');
assert.ok(fs.existsSync(taskSummaryPath), 'T01 summary written by renderAll');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Graceful Degradation (Disk Fallback)
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: graceful fallback reads from disk when artifact not in DB ──');
{
test('── markdown-renderer: graceful fallback reads from disk when artifact not in DB ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -838,29 +811,27 @@ console.log('\n── markdown-renderer: graceful fallback reads from disk when
// Verify no artifact in DB
const before = getArtifact('milestones/M001/M001-ROADMAP.md');
assertEq(before, null, 'artifact not in DB before render');
assert.deepStrictEqual(before, null, 'artifact not in DB before render');
// Render — should read from disk, store in DB
const ok = await renderRoadmapCheckboxes(tmpDir, 'M001');
assertTrue(ok, 'render succeeds with disk fallback');
assert.ok(ok, 'render succeeds with disk fallback');
// Verify artifact now in DB (stored after reading from disk)
const after = getArtifact('milestones/M001/M001-ROADMAP.md');
assertTrue(after !== null, 'artifact stored in DB after disk fallback render');
assertTrue(after!.full_content.includes('[x] **S01:'), 'DB artifact reflects rendered state');
assert.ok(after !== null, 'artifact stored in DB after disk fallback render');
assert.ok(after!.full_content.includes('[x] **S01:'), 'DB artifact reflects rendered state');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// stderr warnings (graceful degradation diagnostics)
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: stderr warning on missing content ──');
{
test('── markdown-renderer: stderr warning on missing content ──', async () => {
openDatabase(':memory:');
// No milestone/slices in DB, no files on disk — should return false and emit stderr
@ -868,18 +839,16 @@ console.log('\n── markdown-renderer: stderr warning on missing content ─
// No slices inserted — should warn about no slices
const ok = await renderRoadmapCheckboxes('/nonexistent/path', 'M001');
assertTrue(!ok, 'returns false when no slices in DB');
assert.ok(!ok, 'returns false when no slices in DB');
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Stale Detection — Plan Checkbox Mismatch
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: detectStaleRenders finds plan checkbox mismatch ──');
{
test('── markdown-renderer: detectStaleRenders finds plan checkbox mismatch ──', () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -910,27 +879,25 @@ console.log('\n── markdown-renderer: detectStaleRenders finds plan checkbox
// The stale detection should find T02 as stale.
const stale = detectStaleRenders(tmpDir);
assertTrue(stale.length > 0, 'detectStaleRenders should find stale entries');
assert.ok(stale.length > 0, 'detectStaleRenders should find stale entries');
const t02Stale = stale.find(s => s.reason.includes('T02'));
assertTrue(!!t02Stale, 'should detect T02 as stale (done in DB, unchecked in plan)');
assertTrue(t02Stale!.reason.includes('done in DB but unchecked'), 'reason should explain the mismatch');
assert.ok(!!t02Stale, 'should detect T02 as stale (done in DB, unchecked in plan)');
assert.ok(t02Stale!.reason.includes('done in DB but unchecked'), 'reason should explain the mismatch');
// T01 should NOT be stale — it's checked and done
const t01Stale = stale.find(s => s.reason.includes('T01'));
assertEq(t01Stale, undefined, 'T01 should not be stale (done and checked)');
assert.deepStrictEqual(t01Stale, undefined, 'T01 should not be stale (done and checked)');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Stale Repair — Plan Checkbox
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: repairStaleRenders fixes plan and second detect returns empty ──');
{
test('── markdown-renderer: repairStaleRenders fixes plan and second detect returns empty ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -956,34 +923,32 @@ console.log('\n── markdown-renderer: repairStaleRenders fixes plan and secon
// Verify stale before repair
const staleBefore = detectStaleRenders(tmpDir);
assertTrue(staleBefore.length > 0, 'should have stale entries before repair');
assert.ok(staleBefore.length > 0, 'should have stale entries before repair');
// Repair
const repaired = await repairStaleRenders(tmpDir);
assertTrue(repaired > 0, 'repairStaleRenders should repair at least 1 file');
assert.ok(repaired > 0, 'repairStaleRenders should repair at least 1 file');
// After repair, detect again — should be empty
clearAllCaches();
const staleAfter = detectStaleRenders(tmpDir);
assertEq(staleAfter.length, 0, 'detectStaleRenders should return empty after repair');
assert.deepStrictEqual(staleAfter.length, 0, 'detectStaleRenders should return empty after repair');
// Verify the plan file was actually updated
const repairedContent = fs.readFileSync(planPath, 'utf-8');
assertTrue(repairedContent.includes('[x] **T01:'), 'T01 should be checked after repair');
assertTrue(repairedContent.includes('[x] **T02:'), 'T02 should be checked after repair');
assert.ok(repairedContent.includes('[x] **T01:'), 'T01 should be checked after repair');
assert.ok(repairedContent.includes('[x] **T02:'), 'T02 should be checked after repair');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Stale Detection — Roadmap Checkbox Mismatch
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: detectStaleRenders finds roadmap checkbox mismatch ──');
{
test('── markdown-renderer: detectStaleRenders finds roadmap checkbox mismatch ──', () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -1007,23 +972,21 @@ console.log('\n── markdown-renderer: detectStaleRenders finds roadmap checkb
const stale = detectStaleRenders(tmpDir);
const s01Stale = stale.find(s => s.reason.includes('S01'));
assertTrue(!!s01Stale, 'should detect S01 as stale (complete in DB, unchecked in roadmap)');
assert.ok(!!s01Stale, 'should detect S01 as stale (complete in DB, unchecked in roadmap)');
const s02Stale = stale.find(s => s.reason.includes('S02'));
assertEq(s02Stale, undefined, 'S02 should not be stale (pending and unchecked — matches)');
assert.deepStrictEqual(s02Stale, undefined, 'S02 should not be stale (pending and unchecked — matches)');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Stale Detection — Missing Task Summary
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: detectStaleRenders finds missing task summary ──');
{
test('── markdown-renderer: detectStaleRenders finds missing task summary ──', () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -1058,21 +1021,19 @@ console.log('\n── markdown-renderer: detectStaleRenders finds missing task s
const stale = detectStaleRenders(tmpDir);
const summaryStale = stale.find(s => s.reason.includes('SUMMARY.md missing'));
assertTrue(!!summaryStale, 'should detect missing T01-SUMMARY.md');
assertTrue(summaryStale!.reason.includes('T01'), 'reason should mention T01');
assert.ok(!!summaryStale, 'should detect missing T01-SUMMARY.md');
assert.ok(summaryStale!.reason.includes('T01'), 'reason should mention T01');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Stale Repair — Missing Task Summary
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: repairStaleRenders writes missing task summary ──');
{
test('── markdown-renderer: repairStaleRenders writes missing task summary ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -1104,32 +1065,30 @@ console.log('\n── markdown-renderer: repairStaleRenders writes missing task
// Repair
const repaired = await repairStaleRenders(tmpDir);
assertTrue(repaired > 0, 'should repair missing summary');
assert.ok(repaired > 0, 'should repair missing summary');
// Verify file written
const summaryPath = path.join(
tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks', 'T01-SUMMARY.md',
);
assertTrue(fs.existsSync(summaryPath), 'T01-SUMMARY.md should exist after repair');
assert.ok(fs.existsSync(summaryPath), 'T01-SUMMARY.md should exist after repair');
// Second detect should be empty
clearAllCaches();
const staleAfter = detectStaleRenders(tmpDir);
const summaryStale = staleAfter.find(s => s.reason.includes('SUMMARY.md missing') && s.reason.includes('T01'));
assertEq(summaryStale, undefined, 'missing summary should be fixed after repair');
assert.deepStrictEqual(summaryStale, undefined, 'missing summary should be fixed after repair');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Stale Repair — Idempotency
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: repairStaleRenders idempotency — fully synced returns 0 ──');
{
test('── markdown-renderer: repairStaleRenders idempotency — fully synced returns 0 ──', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -1152,20 +1111,18 @@ console.log('\n── markdown-renderer: repairStaleRenders idempotency — full
// No stale entries when everything is in sync (no summary to check since no fullSummaryMd)
const repaired = await repairStaleRenders(tmpDir);
assertEq(repaired, 0, 'repairStaleRenders should return 0 on fully synced project');
assert.deepStrictEqual(repaired, 0, 'repairStaleRenders should return 0 on fully synced project');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Stale Detection — Missing Slice Summary + UAT
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── markdown-renderer: detectStaleRenders finds missing slice summary and UAT ──');
{
test('── markdown-renderer: detectStaleRenders finds missing slice summary and UAT ──', () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
@ -1192,14 +1149,13 @@ console.log('\n── markdown-renderer: detectStaleRenders finds missing slice
const summaryStale = stale.find(s => s.reason.includes('SUMMARY.md missing') && s.reason.includes('S01'));
const uatStale = stale.find(s => s.reason.includes('UAT.md missing') && s.reason.includes('S01'));
assertTrue(!!summaryStale, 'should detect missing S01-SUMMARY.md');
assertTrue(!!uatStale, 'should detect missing S01-UAT.md');
assert.ok(!!summaryStale, 'should detect missing S01-SUMMARY.md');
assert.ok(!!uatStale, 'should detect missing S01-UAT.md');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
report();

View file

@ -1,4 +1,3 @@
import { createTestContext } from './test-helpers.ts';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
@ -17,8 +16,8 @@ import {
parseRequirementsSections,
migrateFromMarkdown,
} from '../md-importer.ts';
const { assertEq, assertTrue, report } = createTestContext();
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
// ═══════════════════════════════════════════════════════════════════════════
// Fixtures
@ -135,43 +134,37 @@ function cleanupDir(dir: string): void {
// md-importer: parseDecisionsTable
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== md-importer: parseDecisionsTable ===');
{
test('md-importer: parseDecisionsTable', () => {
const decisions = parseDecisionsTable(DECISIONS_MD);
assertEq(decisions.length, 4, 'should parse 4 decisions');
assertEq(decisions[0].id, 'D001', 'first decision should be D001');
assertEq(decisions[0].decision, 'SQLite library', 'D001 decision text');
assertEq(decisions[0].choice, 'better-sqlite3', 'D001 choice');
assertEq(decisions[0].scope, 'library', 'D001 scope');
assertEq(decisions[0].revisable, 'No', 'D001 revisable');
}
assert.deepStrictEqual(decisions.length, 4, 'should parse 4 decisions');
assert.deepStrictEqual(decisions[0].id, 'D001', 'first decision should be D001');
assert.deepStrictEqual(decisions[0].decision, 'SQLite library', 'D001 decision text');
assert.deepStrictEqual(decisions[0].choice, 'better-sqlite3', 'D001 choice');
assert.deepStrictEqual(decisions[0].scope, 'library', 'D001 scope');
assert.deepStrictEqual(decisions[0].revisable, 'No', 'D001 revisable');
});
console.log('=== md-importer: supersession detection ===');
{
test('md-importer: supersession detection', () => {
const decisions = parseDecisionsTable(DECISIONS_MD);
// D010 amends D001 → D001.superseded_by = D010
const d001 = decisions.find(d => d.id === 'D001');
assertEq(d001?.superseded_by, 'D010', 'D001 should be superseded by D010');
assert.deepStrictEqual(d001?.superseded_by, 'D010', 'D001 should be superseded by D010');
// D020 amends D010 → D010.superseded_by = D020
const d010 = decisions.find(d => d.id === 'D010');
assertEq(d010?.superseded_by, 'D020', 'D010 should be superseded by D020');
assert.deepStrictEqual(d010?.superseded_by, 'D020', 'D010 should be superseded by D020');
// D002 is not amended
const d002 = decisions.find(d => d.id === 'D002');
assertEq(d002?.superseded_by, null, 'D002 should not be superseded');
assert.deepStrictEqual(d002?.superseded_by, null, 'D002 should not be superseded');
// D020 is the latest in chain, not superseded
const d020 = decisions.find(d => d.id === 'D020');
assertEq(d020?.superseded_by, null, 'D020 should not be superseded');
}
assert.deepStrictEqual(d020?.superseded_by, null, 'D020 should not be superseded');
});
console.log('=== md-importer: malformed/empty rows skipped ===');
{
test('md-importer: malformed/empty rows skipped', () => {
const malformedInput = `# Decisions
| # | When | Scope | Decision | Choice | Rationale | Revisable? |
@ -182,24 +175,20 @@ console.log('=== md-importer: malformed/empty rows skipped ===');
| D003 | M001 | arch | Config | JSON | Simple | Yes |
`;
const decisions = parseDecisionsTable(malformedInput);
assertEq(decisions.length, 2, 'should skip rows without D-prefix IDs');
assertEq(decisions[0].id, 'D001', 'first valid row');
assertEq(decisions[1].id, 'D003', 'second valid row (skipping malformed)');
}
assert.deepStrictEqual(decisions.length, 2, 'should skip rows without D-prefix IDs');
assert.deepStrictEqual(decisions[0].id, 'D001', 'first valid row');
assert.deepStrictEqual(decisions[1].id, 'D003', 'second valid row (skipping malformed)');
});
console.log('=== md-importer: made_by backward compatibility (old 7-column format) ===');
{
test('md-importer: made_by backward compatibility (old 7-column format)', () => {
const decisions = parseDecisionsTable(DECISIONS_MD);
// Old format has no Made By column — should default to 'agent'
for (const d of decisions) {
assertEq(d.made_by, 'agent', `${d.id} made_by defaults to agent for legacy format`);
assert.deepStrictEqual(d.made_by, 'agent', `${d.id} made_by defaults to agent for legacy format`);
}
}
});
console.log('=== md-importer: made_by column parsing (new 8-column format) ===');
{
test('md-importer: made_by column parsing (new 8-column format)', () => {
const newFormatMd = `# Decisions Register
| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |
@ -210,62 +199,58 @@ console.log('=== md-importer: made_by column parsing (new 8-column format) ===')
| D004 | M002 | impl | Cache strategy | LRU | Predictable | No | bogus |
`;
const decisions = parseDecisionsTable(newFormatMd);
assertEq(decisions.length, 4, 'should parse 4 decisions with new format');
assertEq(decisions[0].made_by, 'human', 'D001 made_by = human');
assertEq(decisions[1].made_by, 'agent', 'D002 made_by = agent');
assertEq(decisions[2].made_by, 'collaborative', 'D003 made_by = collaborative');
assertEq(decisions[3].made_by, 'agent', 'D004 invalid made_by defaults to agent');
}
assert.deepStrictEqual(decisions.length, 4, 'should parse 4 decisions with new format');
assert.deepStrictEqual(decisions[0].made_by, 'human', 'D001 made_by = human');
assert.deepStrictEqual(decisions[1].made_by, 'agent', 'D002 made_by = agent');
assert.deepStrictEqual(decisions[2].made_by, 'collaborative', 'D003 made_by = collaborative');
assert.deepStrictEqual(decisions[3].made_by, 'agent', 'D004 invalid made_by defaults to agent');
});
// ═══════════════════════════════════════════════════════════════════════════
// md-importer: parseRequirementsSections
// ═══════════════════════════════════════════════════════════════════════════
console.log('=== md-importer: parseRequirementsSections ===');
{
test('md-importer: parseRequirementsSections', () => {
const reqs = parseRequirementsSections(REQUIREMENTS_MD);
assertEq(reqs.length, 5, 'should parse 5 unique requirements');
assert.deepStrictEqual(reqs.length, 5, 'should parse 5 unique requirements');
const r001 = reqs.find(r => r.id === 'R001');
assertTrue(!!r001, 'R001 should exist');
assertEq(r001?.class, 'core-capability', 'R001 class');
assertEq(r001?.status, 'active', 'R001 status');
assertEq(r001?.description, 'A SQLite database with typed wrappers', 'R001 description');
assertEq(r001?.why, 'Foundation for storage', 'R001 why');
assertEq(r001?.source, 'user', 'R001 source');
assertEq(r001?.primary_owner, 'M001/S01', 'R001 primary_owner');
assertEq(r001?.supporting_slices, 'none', 'R001 supporting_slices');
assertEq(r001?.validation, 'unmapped', 'R001 validation');
assertEq(r001?.notes, 'WAL mode enabled', 'R001 notes');
assertTrue(r001?.full_content?.includes('### R001') ?? false, 'R001 full_content should have heading');
assert.ok(!!r001, 'R001 should exist');
assert.deepStrictEqual(r001?.class, 'core-capability', 'R001 class');
assert.deepStrictEqual(r001?.status, 'active', 'R001 status');
assert.deepStrictEqual(r001?.description, 'A SQLite database with typed wrappers', 'R001 description');
assert.deepStrictEqual(r001?.why, 'Foundation for storage', 'R001 why');
assert.deepStrictEqual(r001?.source, 'user', 'R001 source');
assert.deepStrictEqual(r001?.primary_owner, 'M001/S01', 'R001 primary_owner');
assert.deepStrictEqual(r001?.supporting_slices, 'none', 'R001 supporting_slices');
assert.deepStrictEqual(r001?.validation, 'unmapped', 'R001 validation');
assert.deepStrictEqual(r001?.notes, 'WAL mode enabled', 'R001 notes');
assert.ok(r001?.full_content?.includes('### R001') ?? false, 'R001 full_content should have heading');
// Validated section — R017 (abbreviated format with "Validated by" / "Proof" bullets)
const r017 = reqs.find(r => r.id === 'R017');
assertTrue(!!r017, 'R017 should exist');
assertEq(r017?.status, 'validated', 'R017 status from validated section');
assertEq(r017?.validation, 'M001/S01', 'R017 validation (from "Validated by" bullet)');
assertEq(r017?.notes, '50 decisions queried in 0.62ms', 'R017 notes (from "Proof" bullet)');
assert.ok(!!r017, 'R017 should exist');
assert.deepStrictEqual(r017?.status, 'validated', 'R017 status from validated section');
assert.deepStrictEqual(r017?.validation, 'M001/S01', 'R017 validation (from "Validated by" bullet)');
assert.deepStrictEqual(r017?.notes, '50 decisions queried in 0.62ms', 'R017 notes (from "Proof" bullet)');
// Deferred requirement
const r030 = reqs.find(r => r.id === 'R030');
assertEq(r030?.status, 'deferred', 'R030 status should be deferred');
assertEq(r030?.class, 'differentiator', 'R030 class');
assertEq(r030?.description, 'Rust crate for embeddings', 'R030 description');
assert.deepStrictEqual(r030?.status, 'deferred', 'R030 status should be deferred');
assert.deepStrictEqual(r030?.class, 'differentiator', 'R030 class');
assert.deepStrictEqual(r030?.description, 'Rust crate for embeddings', 'R030 description');
// Out of scope
const r040 = reqs.find(r => r.id === 'R040');
assertEq(r040?.status, 'out-of-scope', 'R040 status should be out-of-scope');
assertEq(r040?.class, 'anti-feature', 'R040 class');
}
assert.deepStrictEqual(r040?.status, 'out-of-scope', 'R040 status should be out-of-scope');
assert.deepStrictEqual(r040?.class, 'anti-feature', 'R040 class');
});
// ═══════════════════════════════════════════════════════════════════════════
// md-importer: migrateFromMarkdown orchestrator
// ═══════════════════════════════════════════════════════════════════════════
console.log('=== md-importer: migrateFromMarkdown orchestrator ===');
{
test('md-importer: migrateFromMarkdown orchestrator', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-import-test-'));
createFixtureTree(tmpDir);
@ -273,53 +258,51 @@ console.log('=== md-importer: migrateFromMarkdown orchestrator ===');
openDatabase(':memory:');
const result = migrateFromMarkdown(tmpDir);
assertEq(result.decisions, 4, 'should import 4 decisions');
assertEq(result.requirements, 5, 'should import 5 requirements');
assertTrue(result.artifacts > 0, 'should import some artifacts');
assert.deepStrictEqual(result.decisions, 4, 'should import 4 decisions');
assert.deepStrictEqual(result.requirements, 5, 'should import 5 requirements');
assert.ok(result.artifacts > 0, 'should import some artifacts');
// Verify decisions queryable
const d001 = getDecisionById('D001');
assertTrue(!!d001, 'D001 should be queryable');
assertEq(d001?.superseded_by, 'D010', 'D001 superseded_by should be D010');
assert.ok(!!d001, 'D001 should be queryable');
assert.deepStrictEqual(d001?.superseded_by, 'D010', 'D001 superseded_by should be D010');
// Verify requirements queryable
const r001 = getRequirementById('R001');
assertTrue(!!r001, 'R001 should be queryable');
assertEq(r001?.status, 'active', 'R001 status from DB');
assert.ok(!!r001, 'R001 should be queryable');
assert.deepStrictEqual(r001?.status, 'active', 'R001 status from DB');
// Verify active views
const activeD = getActiveDecisions();
assertEq(activeD.length, 2, 'should have 2 active decisions (D002, D020)');
assert.deepStrictEqual(activeD.length, 2, 'should have 2 active decisions (D002, D020)');
// Verify artifacts table
const adapter = _getAdapter();
const artifacts = adapter?.prepare('SELECT count(*) as c FROM artifacts').get();
assertTrue((artifacts?.c as number) > 0, 'artifacts table should have rows');
assert.ok((artifacts?.c as number) > 0, 'artifacts table should have rows');
// Verify hierarchy correctness
const roadmap = adapter?.prepare('SELECT * FROM artifacts WHERE artifact_type = :type').get({ ':type': 'ROADMAP' });
assertTrue(!!roadmap, 'ROADMAP artifact should exist');
assertEq(roadmap?.milestone_id, 'M001', 'ROADMAP should be in M001');
assert.ok(!!roadmap, 'ROADMAP artifact should exist');
assert.deepStrictEqual(roadmap?.milestone_id, 'M001', 'ROADMAP should be in M001');
const taskPlan = adapter?.prepare('SELECT * FROM artifacts WHERE task_id = :taskId AND artifact_type = :type').get({
':taskId': 'T01',
':type': 'PLAN',
});
assertTrue(!!taskPlan, 'T01-PLAN artifact should exist');
assert.ok(!!taskPlan, 'T01-PLAN artifact should exist');
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// md-importer: idempotent re-import
// ═══════════════════════════════════════════════════════════════════════════
console.log('=== md-importer: idempotent re-import ===');
{
test('md-importer: idempotent re-import', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-idemp-test-'));
createFixtureTree(tmpDir);
@ -328,9 +311,9 @@ console.log('=== md-importer: idempotent re-import ===');
const r1 = migrateFromMarkdown(tmpDir);
const r2 = migrateFromMarkdown(tmpDir);
assertEq(r1.decisions, r2.decisions, 'double import should produce same decision count');
assertEq(r1.requirements, r2.requirements, 'double import should produce same requirement count');
assertEq(r1.artifacts, r2.artifacts, 'double import should produce same artifact count');
assert.deepStrictEqual(r1.decisions, r2.decisions, 'double import should produce same decision count');
assert.deepStrictEqual(r1.requirements, r2.requirements, 'double import should produce same requirement count');
assert.deepStrictEqual(r1.artifacts, r2.artifacts, 'double import should produce same artifact count');
// Verify no duplicates
const adapter = _getAdapter();
@ -338,23 +321,21 @@ console.log('=== md-importer: idempotent re-import ===');
const rc = adapter?.prepare('SELECT count(*) as c FROM requirements').get()?.c as number;
const ac = adapter?.prepare('SELECT count(*) as c FROM artifacts').get()?.c as number;
assertEq(dc, r1.decisions, 'DB decision count matches import count');
assertEq(rc, r1.requirements, 'DB requirement count matches import count');
assertEq(ac, r1.artifacts, 'DB artifact count matches import count');
assert.deepStrictEqual(dc, r1.decisions, 'DB decision count matches import count');
assert.deepStrictEqual(rc, r1.requirements, 'DB requirement count matches import count');
assert.deepStrictEqual(ac, r1.artifacts, 'DB artifact count matches import count');
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// md-importer: missing file graceful handling
// ═══════════════════════════════════════════════════════════════════════════
console.log('=== md-importer: missing file handling ===');
{
test('md-importer: missing file handling', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-empty-test-'));
// Create empty .gsd/ with no files
fs.mkdirSync(path.join(tmpDir, '.gsd'), { recursive: true });
@ -363,43 +344,39 @@ console.log('=== md-importer: missing file handling ===');
openDatabase(':memory:');
const result = migrateFromMarkdown(tmpDir);
assertEq(result.decisions, 0, 'missing DECISIONS.md → 0 decisions');
assertEq(result.requirements, 0, 'missing REQUIREMENTS.md → 0 requirements');
assertEq(result.artifacts, 0, 'empty tree → 0 artifacts');
assert.deepStrictEqual(result.decisions, 0, 'missing DECISIONS.md → 0 decisions');
assert.deepStrictEqual(result.requirements, 0, 'missing REQUIREMENTS.md → 0 requirements');
assert.deepStrictEqual(result.artifacts, 0, 'empty tree → 0 artifacts');
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// md-importer: schema v1→v2 migration on existing DBs
// ═══════════════════════════════════════════════════════════════════════════
console.log('=== md-importer: schema v1→v2 migration ===');
{
test('md-importer: schema v1→v2 migration', () => {
// This test verifies that opening a fresh DB auto-migrates to current schema version
openDatabase(':memory:');
const adapter = _getAdapter();
const version = adapter?.prepare('SELECT MAX(version) as v FROM schema_version').get();
assertEq(version?.v, 10, 'new DB should be at schema version 10');
assert.deepStrictEqual(version?.v, 10, 'new DB should be at schema version 10');
// Artifacts table should exist
const tableCheck = adapter?.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='artifacts'").get();
assertEq(tableCheck?.c, 1, 'artifacts table should exist');
assert.deepStrictEqual(tableCheck?.c, 1, 'artifacts table should exist');
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// md-importer: round-trip fidelity
// ═══════════════════════════════════════════════════════════════════════════
console.log('=== md-importer: round-trip fidelity ===');
{
test('md-importer: round-trip fidelity', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-roundtrip-test-'));
createFixtureTree(tmpDir);
@ -409,32 +386,31 @@ console.log('=== md-importer: round-trip fidelity ===');
// Round-trip: verify imported field values match source
const d002 = getDecisionById('D002');
assertEq(d002?.when_context, 'M001', 'D002 when_context round-trip');
assertEq(d002?.scope, 'arch', 'D002 scope round-trip');
assertEq(d002?.decision, 'DB location', 'D002 decision round-trip');
assertEq(d002?.choice, '.gsd/gsd.db', 'D002 choice round-trip');
assertEq(d002?.rationale, 'Derived state', 'D002 rationale round-trip');
assert.deepStrictEqual(d002?.when_context, 'M001', 'D002 when_context round-trip');
assert.deepStrictEqual(d002?.scope, 'arch', 'D002 scope round-trip');
assert.deepStrictEqual(d002?.decision, 'DB location', 'D002 decision round-trip');
assert.deepStrictEqual(d002?.choice, '.gsd/gsd.db', 'D002 choice round-trip');
assert.deepStrictEqual(d002?.rationale, 'Derived state', 'D002 rationale round-trip');
const r002 = getRequirementById('R002');
assertEq(r002?.class, 'failure-visibility', 'R002 class round-trip');
assertEq(r002?.description, 'Falls back to markdown if SQLite unavailable', 'R002 description round-trip');
assertEq(r002?.why, 'Must not break on exotic platforms', 'R002 why round-trip');
assertEq(r002?.primary_owner, 'M001/S01', 'R002 primary_owner round-trip');
assertEq(r002?.supporting_slices, 'M001/S03', 'R002 supporting_slices round-trip');
assertEq(r002?.notes, 'Transparent fallback', 'R002 notes round-trip');
assertEq(r002?.validation, 'unmapped', 'R002 validation round-trip');
assert.deepStrictEqual(r002?.class, 'failure-visibility', 'R002 class round-trip');
assert.deepStrictEqual(r002?.description, 'Falls back to markdown if SQLite unavailable', 'R002 description round-trip');
assert.deepStrictEqual(r002?.why, 'Must not break on exotic platforms', 'R002 why round-trip');
assert.deepStrictEqual(r002?.primary_owner, 'M001/S01', 'R002 primary_owner round-trip');
assert.deepStrictEqual(r002?.supporting_slices, 'M001/S03', 'R002 supporting_slices round-trip');
assert.deepStrictEqual(r002?.notes, 'Transparent fallback', 'R002 notes round-trip');
assert.deepStrictEqual(r002?.validation, 'unmapped', 'R002 validation round-trip');
// Verify artifact content is stored
const adapter = _getAdapter();
const project = adapter?.prepare("SELECT * FROM artifacts WHERE path = :path").get({ ':path': 'PROJECT.md' });
assertTrue((project?.full_content as string)?.includes('Test Project'), 'PROJECT.md content round-trip');
assert.ok((project?.full_content as string)?.includes('Test Project'), 'PROJECT.md content round-trip');
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
report();

View file

@ -1,4 +1,3 @@
import { createTestContext } from './test-helpers.ts';
import { parseMemoryResponse, _resetExtractionState } from '../memory-extractor.ts';
import {
openDatabase,
@ -10,15 +9,14 @@ import {
getActiveMemoriesRanked,
} from '../memory-store.ts';
import type { MemoryAction } from '../memory-store.ts';
const { assertEq, assertTrue, report } = createTestContext();
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
// ═══════════════════════════════════════════════════════════════════════════
// memory-extractor: parse valid JSON response
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-extractor: parse valid JSON ===');
{
test('memory-extractor: parse valid JSON', () => {
const response = JSON.stringify([
{ action: 'CREATE', category: 'gotcha', content: 'esbuild drops binaries', confidence: 0.85 },
{ action: 'REINFORCE', id: 'MEM001' },
@ -27,56 +25,52 @@ console.log('\n=== memory-extractor: parse valid JSON ===');
]);
const actions = parseMemoryResponse(response);
assertEq(actions.length, 4, 'should parse 4 actions');
assertEq(actions[0].action, 'CREATE', 'first action should be CREATE');
assertEq((actions[0] as any).category, 'gotcha', 'CREATE category');
assertEq((actions[0] as any).confidence, 0.85, 'CREATE confidence');
assertEq(actions[1].action, 'REINFORCE', 'second action should be REINFORCE');
assertEq(actions[2].action, 'UPDATE', 'third action should be UPDATE');
assertEq(actions[3].action, 'SUPERSEDE', 'fourth action should be SUPERSEDE');
}
assert.deepStrictEqual(actions.length, 4, 'should parse 4 actions');
assert.deepStrictEqual(actions[0].action, 'CREATE', 'first action should be CREATE');
assert.deepStrictEqual((actions[0] as any).category, 'gotcha', 'CREATE category');
assert.deepStrictEqual((actions[0] as any).confidence, 0.85, 'CREATE confidence');
assert.deepStrictEqual(actions[1].action, 'REINFORCE', 'second action should be REINFORCE');
assert.deepStrictEqual(actions[2].action, 'UPDATE', 'third action should be UPDATE');
assert.deepStrictEqual(actions[3].action, 'SUPERSEDE', 'fourth action should be SUPERSEDE');
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-extractor: parse fenced JSON response
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-extractor: parse fenced JSON ===');
{
test('memory-extractor: parse fenced JSON', () => {
const response = '```json\n[\n {"action": "CREATE", "category": "convention", "content": "test memory"}\n]\n```';
const actions = parseMemoryResponse(response);
assertEq(actions.length, 1, 'should parse 1 action from fenced JSON');
assertEq(actions[0].action, 'CREATE', 'action should be CREATE');
}
assert.deepStrictEqual(actions.length, 1, 'should parse 1 action from fenced JSON');
assert.deepStrictEqual(actions[0].action, 'CREATE', 'action should be CREATE');
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-extractor: parse empty array response
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-extractor: parse empty array ===');
{
test('memory-extractor: parse empty array', () => {
const actions = parseMemoryResponse('[]');
assertEq(actions.length, 0, 'empty array should parse to empty actions');
}
assert.deepStrictEqual(actions.length, 0, 'empty array should parse to empty actions');
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-extractor: parse malformed response
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-extractor: malformed responses ===');
{
assertEq(parseMemoryResponse('not json at all'), [], 'garbage text should return []');
assertEq(parseMemoryResponse('{"action": "CREATE"}'), [], 'non-array should return []');
assertEq(parseMemoryResponse(''), [], 'empty string should return []');
assertEq(parseMemoryResponse('```\nbroken\n```'), [], 'fenced non-JSON should return []');
}
test('memory-extractor: malformed responses', () => {
assert.deepStrictEqual(parseMemoryResponse('not json at all'), [], 'garbage text should return []');
assert.deepStrictEqual(parseMemoryResponse('{"action": "CREATE"}'), [], 'non-array should return []');
assert.deepStrictEqual(parseMemoryResponse(''), [], 'empty string should return []');
assert.deepStrictEqual(parseMemoryResponse('```\nbroken\n```'), [], 'fenced non-JSON should return []');
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-extractor: validation of required fields
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-extractor: field validation ===');
{
test('memory-extractor: field validation', () => {
const response = JSON.stringify([
// Valid CREATE
{ action: 'CREATE', category: 'gotcha', content: 'valid' },
@ -103,19 +97,18 @@ console.log('\n=== memory-extractor: field validation ===');
]);
const actions = parseMemoryResponse(response);
assertEq(actions.length, 4, 'should only accept 4 valid actions');
assertEq(actions[0].action, 'CREATE', 'first valid is CREATE');
assertEq(actions[1].action, 'REINFORCE', 'second valid is REINFORCE');
assertEq(actions[2].action, 'UPDATE', 'third valid is UPDATE');
assertEq(actions[3].action, 'SUPERSEDE', 'fourth valid is SUPERSEDE');
}
assert.deepStrictEqual(actions.length, 4, 'should only accept 4 valid actions');
assert.deepStrictEqual(actions[0].action, 'CREATE', 'first valid is CREATE');
assert.deepStrictEqual(actions[1].action, 'REINFORCE', 'second valid is REINFORCE');
assert.deepStrictEqual(actions[2].action, 'UPDATE', 'third valid is UPDATE');
assert.deepStrictEqual(actions[3].action, 'SUPERSEDE', 'fourth valid is SUPERSEDE');
});
// ═══════════════════════════════════════════════════════════════════════════
// Integration: applyMemoryActions with mixed actions
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== integration: mixed action lifecycle ===');
{
test('integration: mixed action lifecycle', () => {
openDatabase(':memory:');
// Phase 1: Create initial memories
@ -126,7 +119,7 @@ console.log('\n=== integration: mixed action lifecycle ===');
], 'plan-slice', 'M001/S01');
let active = getActiveMemoriesRanked(30);
assertEq(active.length, 3, 'phase 1: 3 active memories');
assert.deepStrictEqual(active.length, 3, 'phase 1: 3 active memories');
// Phase 2: Reinforce one, update another, create new
applyMemoryActions([
@ -136,13 +129,13 @@ console.log('\n=== integration: mixed action lifecycle ===');
], 'execute-task', 'M001/S01/T01');
active = getActiveMemoriesRanked(30);
assertEq(active.length, 4, 'phase 2: 4 active memories');
assertEq(
assert.deepStrictEqual(active.length, 4, 'phase 2: 4 active memories');
assert.deepStrictEqual(
active.find(m => m.id === 'MEM001')?.content,
'npm run build requires tsc --noEmit first',
'MEM001 content should be updated',
);
assertEq(active.find(m => m.id === 'MEM002')?.hit_count, 1, 'MEM002 should be reinforced');
assert.deepStrictEqual(active.find(m => m.id === 'MEM002')?.hit_count, 1, 'MEM002 should be reinforced');
// Phase 3: Supersede MEM001 with MEM005
applyMemoryActions([
@ -151,30 +144,28 @@ console.log('\n=== integration: mixed action lifecycle ===');
], 'execute-task', 'M001/S01/T02');
active = getActiveMemoriesRanked(30);
assertEq(active.length, 4, 'phase 3: 4 active (1 superseded, 1 created)');
assertTrue(!active.find(m => m.id === 'MEM001'), 'MEM001 should be superseded');
assertTrue(!!active.find(m => m.id === 'MEM005'), 'MEM005 should be active');
assert.deepStrictEqual(active.length, 4, 'phase 3: 4 active (1 superseded, 1 created)');
assert.ok(!active.find(m => m.id === 'MEM001'), 'MEM001 should be superseded');
assert.ok(!!active.find(m => m.id === 'MEM005'), 'MEM005 should be active');
// Verify ranking: MEM003 (0.85) > MEM005 (0.9) but MEM002 has 1 hit
// MEM002: 0.8 * (1 + 1*0.1) = 0.88
// MEM003: 0.85 * 1.0 = 0.85
// MEM005: 0.9 * 1.0 = 0.9
// MEM004: 0.75 * 1.0 = 0.75
assertEq(active[0].id, 'MEM005', 'MEM005 should rank first (0.9)');
assertEq(active[1].id, 'MEM002', 'MEM002 should rank second (0.88)');
assert.deepStrictEqual(active[0].id, 'MEM005', 'MEM005 should rank first (0.9)');
assert.deepStrictEqual(active[1].id, 'MEM002', 'MEM002 should rank second (0.88)');
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-extractor: _resetExtractionState
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-extractor: reset extraction state ===');
{
test('memory-extractor: reset extraction state', () => {
// Just verify it doesn't throw
_resetExtractionState();
assertTrue(true, '_resetExtractionState should not throw');
}
assert.ok(true, '_resetExtractionState should not throw');
});
report();

View file

@ -1,4 +1,3 @@
import { createTestContext } from './test-helpers.ts';
import {
openDatabase,
closeDatabase,
@ -21,94 +20,90 @@ import {
formatMemoriesForPrompt,
} from '../memory-store.ts';
import type { MemoryAction } from '../memory-store.ts';
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
// ═══════════════════════════════════════════════════════════════════════════
// memory-store: fallback when DB not open
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-store: fallback returns empty when DB not open ===');
{
test('memory-store: fallback returns empty when DB not open', () => {
closeDatabase();
assertTrue(!isDbAvailable(), 'DB should not be available');
assert.ok(!isDbAvailable(), 'DB should not be available');
assertEq(getActiveMemories(), [], 'getActiveMemories returns [] when DB closed');
assertEq(getActiveMemoriesRanked(), [], 'getActiveMemoriesRanked returns [] when DB closed');
assertEq(nextMemoryId(), 'MEM001', 'nextMemoryId returns MEM001 when DB closed');
assertEq(createMemory({ category: 'test', content: 'test' }), null, 'createMemory returns null when DB closed');
assertTrue(!reinforceMemory('MEM001'), 'reinforceMemory returns false when DB closed');
assertTrue(!isUnitProcessed('test/key'), 'isUnitProcessed returns false when DB closed');
}
assert.deepStrictEqual(getActiveMemories(), [], 'getActiveMemories returns [] when DB closed');
assert.deepStrictEqual(getActiveMemoriesRanked(), [], 'getActiveMemoriesRanked returns [] when DB closed');
assert.deepStrictEqual(nextMemoryId(), 'MEM001', 'nextMemoryId returns MEM001 when DB closed');
assert.deepStrictEqual(createMemory({ category: 'test', content: 'test' }), null, 'createMemory returns null when DB closed');
assert.ok(!reinforceMemory('MEM001'), 'reinforceMemory returns false when DB closed');
assert.ok(!isUnitProcessed('test/key'), 'isUnitProcessed returns false when DB closed');
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-store: CRUD operations
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-store: create and query memories ===');
{
test('memory-store: create and query memories', () => {
openDatabase(':memory:');
// Create memories
const id1 = createMemory({ category: 'gotcha', content: 'esbuild drops .node binaries' });
assertTrue(id1 !== null, 'createMemory should return an ID');
assertEq(id1, 'MEM001', 'first memory ID should be MEM001');
assert.ok(id1 !== null, 'createMemory should return an ID');
assert.deepStrictEqual(id1, 'MEM001', 'first memory ID should be MEM001');
const id2 = createMemory({ category: 'convention', content: 'use :memory: for tests', confidence: 0.9 });
assertEq(id2, 'MEM002', 'second memory ID should be MEM002');
assert.deepStrictEqual(id2, 'MEM002', 'second memory ID should be MEM002');
const id3 = createMemory({ category: 'architecture', content: 'extensions discovered from src/resources/' });
assertEq(id3, 'MEM003', 'third memory ID should be MEM003');
assert.deepStrictEqual(id3, 'MEM003', 'third memory ID should be MEM003');
// Query all active
const active = getActiveMemories();
assertEq(active.length, 3, 'should have 3 active memories');
assertEq(active[0].category, 'gotcha', 'first memory category');
assertEq(active[0].content, 'esbuild drops .node binaries', 'first memory content');
assertEq(active[1].confidence, 0.9, 'second memory confidence');
assert.deepStrictEqual(active.length, 3, 'should have 3 active memories');
assert.deepStrictEqual(active[0].category, 'gotcha', 'first memory category');
assert.deepStrictEqual(active[0].content, 'esbuild drops .node binaries', 'first memory content');
assert.deepStrictEqual(active[1].confidence, 0.9, 'second memory confidence');
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-store: update and reinforce
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-store: update and reinforce ===');
{
test('memory-store: update and reinforce', () => {
openDatabase(':memory:');
createMemory({ category: 'gotcha', content: 'original content' });
// Update content
const updated = updateMemoryContent('MEM001', 'revised content', 0.95);
assertTrue(updated, 'updateMemoryContent should return true');
assert.ok(updated, 'updateMemoryContent should return true');
const active = getActiveMemories();
assertEq(active[0].content, 'revised content', 'content should be updated');
assertEq(active[0].confidence, 0.95, 'confidence should be updated');
assert.deepStrictEqual(active[0].content, 'revised content', 'content should be updated');
assert.deepStrictEqual(active[0].confidence, 0.95, 'confidence should be updated');
// Reinforce
const reinforced = reinforceMemory('MEM001');
assertTrue(reinforced, 'reinforceMemory should return true');
assert.ok(reinforced, 'reinforceMemory should return true');
const after = getActiveMemories();
assertEq(after[0].hit_count, 1, 'hit_count should be 1 after reinforce');
assert.deepStrictEqual(after[0].hit_count, 1, 'hit_count should be 1 after reinforce');
// Reinforce again
reinforceMemory('MEM001');
const after2 = getActiveMemories();
assertEq(after2[0].hit_count, 2, 'hit_count should be 2 after second reinforce');
assert.deepStrictEqual(after2[0].hit_count, 2, 'hit_count should be 2 after second reinforce');
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-store: supersede
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-store: supersede ===');
{
test('memory-store: supersede', () => {
openDatabase(':memory:');
createMemory({ category: 'convention', content: 'old convention' });
@ -117,18 +112,17 @@ console.log('\n=== memory-store: supersede ===');
supersedeMemory('MEM001', 'MEM002');
const active = getActiveMemories();
assertEq(active.length, 1, 'should have 1 active memory after supersede');
assertEq(active[0].id, 'MEM002', 'active memory should be MEM002');
assert.deepStrictEqual(active.length, 1, 'should have 1 active memory after supersede');
assert.deepStrictEqual(active[0].id, 'MEM002', 'active memory should be MEM002');
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-store: ranked query ordering
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-store: ranked query ordering ===');
{
test('memory-store: ranked query ordering', () => {
openDatabase(':memory:');
// Low confidence, no hits
@ -142,45 +136,43 @@ console.log('\n=== memory-store: ranked query ordering ===');
for (let i = 0; i < 10; i++) reinforceMemory('MEM003');
const ranked = getActiveMemoriesRanked(10);
assertEq(ranked.length, 3, 'should have 3 ranked memories');
assert.deepStrictEqual(ranked.length, 3, 'should have 3 ranked memories');
// MEM003: 0.7 * (1 + 10*0.1) = 0.7 * 2.0 = 1.4
// MEM002: 0.95 * (1 + 0*0.1) = 0.95
// MEM001: 0.5 * (1 + 0*0.1) = 0.5
assertEq(ranked[0].id, 'MEM003', 'highest ranked should be MEM003 (reinforced)');
assertEq(ranked[1].id, 'MEM002', 'second ranked should be MEM002 (high confidence)');
assertEq(ranked[2].id, 'MEM001', 'lowest ranked should be MEM001');
assert.deepStrictEqual(ranked[0].id, 'MEM003', 'highest ranked should be MEM003 (reinforced)');
assert.deepStrictEqual(ranked[1].id, 'MEM002', 'second ranked should be MEM002 (high confidence)');
assert.deepStrictEqual(ranked[2].id, 'MEM001', 'lowest ranked should be MEM001');
// Test limit
const limited = getActiveMemoriesRanked(2);
assertEq(limited.length, 2, 'limit should cap results');
assert.deepStrictEqual(limited.length, 2, 'limit should cap results');
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-store: processed unit tracking
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-store: processed unit tracking ===');
{
test('memory-store: processed unit tracking', () => {
openDatabase(':memory:');
assertTrue(!isUnitProcessed('execute-task/M001/S01/T01'), 'should not be processed initially');
assert.ok(!isUnitProcessed('execute-task/M001/S01/T01'), 'should not be processed initially');
markUnitProcessed('execute-task/M001/S01/T01', '/path/to/activity.jsonl');
assertTrue(isUnitProcessed('execute-task/M001/S01/T01'), 'should be processed after marking');
assertTrue(!isUnitProcessed('execute-task/M001/S01/T02'), 'different key should not be processed');
assert.ok(isUnitProcessed('execute-task/M001/S01/T01'), 'should be processed after marking');
assert.ok(!isUnitProcessed('execute-task/M001/S01/T02'), 'different key should not be processed');
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-store: enforce memory cap
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-store: enforce memory cap ===');
{
test('memory-store: enforce memory cap', () => {
openDatabase(':memory:');
// Create 5 memories with varying confidence
@ -194,23 +186,22 @@ console.log('\n=== memory-store: enforce memory cap ===');
enforceMemoryCap(3);
const active = getActiveMemories();
assertEq(active.length, 3, 'should have 3 active memories after cap enforcement');
assert.deepStrictEqual(active.length, 3, 'should have 3 active memories after cap enforcement');
// The 2 lowest-ranked (MEM003=0.3 and MEM002=0.5) should be superseded
const ids = active.map(m => m.id).sort();
assertTrue(ids.includes('MEM001'), 'MEM001 (0.9) should survive');
assertTrue(ids.includes('MEM004'), 'MEM004 (0.95) should survive');
assertTrue(ids.includes('MEM005'), 'MEM005 (0.7) should survive');
assert.ok(ids.includes('MEM001'), 'MEM001 (0.9) should survive');
assert.ok(ids.includes('MEM004'), 'MEM004 (0.95) should survive');
assert.ok(ids.includes('MEM005'), 'MEM005 (0.7) should survive');
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-store: applyMemoryActions transaction
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-store: applyMemoryActions ===');
{
test('memory-store: applyMemoryActions', () => {
openDatabase(':memory:');
const actions: MemoryAction[] = [
@ -221,7 +212,7 @@ console.log('\n=== memory-store: applyMemoryActions ===');
applyMemoryActions(actions, 'execute-task', 'M001/S01/T01');
let active = getActiveMemories();
assertEq(active.length, 2, 'should have 2 memories after CREATE actions');
assert.deepStrictEqual(active.length, 2, 'should have 2 memories after CREATE actions');
// Now apply UPDATE + REINFORCE
const updateActions: MemoryAction[] = [
@ -232,8 +223,8 @@ console.log('\n=== memory-store: applyMemoryActions ===');
applyMemoryActions(updateActions, 'execute-task', 'M001/S01/T02');
active = getActiveMemories();
assertEq(active.find(m => m.id === 'MEM001')?.content, 'updated gotcha', 'MEM001 should be updated');
assertEq(active.find(m => m.id === 'MEM002')?.hit_count, 1, 'MEM002 should be reinforced');
assert.deepStrictEqual(active.find(m => m.id === 'MEM001')?.content, 'updated gotcha', 'MEM001 should be updated');
assert.deepStrictEqual(active.find(m => m.id === 'MEM002')?.hit_count, 1, 'MEM002 should be reinforced');
// SUPERSEDE
const supersedeActions: MemoryAction[] = [
@ -244,19 +235,18 @@ console.log('\n=== memory-store: applyMemoryActions ===');
applyMemoryActions(supersedeActions, 'execute-task', 'M001/S01/T03');
active = getActiveMemories();
assertEq(active.length, 2, 'should have 2 active after supersede');
assertTrue(!active.find(m => m.id === 'MEM001'), 'MEM001 should be superseded');
assertTrue(!!active.find(m => m.id === 'MEM003'), 'MEM003 should be active');
assert.deepStrictEqual(active.length, 2, 'should have 2 active after supersede');
assert.ok(!active.find(m => m.id === 'MEM001'), 'MEM001 should be superseded');
assert.ok(!!active.find(m => m.id === 'MEM003'), 'MEM003 should be active');
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-store: formatMemoriesForPrompt
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-store: formatMemoriesForPrompt ===');
{
test('memory-store: formatMemoriesForPrompt', () => {
openDatabase(':memory:');
createMemory({ category: 'gotcha', content: 'esbuild drops .node binaries' });
@ -267,18 +257,18 @@ console.log('\n=== memory-store: formatMemoriesForPrompt ===');
const memories = getActiveMemoriesRanked(30);
const formatted = formatMemoriesForPrompt(memories);
assertTrue(formatted.includes('## Project Memory (auto-learned)'), 'should have header');
assertTrue(formatted.includes('### Gotcha'), 'should have gotcha category');
assertTrue(formatted.includes('### Convention'), 'should have convention category');
assertTrue(formatted.includes('### Architecture'), 'should have architecture category');
assertTrue(formatted.includes('- esbuild drops .node binaries'), 'should have gotcha content');
assertTrue(formatted.includes('- use :memory: for tests'), 'should have convention content');
assert.ok(formatted.includes('## Project Memory (auto-learned)'), 'should have header');
assert.ok(formatted.includes('### Gotcha'), 'should have gotcha category');
assert.ok(formatted.includes('### Convention'), 'should have convention category');
assert.ok(formatted.includes('### Architecture'), 'should have architecture category');
assert.ok(formatted.includes('- esbuild drops .node binaries'), 'should have gotcha content');
assert.ok(formatted.includes('- use :memory: for tests'), 'should have convention content');
// Test empty memories
closeDatabase();
openDatabase(':memory:');
const emptyFormatted = formatMemoriesForPrompt([]);
assertEq(emptyFormatted, '', 'empty memories should return empty string');
assert.deepStrictEqual(emptyFormatted, '', 'empty memories should return empty string');
// Test token budget truncation
closeDatabase();
@ -288,58 +278,55 @@ console.log('\n=== memory-store: formatMemoriesForPrompt ===');
}
const budgetMemories = getActiveMemoriesRanked(30);
const truncated = formatMemoriesForPrompt(budgetMemories, 500);
assertTrue(truncated.length < 2500, `formatted length ${truncated.length} should be under budget`);
assert.ok(truncated.length < 2500, `formatted length ${truncated.length} should be under budget`);
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-store: ID generation
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-store: ID generation ===');
{
test('memory-store: ID generation', () => {
openDatabase(':memory:');
assertEq(nextMemoryId(), 'MEM001', 'first ID should be MEM001');
assert.deepStrictEqual(nextMemoryId(), 'MEM001', 'first ID should be MEM001');
createMemory({ category: 'test', content: 'test' });
assertEq(nextMemoryId(), 'MEM002', 'after first create, next should be MEM002');
assert.deepStrictEqual(nextMemoryId(), 'MEM002', 'after first create, next should be MEM002');
// Create several more
for (let i = 0; i < 98; i++) createMemory({ category: 'test', content: `test ${i}` });
assertEq(nextMemoryId(), 'MEM100', 'after 99 creates, next should be MEM100');
assert.deepStrictEqual(nextMemoryId(), 'MEM100', 'after 99 creates, next should be MEM100');
closeDatabase();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// memory-store: schema migration (v2 → v3)
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== memory-store: schema includes memories table ===');
{
test('memory-store: schema includes memories table', () => {
openDatabase(':memory:');
const adapter = _getAdapter()!;
// Verify memories table exists
const memCount = adapter.prepare('SELECT count(*) as cnt FROM memories').get();
assertEq(memCount?.['cnt'], 0, 'memories table should exist and be empty');
assert.deepStrictEqual(memCount?.['cnt'], 0, 'memories table should exist and be empty');
// Verify memory_processed_units table exists
const procCount = adapter.prepare('SELECT count(*) as cnt FROM memory_processed_units').get();
assertEq(procCount?.['cnt'], 0, 'memory_processed_units table should exist and be empty');
assert.deepStrictEqual(procCount?.['cnt'], 0, 'memory_processed_units table should exist and be empty');
// Verify active_memories view exists
const viewCount = adapter.prepare('SELECT count(*) as cnt FROM active_memories').get();
assertEq(viewCount?.['cnt'], 0, 'active_memories view should exist');
assert.deepStrictEqual(viewCount?.['cnt'], 0, 'active_memories view should exist');
// Verify schema version is 10 (after M001 planning migrations)
const version = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get();
assertEq(version?.['v'], 10, 'schema version should be 10');
assert.deepStrictEqual(version?.['v'], 10, 'schema version should be 10');
closeDatabase();
}
});
report();

View file

@ -15,9 +15,9 @@ import {
writeGSDDirectory,
} from '../migrate/index.ts';
import { deriveState } from '../state.ts';
import { createTestContext } from './test-helpers.ts';
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
const { assertEq, assertTrue, report } = createTestContext();
// ─── Fixture Helpers ───────────────────────────────────────────────────────
const SAMPLE_PROJECT = `# Integration Test Project
@ -195,11 +195,9 @@ function createCompleteFixture(): string {
// Tests
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
// ─── Test 1: Path resolution — .planning appended when missing ─────────
console.log('\n=== Path resolution: .planning appended when source path lacks it ===');
{
test('Path resolution: .planning appended when source path lacks it', () => {
const base = createCompleteFixture();
try {
// Simulate the command's path resolution logic
@ -207,16 +205,16 @@ async function main(): Promise<void> {
if (!sourcePath.endsWith('.planning')) {
sourcePath = join(sourcePath, '.planning');
}
assertTrue(sourcePath.endsWith('.planning'), 'path-resolution: .planning appended');
assertTrue(existsSync(sourcePath), 'path-resolution: appended path exists');
assert.ok(sourcePath.endsWith('.planning'), 'path-resolution: .planning appended');
assert.ok(existsSync(sourcePath), 'path-resolution: appended path exists');
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});
// ─── Test 2: Path resolution — .planning used as-is ────────────────────
console.log('\n=== Path resolution: .planning used as-is when already present ===');
{
test('Path resolution: .planning used as-is when already present', () => {
const base = createCompleteFixture();
try {
const planningPath = join(base, '.planning');
@ -224,39 +222,39 @@ async function main(): Promise<void> {
if (!sourcePath.endsWith('.planning')) {
sourcePath = join(sourcePath, '.planning');
}
assertEq(sourcePath, resolve(planningPath), 'path-resolution: .planning not double-appended');
assertTrue(existsSync(sourcePath), 'path-resolution: direct path exists');
assert.deepStrictEqual(sourcePath, resolve(planningPath), 'path-resolution: .planning not double-appended');
assert.ok(existsSync(sourcePath), 'path-resolution: direct path exists');
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});
// ─── Test 3: Validation gating — non-existent path ─────────────────────
console.log('\n=== Validation gating: non-existent path returns invalid ===');
{
test('Validation gating: non-existent path returns invalid', async () => {
const fakePath = join(tmpdir(), 'gsd-cmd-nonexistent-' + Date.now(), '.planning');
const result = await validatePlanningDirectory(fakePath);
assertEq(result.valid, false, 'validation: non-existent path is invalid');
assertTrue(result.issues.length > 0, 'validation: has issues for non-existent path');
assert.deepStrictEqual(result.valid, false, 'validation: non-existent path is invalid');
assert.ok(result.issues.length > 0, 'validation: has issues for non-existent path');
const hasFatal = result.issues.some(i => i.severity === 'fatal');
assertTrue(hasFatal, 'validation: non-existent path has fatal issue');
}
assert.ok(hasFatal, 'validation: non-existent path has fatal issue');
});
// ─── Test 4: Validation gating — valid fixture passes ──────────────────
console.log('\n=== Validation gating: valid fixture passes validation ===');
{
test('Validation gating: valid fixture passes validation', async () => {
const base = createCompleteFixture();
try {
const result = await validatePlanningDirectory(join(base, '.planning'));
assertTrue(result.valid === true, 'validation: valid fixture passes');
assert.ok(result.valid === true, 'validation: valid fixture passes');
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});
// ─── Test 5: Full pipeline round-trip ──────────────────────────────────
console.log('\n=== Full pipeline: parse → transform → preview → write → deriveState ===');
{
test('Full pipeline: parse → transform → preview → write → deriveState', async () => {
const base = createCompleteFixture();
const writeTarget = mkdtempSync(join(tmpdir(), 'gsd-cmd-write-'));
try {
@ -264,17 +262,17 @@ async function main(): Promise<void> {
// (a) Validate
const validation = await validatePlanningDirectory(planningPath);
assertTrue(validation.valid === true, 'pipeline: validation passes');
assert.ok(validation.valid === true, 'pipeline: validation passes');
// (b) Parse
const parsed = await parsePlanningDirectory(planningPath);
assertTrue(parsed.roadmap !== null, 'pipeline: roadmap parsed');
assertTrue(Object.keys(parsed.phases).length >= 2, 'pipeline: phases parsed');
assert.ok(parsed.roadmap !== null, 'pipeline: roadmap parsed');
assert.ok(Object.keys(parsed.phases).length >= 2, 'pipeline: phases parsed');
// (c) Transform
const project = transformToGSD(parsed);
assertTrue(project.milestones.length >= 1, 'pipeline: has milestones');
assertTrue(project.milestones[0].slices.length >= 1, 'pipeline: has slices');
assert.ok(project.milestones.length >= 1, 'pipeline: has milestones');
assert.ok(project.milestones[0].slices.length >= 1, 'pipeline: has slices');
// Count totals for preview verification
let totalTasks = 0;
@ -294,76 +292,69 @@ async function main(): Promise<void> {
// (d) Preview — verify counts match project data
const preview = generatePreview(project);
assertEq(preview.milestoneCount, project.milestones.length, 'pipeline: preview milestoneCount');
assertEq(preview.totalSlices, totalSlices, 'pipeline: preview totalSlices');
assertEq(preview.totalTasks, totalTasks, 'pipeline: preview totalTasks');
assertEq(preview.doneSlices, doneSlices, 'pipeline: preview doneSlices');
assertEq(preview.doneTasks, doneTasks, 'pipeline: preview doneTasks');
assert.deepStrictEqual(preview.milestoneCount, project.milestones.length, 'pipeline: preview milestoneCount');
assert.deepStrictEqual(preview.totalSlices, totalSlices, 'pipeline: preview totalSlices');
assert.deepStrictEqual(preview.totalTasks, totalTasks, 'pipeline: preview totalTasks');
assert.deepStrictEqual(preview.doneSlices, doneSlices, 'pipeline: preview doneSlices');
assert.deepStrictEqual(preview.doneTasks, doneTasks, 'pipeline: preview doneTasks');
// Completion percentages
const expectedSlicePct = totalSlices > 0 ? Math.round((doneSlices / totalSlices) * 100) : 0;
const expectedTaskPct = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0;
assertEq(preview.sliceCompletionPct, expectedSlicePct, 'pipeline: preview sliceCompletionPct');
assertEq(preview.taskCompletionPct, expectedTaskPct, 'pipeline: preview taskCompletionPct');
assert.deepStrictEqual(preview.sliceCompletionPct, expectedSlicePct, 'pipeline: preview sliceCompletionPct');
assert.deepStrictEqual(preview.taskCompletionPct, expectedTaskPct, 'pipeline: preview taskCompletionPct');
// Requirements in preview
assertEq(preview.requirements.active, 1, 'pipeline: preview requirements active');
assertEq(preview.requirements.validated, 1, 'pipeline: preview requirements validated');
assertEq(preview.requirements.total, 2, 'pipeline: preview requirements total');
assert.deepStrictEqual(preview.requirements.active, 1, 'pipeline: preview requirements active');
assert.deepStrictEqual(preview.requirements.validated, 1, 'pipeline: preview requirements validated');
assert.deepStrictEqual(preview.requirements.total, 2, 'pipeline: preview requirements total');
// (e) Write
const result = await writeGSDDirectory(project, writeTarget);
assertTrue(result.paths.length > 0, 'pipeline: files written');
assert.ok(result.paths.length > 0, 'pipeline: files written');
// Key files exist
const gsd = join(writeTarget, '.gsd');
assertTrue(existsSync(join(gsd, 'PROJECT.md')), 'pipeline: PROJECT.md written');
assertTrue(existsSync(join(gsd, 'STATE.md')), 'pipeline: STATE.md written');
assertTrue(existsSync(join(gsd, 'REQUIREMENTS.md')), 'pipeline: REQUIREMENTS.md written');
assert.ok(existsSync(join(gsd, 'PROJECT.md')), 'pipeline: PROJECT.md written');
assert.ok(existsSync(join(gsd, 'STATE.md')), 'pipeline: STATE.md written');
assert.ok(existsSync(join(gsd, 'REQUIREMENTS.md')), 'pipeline: REQUIREMENTS.md written');
const m001 = join(gsd, 'milestones', 'M001');
assertTrue(existsSync(join(m001, 'M001-ROADMAP.md')), 'pipeline: M001-ROADMAP.md written');
assertTrue(existsSync(join(m001, 'M001-CONTEXT.md')), 'pipeline: M001-CONTEXT.md written');
assert.ok(existsSync(join(m001, 'M001-ROADMAP.md')), 'pipeline: M001-ROADMAP.md written');
assert.ok(existsSync(join(m001, 'M001-CONTEXT.md')), 'pipeline: M001-CONTEXT.md written');
// At least one slice plan exists
const s01Plan = join(m001, 'slices', 'S01', 'S01-PLAN.md');
assertTrue(existsSync(s01Plan), 'pipeline: S01-PLAN.md written');
assert.ok(existsSync(s01Plan), 'pipeline: S01-PLAN.md written');
// (f) deriveState — coherent state from written output
console.log(' --- deriveState ---');
const state = await deriveState(writeTarget);
assertTrue(state.phase !== undefined, 'pipeline: deriveState returns phase');
assertTrue(state.activeMilestone !== null, 'pipeline: deriveState has activeMilestone');
assertEq(state.activeMilestone!.id, 'M001', 'pipeline: deriveState activeMilestone is M001');
assertTrue(state.progress!.slices !== undefined, 'pipeline: deriveState has slices progress');
assertTrue(state.progress!.tasks !== undefined, 'pipeline: deriveState has tasks progress');
assert.ok(state.phase !== undefined, 'pipeline: deriveState returns phase');
assert.ok(state.activeMilestone !== null, 'pipeline: deriveState has activeMilestone');
assert.deepStrictEqual(state.activeMilestone!.id, 'M001', 'pipeline: deriveState activeMilestone is M001');
assert.ok(state.progress!.slices !== undefined, 'pipeline: deriveState has slices progress');
assert.ok(state.progress!.tasks !== undefined, 'pipeline: deriveState has tasks progress');
} finally {
rmSync(base, { recursive: true, force: true });
rmSync(writeTarget, { recursive: true, force: true });
}
}
});
// ─── Test 6: .gsd/ exists detection ────────────────────────────────────
console.log('\n=== .gsd/ exists detection ===');
{
test('.gsd/ exists detection', () => {
const base = mkdtempSync(join(tmpdir(), 'gsd-cmd-exists-'));
try {
// No .gsd/ yet
assertTrue(!existsSync(join(base, '.gsd')), 'exists-detection: .gsd absent initially');
assert.ok(!existsSync(join(base, '.gsd')), 'exists-detection: .gsd absent initially');
// Create .gsd/
mkdirSync(join(base, '.gsd'), { recursive: true });
assertTrue(existsSync(join(base, '.gsd')), 'exists-detection: .gsd detected after creation');
assert.ok(existsSync(join(base, '.gsd')), 'exists-detection: .gsd detected after creation');
} finally {
rmSync(base, { recursive: true, force: true });
}
}
report();
}
main().catch((err) => {
console.error('Unhandled error:', err);
process.exit(1);
});

View file

@ -18,9 +18,8 @@ import {
getActiveTaskFromDb,
} from '../gsd-db.ts';
import { migrateHierarchyToDb } from '../md-importer.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
// ─── Fixture Helpers ───────────────────────────────────────────────────────
@ -98,11 +97,9 @@ const PLAN_S02_1_TASK = `# S02: Second Slice
// Test Cases
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
// ─── Test (a): Single milestone with 2 slices, 3 tasks ────────────────
console.log('\n=== migrate-hier: single milestone with 2 slices, 3 tasks ===');
{
test('migrate-hier: single milestone with 2 slices, 3 tasks', () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_2_SLICES);
@ -112,48 +109,48 @@ async function main(): Promise<void> {
openDatabase(':memory:');
const counts = migrateHierarchyToDb(base);
assertEq(counts.milestones, 1, 'single-ms: 1 milestone inserted');
assertEq(counts.slices, 2, 'single-ms: 2 slices inserted');
assertEq(counts.tasks, 4, 'single-ms: 4 tasks inserted (3 + 1)');
assert.deepStrictEqual(counts.milestones, 1, 'single-ms: 1 milestone inserted');
assert.deepStrictEqual(counts.slices, 2, 'single-ms: 2 slices inserted');
assert.deepStrictEqual(counts.tasks, 4, 'single-ms: 4 tasks inserted (3 + 1)');
const milestones = getAllMilestones();
assertEq(milestones.length, 1, 'single-ms: 1 milestone in DB');
assertEq(milestones[0]!.id, 'M001', 'single-ms: milestone ID is M001');
assertEq(milestones[0]!.title, 'M001: Test Milestone', 'single-ms: milestone title correct');
assertEq(milestones[0]!.status, 'active', 'single-ms: milestone status is active');
assert.deepStrictEqual(milestones.length, 1, 'single-ms: 1 milestone in DB');
assert.deepStrictEqual(milestones[0]!.id, 'M001', 'single-ms: milestone ID is M001');
assert.deepStrictEqual(milestones[0]!.title, 'M001: Test Milestone', 'single-ms: milestone title correct');
assert.deepStrictEqual(milestones[0]!.status, 'active', 'single-ms: milestone status is active');
const slices = getMilestoneSlices('M001');
assertEq(slices.length, 2, 'single-ms: 2 slices in DB');
assertEq(slices[0]!.id, 'S01', 'single-ms: first slice is S01');
assertEq(slices[0]!.title, 'First Slice', 'single-ms: S01 title correct');
assertEq(slices[0]!.risk, 'low', 'single-ms: S01 risk is low');
assertEq(slices[0]!.status, 'pending', 'single-ms: S01 status is pending');
assertEq(slices[1]!.id, 'S02', 'single-ms: second slice is S02');
assertEq(slices[1]!.risk, 'high', 'single-ms: S02 risk is high');
assert.deepStrictEqual(slices.length, 2, 'single-ms: 2 slices in DB');
assert.deepStrictEqual(slices[0]!.id, 'S01', 'single-ms: first slice is S01');
assert.deepStrictEqual(slices[0]!.title, 'First Slice', 'single-ms: S01 title correct');
assert.deepStrictEqual(slices[0]!.risk, 'low', 'single-ms: S01 risk is low');
assert.deepStrictEqual(slices[0]!.status, 'pending', 'single-ms: S01 status is pending');
assert.deepStrictEqual(slices[1]!.id, 'S02', 'single-ms: second slice is S02');
assert.deepStrictEqual(slices[1]!.risk, 'high', 'single-ms: S02 risk is high');
const s01Tasks = getSliceTasks('M001', 'S01');
assertEq(s01Tasks.length, 3, 'single-ms: 3 tasks for S01');
assertEq(s01Tasks[0]!.id, 'T01', 'single-ms: first task is T01');
assertEq(s01Tasks[0]!.title, 'First Task', 'single-ms: T01 title correct');
assertEq(s01Tasks[0]!.status, 'pending', 'single-ms: T01 status is pending');
assertEq(s01Tasks[1]!.id, 'T02', 'single-ms: second task is T02');
assertEq(s01Tasks[1]!.status, 'complete', 'single-ms: T02 status is complete (was [x])');
assertEq(s01Tasks[2]!.id, 'T03', 'single-ms: third task is T03');
assert.deepStrictEqual(s01Tasks.length, 3, 'single-ms: 3 tasks for S01');
assert.deepStrictEqual(s01Tasks[0]!.id, 'T01', 'single-ms: first task is T01');
assert.deepStrictEqual(s01Tasks[0]!.title, 'First Task', 'single-ms: T01 title correct');
assert.deepStrictEqual(s01Tasks[0]!.status, 'pending', 'single-ms: T01 status is pending');
assert.deepStrictEqual(s01Tasks[1]!.id, 'T02', 'single-ms: second task is T02');
assert.deepStrictEqual(s01Tasks[1]!.status, 'complete', 'single-ms: T02 status is complete (was [x])');
assert.deepStrictEqual(s01Tasks[2]!.id, 'T03', 'single-ms: third task is T03');
const s02Tasks = getSliceTasks('M001', 'S02');
assertEq(s02Tasks.length, 1, 'single-ms: 1 task for S02');
assertEq(s02Tasks[0]!.id, 'T01', 'single-ms: S02 T01 correct');
assert.deepStrictEqual(s02Tasks.length, 1, 'single-ms: 1 task for S02');
assert.deepStrictEqual(s02Tasks[0]!.id, 'T01', 'single-ms: S02 T01 correct');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test (b): Multi-milestone — M001 complete, M002 active with deps ─
console.log('\n=== migrate-hier: multi-milestone with deps ===');
{
test('migrate-hier: multi-milestone with deps', () => {
const base = createFixtureBase();
try {
// M001: complete (has SUMMARY)
@ -197,35 +194,35 @@ Depends on M001 completion.
openDatabase(':memory:');
const counts = migrateHierarchyToDb(base);
assertEq(counts.milestones, 2, 'multi-ms: 2 milestones inserted');
assert.deepStrictEqual(counts.milestones, 2, 'multi-ms: 2 milestones inserted');
const m001 = getMilestone('M001');
assertTrue(m001 !== null, 'multi-ms: M001 exists');
assertEq(m001!.status, 'complete', 'multi-ms: M001 is complete');
assert.ok(m001 !== null, 'multi-ms: M001 exists');
assert.deepStrictEqual(m001!.status, 'complete', 'multi-ms: M001 is complete');
const m002 = getMilestone('M002');
assertTrue(m002 !== null, 'multi-ms: M002 exists');
assertEq(m002!.status, 'active', 'multi-ms: M002 is active');
assertEq(m002!.depends_on, ['M001'], 'multi-ms: M002 depends on M001');
assert.ok(m002 !== null, 'multi-ms: M002 exists');
assert.deepStrictEqual(m002!.status, 'active', 'multi-ms: M002 is active');
assert.deepStrictEqual(m002!.depends_on, ['M001'], 'multi-ms: M002 depends on M001');
// Active milestone should be M002
const active = getActiveMilestoneFromDb();
assertEq(active?.id, 'M002', 'multi-ms: active milestone is M002');
assert.deepStrictEqual(active?.id, 'M002', 'multi-ms: active milestone is M002');
// Active slice in M002 should be S01 (S02 depends on S01)
const activeSlice = getActiveSliceFromDb('M002');
assertEq(activeSlice?.id, 'S01', 'multi-ms: active slice is S01');
assert.deepStrictEqual(activeSlice?.id, 'S01', 'multi-ms: active slice is S01');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test (c): Partially-completed slice — some tasks [x], some [ ] ───
console.log('\n=== migrate-hier: partially-completed slice ===');
{
test('migrate-hier: partially-completed slice', () => {
const base = createFixtureBase();
try {
const roadmap = `# M001: Partial
@ -260,25 +257,25 @@ Depends on M001 completion.
migrateHierarchyToDb(base);
const tasks = getSliceTasks('M001', 'S01');
assertEq(tasks.length, 3, 'partial: 3 tasks');
assertEq(tasks[0]!.status, 'complete', 'partial: T01 is complete');
assertEq(tasks[1]!.status, 'complete', 'partial: T02 is complete');
assertEq(tasks[2]!.status, 'pending', 'partial: T03 is pending');
assert.deepStrictEqual(tasks.length, 3, 'partial: 3 tasks');
assert.deepStrictEqual(tasks[0]!.status, 'complete', 'partial: T01 is complete');
assert.deepStrictEqual(tasks[1]!.status, 'complete', 'partial: T02 is complete');
assert.deepStrictEqual(tasks[2]!.status, 'pending', 'partial: T03 is pending');
// Active task should be T03
const activeTask = getActiveTaskFromDb('M001', 'S01');
assertEq(activeTask?.id, 'T03', 'partial: active task is T03');
assert.deepStrictEqual(activeTask?.id, 'T03', 'partial: active task is T03');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test (d): Ghost milestone skipped ────────────────────────────────
console.log('\n=== migrate-hier: ghost milestone skipped ===');
{
test('migrate-hier: ghost milestone skipped', () => {
const base = createFixtureBase();
try {
// M001: real milestone
@ -289,21 +286,21 @@ Depends on M001 completion.
openDatabase(':memory:');
const counts = migrateHierarchyToDb(base);
assertEq(counts.milestones, 1, 'ghost: only 1 milestone inserted');
assert.deepStrictEqual(counts.milestones, 1, 'ghost: only 1 milestone inserted');
const milestones = getAllMilestones();
assertEq(milestones.length, 1, 'ghost: 1 milestone in DB');
assertEq(milestones[0]!.id, 'M001', 'ghost: only M001 in DB');
assert.deepStrictEqual(milestones.length, 1, 'ghost: 1 milestone in DB');
assert.deepStrictEqual(milestones[0]!.id, 'M001', 'ghost: only M001 in DB');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test (e): Idempotent re-run — calling twice doesn't duplicate ────
console.log('\n=== migrate-hier: idempotent re-run ===');
{
test('migrate-hier: idempotent re-run', () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_2_SLICES);
@ -313,31 +310,31 @@ Depends on M001 completion.
// First run
const counts1 = migrateHierarchyToDb(base);
assertEq(counts1.milestones, 1, 'idempotent-1: 1 milestone first run');
assertEq(counts1.slices, 2, 'idempotent-1: 2 slices first run');
assertEq(counts1.tasks, 3, 'idempotent-1: 3 tasks first run');
assert.deepStrictEqual(counts1.milestones, 1, 'idempotent-1: 1 milestone first run');
assert.deepStrictEqual(counts1.slices, 2, 'idempotent-1: 2 slices first run');
assert.deepStrictEqual(counts1.tasks, 3, 'idempotent-1: 3 tasks first run');
// Second run — INSERT OR IGNORE means no duplicates
const counts2 = migrateHierarchyToDb(base);
// Counts reflect attempts, not actual inserts (INSERT OR IGNORE silently skips)
// The important thing: DB doesn't have duplicates
const milestones = getAllMilestones();
assertEq(milestones.length, 1, 'idempotent-2: still 1 milestone after second run');
assert.deepStrictEqual(milestones.length, 1, 'idempotent-2: still 1 milestone after second run');
const slices = getMilestoneSlices('M001');
assertEq(slices.length, 2, 'idempotent-2: still 2 slices after second run');
assert.deepStrictEqual(slices.length, 2, 'idempotent-2: still 2 slices after second run');
const tasks = getSliceTasks('M001', 'S01');
assertEq(tasks.length, 3, 'idempotent-2: still 3 tasks for S01 after second run');
assert.deepStrictEqual(tasks.length, 3, 'idempotent-2: still 3 tasks for S01 after second run');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test (f): Empty roadmap — milestone inserted but no slices ───────
console.log('\n=== migrate-hier: empty roadmap, no slices ===');
{
test('migrate-hier: empty roadmap, no slices', () => {
const base = createFixtureBase();
try {
const emptyRoadmap = `# M001: Empty Milestone
@ -353,27 +350,27 @@ Depends on M001 completion.
openDatabase(':memory:');
const counts = migrateHierarchyToDb(base);
assertEq(counts.milestones, 1, 'empty-roadmap: 1 milestone inserted');
assertEq(counts.slices, 0, 'empty-roadmap: 0 slices inserted');
assertEq(counts.tasks, 0, 'empty-roadmap: 0 tasks inserted');
assert.deepStrictEqual(counts.milestones, 1, 'empty-roadmap: 1 milestone inserted');
assert.deepStrictEqual(counts.slices, 0, 'empty-roadmap: 0 slices inserted');
assert.deepStrictEqual(counts.tasks, 0, 'empty-roadmap: 0 tasks inserted');
const milestones = getAllMilestones();
assertEq(milestones.length, 1, 'empty-roadmap: 1 milestone in DB');
assertEq(milestones[0]!.title, 'M001: Empty Milestone', 'empty-roadmap: title correct');
assert.deepStrictEqual(milestones.length, 1, 'empty-roadmap: 1 milestone in DB');
assert.deepStrictEqual(milestones[0]!.title, 'M001: Empty Milestone', 'empty-roadmap: title correct');
const slices = getMilestoneSlices('M001');
assertEq(slices.length, 0, 'empty-roadmap: no slices in DB');
assert.deepStrictEqual(slices.length, 0, 'empty-roadmap: no slices in DB');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test (g): Slice depends parsed correctly ─────────────────────────
console.log('\n=== migrate-hier: slice depends parsed ===');
{
test('migrate-hier: slice depends parsed', () => {
const base = createFixtureBase();
try {
const roadmap = `# M001: Deps Test
@ -397,21 +394,21 @@ Depends on M001 completion.
migrateHierarchyToDb(base);
const slices = getMilestoneSlices('M001');
assertEq(slices.length, 3, 'depends: 3 slices');
assertEq(slices[0]!.depends, [], 'depends: S01 has no deps');
assertEq(slices[1]!.depends, ['S01'], 'depends: S02 depends on S01');
assertEq(slices[2]!.depends, ['S01', 'S02'], 'depends: S03 depends on S01,S02');
assert.deepStrictEqual(slices.length, 3, 'depends: 3 slices');
assert.deepStrictEqual(slices[0]!.depends, [], 'depends: S01 has no deps');
assert.deepStrictEqual(slices[1]!.depends, ['S01'], 'depends: S02 depends on S01');
assert.deepStrictEqual(slices[2]!.depends, ['S01', 'S02'], 'depends: S03 depends on S01,S02');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test (h): Demo text extracted from roadmap ───────────────────────
console.log('\n=== migrate-hier: demo text extracted ===');
{
test('migrate-hier: demo text extracted', () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_2_SLICES);
@ -420,20 +417,13 @@ Depends on M001 completion.
migrateHierarchyToDb(base);
const slices = getMilestoneSlices('M001');
assertEq(slices[0]!.demo, 'First slice done.', 'demo: S01 demo text correct');
assertEq(slices[1]!.demo, 'All slices done.', 'demo: S02 demo text correct');
assert.deepStrictEqual(slices[0]!.demo, 'First slice done.', 'demo: S01 demo text correct');
assert.deepStrictEqual(slices[1]!.demo, 'All slices done.', 'demo: S02 demo text correct');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -10,9 +10,9 @@ import { parsePlanningDirectory } from '../migrate/parser.ts';
import { validatePlanningDirectory } from '../migrate/validator.ts';
import type { PlanningProject, ValidationResult } from '../migrate/types.ts';
import { createTestContext } from './test-helpers.ts';
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
const { assertEq, assertTrue, report } = createTestContext();
// ─── Fixture Helpers ───────────────────────────────────────────────────────
function createFixtureBase(): string {
@ -241,11 +241,9 @@ Fixed the login button by correcting the touch event handler.
// Test Groups
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
// ─── Test 1: Complete .planning directory ──────────────────────────────
console.log('\n=== Complete .planning directory with all file types ===');
{
test('Complete .planning directory with all file types', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -313,86 +311,86 @@ Dashboard needs auth to be complete first.
const project = await parsePlanningDirectory(planning);
// Top-level structure
assertEq(project.path, planning, 'project.path matches');
assertTrue(project.project !== null, 'PROJECT.md parsed');
assertTrue(project.roadmap !== null, 'ROADMAP.md parsed');
assertTrue(project.requirements.length > 0, 'requirements parsed');
assertTrue(project.state !== null, 'STATE.md parsed');
assertTrue(project.config !== null, 'config.json parsed');
assert.deepStrictEqual(project.path, planning, 'project.path matches');
assert.ok(project.project !== null, 'PROJECT.md parsed');
assert.ok(project.roadmap !== null, 'ROADMAP.md parsed');
assert.ok(project.requirements.length > 0, 'requirements parsed');
assert.ok(project.state !== null, 'STATE.md parsed');
assert.ok(project.config !== null, 'config.json parsed');
// Phases
assertTrue('29-auth-system' in project.phases, 'phase 29 present');
assertTrue('30-dashboard' in project.phases, 'phase 30 present');
assert.ok('29-auth-system' in project.phases, 'phase 29 present');
assert.ok('30-dashboard' in project.phases, 'phase 30 present');
const phase29 = project.phases['29-auth-system'];
assertEq(phase29?.number, 29, 'phase 29 number');
assertEq(phase29?.slug, 'auth-system', 'phase 29 slug');
assertTrue('01' in (phase29?.plans ?? {}), 'phase 29 has plan 01');
assertTrue('01' in (phase29?.summaries ?? {}), 'phase 29 has summary 01');
assertTrue((phase29?.research?.length ?? 0) > 0, 'phase 29 has research');
assert.deepStrictEqual(phase29?.number, 29, 'phase 29 number');
assert.deepStrictEqual(phase29?.slug, 'auth-system', 'phase 29 slug');
assert.ok('01' in (phase29?.plans ?? {}), 'phase 29 has plan 01');
assert.ok('01' in (phase29?.summaries ?? {}), 'phase 29 has summary 01');
assert.ok((phase29?.research?.length ?? 0) > 0, 'phase 29 has research');
// Plan content (XML-in-markdown)
const plan29 = phase29?.plans?.['01'];
assertTrue(plan29 !== undefined, 'plan 29-01 exists');
assertTrue(plan29?.objective?.includes('authentication') ?? false, 'plan objective extracted');
assertTrue((plan29?.tasks?.length ?? 0) >= 3, 'plan tasks extracted');
assertTrue(plan29?.context?.includes('JWT') ?? false, 'plan context extracted');
assertTrue(plan29?.verification !== '', 'plan verification extracted');
assertTrue(plan29?.successCriteria !== '', 'plan success criteria extracted');
assert.ok(plan29 !== undefined, 'plan 29-01 exists');
assert.ok(plan29?.objective?.includes('authentication') ?? false, 'plan objective extracted');
assert.ok((plan29?.tasks?.length ?? 0) >= 3, 'plan tasks extracted');
assert.ok(plan29?.context?.includes('JWT') ?? false, 'plan context extracted');
assert.ok(plan29?.verification !== '', 'plan verification extracted');
assert.ok(plan29?.successCriteria !== '', 'plan success criteria extracted');
// Plan frontmatter
assertEq(plan29?.frontmatter?.phase, '29-auth-system', 'plan frontmatter phase');
assertEq(plan29?.frontmatter?.plan, '01', 'plan frontmatter plan');
assertEq(plan29?.frontmatter?.type, 'implementation', 'plan frontmatter type');
assertEq(plan29?.frontmatter?.wave, 1, 'plan frontmatter wave');
assertEq(plan29?.frontmatter?.autonomous, true, 'plan frontmatter autonomous');
assert.deepStrictEqual(plan29?.frontmatter?.phase, '29-auth-system', 'plan frontmatter phase');
assert.deepStrictEqual(plan29?.frontmatter?.plan, '01', 'plan frontmatter plan');
assert.deepStrictEqual(plan29?.frontmatter?.type, 'implementation', 'plan frontmatter type');
assert.deepStrictEqual(plan29?.frontmatter?.wave, 1, 'plan frontmatter wave');
assert.deepStrictEqual(plan29?.frontmatter?.autonomous, true, 'plan frontmatter autonomous');
// Summary content
const summary29 = phase29?.summaries?.['01'];
assertTrue(summary29 !== undefined, 'summary 29-01 exists');
assertEq(summary29?.frontmatter?.phase, '29-auth-system', 'summary frontmatter phase');
assertEq(summary29?.frontmatter?.plan, '01', 'summary frontmatter plan');
assertEq(summary29?.frontmatter?.subsystem, 'auth', 'summary frontmatter subsystem');
assertTrue((summary29?.frontmatter?.tags?.length ?? 0) >= 2, 'summary frontmatter tags');
assertTrue((summary29?.frontmatter?.provides?.length ?? 0) >= 2, 'summary frontmatter provides');
assertTrue((summary29?.frontmatter?.affects?.length ?? 0) >= 1, 'summary frontmatter affects');
assertTrue((summary29?.frontmatter?.['tech-stack']?.length ?? 0) >= 2, 'summary frontmatter tech-stack');
assertTrue((summary29?.frontmatter?.['key-files']?.length ?? 0) >= 2, 'summary frontmatter key-files');
assertTrue((summary29?.frontmatter?.['key-decisions']?.length ?? 0) >= 2, 'summary frontmatter key-decisions');
assertTrue((summary29?.frontmatter?.['patterns-established']?.length ?? 0) >= 1, 'summary frontmatter patterns-established');
assertEq(summary29?.frontmatter?.duration, '2h', 'summary frontmatter duration');
assertEq(summary29?.frontmatter?.completed, '2026-01-15', 'summary frontmatter completed');
assert.ok(summary29 !== undefined, 'summary 29-01 exists');
assert.deepStrictEqual(summary29?.frontmatter?.phase, '29-auth-system', 'summary frontmatter phase');
assert.deepStrictEqual(summary29?.frontmatter?.plan, '01', 'summary frontmatter plan');
assert.deepStrictEqual(summary29?.frontmatter?.subsystem, 'auth', 'summary frontmatter subsystem');
assert.ok((summary29?.frontmatter?.tags?.length ?? 0) >= 2, 'summary frontmatter tags');
assert.ok((summary29?.frontmatter?.provides?.length ?? 0) >= 2, 'summary frontmatter provides');
assert.ok((summary29?.frontmatter?.affects?.length ?? 0) >= 1, 'summary frontmatter affects');
assert.ok((summary29?.frontmatter?.['tech-stack']?.length ?? 0) >= 2, 'summary frontmatter tech-stack');
assert.ok((summary29?.frontmatter?.['key-files']?.length ?? 0) >= 2, 'summary frontmatter key-files');
assert.ok((summary29?.frontmatter?.['key-decisions']?.length ?? 0) >= 2, 'summary frontmatter key-decisions');
assert.ok((summary29?.frontmatter?.['patterns-established']?.length ?? 0) >= 1, 'summary frontmatter patterns-established');
assert.deepStrictEqual(summary29?.frontmatter?.duration, '2h', 'summary frontmatter duration');
assert.deepStrictEqual(summary29?.frontmatter?.completed, '2026-01-15', 'summary frontmatter completed');
// Quick tasks
assertTrue(project.quickTasks.length >= 1, 'quick tasks parsed');
assertEq(project.quickTasks[0]?.number, 1, 'quick task number');
assertTrue(project.quickTasks[0]?.plan !== null, 'quick task has plan');
assertTrue(project.quickTasks[0]?.summary !== null, 'quick task has summary');
assert.ok(project.quickTasks.length >= 1, 'quick tasks parsed');
assert.deepStrictEqual(project.quickTasks[0]?.number, 1, 'quick task number');
assert.ok(project.quickTasks[0]?.plan !== null, 'quick task has plan');
assert.ok(project.quickTasks[0]?.summary !== null, 'quick task has summary');
// Milestones
assertTrue(project.milestones.length >= 1, 'milestones parsed');
assert.ok(project.milestones.length >= 1, 'milestones parsed');
// Root research
assertTrue(project.research.length >= 1, 'root research parsed');
assert.ok(project.research.length >= 1, 'root research parsed');
// Config
assertEq(project.config?.projectName, 'test-project', 'config projectName');
assert.deepStrictEqual(project.config?.projectName, 'test-project', 'config projectName');
// State
assertTrue(project.state?.currentPhase?.includes('30') ?? false, 'state current phase');
assertEq(project.state?.status, 'in-progress', 'state status');
assert.ok(project.state?.currentPhase?.includes('30') ?? false, 'state current phase');
assert.deepStrictEqual(project.state?.status, 'in-progress', 'state status');
// Validation
assertEq(project.validation.valid, true, 'validation passes for complete dir');
assertEq(project.validation.issues.length, 0, 'no validation issues');
assert.deepStrictEqual(project.validation.valid, true, 'validation passes for complete dir');
assert.deepStrictEqual(project.validation.issues.length, 0, 'no validation issues');
} finally {
cleanup(base);
}
}
});
// ─── Test 2: Minimal .planning directory (only ROADMAP.md) ─────────────
console.log('\n=== Minimal .planning directory (only ROADMAP.md) ===');
{
test('Minimal .planning directory (only ROADMAP.md)', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -400,42 +398,42 @@ Dashboard needs auth to be complete first.
const project = await parsePlanningDirectory(planning);
assertEq(project.project, null, 'minimal: PROJECT.md is null');
assertTrue(project.roadmap !== null, 'minimal: ROADMAP.md parsed');
assertEq(project.requirements.length, 0, 'minimal: no requirements');
assertEq(project.state, null, 'minimal: no state');
assertEq(project.config, null, 'minimal: no config');
assertEq(Object.keys(project.phases).length, 0, 'minimal: no phases');
assertEq(project.quickTasks.length, 0, 'minimal: no quick tasks');
assertEq(project.milestones.length, 0, 'minimal: no milestones');
assertEq(project.research.length, 0, 'minimal: no research');
assertEq(project.validation.valid, true, 'minimal: validation passes');
assert.deepStrictEqual(project.project, null, 'minimal: PROJECT.md is null');
assert.ok(project.roadmap !== null, 'minimal: ROADMAP.md parsed');
assert.deepStrictEqual(project.requirements.length, 0, 'minimal: no requirements');
assert.deepStrictEqual(project.state, null, 'minimal: no state');
assert.deepStrictEqual(project.config, null, 'minimal: no config');
assert.deepStrictEqual(Object.keys(project.phases).length, 0, 'minimal: no phases');
assert.deepStrictEqual(project.quickTasks.length, 0, 'minimal: no quick tasks');
assert.deepStrictEqual(project.milestones.length, 0, 'minimal: no milestones');
assert.deepStrictEqual(project.research.length, 0, 'minimal: no research');
assert.deepStrictEqual(project.validation.valid, true, 'minimal: validation passes');
} finally {
cleanup(base);
}
}
});
// ─── Test 3: Missing directory → validation fatal error ────────────────
console.log('\n=== Missing directory → validation returns fatal error ===');
{
test('Missing directory → validation returns fatal error', async () => {
const base = createFixtureBase();
try {
const result = await validatePlanningDirectory(join(base, 'nonexistent'));
assertEq(result.valid, false, 'missing dir: validation fails');
assertTrue(result.issues.length > 0, 'missing dir: has issues');
assertTrue(
assert.deepStrictEqual(result.valid, false, 'missing dir: validation fails');
assert.ok(result.issues.length > 0, 'missing dir: has issues');
assert.ok(
result.issues.some(i => i.severity === 'fatal'),
'missing dir: has fatal issue'
);
} finally {
cleanup(base);
}
}
});
// ─── Test 4: Duplicate phase numbers ───────────────────────────────────
console.log('\n=== Phase directory with duplicate numbers ===');
{
test('Phase directory with duplicate numbers', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -456,18 +454,18 @@ Dashboard needs auth to be complete first.
const project = await parsePlanningDirectory(planning);
assertTrue('45-core-infrastructure' in project.phases, 'dup nums: core-infrastructure phase present');
assertTrue('45-logging-config' in project.phases, 'dup nums: logging-config phase present');
assertEq(project.phases['45-core-infrastructure']?.number, 45, 'dup nums: both have number 45 (a)');
assertEq(project.phases['45-logging-config']?.number, 45, 'dup nums: both have number 45 (b)');
assert.ok('45-core-infrastructure' in project.phases, 'dup nums: core-infrastructure phase present');
assert.ok('45-logging-config' in project.phases, 'dup nums: logging-config phase present');
assert.deepStrictEqual(project.phases['45-core-infrastructure']?.number, 45, 'dup nums: both have number 45 (a)');
assert.deepStrictEqual(project.phases['45-logging-config']?.number, 45, 'dup nums: both have number 45 (b)');
} finally {
cleanup(base);
}
}
});
// ─── Test 5: XML-in-markdown plan parsing ──────────────────────────────
console.log('\n=== Plan file with XML-in-markdown ===');
{
test('Plan file with XML-in-markdown', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -480,21 +478,21 @@ Dashboard needs auth to be complete first.
const project = await parsePlanningDirectory(planning);
const plan = project.phases['29-auth-system']?.plans?.['01'];
assertTrue(plan !== undefined, 'xml plan: plan exists');
assertTrue(plan?.objective?.includes('authentication') ?? false, 'xml plan: objective extracted');
assertTrue((plan?.tasks?.length ?? 0) === 3, 'xml plan: 3 tasks extracted');
assertTrue(plan?.tasks?.[0]?.includes('auth middleware') ?? false, 'xml plan: first task content');
assertTrue(plan?.context?.includes('JWT') ?? false, 'xml plan: context extracted');
assertTrue(plan?.verification?.includes('Login returns') ?? false, 'xml plan: verification extracted');
assertTrue(plan?.successCriteria?.includes('endpoints respond') ?? false, 'xml plan: success criteria extracted');
assert.ok(plan !== undefined, 'xml plan: plan exists');
assert.ok(plan?.objective?.includes('authentication') ?? false, 'xml plan: objective extracted');
assert.ok((plan?.tasks?.length ?? 0) === 3, 'xml plan: 3 tasks extracted');
assert.ok(plan?.tasks?.[0]?.includes('auth middleware') ?? false, 'xml plan: first task content');
assert.ok(plan?.context?.includes('JWT') ?? false, 'xml plan: context extracted');
assert.ok(plan?.verification?.includes('Login returns') ?? false, 'xml plan: verification extracted');
assert.ok(plan?.successCriteria?.includes('endpoints respond') ?? false, 'xml plan: success criteria extracted');
} finally {
cleanup(base);
}
}
});
// ─── Test 6: Summary file with YAML frontmatter ───────────────────────
console.log('\n=== Summary file with YAML frontmatter ===');
{
test('Summary file with YAML frontmatter', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -507,27 +505,27 @@ Dashboard needs auth to be complete first.
const project = await parsePlanningDirectory(planning);
const summary = project.phases['29-auth-system']?.summaries?.['01'];
assertTrue(summary !== undefined, 'summary fm: summary exists');
assertEq(summary?.frontmatter?.phase, '29-auth-system', 'summary fm: phase');
assertEq(summary?.frontmatter?.plan, '01', 'summary fm: plan');
assertEq(summary?.frontmatter?.subsystem, 'auth', 'summary fm: subsystem');
assertEq(summary?.frontmatter?.tags, ['authentication', 'security'], 'summary fm: tags');
assertEq(summary?.frontmatter?.provides, ['auth-middleware', 'jwt-validation'], 'summary fm: provides');
assertEq(summary?.frontmatter?.affects, ['api-routes'], 'summary fm: affects');
assertEq(summary?.frontmatter?.['tech-stack'], ['jsonwebtoken', 'express'], 'summary fm: tech-stack');
assertEq(summary?.frontmatter?.['key-files'], ['src/auth.ts', 'src/middleware/auth.ts'], 'summary fm: key-files');
assertEq(summary?.frontmatter?.['key-decisions'], ['Use RS256 for JWT signing', 'Store refresh tokens in DB'], 'summary fm: key-decisions');
assertEq(summary?.frontmatter?.['patterns-established'], ['Middleware-based auth'], 'summary fm: patterns-established');
assertEq(summary?.frontmatter?.duration, '2h', 'summary fm: duration');
assertEq(summary?.frontmatter?.completed, '2026-01-15', 'summary fm: completed');
assert.ok(summary !== undefined, 'summary fm: summary exists');
assert.deepStrictEqual(summary?.frontmatter?.phase, '29-auth-system', 'summary fm: phase');
assert.deepStrictEqual(summary?.frontmatter?.plan, '01', 'summary fm: plan');
assert.deepStrictEqual(summary?.frontmatter?.subsystem, 'auth', 'summary fm: subsystem');
assert.deepStrictEqual(summary?.frontmatter?.tags, ['authentication', 'security'], 'summary fm: tags');
assert.deepStrictEqual(summary?.frontmatter?.provides, ['auth-middleware', 'jwt-validation'], 'summary fm: provides');
assert.deepStrictEqual(summary?.frontmatter?.affects, ['api-routes'], 'summary fm: affects');
assert.deepStrictEqual(summary?.frontmatter?.['tech-stack'], ['jsonwebtoken', 'express'], 'summary fm: tech-stack');
assert.deepStrictEqual(summary?.frontmatter?.['key-files'], ['src/auth.ts', 'src/middleware/auth.ts'], 'summary fm: key-files');
assert.deepStrictEqual(summary?.frontmatter?.['key-decisions'], ['Use RS256 for JWT signing', 'Store refresh tokens in DB'], 'summary fm: key-decisions');
assert.deepStrictEqual(summary?.frontmatter?.['patterns-established'], ['Middleware-based auth'], 'summary fm: patterns-established');
assert.deepStrictEqual(summary?.frontmatter?.duration, '2h', 'summary fm: duration');
assert.deepStrictEqual(summary?.frontmatter?.completed, '2026-01-15', 'summary fm: completed');
} finally {
cleanup(base);
}
}
});
// ─── Test 7: Orphan summaries (no matching plan) ──────────────────────
console.log('\n=== Orphan summaries (no matching plan) ===');
{
test('Orphan summaries (no matching plan)', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -561,19 +559,19 @@ Another orphan.
const project = await parsePlanningDirectory(planning);
const phase = project.phases['45-logging-config'];
assertTrue(phase !== undefined, 'orphan: phase exists');
assertEq(Object.keys(phase?.plans ?? {}).length, 0, 'orphan: no plans');
assertTrue(Object.keys(phase?.summaries ?? {}).length >= 2, 'orphan: summaries preserved');
assertTrue('04' in (phase?.summaries ?? {}), 'orphan: summary 04 present');
assertTrue('05' in (phase?.summaries ?? {}), 'orphan: summary 05 present');
assert.ok(phase !== undefined, 'orphan: phase exists');
assert.deepStrictEqual(Object.keys(phase?.plans ?? {}).length, 0, 'orphan: no plans');
assert.ok(Object.keys(phase?.summaries ?? {}).length >= 2, 'orphan: summaries preserved');
assert.ok('04' in (phase?.summaries ?? {}), 'orphan: summary 04 present');
assert.ok('05' in (phase?.summaries ?? {}), 'orphan: summary 05 present');
} finally {
cleanup(base);
}
}
});
// ─── Test 8: .archive/ directory skipped ──────────────────────────────
console.log('\n=== .archive/ directory → skipped by default ===');
{
test('.archive/ directory → skipped by default', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -591,17 +589,17 @@ Another orphan.
const project = await parsePlanningDirectory(planning);
assertTrue('29-auth-system' in project.phases, 'archive: normal phase present');
assert.ok('29-auth-system' in project.phases, 'archive: normal phase present');
// Archive phases should not appear in the phases map
assertTrue(!Object.keys(project.phases).some(k => k.includes('old-auth')), 'archive: archived phase not present');
assert.ok(!Object.keys(project.phases).some(k => k.includes('old-auth')), 'archive: archived phase not present');
} finally {
cleanup(base);
}
}
});
// ─── Test 9: Quick tasks ──────────────────────────────────────────────
console.log('\n=== Quick tasks parsed ===');
{
test('Quick tasks parsed', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -620,22 +618,22 @@ Another orphan.
const project = await parsePlanningDirectory(planning);
assertEq(project.quickTasks.length, 2, 'quick: 2 quick tasks');
assertEq(project.quickTasks[0]?.number, 1, 'quick: first task number');
assertEq(project.quickTasks[0]?.slug, 'fix-login', 'quick: first task slug');
assertTrue(project.quickTasks[0]?.plan !== null, 'quick: first task has plan');
assertTrue(project.quickTasks[0]?.summary !== null, 'quick: first task has summary');
assertEq(project.quickTasks[1]?.number, 2, 'quick: second task number');
assertTrue(project.quickTasks[1]?.plan !== null, 'quick: second task has plan');
assertEq(project.quickTasks[1]?.summary, null, 'quick: second task has no summary');
assert.deepStrictEqual(project.quickTasks.length, 2, 'quick: 2 quick tasks');
assert.deepStrictEqual(project.quickTasks[0]?.number, 1, 'quick: first task number');
assert.deepStrictEqual(project.quickTasks[0]?.slug, 'fix-login', 'quick: first task slug');
assert.ok(project.quickTasks[0]?.plan !== null, 'quick: first task has plan');
assert.ok(project.quickTasks[0]?.summary !== null, 'quick: first task has summary');
assert.deepStrictEqual(project.quickTasks[1]?.number, 2, 'quick: second task number');
assert.ok(project.quickTasks[1]?.plan !== null, 'quick: second task has plan');
assert.deepStrictEqual(project.quickTasks[1]?.summary, null, 'quick: second task has no summary');
} finally {
cleanup(base);
}
}
});
// ─── Test 10: Roadmap with milestone sections and <details> ────────────
console.log('\n=== Roadmap with milestone sections and <details> blocks ===');
{
test('Roadmap with milestone sections and <details> blocks', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -643,35 +641,35 @@ Another orphan.
const project = await parsePlanningDirectory(planning);
assertTrue(project.roadmap !== null, 'ms roadmap: roadmap parsed');
assertTrue((project.roadmap?.milestones?.length ?? 0) >= 2, 'ms roadmap: has milestone sections');
assert.ok(project.roadmap !== null, 'ms roadmap: roadmap parsed');
assert.ok((project.roadmap?.milestones?.length ?? 0) >= 2, 'ms roadmap: has milestone sections');
// Check collapsed milestone
const v20 = project.roadmap?.milestones?.find(m => m.id.includes('2.0'));
assertTrue(v20 !== undefined, 'ms roadmap: v2.0 milestone found');
assertEq(v20?.collapsed, true, 'ms roadmap: v2.0 is collapsed');
assertTrue((v20?.phases?.length ?? 0) >= 2, 'ms roadmap: v2.0 has phases');
assertTrue(v20?.phases?.every(p => p.done) ?? false, 'ms roadmap: v2.0 phases all done');
assert.ok(v20 !== undefined, 'ms roadmap: v2.0 milestone found');
assert.deepStrictEqual(v20?.collapsed, true, 'ms roadmap: v2.0 is collapsed');
assert.ok((v20?.phases?.length ?? 0) >= 2, 'ms roadmap: v2.0 has phases');
assert.ok(v20?.phases?.every(p => p.done) ?? false, 'ms roadmap: v2.0 phases all done');
// Check active milestone
const v25 = project.roadmap?.milestones?.find(m => m.id.includes('2.5'));
assertTrue(v25 !== undefined, 'ms roadmap: v2.5 milestone found');
assertEq(v25?.collapsed, false, 'ms roadmap: v2.5 is not collapsed');
assertTrue((v25?.phases?.length ?? 0) >= 3, 'ms roadmap: v2.5 has phases');
assert.ok(v25 !== undefined, 'ms roadmap: v2.5 milestone found');
assert.deepStrictEqual(v25?.collapsed, false, 'ms roadmap: v2.5 is not collapsed');
assert.ok((v25?.phases?.length ?? 0) >= 3, 'ms roadmap: v2.5 has phases');
// Check completion state
const phase29 = v25?.phases?.find(p => p.number === 29);
assertTrue(phase29?.done === true, 'ms roadmap: phase 29 is done');
assert.ok(phase29?.done === true, 'ms roadmap: phase 29 is done');
const phase30 = v25?.phases?.find(p => p.number === 30);
assertTrue(phase30?.done === false, 'ms roadmap: phase 30 is not done');
assert.ok(phase30?.done === false, 'ms roadmap: phase 30 is not done');
} finally {
cleanup(base);
}
}
});
// ─── Test 11: Non-standard phase files → extra files ──────────────────
console.log('\n=== Non-standard phase files → collected as extra files ===');
{
test('Non-standard phase files → collected as extra files', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -687,28 +685,28 @@ Another orphan.
const project = await parsePlanningDirectory(planning);
const phase = project.phases['36-attachment-system'];
assertTrue(phase !== undefined, 'extra: phase exists');
assertTrue((phase?.extraFiles?.length ?? 0) >= 3, 'extra: non-standard files collected');
assertTrue(
assert.ok(phase !== undefined, 'extra: phase exists');
assert.ok((phase?.extraFiles?.length ?? 0) >= 3, 'extra: non-standard files collected');
assert.ok(
phase?.extraFiles?.some(f => f.fileName === 'BASELINE.md') ?? false,
'extra: BASELINE.md collected'
);
assertTrue(
assert.ok(
phase?.extraFiles?.some(f => f.fileName === 'BUNDLE-ANALYSIS.md') ?? false,
'extra: BUNDLE-ANALYSIS.md collected'
);
assertTrue(
assert.ok(
phase?.extraFiles?.some(f => f.fileName === 'depcheck-results.txt') ?? false,
'extra: depcheck-results.txt collected'
);
} finally {
cleanup(base);
}
}
});
// ─── Test 12: Validation — missing ROADMAP.md → warning (not fatal) ───
console.log('\n=== Validation: missing ROADMAP.md → warning (not fatal) ===');
{
test('Validation: missing ROADMAP.md → warning (not fatal)', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -717,19 +715,19 @@ Another orphan.
const result = await validatePlanningDirectory(planning);
assertEq(result.valid, true, 'no roadmap: validation still passes');
assertTrue(
assert.deepStrictEqual(result.valid, true, 'no roadmap: validation still passes');
assert.ok(
result.issues.some(i => i.severity === 'warning' && i.file.includes('ROADMAP')),
'no roadmap: warning issue mentions ROADMAP'
);
} finally {
cleanup(base);
}
}
});
// ─── Test 13: Validation — missing PROJECT.md → warning ───────────────
console.log('\n=== Validation: missing PROJECT.md → warning ===');
{
test('Validation: missing PROJECT.md → warning', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -738,20 +736,13 @@ Another orphan.
const result = await validatePlanningDirectory(planning);
assertEq(result.valid, true, 'no project: validation passes (warning only)');
assertTrue(
assert.deepStrictEqual(result.valid, true, 'no project: validation passes (warning only)');
assert.ok(
result.issues.some(i => i.severity === 'warning' && i.file.includes('PROJECT')),
'no project: warning issue mentions PROJECT'
);
} finally {
cleanup(base);
}
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -19,9 +19,9 @@ import type {
GSDSlice,
GSDTask,
} from '../migrate/types.ts';
import { createTestContext } from './test-helpers.ts';
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
const { assertEq, assertTrue, report } = createTestContext();
// ─── Fixture Helpers ───────────────────────────────────────────────────────
function emptyProject(overrides: Partial<PlanningProject> = {}): PlanningProject {
@ -134,8 +134,7 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
// ─── Scenario 1: Flat Single-Milestone (3 phases → M001 with S01/S02/S03) ──
{
console.log('Scenario 1: Flat single-milestone');
test('Scenario 1: Flat single-milestone', () => {
const project = emptyProject({
project: '# My Project\nA cool project.',
@ -159,26 +158,25 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
const result = transformToGSD(project);
assertEq(result.milestones.length, 1, 'flat: produces 1 milestone');
assertTrue(result.milestones[0]?.id === 'M001', 'flat: milestone ID is M001');
assertEq(result.milestones[0]?.slices.length, 3, 'flat: 3 slices');
assertEq(result.milestones[0]?.slices[0]?.id, 'S01', 'flat: first slice is S01');
assertEq(result.milestones[0]?.slices[1]?.id, 'S02', 'flat: second slice is S02');
assertEq(result.milestones[0]?.slices[2]?.id, 'S03', 'flat: third slice is S03');
assertTrue(result.milestones[0]?.slices[0]?.title.length > 0, 'flat: slice title not empty');
assertEq(result.milestones[0]?.slices[0]?.tasks.length, 1, 'flat: S01 has 1 task');
assertEq(result.milestones[0]?.slices[1]?.tasks.length, 2, 'flat: S02 has 2 tasks');
assertEq(result.milestones[0]?.slices[2]?.tasks.length, 1, 'flat: S03 has 1 task');
assertEq(result.milestones[0]?.slices[0]?.tasks[0]?.id, 'T01', 'flat: first task is T01');
assertEq(result.milestones[0]?.slices[1]?.tasks[1]?.id, 'T02', 'flat: second task in S02 is T02');
assertTrue(result.projectContent.includes('My Project'), 'flat: projectContent preserved');
assertEq(result.milestones[0]?.boundaryMap, [], 'flat: boundaryMap defaults to empty');
}
assert.deepStrictEqual(result.milestones.length, 1, 'flat: produces 1 milestone');
assert.ok(result.milestones[0]?.id === 'M001', 'flat: milestone ID is M001');
assert.deepStrictEqual(result.milestones[0]?.slices.length, 3, 'flat: 3 slices');
assert.deepStrictEqual(result.milestones[0]?.slices[0]?.id, 'S01', 'flat: first slice is S01');
assert.deepStrictEqual(result.milestones[0]?.slices[1]?.id, 'S02', 'flat: second slice is S02');
assert.deepStrictEqual(result.milestones[0]?.slices[2]?.id, 'S03', 'flat: third slice is S03');
assert.ok(result.milestones[0]?.slices[0]?.title.length > 0, 'flat: slice title not empty');
assert.deepStrictEqual(result.milestones[0]?.slices[0]?.tasks.length, 1, 'flat: S01 has 1 task');
assert.deepStrictEqual(result.milestones[0]?.slices[1]?.tasks.length, 2, 'flat: S02 has 2 tasks');
assert.deepStrictEqual(result.milestones[0]?.slices[2]?.tasks.length, 1, 'flat: S03 has 1 task');
assert.deepStrictEqual(result.milestones[0]?.slices[0]?.tasks[0]?.id, 'T01', 'flat: first task is T01');
assert.deepStrictEqual(result.milestones[0]?.slices[1]?.tasks[1]?.id, 'T02', 'flat: second task in S02 is T02');
assert.ok(result.projectContent.includes('My Project'), 'flat: projectContent preserved');
assert.deepStrictEqual(result.milestones[0]?.boundaryMap, [], 'flat: boundaryMap defaults to empty');
});
// ─── Scenario 2: Multi-Milestone (2 milestones with independent numbering) ──
{
console.log('Scenario 2: Multi-milestone');
test('Scenario 2: Multi-milestone', () => {
const project = emptyProject({
roadmap: milestoneRoadmap([
@ -206,23 +204,22 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
const result = transformToGSD(project);
assertEq(result.milestones.length, 2, 'multi: 2 milestones');
assertEq(result.milestones[0]?.id, 'M001', 'multi: first milestone M001');
assertEq(result.milestones[1]?.id, 'M002', 'multi: second milestone M002');
assertEq(result.milestones[0]?.slices.length, 2, 'multi: M001 has 2 slices');
assertEq(result.milestones[1]?.slices.length, 3, 'multi: M002 has 3 slices');
assert.deepStrictEqual(result.milestones.length, 2, 'multi: 2 milestones');
assert.deepStrictEqual(result.milestones[0]?.id, 'M001', 'multi: first milestone M001');
assert.deepStrictEqual(result.milestones[1]?.id, 'M002', 'multi: second milestone M002');
assert.deepStrictEqual(result.milestones[0]?.slices.length, 2, 'multi: M001 has 2 slices');
assert.deepStrictEqual(result.milestones[1]?.slices.length, 3, 'multi: M002 has 3 slices');
// Independent numbering: both start at S01
assertEq(result.milestones[0]?.slices[0]?.id, 'S01', 'multi: M001 starts at S01');
assertEq(result.milestones[1]?.slices[0]?.id, 'S01', 'multi: M002 starts at S01');
assertEq(result.milestones[1]?.slices[2]?.id, 'S03', 'multi: M002 third slice is S03');
assertTrue(result.milestones[0]?.title.length > 0, 'multi: M001 has title');
assertTrue(result.milestones[1]?.title.length > 0, 'multi: M002 has title');
}
assert.deepStrictEqual(result.milestones[0]?.slices[0]?.id, 'S01', 'multi: M001 starts at S01');
assert.deepStrictEqual(result.milestones[1]?.slices[0]?.id, 'S01', 'multi: M002 starts at S01');
assert.deepStrictEqual(result.milestones[1]?.slices[2]?.id, 'S03', 'multi: M002 third slice is S03');
assert.ok(result.milestones[0]?.title.length > 0, 'multi: M001 has title');
assert.ok(result.milestones[1]?.title.length > 0, 'multi: M002 has title');
});
// ─── Scenario 3: Decimal Phase Ordering (1, 2, 2.1, 2.2, 3 → S01S05) ──
{
console.log('Scenario 3: Decimal phase ordering');
test('Scenario 3: Decimal phase ordering', () => {
const project = emptyProject({
roadmap: flatRoadmap([
@ -243,27 +240,26 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
const result = transformToGSD(project);
assertEq(result.milestones[0]?.slices.length, 5, 'decimal: 5 slices total');
assertEq(result.milestones[0]?.slices[0]?.id, 'S01', 'decimal: first is S01');
assertEq(result.milestones[0]?.slices[1]?.id, 'S02', 'decimal: second is S02');
assertEq(result.milestones[0]?.slices[2]?.id, 'S03', 'decimal: third is S03');
assertEq(result.milestones[0]?.slices[3]?.id, 'S04', 'decimal: fourth is S04');
assertEq(result.milestones[0]?.slices[4]?.id, 'S05', 'decimal: fifth is S05');
assert.deepStrictEqual(result.milestones[0]?.slices.length, 5, 'decimal: 5 slices total');
assert.deepStrictEqual(result.milestones[0]?.slices[0]?.id, 'S01', 'decimal: first is S01');
assert.deepStrictEqual(result.milestones[0]?.slices[1]?.id, 'S02', 'decimal: second is S02');
assert.deepStrictEqual(result.milestones[0]?.slices[2]?.id, 'S03', 'decimal: third is S03');
assert.deepStrictEqual(result.milestones[0]?.slices[3]?.id, 'S04', 'decimal: fourth is S04');
assert.deepStrictEqual(result.milestones[0]?.slices[4]?.id, 'S05', 'decimal: fifth is S05');
// Order must be by float value: 1, 2, 2.1, 2.2, 3
assertTrue(
assert.ok(
result.milestones[0]?.slices[0]?.title.toLowerCase().includes('foundation'),
'decimal: S01 is foundation (phase 1)',
);
assertTrue(
assert.ok(
result.milestones[0]?.slices[4]?.title.toLowerCase().includes('finalize'),
'decimal: S05 is finalize (phase 3)',
);
}
});
// ─── Scenario 4: Completion State ──────────────────────────────────────────
{
console.log('Scenario 4: Completion state mapping');
test('Scenario 4: Completion state mapping', () => {
const project = emptyProject({
roadmap: flatRoadmap([
@ -288,26 +284,25 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
const doneSlice = result.milestones[0]?.slices[0];
const activeSlice = result.milestones[0]?.slices[1];
assertTrue(doneSlice?.done === true, 'completion: done phase → done slice');
assertTrue(activeSlice?.done === false, 'completion: active phase → not-done slice');
assertTrue(doneSlice?.tasks[0]?.done === true, 'completion: plan with summary → done task');
assertTrue(doneSlice?.tasks[1]?.done === false, 'completion: plan without summary → not-done task');
assertTrue(doneSlice?.tasks[0]?.summary !== null, 'completion: done task has summary data');
assertTrue(doneSlice?.tasks[1]?.summary === null, 'completion: not-done task has null summary');
assertEq(doneSlice?.tasks[0]?.summary?.completedAt, '2026-01-15', 'completion: summary completedAt from frontmatter');
assertEq(doneSlice?.tasks[0]?.summary?.duration, '2h', 'completion: summary duration from frontmatter');
assertEq(doneSlice?.tasks[0]?.summary?.provides, ['feature-01'], 'completion: summary provides from frontmatter');
assertEq(doneSlice?.tasks[0]?.summary?.keyFiles, ['file-01.ts'], 'completion: summary keyFiles from frontmatter');
assertTrue(doneSlice?.tasks[0]?.summary?.whatHappened?.includes('Summary body') ?? false, 'completion: summary whatHappened from body');
assertTrue(doneSlice?.summary !== null, 'completion: done slice has slice summary');
assertTrue(activeSlice?.summary === null, 'completion: active slice has null summary');
assertEq(doneSlice?.tasks[0]?.estimate, '2h', 'completion: task estimate from summary duration');
}
assert.ok(doneSlice?.done === true, 'completion: done phase → done slice');
assert.ok(activeSlice?.done === false, 'completion: active phase → not-done slice');
assert.ok(doneSlice?.tasks[0]?.done === true, 'completion: plan with summary → done task');
assert.ok(doneSlice?.tasks[1]?.done === false, 'completion: plan without summary → not-done task');
assert.ok(doneSlice?.tasks[0]?.summary !== null, 'completion: done task has summary data');
assert.ok(doneSlice?.tasks[1]?.summary === null, 'completion: not-done task has null summary');
assert.deepStrictEqual(doneSlice?.tasks[0]?.summary?.completedAt, '2026-01-15', 'completion: summary completedAt from frontmatter');
assert.deepStrictEqual(doneSlice?.tasks[0]?.summary?.duration, '2h', 'completion: summary duration from frontmatter');
assert.deepStrictEqual(doneSlice?.tasks[0]?.summary?.provides, ['feature-01'], 'completion: summary provides from frontmatter');
assert.deepStrictEqual(doneSlice?.tasks[0]?.summary?.keyFiles, ['file-01.ts'], 'completion: summary keyFiles from frontmatter');
assert.ok(doneSlice?.tasks[0]?.summary?.whatHappened?.includes('Summary body') ?? false, 'completion: summary whatHappened from body');
assert.ok(doneSlice?.summary !== null, 'completion: done slice has slice summary');
assert.ok(activeSlice?.summary === null, 'completion: active slice has null summary');
assert.deepStrictEqual(doneSlice?.tasks[0]?.estimate, '2h', 'completion: task estimate from summary duration');
});
// ─── Scenario 5: Research Consolidation ────────────────────────────────────
{
console.log('Scenario 5: Research consolidation');
test('Scenario 5: Research consolidation', () => {
const project = emptyProject({
roadmap: flatRoadmap([roadmapEntry(1, 'researched-phase')]),
@ -328,28 +323,27 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
const result = transformToGSD(project);
// Project-level research → milestone research
assertTrue(result.milestones[0]?.research !== null, 'research: milestone has consolidated research');
assertTrue(result.milestones[0]?.research!.includes('Project Summary'), 'research: includes SUMMARY content');
assertTrue(result.milestones[0]?.research!.includes('Architecture'), 'research: includes ARCHITECTURE content');
assertTrue(result.milestones[0]?.research!.includes('Pitfalls'), 'research: includes PITFALLS content');
assert.ok(result.milestones[0]?.research !== null, 'research: milestone has consolidated research');
assert.ok(result.milestones[0]?.research!.includes('Project Summary'), 'research: includes SUMMARY content');
assert.ok(result.milestones[0]?.research!.includes('Architecture'), 'research: includes ARCHITECTURE content');
assert.ok(result.milestones[0]?.research!.includes('Pitfalls'), 'research: includes PITFALLS content');
// Fixed ordering: SUMMARY before ARCHITECTURE before PITFALLS
const summaryIdx = result.milestones[0]?.research!.indexOf('Project Summary') ?? -1;
const archIdx = result.milestones[0]?.research!.indexOf('Architecture') ?? -1;
const pitfallIdx = result.milestones[0]?.research!.indexOf('Pitfalls') ?? -1;
assertTrue(summaryIdx < archIdx, 'research: SUMMARY before ARCHITECTURE in consolidated');
assertTrue(archIdx < pitfallIdx, 'research: ARCHITECTURE before PITFALLS in consolidated');
assert.ok(summaryIdx < archIdx, 'research: SUMMARY before ARCHITECTURE in consolidated');
assert.ok(archIdx < pitfallIdx, 'research: ARCHITECTURE before PITFALLS in consolidated');
// Phase-level research → slice research
const slice = result.milestones[0]?.slices[0];
assertTrue(slice?.research !== null, 'research: slice has phase research');
assertTrue(slice?.research!.includes('Phase Features'), 'research: slice research includes phase content');
}
assert.ok(slice?.research !== null, 'research: slice has phase research');
assert.ok(slice?.research!.includes('Phase Features'), 'research: slice research includes phase content');
});
// ─── Scenario 6: Requirements Classification ──────────────────────────────
{
console.log('Scenario 6: Requirements classification');
test('Scenario 6: Requirements classification', () => {
const project = emptyProject({
roadmap: flatRoadmap([roadmapEntry(1, 'req-phase')]),
@ -365,22 +359,21 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
const result = transformToGSD(project);
assertEq(result.requirements.length, 3, 'requirements: 3 requirements');
assertEq(result.requirements[0]?.id, 'R001', 'requirements: first is R001');
assertEq(result.requirements[0]?.status, 'active', 'requirements: R001 status active');
assertEq(result.requirements[1]?.status, 'validated', 'requirements: R002 status validated');
assertEq(result.requirements[2]?.status, 'deferred', 'requirements: R003 status deferred');
assertTrue(result.requirements[0]?.title === 'Core Feature', 'requirements: R001 title preserved');
assertTrue(result.requirements[0]?.description.includes('Description for R001'), 'requirements: R001 description preserved');
assertEq(result.requirements[0]?.class, 'core-capability', 'requirements: default class');
assertEq(result.requirements[0]?.source, 'inferred', 'requirements: default source');
assertEq(result.requirements[0]?.primarySlice, 'none yet', 'requirements: default primarySlice');
}
assert.deepStrictEqual(result.requirements.length, 3, 'requirements: 3 requirements');
assert.deepStrictEqual(result.requirements[0]?.id, 'R001', 'requirements: first is R001');
assert.deepStrictEqual(result.requirements[0]?.status, 'active', 'requirements: R001 status active');
assert.deepStrictEqual(result.requirements[1]?.status, 'validated', 'requirements: R002 status validated');
assert.deepStrictEqual(result.requirements[2]?.status, 'deferred', 'requirements: R003 status deferred');
assert.ok(result.requirements[0]?.title === 'Core Feature', 'requirements: R001 title preserved');
assert.ok(result.requirements[0]?.description.includes('Description for R001'), 'requirements: R001 description preserved');
assert.deepStrictEqual(result.requirements[0]?.class, 'core-capability', 'requirements: default class');
assert.deepStrictEqual(result.requirements[0]?.source, 'inferred', 'requirements: default source');
assert.deepStrictEqual(result.requirements[0]?.primarySlice, 'none yet', 'requirements: default primarySlice');
});
// ─── Scenario 7: Empty Phase (no plans → slice with 0 tasks) ───────────────
{
console.log('Scenario 7: Empty phase');
test('Scenario 7: Empty phase', () => {
const project = emptyProject({
roadmap: flatRoadmap([
@ -397,15 +390,14 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
const result = transformToGSD(project);
assertEq(result.milestones[0]?.slices[0]?.tasks.length, 0, 'empty: empty phase → 0 tasks');
assertEq(result.milestones[0]?.slices[1]?.tasks.length, 1, 'empty: non-empty phase → 1 task');
assertTrue(result.milestones[0]?.slices[0]?.id === 'S01', 'empty: empty slice still gets ID');
}
assert.deepStrictEqual(result.milestones[0]?.slices[0]?.tasks.length, 0, 'empty: empty phase → 0 tasks');
assert.deepStrictEqual(result.milestones[0]?.slices[1]?.tasks.length, 1, 'empty: non-empty phase → 1 task');
assert.ok(result.milestones[0]?.slices[0]?.id === 'S01', 'empty: empty slice still gets ID');
});
// ─── Scenario 8: Demo Derivation from Plan Objective ───────────────────────
{
console.log('Scenario 8: Demo derivation');
test('Scenario 8: Demo derivation', () => {
const project = emptyProject({
roadmap: flatRoadmap([roadmapEntry(1, 'demo-phase')]),
@ -420,19 +412,18 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
const result = transformToGSD(project);
assertTrue(result.milestones[0]?.slices[0]?.demo.length > 0, 'demo: slice demo is not empty');
assertTrue(
assert.ok(result.milestones[0]?.slices[0]?.demo.length > 0, 'demo: slice demo is not empty');
assert.ok(
result.milestones[0]?.slices[0]?.demo.includes('authentication') ||
result.milestones[0]?.slices[0]?.demo.includes('Build'),
'demo: slice demo derived from first plan objective',
);
assertTrue(result.milestones[0]?.slices[0]?.goal.length > 0, 'demo: slice goal is not empty');
}
assert.ok(result.milestones[0]?.slices[0]?.goal.length > 0, 'demo: slice goal is not empty');
});
// ─── Scenario 9: Field Defaults and Type Safety ────────────────────────────
{
console.log('Scenario 9: Field defaults');
test('Scenario 9: Field defaults', () => {
const project = emptyProject({
roadmap: flatRoadmap([roadmapEntry(1, 'defaults-phase')]),
@ -460,20 +451,19 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
const slice = result.milestones[0]?.slices[0];
const task = slice?.tasks[0];
assertEq(slice?.risk, 'medium', 'defaults: slice risk defaults to medium');
assertEq(slice?.depends, [], 'defaults: S01 has no depends');
assertTrue(task?.description.length > 0, 'defaults: task description not empty');
assertEq(task?.files, ['src/auth.ts', 'src/db.ts'], 'defaults: task files from frontmatter');
assertEq(task?.mustHaves, ['Auth works', 'DB connected'], 'defaults: task mustHaves from frontmatter');
assertEq(task?.done, false, 'defaults: task without summary is not done');
assertEq(task?.estimate, '', 'defaults: task without summary has empty estimate');
assertTrue(task?.summary === null, 'defaults: task without summary has null summary');
}
assert.deepStrictEqual(slice?.risk, 'medium', 'defaults: slice risk defaults to medium');
assert.deepStrictEqual(slice?.depends, [], 'defaults: S01 has no depends');
assert.ok(task?.description.length > 0, 'defaults: task description not empty');
assert.deepStrictEqual(task?.files, ['src/auth.ts', 'src/db.ts'], 'defaults: task files from frontmatter');
assert.deepStrictEqual(task?.mustHaves, ['Auth works', 'DB connected'], 'defaults: task mustHaves from frontmatter');
assert.deepStrictEqual(task?.done, false, 'defaults: task without summary is not done');
assert.deepStrictEqual(task?.estimate, '', 'defaults: task without summary has empty estimate');
assert.ok(task?.summary === null, 'defaults: task without summary has null summary');
});
// ─── Scenario 10: Sequential Depends ──────────────────────────────────────
{
console.log('Scenario 10: Sequential depends');
test('Scenario 10: Sequential depends', () => {
const project = emptyProject({
roadmap: flatRoadmap([
@ -491,15 +481,14 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
const result = transformToGSD(project);
const slices = result.milestones[0]?.slices;
assertEq(slices?.[0]?.depends, [], 'depends: S01 has empty depends');
assertEq(slices?.[1]?.depends, ['S01'], 'depends: S02 depends on S01');
assertEq(slices?.[2]?.depends, ['S02'], 'depends: S03 depends on S02');
}
assert.deepStrictEqual(slices?.[0]?.depends, [], 'depends: S01 has empty depends');
assert.deepStrictEqual(slices?.[1]?.depends, ['S01'], 'depends: S02 depends on S01');
assert.deepStrictEqual(slices?.[2]?.depends, ['S02'], 'depends: S03 depends on S02');
});
// ─── Scenario 11: Requirements with unknown status and missing IDs ─────────
{
console.log('Scenario 11: Requirements edge cases');
test('Scenario 11: Requirements edge cases', () => {
const project = emptyProject({
roadmap: flatRoadmap([roadmapEntry(1, 'req-edge')]),
@ -516,17 +505,16 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
const result = transformToGSD(project);
assertEq(result.requirements[0]?.id, 'R001', 'req-edge: empty id gets R001');
assertEq(result.requirements[1]?.id, 'R002', 'req-edge: second empty id gets R002');
assertEq(result.requirements[2]?.id, 'R005', 'req-edge: existing id preserved');
assertEq(result.requirements[2]?.status, 'active', 'req-edge: unknown status normalized to active');
assertEq(result.requirements[3]?.status, 'deferred', 'req-edge: uppercase DEFERRED normalized');
}
assert.deepStrictEqual(result.requirements[0]?.id, 'R001', 'req-edge: empty id gets R001');
assert.deepStrictEqual(result.requirements[1]?.id, 'R002', 'req-edge: second empty id gets R002');
assert.deepStrictEqual(result.requirements[2]?.id, 'R005', 'req-edge: existing id preserved');
assert.deepStrictEqual(result.requirements[2]?.status, 'active', 'req-edge: unknown status normalized to active');
assert.deepStrictEqual(result.requirements[3]?.status, 'deferred', 'req-edge: uppercase DEFERRED normalized');
});
// ─── Scenario 12: Vision derivation ────────────────────────────────────────
{
console.log('Scenario 12: Vision derivation');
test('Scenario 12: Vision derivation', () => {
// Vision from project description
const project1 = emptyProject({
@ -536,7 +524,7 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
});
const result1 = transformToGSD(project1);
assertTrue(result1.milestones[0]?.vision.includes('revolutionary'), 'vision: derived from project first line');
assert.ok(result1.milestones[0]?.vision.includes('revolutionary'), 'vision: derived from project first line');
// Vision fallback when no project
const project2 = emptyProject({
@ -545,13 +533,12 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
});
const result2 = transformToGSD(project2);
assertTrue(result2.milestones[0]?.vision.length > 0, 'vision: fallback is non-empty');
}
assert.ok(result2.milestones[0]?.vision.length > 0, 'vision: fallback is non-empty');
});
// ─── Scenario 13: Decisions content from summaries ─────────────────────────
{
console.log('Scenario 13: Decisions content');
test('Scenario 13: Decisions content', () => {
const project = emptyProject({
roadmap: flatRoadmap([roadmapEntry(1, 'decision-phase', true)]),
@ -565,13 +552,12 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
const result = transformToGSD(project);
assertTrue(result.decisionsContent.includes('decision-01'), 'decisions: extracts key-decisions from summaries');
}
assert.ok(result.decisionsContent.includes('decision-01'), 'decisions: extracts key-decisions from summaries');
});
// ─── Scenario 14: No undefined values in output ───────────────────────────
{
console.log('Scenario 14: No undefined values');
test('Scenario 14: No undefined values', () => {
const project = emptyProject({
project: '# Test\nDescription.',
@ -596,7 +582,7 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
// Deep check for undefined values
function checkNoUndefined(obj: unknown, path: string): void {
if (obj === undefined) {
assertTrue(false, `no-undefined: ${path} is undefined`);
assert.ok(false, `no-undefined: ${path} is undefined`);
return;
}
if (obj === null) return; // null is allowed (e.g. research, summary)
@ -612,13 +598,12 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
}
checkNoUndefined(result, 'result');
assertTrue(true, 'no-undefined: deep check completed without finding undefined values');
}
assert.ok(true, 'no-undefined: deep check completed without finding undefined values');
});
// ─── Scenario 15: Research with no files ───────────────────────────────────
{
console.log('Scenario 15: Empty research');
test('Scenario 15: Empty research', () => {
const project = emptyProject({
roadmap: flatRoadmap([roadmapEntry(1, 'no-research')]),
@ -626,10 +611,9 @@ function makeResearch(fileName: string, content: string): PlanningResearch {
});
const result = transformToGSD(project);
assertTrue(result.milestones[0]?.research === null, 'empty-research: milestone research is null');
assertTrue(result.milestones[0]?.slices[0]?.research === null, 'empty-research: slice research is null');
}
assert.ok(result.milestones[0]?.research === null, 'empty-research: milestone research is null');
assert.ok(result.milestones[0]?.slices[0]?.research === null, 'empty-research: slice research is null');
});
// ─── Results ───────────────────────────────────────────────────────────────
report();

View file

@ -15,9 +15,9 @@ import {
parseOldState,
parseOldConfig,
} from '../migrate/parsers.ts';
import { createTestContext } from './test-helpers.ts';
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
const { assertEq, assertTrue, report } = createTestContext();
function createFixtureBase(): string {
return mkdtempSync(join(tmpdir(), 'gsd-migrate-t02-'));
}
@ -173,55 +173,49 @@ const SAMPLE_STATE = `# State
**Status:** in-progress
`;
async function main(): Promise<void> {
// ═══════════════════════════════════════════════════════════════════════
// Validator Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== Validator: missing directory → fatal ===');
{
test('Validator: missing directory → fatal', async () => {
const base = createFixtureBase();
try {
const result = await validatePlanningDirectory(join(base, 'nonexistent'));
assertEq(result.valid, false, 'missing dir: validation fails');
assertTrue(result.issues.length > 0, 'missing dir: has issues');
assertTrue(result.issues.some(i => i.severity === 'fatal'), 'missing dir: has fatal issue');
assert.deepStrictEqual(result.valid, false, 'missing dir: validation fails');
assert.ok(result.issues.length > 0, 'missing dir: has issues');
assert.ok(result.issues.some(i => i.severity === 'fatal'), 'missing dir: has fatal issue');
} finally {
cleanup(base);
}
}
});
console.log('\n=== Validator: missing ROADMAP.md → warning (not fatal) ===');
{
test('Validator: missing ROADMAP.md → warning (not fatal)', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
writeFileSync(join(planning, 'PROJECT.md'), SAMPLE_PROJECT);
const result = await validatePlanningDirectory(planning);
assertEq(result.valid, true, 'no roadmap: validation still passes');
assertTrue(result.issues.some(i => i.severity === 'warning' && i.file.includes('ROADMAP')), 'no roadmap: warning issue mentions ROADMAP');
assert.deepStrictEqual(result.valid, true, 'no roadmap: validation still passes');
assert.ok(result.issues.some(i => i.severity === 'warning' && i.file.includes('ROADMAP')), 'no roadmap: warning issue mentions ROADMAP');
} finally {
cleanup(base);
}
}
});
console.log('\n=== Validator: missing PROJECT.md → warning ===');
{
test('Validator: missing PROJECT.md → warning', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
writeFileSync(join(planning, 'ROADMAP.md'), SAMPLE_ROADMAP);
const result = await validatePlanningDirectory(planning);
assertEq(result.valid, true, 'no project: validation passes (warning only)');
assertTrue(result.issues.some(i => i.severity === 'warning' && i.file.includes('PROJECT')), 'no project: warning issue mentions PROJECT');
assert.deepStrictEqual(result.valid, true, 'no project: validation passes (warning only)');
assert.ok(result.issues.some(i => i.severity === 'warning' && i.file.includes('PROJECT')), 'no project: warning issue mentions PROJECT');
} finally {
cleanup(base);
}
}
});
console.log('\n=== Validator: complete directory → valid with no issues ===');
{
test('Validator: complete directory → valid with no issues', async () => {
const base = createFixtureBase();
try {
const planning = createPlanningDir(base);
@ -231,78 +225,74 @@ async function main(): Promise<void> {
writeFileSync(join(planning, 'STATE.md'), SAMPLE_STATE);
mkdirSync(join(planning, 'phases'), { recursive: true });
const result = await validatePlanningDirectory(planning);
assertEq(result.valid, true, 'complete dir: validation passes');
assertEq(result.issues.length, 0, 'complete dir: no issues');
assert.deepStrictEqual(result.valid, true, 'complete dir: validation passes');
assert.deepStrictEqual(result.issues.length, 0, 'complete dir: no issues');
} finally {
cleanup(base);
}
}
});
// ═══════════════════════════════════════════════════════════════════════
// Roadmap Parser Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== parseOldRoadmap: flat format ===');
{
test('parseOldRoadmap: flat format', () => {
const roadmap = parseOldRoadmap(SAMPLE_ROADMAP);
assertEq(roadmap.milestones.length, 0, 'flat roadmap: no milestone sections');
assertEq(roadmap.phases.length, 3, 'flat roadmap: 3 phases');
assertEq(roadmap.phases[0].number, 29, 'flat roadmap: first phase number');
assertEq(roadmap.phases[0].title, 'Auth System', 'flat roadmap: first phase title');
assertEq(roadmap.phases[0].done, true, 'flat roadmap: first phase done');
assertEq(roadmap.phases[1].done, false, 'flat roadmap: second phase not done');
}
assert.deepStrictEqual(roadmap.milestones.length, 0, 'flat roadmap: no milestone sections');
assert.deepStrictEqual(roadmap.phases.length, 3, 'flat roadmap: 3 phases');
assert.deepStrictEqual(roadmap.phases[0].number, 29, 'flat roadmap: first phase number');
assert.deepStrictEqual(roadmap.phases[0].title, 'Auth System', 'flat roadmap: first phase title');
assert.deepStrictEqual(roadmap.phases[0].done, true, 'flat roadmap: first phase done');
assert.deepStrictEqual(roadmap.phases[1].done, false, 'flat roadmap: second phase not done');
});
console.log('\n=== parseOldRoadmap: milestone-sectioned with <details> ===');
{
test('parseOldRoadmap: milestone-sectioned with <details>', () => {
const roadmap = parseOldRoadmap(SAMPLE_MILESTONE_SECTIONED_ROADMAP);
assertTrue(roadmap.milestones.length >= 2, 'ms roadmap: has milestone sections');
assert.ok(roadmap.milestones.length >= 2, 'ms roadmap: has milestone sections');
const v20 = roadmap.milestones.find(m => m.id.includes('2.0'));
assertTrue(v20 !== undefined, 'ms roadmap: v2.0 found');
assertEq(v20?.collapsed, true, 'ms roadmap: v2.0 collapsed');
assertTrue((v20?.phases.length ?? 0) >= 2, 'ms roadmap: v2.0 has phases');
assertTrue(v20?.phases.every(p => p.done) ?? false, 'ms roadmap: v2.0 all done');
assert.ok(v20 !== undefined, 'ms roadmap: v2.0 found');
assert.deepStrictEqual(v20?.collapsed, true, 'ms roadmap: v2.0 collapsed');
assert.ok((v20?.phases.length ?? 0) >= 2, 'ms roadmap: v2.0 has phases');
assert.ok(v20?.phases.every(p => p.done) ?? false, 'ms roadmap: v2.0 all done');
const v25 = roadmap.milestones.find(m => m.id.includes('2.5'));
assertTrue(v25 !== undefined, 'ms roadmap: v2.5 found');
assertEq(v25?.collapsed, false, 'ms roadmap: v2.5 not collapsed');
assertTrue((v25?.phases.length ?? 0) >= 3, 'ms roadmap: v2.5 has 3 phases');
assert.ok(v25 !== undefined, 'ms roadmap: v2.5 found');
assert.deepStrictEqual(v25?.collapsed, false, 'ms roadmap: v2.5 not collapsed');
assert.ok((v25?.phases.length ?? 0) >= 3, 'ms roadmap: v2.5 has 3 phases');
const p29 = v25?.phases.find(p => p.number === 29);
assertEq(p29?.done, true, 'ms roadmap: phase 29 done');
assert.deepStrictEqual(p29?.done, true, 'ms roadmap: phase 29 done');
const p30 = v25?.phases.find(p => p.number === 30);
assertEq(p30?.done, false, 'ms roadmap: phase 30 not done');
}
assert.deepStrictEqual(p30?.done, false, 'ms roadmap: phase 30 not done');
});
// ═══════════════════════════════════════════════════════════════════════
// Plan Parser Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== parseOldPlan: XML-in-markdown ===');
{
test('parseOldPlan: XML-in-markdown', () => {
const plan = parseOldPlan(SAMPLE_PLAN_XML, '29-01-PLAN.md', '01');
assertTrue(plan.objective.includes('authentication'), 'plan: objective extracted');
assertEq(plan.tasks.length, 3, 'plan: 3 tasks');
assertTrue(plan.tasks[0].includes('auth middleware'), 'plan: first task content');
assertTrue(plan.context.includes('JWT'), 'plan: context extracted');
assertTrue(plan.verification.includes('Login returns'), 'plan: verification extracted');
assertTrue(plan.successCriteria.includes('endpoints respond'), 'plan: success criteria extracted');
assert.ok(plan.objective.includes('authentication'), 'plan: objective extracted');
assert.deepStrictEqual(plan.tasks.length, 3, 'plan: 3 tasks');
assert.ok(plan.tasks[0].includes('auth middleware'), 'plan: first task content');
assert.ok(plan.context.includes('JWT'), 'plan: context extracted');
assert.ok(plan.verification.includes('Login returns'), 'plan: verification extracted');
assert.ok(plan.successCriteria.includes('endpoints respond'), 'plan: success criteria extracted');
// Frontmatter
assertEq(plan.frontmatter.phase, '29-auth-system', 'plan fm: phase');
assertEq(plan.frontmatter.plan, '01', 'plan fm: plan');
assertEq(plan.frontmatter.type, 'implementation', 'plan fm: type');
assertEq(plan.frontmatter.wave, 1, 'plan fm: wave');
assertEq(plan.frontmatter.autonomous, true, 'plan fm: autonomous');
assertTrue(plan.frontmatter.files_modified.length >= 2, 'plan fm: files_modified');
assertTrue(plan.frontmatter.must_haves !== null, 'plan fm: must_haves parsed');
assertTrue((plan.frontmatter.must_haves?.truths.length ?? 0) >= 1, 'plan fm: must_haves truths');
assertTrue((plan.frontmatter.must_haves?.artifacts.length ?? 0) >= 1, 'plan fm: must_haves artifacts');
}
assert.deepStrictEqual(plan.frontmatter.phase, '29-auth-system', 'plan fm: phase');
assert.deepStrictEqual(plan.frontmatter.plan, '01', 'plan fm: plan');
assert.deepStrictEqual(plan.frontmatter.type, 'implementation', 'plan fm: type');
assert.deepStrictEqual(plan.frontmatter.wave, 1, 'plan fm: wave');
assert.deepStrictEqual(plan.frontmatter.autonomous, true, 'plan fm: autonomous');
assert.ok(plan.frontmatter.files_modified.length >= 2, 'plan fm: files_modified');
assert.ok(plan.frontmatter.must_haves !== null, 'plan fm: must_haves parsed');
assert.ok((plan.frontmatter.must_haves?.truths.length ?? 0) >= 1, 'plan fm: must_haves truths');
assert.ok((plan.frontmatter.must_haves?.artifacts.length ?? 0) >= 1, 'plan fm: must_haves artifacts');
});
console.log('\n=== parseOldPlan: plain markdown (no XML tags) ===');
{
test('parseOldPlan: plain markdown (no XML tags)', () => {
const plainPlan = `# 001: Fix Login Bug
## Description
@ -315,100 +305,86 @@ Fix the login button not responding on mobile.
2. Fix event propagation
`;
const plan = parseOldPlan(plainPlan, '001-PLAN.md', '001');
assertEq(plan.objective, '', 'plain plan: no objective (no XML)');
assertEq(plan.tasks.length, 0, 'plain plan: no tasks (no XML)');
assertEq(plan.frontmatter.phase, '', 'plain plan: no frontmatter phase');
}
assert.deepStrictEqual(plan.objective, '', 'plain plan: no objective (no XML)');
assert.deepStrictEqual(plan.tasks.length, 0, 'plain plan: no tasks (no XML)');
assert.deepStrictEqual(plan.frontmatter.phase, '', 'plain plan: no frontmatter phase');
});
// ═══════════════════════════════════════════════════════════════════════
// Summary Parser Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== parseOldSummary: YAML frontmatter ===');
{
test('parseOldSummary: YAML frontmatter', () => {
const summary = parseOldSummary(SAMPLE_SUMMARY, '29-01-SUMMARY.md', '01');
assertEq(summary.frontmatter.phase, '29-auth-system', 'summary fm: phase');
assertEq(summary.frontmatter.plan, '01', 'summary fm: plan');
assertEq(summary.frontmatter.subsystem, 'auth', 'summary fm: subsystem');
assertEq(summary.frontmatter.tags, ['authentication', 'security'], 'summary fm: tags');
assertEq(summary.frontmatter.provides, ['auth-middleware', 'jwt-validation'], 'summary fm: provides');
assertEq(summary.frontmatter.affects, ['api-routes'], 'summary fm: affects');
assertEq(summary.frontmatter['tech-stack'], ['jsonwebtoken', 'express'], 'summary fm: tech-stack');
assertEq(summary.frontmatter['key-files'], ['src/auth.ts', 'src/middleware/auth.ts'], 'summary fm: key-files');
assertEq(summary.frontmatter['key-decisions'], ['Use RS256 for JWT signing', 'Store refresh tokens in DB'], 'summary fm: key-decisions');
assertEq(summary.frontmatter['patterns-established'], ['Middleware-based auth'], 'summary fm: patterns-established');
assertEq(summary.frontmatter.duration, '2h', 'summary fm: duration');
assertEq(summary.frontmatter.completed, '2026-01-15', 'summary fm: completed');
assertTrue(summary.body.includes('Auth Implementation Summary'), 'summary: body content present');
}
assert.deepStrictEqual(summary.frontmatter.phase, '29-auth-system', 'summary fm: phase');
assert.deepStrictEqual(summary.frontmatter.plan, '01', 'summary fm: plan');
assert.deepStrictEqual(summary.frontmatter.subsystem, 'auth', 'summary fm: subsystem');
assert.deepStrictEqual(summary.frontmatter.tags, ['authentication', 'security'], 'summary fm: tags');
assert.deepStrictEqual(summary.frontmatter.provides, ['auth-middleware', 'jwt-validation'], 'summary fm: provides');
assert.deepStrictEqual(summary.frontmatter.affects, ['api-routes'], 'summary fm: affects');
assert.deepStrictEqual(summary.frontmatter['tech-stack'], ['jsonwebtoken', 'express'], 'summary fm: tech-stack');
assert.deepStrictEqual(summary.frontmatter['key-files'], ['src/auth.ts', 'src/middleware/auth.ts'], 'summary fm: key-files');
assert.deepStrictEqual(summary.frontmatter['key-decisions'], ['Use RS256 for JWT signing', 'Store refresh tokens in DB'], 'summary fm: key-decisions');
assert.deepStrictEqual(summary.frontmatter['patterns-established'], ['Middleware-based auth'], 'summary fm: patterns-established');
assert.deepStrictEqual(summary.frontmatter.duration, '2h', 'summary fm: duration');
assert.deepStrictEqual(summary.frontmatter.completed, '2026-01-15', 'summary fm: completed');
assert.ok(summary.body.includes('Auth Implementation Summary'), 'summary: body content present');
});
// ═══════════════════════════════════════════════════════════════════════
// Requirements Parser Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== parseOldRequirements ===');
{
test('parseOldRequirements', () => {
const reqs = parseOldRequirements(SAMPLE_REQUIREMENTS);
assertEq(reqs.length, 4, 'requirements: 4 entries');
assertEq(reqs[0].id, 'R001', 'req 0: id');
assertEq(reqs[0].title, 'User Authentication', 'req 0: title');
assertEq(reqs[0].status, 'active', 'req 0: status');
assertTrue(reqs[0].description.includes('log in'), 'req 0: description');
assertEq(reqs[2].id, 'R003', 'req 2: id');
assertEq(reqs[2].status, 'validated', 'req 2: status');
assertEq(reqs[3].id, 'R004', 'req 3: id');
assertEq(reqs[3].status, 'deferred', 'req 3: status');
}
assert.deepStrictEqual(reqs.length, 4, 'requirements: 4 entries');
assert.deepStrictEqual(reqs[0].id, 'R001', 'req 0: id');
assert.deepStrictEqual(reqs[0].title, 'User Authentication', 'req 0: title');
assert.deepStrictEqual(reqs[0].status, 'active', 'req 0: status');
assert.ok(reqs[0].description.includes('log in'), 'req 0: description');
assert.deepStrictEqual(reqs[2].id, 'R003', 'req 2: id');
assert.deepStrictEqual(reqs[2].status, 'validated', 'req 2: status');
assert.deepStrictEqual(reqs[3].id, 'R004', 'req 3: id');
assert.deepStrictEqual(reqs[3].status, 'deferred', 'req 3: status');
});
// ═══════════════════════════════════════════════════════════════════════
// State Parser Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== parseOldState ===');
{
test('parseOldState', () => {
const state = parseOldState(SAMPLE_STATE);
assertTrue(state.currentPhase?.includes('30') ?? false, 'state: current phase includes 30');
assertEq(state.status, 'in-progress', 'state: status');
assertTrue(state.raw === SAMPLE_STATE, 'state: raw preserved');
}
assert.ok(state.currentPhase?.includes('30') ?? false, 'state: current phase includes 30');
assert.deepStrictEqual(state.status, 'in-progress', 'state: status');
assert.ok(state.raw === SAMPLE_STATE, 'state: raw preserved');
});
// ═══════════════════════════════════════════════════════════════════════
// Config Parser Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== parseOldConfig: valid JSON ===');
{
test('parseOldConfig: valid JSON', () => {
const config = parseOldConfig('{"projectName":"test","version":"1.0"}');
assertTrue(config !== null, 'config: parsed');
assertEq(config?.projectName, 'test', 'config: projectName');
}
assert.ok(config !== null, 'config: parsed');
assert.deepStrictEqual(config?.projectName, 'test', 'config: projectName');
});
console.log('\n=== parseOldConfig: invalid JSON → null ===');
{
test('parseOldConfig: invalid JSON → null', () => {
const config = parseOldConfig('not json at all {{{');
assertEq(config, null, 'config: invalid JSON returns null');
}
assert.deepStrictEqual(config, null, 'config: invalid JSON returns null');
});
console.log('\n=== parseOldConfig: non-object JSON → null ===');
{
test('parseOldConfig: non-object JSON → null', () => {
const config = parseOldConfig('"just a string"');
assertEq(config, null, 'config: non-object returns null');
}
assert.deepStrictEqual(config, null, 'config: non-object returns null');
});
// ═══════════════════════════════════════════════════════════════════════
// Project Parser Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== parseOldProject ===');
{
test('parseOldProject', () => {
const project = parseOldProject(SAMPLE_PROJECT);
assertEq(project, SAMPLE_PROJECT, 'project: returns raw content');
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
assert.deepStrictEqual(project, SAMPLE_PROJECT, 'project: returns raw content');
});

View file

@ -20,9 +20,9 @@ import type {
GSDTask,
GSDRequirement,
} from '../migrate/types.ts';
import { createTestContext } from './test-helpers.ts';
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
const { assertEq, assertTrue, report } = createTestContext();
// ─── Fixture Builders ──────────────────────────────────────────────────────
function makeTask(id: string, title: string, done: boolean, hasSummary: boolean): GSDTask {
@ -130,11 +130,9 @@ function buildCompleteProject(): GSDProject {
// Tests
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
// ─── Scenario 1: Incomplete project ────────────────────────────────────
console.log('\n=== Scenario 1: Incomplete project — write, parse, deriveState ===');
{
test('Scenario 1: Incomplete project — write, parse, deriveState', async () => {
const base = mkdtempSync(join(tmpdir(), 'gsd-writer-int-'));
try {
const project = buildIncompleteProject();
@ -145,64 +143,64 @@ async function main(): Promise<void> {
const gsd = join(base, '.gsd');
const m = join(gsd, 'milestones', 'M001');
assertTrue(existsSync(join(m, 'M001-ROADMAP.md')), 'incomplete: M001-ROADMAP.md exists');
assertTrue(existsSync(join(m, 'M001-CONTEXT.md')), 'incomplete: M001-CONTEXT.md exists');
assertTrue(existsSync(join(m, 'M001-RESEARCH.md')), 'incomplete: M001-RESEARCH.md exists');
assertTrue(existsSync(join(m, 'slices', 'S01', 'S01-PLAN.md')), 'incomplete: S01-PLAN.md exists');
assertTrue(existsSync(join(m, 'slices', 'S02', 'S02-PLAN.md')), 'incomplete: S02-PLAN.md exists');
assertTrue(existsSync(join(m, 'slices', 'S01', 'S01-SUMMARY.md')), 'incomplete: S01-SUMMARY.md exists');
assertTrue(!existsSync(join(m, 'slices', 'S02', 'S02-SUMMARY.md')), 'incomplete: S02-SUMMARY.md NOT written (null)');
assertTrue(existsSync(join(gsd, 'REQUIREMENTS.md')), 'incomplete: REQUIREMENTS.md exists');
assertTrue(existsSync(join(gsd, 'PROJECT.md')), 'incomplete: PROJECT.md exists');
assertTrue(existsSync(join(gsd, 'DECISIONS.md')), 'incomplete: DECISIONS.md exists');
assertTrue(existsSync(join(gsd, 'STATE.md')), 'incomplete: STATE.md exists');
assert.ok(existsSync(join(m, 'M001-ROADMAP.md')), 'incomplete: M001-ROADMAP.md exists');
assert.ok(existsSync(join(m, 'M001-CONTEXT.md')), 'incomplete: M001-CONTEXT.md exists');
assert.ok(existsSync(join(m, 'M001-RESEARCH.md')), 'incomplete: M001-RESEARCH.md exists');
assert.ok(existsSync(join(m, 'slices', 'S01', 'S01-PLAN.md')), 'incomplete: S01-PLAN.md exists');
assert.ok(existsSync(join(m, 'slices', 'S02', 'S02-PLAN.md')), 'incomplete: S02-PLAN.md exists');
assert.ok(existsSync(join(m, 'slices', 'S01', 'S01-SUMMARY.md')), 'incomplete: S01-SUMMARY.md exists');
assert.ok(!existsSync(join(m, 'slices', 'S02', 'S02-SUMMARY.md')), 'incomplete: S02-SUMMARY.md NOT written (null)');
assert.ok(existsSync(join(gsd, 'REQUIREMENTS.md')), 'incomplete: REQUIREMENTS.md exists');
assert.ok(existsSync(join(gsd, 'PROJECT.md')), 'incomplete: PROJECT.md exists');
assert.ok(existsSync(join(gsd, 'DECISIONS.md')), 'incomplete: DECISIONS.md exists');
assert.ok(existsSync(join(gsd, 'STATE.md')), 'incomplete: STATE.md exists');
// Task files
assertTrue(existsSync(join(m, 'slices', 'S01', 'tasks', 'T01-PLAN.md')), 'incomplete: T01-PLAN.md exists');
assertTrue(existsSync(join(m, 'slices', 'S01', 'tasks', 'T01-SUMMARY.md')), 'incomplete: T01-SUMMARY.md exists');
assertTrue(existsSync(join(m, 'slices', 'S01', 'tasks', 'T02-PLAN.md')), 'incomplete: T02-PLAN.md exists (auth task)');
assertTrue(existsSync(join(m, 'slices', 'S01', 'tasks', 'T02-SUMMARY.md')), 'incomplete: T02-SUMMARY.md exists (auth task)');
assertTrue(existsSync(join(m, 'slices', 'S02', 'tasks', 'T03-PLAN.md')), 'incomplete: T03-PLAN.md exists');
assertTrue(!existsSync(join(m, 'slices', 'S02', 'tasks', 'T03-SUMMARY.md')), 'incomplete: T03-SUMMARY.md NOT written (null)');
assert.ok(existsSync(join(m, 'slices', 'S01', 'tasks', 'T01-PLAN.md')), 'incomplete: T01-PLAN.md exists');
assert.ok(existsSync(join(m, 'slices', 'S01', 'tasks', 'T01-SUMMARY.md')), 'incomplete: T01-SUMMARY.md exists');
assert.ok(existsSync(join(m, 'slices', 'S01', 'tasks', 'T02-PLAN.md')), 'incomplete: T02-PLAN.md exists (auth task)');
assert.ok(existsSync(join(m, 'slices', 'S01', 'tasks', 'T02-SUMMARY.md')), 'incomplete: T02-SUMMARY.md exists (auth task)');
assert.ok(existsSync(join(m, 'slices', 'S02', 'tasks', 'T03-PLAN.md')), 'incomplete: T03-PLAN.md exists');
assert.ok(!existsSync(join(m, 'slices', 'S02', 'tasks', 'T03-SUMMARY.md')), 'incomplete: T03-SUMMARY.md NOT written (null)');
// WrittenFiles counts
console.log(' --- WrittenFiles counts ---');
assertEq(result.counts.roadmaps, 1, 'incomplete: WrittenFiles roadmaps count');
assertEq(result.counts.plans, 2, 'incomplete: WrittenFiles plans count');
assertEq(result.counts.taskPlans, 3, 'incomplete: WrittenFiles taskPlans count');
assertEq(result.counts.taskSummaries, 2, 'incomplete: WrittenFiles taskSummaries count');
assertEq(result.counts.sliceSummaries, 1, 'incomplete: WrittenFiles sliceSummaries count');
assertEq(result.counts.research, 1, 'incomplete: WrittenFiles research count');
assertEq(result.counts.requirements, 1, 'incomplete: WrittenFiles requirements count');
assertEq(result.counts.contexts, 1, 'incomplete: WrittenFiles contexts count');
assert.deepStrictEqual(result.counts.roadmaps, 1, 'incomplete: WrittenFiles roadmaps count');
assert.deepStrictEqual(result.counts.plans, 2, 'incomplete: WrittenFiles plans count');
assert.deepStrictEqual(result.counts.taskPlans, 3, 'incomplete: WrittenFiles taskPlans count');
assert.deepStrictEqual(result.counts.taskSummaries, 2, 'incomplete: WrittenFiles taskSummaries count');
assert.deepStrictEqual(result.counts.sliceSummaries, 1, 'incomplete: WrittenFiles sliceSummaries count');
assert.deepStrictEqual(result.counts.research, 1, 'incomplete: WrittenFiles research count');
assert.deepStrictEqual(result.counts.requirements, 1, 'incomplete: WrittenFiles requirements count');
assert.deepStrictEqual(result.counts.contexts, 1, 'incomplete: WrittenFiles contexts count');
// (b) parseRoadmap on written roadmap
console.log(' --- parseRoadmap ---');
const roadmapContent = readFileSync(join(m, 'M001-ROADMAP.md'), 'utf-8');
const roadmap = parseRoadmap(roadmapContent);
assertEq(roadmap.slices.length, 2, 'incomplete: roadmap has 2 slices');
assertTrue(roadmap.slices[0].done === true, 'incomplete: roadmap S01 is done');
assertTrue(roadmap.slices[1].done === false, 'incomplete: roadmap S02 is not done');
assertEq(roadmap.slices[0].id, 'S01', 'incomplete: roadmap slice 0 id');
assertEq(roadmap.slices[1].id, 'S02', 'incomplete: roadmap slice 1 id');
assert.deepStrictEqual(roadmap.slices.length, 2, 'incomplete: roadmap has 2 slices');
assert.ok(roadmap.slices[0].done === true, 'incomplete: roadmap S01 is done');
assert.ok(roadmap.slices[1].done === false, 'incomplete: roadmap S02 is not done');
assert.deepStrictEqual(roadmap.slices[0].id, 'S01', 'incomplete: roadmap slice 0 id');
assert.deepStrictEqual(roadmap.slices[1].id, 'S02', 'incomplete: roadmap slice 1 id');
// (c) parsePlan on S01 plan
console.log(' --- parsePlan S01 ---');
const s01PlanContent = readFileSync(join(m, 'slices', 'S01', 'S01-PLAN.md'), 'utf-8');
const s01Plan = parsePlan(s01PlanContent);
assertEq(s01Plan.tasks.length, 2, 'incomplete: S01 plan has 2 tasks');
assertTrue(s01Plan.tasks[0].done === true, 'incomplete: S01 T01 is done');
assertTrue(s01Plan.tasks[1].done === true, 'incomplete: S01 T02 is done');
assert.deepStrictEqual(s01Plan.tasks.length, 2, 'incomplete: S01 plan has 2 tasks');
assert.ok(s01Plan.tasks[0].done === true, 'incomplete: S01 T01 is done');
assert.ok(s01Plan.tasks[1].done === true, 'incomplete: S01 T02 is done');
// (d) parseSummary on S01 summary
console.log(' --- parseSummary S01 ---');
const s01SummaryContent = readFileSync(join(m, 'slices', 'S01', 'S01-SUMMARY.md'), 'utf-8');
const s01Summary = parseSummary(s01SummaryContent);
assertTrue(
assert.ok(
(s01Summary.frontmatter.key_files as string[]).length > 0,
'incomplete: S01 summary has key_files',
);
assertTrue(
assert.ok(
(s01Summary.frontmatter.provides as string[]).length > 0,
'incomplete: S01 summary has provides',
);
@ -211,50 +209,50 @@ async function main(): Promise<void> {
console.log(' --- deriveState ---');
invalidateAllCaches();
const state = await deriveState(base);
assertEq(state.phase, 'executing', 'incomplete: deriveState phase is executing');
assertTrue(state.activeMilestone !== null, 'incomplete: deriveState has activeMilestone');
assertEq(state.activeMilestone!.id, 'M001', 'incomplete: deriveState activeMilestone is M001');
assertTrue(state.activeSlice !== null, 'incomplete: deriveState has activeSlice');
assertEq(state.activeSlice!.id, 'S02', 'incomplete: deriveState activeSlice is S02');
assertTrue(state.activeTask !== null, 'incomplete: deriveState has activeTask');
assertEq(state.activeTask!.id, 'T03', 'incomplete: deriveState activeTask is T03');
assertTrue(state.progress!.slices !== undefined, 'incomplete: deriveState has slices progress');
assertEq(state.progress!.slices!.done, 1, 'incomplete: deriveState slices done count');
assertEq(state.progress!.slices!.total, 2, 'incomplete: deriveState slices total count');
assertTrue(state.progress!.tasks !== undefined, 'incomplete: deriveState has tasks progress');
assert.deepStrictEqual(state.phase, 'executing', 'incomplete: deriveState phase is executing');
assert.ok(state.activeMilestone !== null, 'incomplete: deriveState has activeMilestone');
assert.deepStrictEqual(state.activeMilestone!.id, 'M001', 'incomplete: deriveState activeMilestone is M001');
assert.ok(state.activeSlice !== null, 'incomplete: deriveState has activeSlice');
assert.deepStrictEqual(state.activeSlice!.id, 'S02', 'incomplete: deriveState activeSlice is S02');
assert.ok(state.activeTask !== null, 'incomplete: deriveState has activeTask');
assert.deepStrictEqual(state.activeTask!.id, 'T03', 'incomplete: deriveState activeTask is T03');
assert.ok(state.progress!.slices !== undefined, 'incomplete: deriveState has slices progress');
assert.deepStrictEqual(state.progress!.slices!.done, 1, 'incomplete: deriveState slices done count');
assert.deepStrictEqual(state.progress!.slices!.total, 2, 'incomplete: deriveState slices total count');
assert.ok(state.progress!.tasks !== undefined, 'incomplete: deriveState has tasks progress');
// S02 has 1 task, 0 done (only active slice tasks counted)
assertEq(state.progress!.tasks!.done, 0, 'incomplete: deriveState tasks done (in active slice)');
assertEq(state.progress!.tasks!.total, 1, 'incomplete: deriveState tasks total (in active slice)');
assert.deepStrictEqual(state.progress!.tasks!.done, 0, 'incomplete: deriveState tasks done (in active slice)');
assert.deepStrictEqual(state.progress!.tasks!.total, 1, 'incomplete: deriveState tasks total (in active slice)');
// Requirements
assertEq(state.requirements!.active, 1, 'incomplete: deriveState requirements active');
assertEq(state.requirements!.validated, 1, 'incomplete: deriveState requirements validated');
assertEq(state.requirements!.deferred, 1, 'incomplete: deriveState requirements deferred');
assertEq(state.requirements!.outOfScope, 1, 'incomplete: deriveState requirements outOfScope');
assert.deepStrictEqual(state.requirements!.active, 1, 'incomplete: deriveState requirements active');
assert.deepStrictEqual(state.requirements!.validated, 1, 'incomplete: deriveState requirements validated');
assert.deepStrictEqual(state.requirements!.deferred, 1, 'incomplete: deriveState requirements deferred');
assert.deepStrictEqual(state.requirements!.outOfScope, 1, 'incomplete: deriveState requirements outOfScope');
// (f) generatePreview
console.log(' --- generatePreview ---');
const preview = generatePreview(project);
assertEq(preview.milestoneCount, 1, 'incomplete: preview milestoneCount');
assertEq(preview.totalSlices, 2, 'incomplete: preview totalSlices');
assertEq(preview.totalTasks, 3, 'incomplete: preview totalTasks');
assertEq(preview.doneSlices, 1, 'incomplete: preview doneSlices');
assertEq(preview.doneTasks, 2, 'incomplete: preview doneTasks');
assertEq(preview.sliceCompletionPct, 50, 'incomplete: preview sliceCompletionPct');
assertEq(preview.taskCompletionPct, 67, 'incomplete: preview taskCompletionPct');
assertEq(preview.requirements.active, 1, 'incomplete: preview requirements active');
assertEq(preview.requirements.validated, 1, 'incomplete: preview requirements validated');
assertEq(preview.requirements.deferred, 1, 'incomplete: preview requirements deferred');
assertEq(preview.requirements.outOfScope, 1, 'incomplete: preview requirements outOfScope');
assertEq(preview.requirements.total, 4, 'incomplete: preview requirements total');
assert.deepStrictEqual(preview.milestoneCount, 1, 'incomplete: preview milestoneCount');
assert.deepStrictEqual(preview.totalSlices, 2, 'incomplete: preview totalSlices');
assert.deepStrictEqual(preview.totalTasks, 3, 'incomplete: preview totalTasks');
assert.deepStrictEqual(preview.doneSlices, 1, 'incomplete: preview doneSlices');
assert.deepStrictEqual(preview.doneTasks, 2, 'incomplete: preview doneTasks');
assert.deepStrictEqual(preview.sliceCompletionPct, 50, 'incomplete: preview sliceCompletionPct');
assert.deepStrictEqual(preview.taskCompletionPct, 67, 'incomplete: preview taskCompletionPct');
assert.deepStrictEqual(preview.requirements.active, 1, 'incomplete: preview requirements active');
assert.deepStrictEqual(preview.requirements.validated, 1, 'incomplete: preview requirements validated');
assert.deepStrictEqual(preview.requirements.deferred, 1, 'incomplete: preview requirements deferred');
assert.deepStrictEqual(preview.requirements.outOfScope, 1, 'incomplete: preview requirements outOfScope');
assert.deepStrictEqual(preview.requirements.total, 4, 'incomplete: preview requirements total');
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});
// ─── Scenario 2: Fully complete project ────────────────────────────────
console.log('\n=== Scenario 2: Fully complete project — deriveState phase ===');
{
test('Scenario 2: Fully complete project — deriveState phase', async () => {
const base = mkdtempSync(join(tmpdir(), 'gsd-writer-int-complete-'));
try {
const project = buildCompleteProject();
@ -262,43 +260,36 @@ async function main(): Promise<void> {
// Null research should NOT produce a file
const m = join(base, '.gsd', 'milestones', 'M001');
assertTrue(!existsSync(join(m, 'M001-RESEARCH.md')), 'complete: M001-RESEARCH.md NOT written (null)');
assert.ok(!existsSync(join(m, 'M001-RESEARCH.md')), 'complete: M001-RESEARCH.md NOT written (null)');
// No REQUIREMENTS.md since empty requirements
assertTrue(!existsSync(join(base, '.gsd', 'REQUIREMENTS.md')), 'complete: REQUIREMENTS.md NOT written (empty)');
assert.ok(!existsSync(join(base, '.gsd', 'REQUIREMENTS.md')), 'complete: REQUIREMENTS.md NOT written (empty)');
// Completed milestone should have VALIDATION and SUMMARY from migration (#819)
assertTrue(existsSync(join(m, 'M001-VALIDATION.md')), 'complete: M001-VALIDATION.md written for completed milestone');
assertTrue(existsSync(join(m, 'M001-SUMMARY.md')), 'complete: M001-SUMMARY.md written for completed milestone');
assert.ok(existsSync(join(m, 'M001-VALIDATION.md')), 'complete: M001-VALIDATION.md written for completed milestone');
assert.ok(existsSync(join(m, 'M001-SUMMARY.md')), 'complete: M001-SUMMARY.md written for completed milestone');
// deriveState: all slices done, all tasks done — migration now writes
// VALIDATION.md and SUMMARY.md for completed milestones (#819),
// so the milestone should be fully complete.
invalidateAllCaches();
const state = await deriveState(base);
assertEq(state.phase, 'complete', 'complete: deriveState phase is complete (validation + summary written by migration)');
assert.deepStrictEqual(state.phase, 'complete', 'complete: deriveState phase is complete (validation + summary written by migration)');
// When all milestones are complete, activeMilestone points to the last entry (for display)
assertTrue(state.activeMilestone !== null, 'complete: deriveState has activeMilestone (last entry)');
assertEq(state.activeMilestone!.id, 'M001', 'complete: deriveState activeMilestone is M001');
assert.ok(state.activeMilestone !== null, 'complete: deriveState has activeMilestone (last entry)');
assert.deepStrictEqual(state.activeMilestone!.id, 'M001', 'complete: deriveState activeMilestone is M001');
// generatePreview for complete project
const preview = generatePreview(project);
assertEq(preview.milestoneCount, 1, 'complete: preview milestoneCount');
assertEq(preview.totalSlices, 1, 'complete: preview totalSlices');
assertEq(preview.doneSlices, 1, 'complete: preview doneSlices');
assertEq(preview.totalTasks, 1, 'complete: preview totalTasks');
assertEq(preview.doneTasks, 1, 'complete: preview doneTasks');
assertEq(preview.sliceCompletionPct, 100, 'complete: preview sliceCompletionPct');
assertEq(preview.taskCompletionPct, 100, 'complete: preview taskCompletionPct');
assertEq(preview.requirements.total, 0, 'complete: preview requirements total');
assert.deepStrictEqual(preview.milestoneCount, 1, 'complete: preview milestoneCount');
assert.deepStrictEqual(preview.totalSlices, 1, 'complete: preview totalSlices');
assert.deepStrictEqual(preview.doneSlices, 1, 'complete: preview doneSlices');
assert.deepStrictEqual(preview.totalTasks, 1, 'complete: preview totalTasks');
assert.deepStrictEqual(preview.doneTasks, 1, 'complete: preview doneTasks');
assert.deepStrictEqual(preview.sliceCompletionPct, 100, 'complete: preview sliceCompletionPct');
assert.deepStrictEqual(preview.taskCompletionPct, 100, 'complete: preview taskCompletionPct');
assert.deepStrictEqual(preview.requirements.total, 0, 'complete: preview requirements total');
} finally {
rmSync(base, { recursive: true, force: true });
}
}
report();
}
main().catch((err) => {
console.error('Unhandled error:', err);
process.exit(1);
});

View file

@ -31,9 +31,9 @@ import type {
GSDSliceSummaryData,
GSDTaskSummaryData,
} from '../migrate/types.ts';
import { createTestContext } from './test-helpers.ts';
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
const { assertEq, assertTrue, report } = createTestContext();
// ─── Test Data Builders ────────────────────────────────────────────────────
function makeTask(overrides: Partial<GSDTask> = {}): GSDTask {
@ -103,11 +103,7 @@ function makeTaskSummary(overrides: Partial<GSDTaskSummaryData> = {}): GSDTaskSu
};
}
// ═══════════════════════════════════════════════════════════════════════════
// Scenario A: Roadmap round-trip with 2 slices (1 done, 1 not)
// ═══════════════════════════════════════════════════════════════════════════
{
test('Scenario A: Roadmap round-trip with 2 slices (1 done, 1 not)', () => {
const milestone = makeMilestone({
slices: [
makeSlice({
@ -132,35 +128,31 @@ function makeTaskSummary(overrides: Partial<GSDTaskSummaryData> = {}): GSDTaskSu
const output = formatRoadmap(milestone);
const parsed = parseRoadmap(output);
assertEq(parsed.title, 'M001: Core Platform', 'roadmap: title');
assertEq(parsed.vision, 'Build the core platform', 'roadmap: vision');
assertEq(parsed.successCriteria.length, 2, 'roadmap: successCriteria count');
assertEq(parsed.successCriteria[0], 'All tests pass', 'roadmap: successCriteria[0]');
assertEq(parsed.successCriteria[1], 'Deploy to staging', 'roadmap: successCriteria[1]');
assertEq(parsed.slices.length, 2, 'roadmap: slices count');
assert.deepStrictEqual(parsed.title, 'M001: Core Platform', 'roadmap: title');
assert.deepStrictEqual(parsed.vision, 'Build the core platform', 'roadmap: vision');
assert.deepStrictEqual(parsed.successCriteria.length, 2, 'roadmap: successCriteria count');
assert.deepStrictEqual(parsed.successCriteria[0], 'All tests pass', 'roadmap: successCriteria[0]');
assert.deepStrictEqual(parsed.successCriteria[1], 'Deploy to staging', 'roadmap: successCriteria[1]');
assert.deepStrictEqual(parsed.slices.length, 2, 'roadmap: slices count');
assertEq(parsed.slices[0].id, 'S01', 'roadmap: S01 id');
assertEq(parsed.slices[0].title, 'Auth System', 'roadmap: S01 title');
assertEq(parsed.slices[0].done, true, 'roadmap: S01 done');
assertEq(parsed.slices[0].risk, 'high', 'roadmap: S01 risk');
assertEq(parsed.slices[0].depends.length, 0, 'roadmap: S01 depends empty');
assertEq(parsed.slices[0].demo, 'Login flow works', 'roadmap: S01 demo');
assert.deepStrictEqual(parsed.slices[0].id, 'S01', 'roadmap: S01 id');
assert.deepStrictEqual(parsed.slices[0].title, 'Auth System', 'roadmap: S01 title');
assert.deepStrictEqual(parsed.slices[0].done, true, 'roadmap: S01 done');
assert.deepStrictEqual(parsed.slices[0].risk, 'high', 'roadmap: S01 risk');
assert.deepStrictEqual(parsed.slices[0].depends.length, 0, 'roadmap: S01 depends empty');
assert.deepStrictEqual(parsed.slices[0].demo, 'Login flow works', 'roadmap: S01 demo');
assertEq(parsed.slices[1].id, 'S02', 'roadmap: S02 id');
assertEq(parsed.slices[1].title, 'Dashboard', 'roadmap: S02 title');
assertEq(parsed.slices[1].done, false, 'roadmap: S02 done');
assertEq(parsed.slices[1].risk, 'low', 'roadmap: S02 risk');
assertEq(parsed.slices[1].depends, ['S01'], 'roadmap: S02 depends');
assertEq(parsed.slices[1].demo, 'Dashboard renders data', 'roadmap: S02 demo');
assert.deepStrictEqual(parsed.slices[1].id, 'S02', 'roadmap: S02 id');
assert.deepStrictEqual(parsed.slices[1].title, 'Dashboard', 'roadmap: S02 title');
assert.deepStrictEqual(parsed.slices[1].done, false, 'roadmap: S02 done');
assert.deepStrictEqual(parsed.slices[1].risk, 'low', 'roadmap: S02 risk');
assert.deepStrictEqual(parsed.slices[1].depends, ['S01'], 'roadmap: S02 depends');
assert.deepStrictEqual(parsed.slices[1].demo, 'Dashboard renders data', 'roadmap: S02 demo');
assertEq(parsed.boundaryMap.length, 0, 'roadmap: boundaryMap empty');
}
assert.deepStrictEqual(parsed.boundaryMap.length, 0, 'roadmap: boundaryMap empty');
});
// ═══════════════════════════════════════════════════════════════════════════
// Scenario B: Plan round-trip with 3 tasks (mixed done)
// ═══════════════════════════════════════════════════════════════════════════
{
test('Scenario B: Plan round-trip with 3 tasks (mixed done)', () => {
const slice = makeSlice({
id: 'S01',
title: 'Auth System',
@ -176,31 +168,27 @@ function makeTaskSummary(overrides: Partial<GSDTaskSummaryData> = {}): GSDTaskSu
const output = formatPlan(slice);
const parsed = parsePlan(output);
assertEq(parsed.id, 'S01', 'plan: id');
assertEq(parsed.title, 'Auth System', 'plan: title');
assertEq(parsed.goal, 'Working authentication system', 'plan: goal');
assertEq(parsed.demo, 'Login works with valid credentials', 'plan: demo');
assertEq(parsed.tasks.length, 3, 'plan: tasks count');
assert.deepStrictEqual(parsed.id, 'S01', 'plan: id');
assert.deepStrictEqual(parsed.title, 'Auth System', 'plan: title');
assert.deepStrictEqual(parsed.goal, 'Working authentication system', 'plan: goal');
assert.deepStrictEqual(parsed.demo, 'Login works with valid credentials', 'plan: demo');
assert.deepStrictEqual(parsed.tasks.length, 3, 'plan: tasks count');
assertEq(parsed.tasks[0].id, 'T01', 'plan: T01 id');
assertEq(parsed.tasks[0].title, 'Setup Models', 'plan: T01 title');
assertEq(parsed.tasks[0].done, true, 'plan: T01 done');
assertEq(parsed.tasks[0].estimate, '15m', 'plan: T01 estimate');
assert.deepStrictEqual(parsed.tasks[0].id, 'T01', 'plan: T01 id');
assert.deepStrictEqual(parsed.tasks[0].title, 'Setup Models', 'plan: T01 title');
assert.deepStrictEqual(parsed.tasks[0].done, true, 'plan: T01 done');
assert.deepStrictEqual(parsed.tasks[0].estimate, '15m', 'plan: T01 estimate');
assertEq(parsed.tasks[1].id, 'T02', 'plan: T02 id');
assertEq(parsed.tasks[1].done, false, 'plan: T02 done');
assertEq(parsed.tasks[1].estimate, '30m', 'plan: T02 estimate');
assert.deepStrictEqual(parsed.tasks[1].id, 'T02', 'plan: T02 id');
assert.deepStrictEqual(parsed.tasks[1].done, false, 'plan: T02 done');
assert.deepStrictEqual(parsed.tasks[1].estimate, '30m', 'plan: T02 estimate');
assertEq(parsed.tasks[2].id, 'T03', 'plan: T03 id');
assertEq(parsed.tasks[2].done, true, 'plan: T03 done');
assertEq(parsed.tasks[2].estimate, '20m', 'plan: T03 estimate');
}
assert.deepStrictEqual(parsed.tasks[2].id, 'T03', 'plan: T03 id');
assert.deepStrictEqual(parsed.tasks[2].done, true, 'plan: T03 done');
assert.deepStrictEqual(parsed.tasks[2].estimate, '20m', 'plan: T03 estimate');
});
// ═══════════════════════════════════════════════════════════════════════════
// Scenario C: Slice summary round-trip with full data
// ═══════════════════════════════════════════════════════════════════════════
{
test('Scenario C: Slice summary round-trip with full data', () => {
const slice = makeSlice({
id: 'S01',
title: 'Auth System',
@ -211,28 +199,24 @@ function makeTaskSummary(overrides: Partial<GSDTaskSummaryData> = {}): GSDTaskSu
const output = formatSliceSummary(slice, 'M001');
const parsed = parseSummary(output);
assertEq(parsed.frontmatter.id, 'S01', 'sliceSummary: id');
assertEq(parsed.frontmatter.parent, 'M001', 'sliceSummary: parent');
assertEq(parsed.frontmatter.milestone, 'M001', 'sliceSummary: milestone');
assertEq(parsed.frontmatter.provides, ['auth-flow', 'jwt-tokens'], 'sliceSummary: provides');
assertEq(parsed.frontmatter.requires.length, 0, 'sliceSummary: requires empty');
assertEq(parsed.frontmatter.affects.length, 0, 'sliceSummary: affects empty');
assertEq(parsed.frontmatter.key_files, ['src/auth.ts', 'src/middleware.ts'], 'sliceSummary: key_files');
assertEq(parsed.frontmatter.key_decisions, ['Use JWT over sessions'], 'sliceSummary: key_decisions');
assertEq(parsed.frontmatter.patterns_established, ['Middleware pattern'], 'sliceSummary: patterns_established');
assertEq(parsed.frontmatter.duration, '2h', 'sliceSummary: duration');
assertEq(parsed.frontmatter.completed_at, '2026-03-10', 'sliceSummary: completed_at');
assertEq(parsed.frontmatter.verification_result, 'passed', 'sliceSummary: verification_result');
assertEq(parsed.frontmatter.blocker_discovered, false, 'sliceSummary: blocker_discovered');
assertTrue(parsed.whatHappened.includes('Implemented full auth system'), 'sliceSummary: whatHappened content');
assertEq(parsed.title, 'S01: Auth System', 'sliceSummary: title');
}
assert.deepStrictEqual(parsed.frontmatter.id, 'S01', 'sliceSummary: id');
assert.deepStrictEqual(parsed.frontmatter.parent, 'M001', 'sliceSummary: parent');
assert.deepStrictEqual(parsed.frontmatter.milestone, 'M001', 'sliceSummary: milestone');
assert.deepStrictEqual(parsed.frontmatter.provides, ['auth-flow', 'jwt-tokens'], 'sliceSummary: provides');
assert.deepStrictEqual(parsed.frontmatter.requires.length, 0, 'sliceSummary: requires empty');
assert.deepStrictEqual(parsed.frontmatter.affects.length, 0, 'sliceSummary: affects empty');
assert.deepStrictEqual(parsed.frontmatter.key_files, ['src/auth.ts', 'src/middleware.ts'], 'sliceSummary: key_files');
assert.deepStrictEqual(parsed.frontmatter.key_decisions, ['Use JWT over sessions'], 'sliceSummary: key_decisions');
assert.deepStrictEqual(parsed.frontmatter.patterns_established, ['Middleware pattern'], 'sliceSummary: patterns_established');
assert.deepStrictEqual(parsed.frontmatter.duration, '2h', 'sliceSummary: duration');
assert.deepStrictEqual(parsed.frontmatter.completed_at, '2026-03-10', 'sliceSummary: completed_at');
assert.deepStrictEqual(parsed.frontmatter.verification_result, 'passed', 'sliceSummary: verification_result');
assert.deepStrictEqual(parsed.frontmatter.blocker_discovered, false, 'sliceSummary: blocker_discovered');
assert.ok(parsed.whatHappened.includes('Implemented full auth system'), 'sliceSummary: whatHappened content');
assert.deepStrictEqual(parsed.title, 'S01: Auth System', 'sliceSummary: title');
});
// ═══════════════════════════════════════════════════════════════════════════
// Scenario D: Task summary round-trip
// ═══════════════════════════════════════════════════════════════════════════
{
test('Scenario D: Task summary round-trip', () => {
const task = makeTask({
id: 'T01',
title: 'Setup Auth',
@ -243,22 +227,18 @@ function makeTaskSummary(overrides: Partial<GSDTaskSummaryData> = {}): GSDTaskSu
const output = formatTaskSummary(task, 'S01', 'M001');
const parsed = parseSummary(output);
assertEq(parsed.frontmatter.id, 'T01', 'taskSummary: id');
assertEq(parsed.frontmatter.parent, 'S01', 'taskSummary: parent');
assertEq(parsed.frontmatter.milestone, 'M001', 'taskSummary: milestone');
assertEq(parsed.frontmatter.provides, ['auth-endpoint'], 'taskSummary: provides');
assertEq(parsed.frontmatter.key_files, ['src/auth.ts'], 'taskSummary: key_files');
assertEq(parsed.frontmatter.duration, '45m', 'taskSummary: duration');
assertEq(parsed.frontmatter.completed_at, '2026-03-09', 'taskSummary: completed_at');
assertTrue(parsed.whatHappened.includes('Built the auth endpoint'), 'taskSummary: whatHappened content');
assertEq(parsed.title, 'T01: Setup Auth', 'taskSummary: title');
}
assert.deepStrictEqual(parsed.frontmatter.id, 'T01', 'taskSummary: id');
assert.deepStrictEqual(parsed.frontmatter.parent, 'S01', 'taskSummary: parent');
assert.deepStrictEqual(parsed.frontmatter.milestone, 'M001', 'taskSummary: milestone');
assert.deepStrictEqual(parsed.frontmatter.provides, ['auth-endpoint'], 'taskSummary: provides');
assert.deepStrictEqual(parsed.frontmatter.key_files, ['src/auth.ts'], 'taskSummary: key_files');
assert.deepStrictEqual(parsed.frontmatter.duration, '45m', 'taskSummary: duration');
assert.deepStrictEqual(parsed.frontmatter.completed_at, '2026-03-09', 'taskSummary: completed_at');
assert.ok(parsed.whatHappened.includes('Built the auth endpoint'), 'taskSummary: whatHappened content');
assert.deepStrictEqual(parsed.title, 'T01: Setup Auth', 'taskSummary: title');
});
// ═══════════════════════════════════════════════════════════════════════════
// Scenario E: Requirements round-trip with mixed statuses
// ═══════════════════════════════════════════════════════════════════════════
{
test('Scenario E: Requirements round-trip with mixed statuses', () => {
const requirements: GSDRequirement[] = [
{ id: 'R001', title: 'Auth Required', class: 'core-capability', status: 'active', description: 'Must have auth', source: 'spec', primarySlice: 'S01' },
{ id: 'R002', title: 'Logging', class: 'observability', status: 'active', description: 'Must log', source: 'spec', primarySlice: 'S02' },
@ -270,110 +250,93 @@ function makeTaskSummary(overrides: Partial<GSDTaskSummaryData> = {}): GSDTaskSu
const output = formatRequirements(requirements);
const counts = parseRequirementCounts(output);
assertEq(counts.active, 2, 'requirements: active count');
assertEq(counts.validated, 1, 'requirements: validated count');
assertEq(counts.deferred, 1, 'requirements: deferred count');
assertEq(counts.outOfScope, 1, 'requirements: outOfScope count');
assertEq(counts.total, 5, 'requirements: total count');
}
assert.deepStrictEqual(counts.active, 2, 'requirements: active count');
assert.deepStrictEqual(counts.validated, 1, 'requirements: validated count');
assert.deepStrictEqual(counts.deferred, 1, 'requirements: deferred count');
assert.deepStrictEqual(counts.outOfScope, 1, 'requirements: outOfScope count');
assert.deepStrictEqual(counts.total, 5, 'requirements: total count');
});
// ═══════════════════════════════════════════════════════════════════════════
// Scenario F: Edge cases
// ═══════════════════════════════════════════════════════════════════════════
// F1: Empty vision → fallback text
{
test('F1: Empty vision → fallback text', () => {
const milestone = makeMilestone({ vision: '' });
const output = formatRoadmap(milestone);
const parsed = parseRoadmap(output);
assertEq(parsed.vision, '(migrated project)', 'edge: empty vision fallback');
}
assert.deepStrictEqual(parsed.vision, '(migrated project)', 'edge: empty vision fallback');
});
// F2: Empty successCriteria → empty array
{
test('F2: Empty successCriteria → empty array', () => {
const milestone = makeMilestone({ successCriteria: [] });
const output = formatRoadmap(milestone);
const parsed = parseRoadmap(output);
assertEq(parsed.successCriteria.length, 0, 'edge: empty successCriteria');
}
assert.deepStrictEqual(parsed.successCriteria.length, 0, 'edge: empty successCriteria');
});
// F3: Empty tasks → empty array in parsed plan
{
test('F3: Empty tasks → empty array in parsed plan', () => {
const slice = makeSlice({ tasks: [] });
const output = formatPlan(slice);
const parsed = parsePlan(output);
assertEq(parsed.tasks.length, 0, 'edge: empty tasks');
}
assert.deepStrictEqual(parsed.tasks.length, 0, 'edge: empty tasks');
});
// F4: Null summary → empty string from formatSliceSummary
{
test('F4: Null summary → empty string from formatSliceSummary', () => {
const slice = makeSlice({ summary: null });
const output = formatSliceSummary(slice, 'M001');
assertEq(output, '', 'edge: null summary returns empty string');
}
assert.deepStrictEqual(output, '', 'edge: null summary returns empty string');
});
// F5: Done=true checkbox in roadmap
{
test('F5: Done=true checkbox in roadmap', () => {
const milestone = makeMilestone({
slices: [makeSlice({ id: 'S01', done: true })],
});
const output = formatRoadmap(milestone);
const parsed = parseRoadmap(output);
assertEq(parsed.slices[0].done, true, 'edge: done checkbox true');
}
assert.deepStrictEqual(parsed.slices[0].done, true, 'edge: done checkbox true');
});
// F6: Done=false checkbox in roadmap
{
test('F6: Done=false checkbox in roadmap', () => {
const milestone = makeMilestone({
slices: [makeSlice({ id: 'S01', done: false })],
});
const output = formatRoadmap(milestone);
const parsed = parseRoadmap(output);
assertEq(parsed.slices[0].done, false, 'edge: done checkbox false');
}
assert.deepStrictEqual(parsed.slices[0].done, false, 'edge: done checkbox false');
});
// F7: Null task summary → empty string from formatTaskSummary
{
test('F7: Null task summary → empty string from formatTaskSummary', () => {
const task = makeTask({ summary: null });
const output = formatTaskSummary(task, 'S01', 'M001');
assertEq(output, '', 'edge: null task summary returns empty string');
}
assert.deepStrictEqual(output, '', 'edge: null task summary returns empty string');
});
// F8: Empty requirements → all zeros
{
test('F8: Empty requirements → all zeros', () => {
const output = formatRequirements([]);
const counts = parseRequirementCounts(output);
assertEq(counts.total, 0, 'edge: empty requirements total 0');
}
assert.deepStrictEqual(counts.total, 0, 'edge: empty requirements total 0');
});
// F9: formatProject with empty content → produces valid stub
{
test('F9: formatProject with empty content → produces valid stub', () => {
const output = formatProject('');
assertTrue(output.includes('# Project'), 'edge: empty project has heading');
assertTrue(output.length > 10, 'edge: empty project not blank');
}
assert.ok(output.includes('# Project'), 'edge: empty project has heading');
assert.ok(output.length > 10, 'edge: empty project not blank');
});
// F10: formatProject with existing content → passes through
{
test('F10: formatProject with existing content → passes through', () => {
const content = '# My Project\n\nDescription here.\n';
const output = formatProject(content);
assertEq(output, content, 'edge: project passthrough');
}
assert.deepStrictEqual(output, content, 'edge: project passthrough');
});
// F11: formatDecisions with empty content → produces valid stub
{
test('F11: formatDecisions with empty content → produces valid stub', () => {
const output = formatDecisions('');
assertTrue(output.includes('# Decisions'), 'edge: empty decisions has heading');
}
assert.ok(output.includes('# Decisions'), 'edge: empty decisions has heading');
});
// F12: formatContext produces valid content
{
test('F12: formatContext produces valid content', () => {
const output = formatContext('M001');
assertTrue(output.includes('M001'), 'edge: context mentions milestone');
}
assert.ok(output.includes('M001'), 'edge: context mentions milestone');
});
// F13: formatState produces valid content
{
test('F13: formatState produces valid content', () => {
const milestones = [makeMilestone({
slices: [
makeSlice({ done: true }),
@ -381,20 +344,18 @@ function makeTaskSummary(overrides: Partial<GSDTaskSummaryData> = {}): GSDTaskSu
],
})];
const output = formatState(milestones);
assertTrue(output.includes('1/2'), 'edge: state shows slice progress');
}
assert.ok(output.includes('1/2'), 'edge: state shows slice progress');
});
// F14: Task with no estimate → no est backtick in plan
{
test('F14: Task with no estimate → no est backtick in plan', () => {
const slice = makeSlice({
tasks: [makeTask({ id: 'T01', title: 'Quick Fix', estimate: '' })],
});
const output = formatPlan(slice);
const parsed = parsePlan(output);
assertEq(parsed.tasks[0].id, 'T01', 'edge: task no estimate id');
assertEq(parsed.tasks[0].estimate, '', 'edge: task no estimate empty');
}
assert.deepStrictEqual(parsed.tasks[0].id, 'T01', 'edge: task no estimate id');
assert.deepStrictEqual(parsed.tasks[0].estimate, '', 'edge: task no estimate empty');
});
// ═══════════════════════════════════════════════════════════════════════════
report();

View file

@ -1,13 +1,12 @@
import { parseTaskPlanMustHaves } from '../files.ts';
import { createTestContext } from './test-helpers.ts';
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
const { assertEq, assertTrue, report } = createTestContext();
// ═══════════════════════════════════════════════════════════════════════════
// (a) Standard unchecked format: - [ ] text
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== parseTaskPlanMustHaves: standard unchecked ===');
{
test('parseTaskPlanMustHaves: standard unchecked', () => {
const content = `# T01: Test Task
## Must-Haves
@ -16,56 +15,53 @@ console.log('\n=== parseTaskPlanMustHaves: standard unchecked ===');
- [ ] Second must-have item
`;
const result = parseTaskPlanMustHaves(content);
assertEq(result.length, 2, 'should return 2 items');
assertEq(result[0].text, 'First must-have item', 'first item text');
assertEq(result[0].checked, false, 'first item unchecked');
assertEq(result[1].text, 'Second must-have item', 'second item text');
assertEq(result[1].checked, false, 'second item unchecked');
}
assert.deepStrictEqual(result.length, 2, 'should return 2 items');
assert.deepStrictEqual(result[0].text, 'First must-have item', 'first item text');
assert.deepStrictEqual(result[0].checked, false, 'first item unchecked');
assert.deepStrictEqual(result[1].text, 'Second must-have item', 'second item text');
assert.deepStrictEqual(result[1].checked, false, 'second item unchecked');
});
// ═══════════════════════════════════════════════════════════════════════════
// (b) Checked variants: - [x] and - [X]
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== parseTaskPlanMustHaves: checked [x] and [X] ===');
{
test('parseTaskPlanMustHaves: checked [x] and [X]', () => {
const content = `## Must-Haves
- [x] Lowercase checked item
- [X] Uppercase checked item
`;
const result = parseTaskPlanMustHaves(content);
assertEq(result.length, 2, 'should return 2 items');
assertEq(result[0].checked, true, 'lowercase x is checked');
assertEq(result[0].text, 'Lowercase checked item', 'lowercase x text');
assertEq(result[1].checked, true, 'uppercase X is checked');
assertEq(result[1].text, 'Uppercase checked item', 'uppercase X text');
}
assert.deepStrictEqual(result.length, 2, 'should return 2 items');
assert.deepStrictEqual(result[0].checked, true, 'lowercase x is checked');
assert.deepStrictEqual(result[0].text, 'Lowercase checked item', 'lowercase x text');
assert.deepStrictEqual(result[1].checked, true, 'uppercase X is checked');
assert.deepStrictEqual(result[1].text, 'Uppercase checked item', 'uppercase X text');
});
// ═══════════════════════════════════════════════════════════════════════════
// (c) No-checkbox bullets: - text
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== parseTaskPlanMustHaves: no-checkbox bullets ===');
{
test('parseTaskPlanMustHaves: no-checkbox bullets', () => {
const content = `## Must-Haves
- Plain bullet item
- Another plain item
`;
const result = parseTaskPlanMustHaves(content);
assertEq(result.length, 2, 'should return 2 items');
assertEq(result[0].text, 'Plain bullet item', 'plain bullet text');
assertEq(result[0].checked, false, 'plain bullet defaults to unchecked');
assertEq(result[1].text, 'Another plain item', 'second plain bullet text');
}
assert.deepStrictEqual(result.length, 2, 'should return 2 items');
assert.deepStrictEqual(result[0].text, 'Plain bullet item', 'plain bullet text');
assert.deepStrictEqual(result[0].checked, false, 'plain bullet defaults to unchecked');
assert.deepStrictEqual(result[1].text, 'Another plain item', 'second plain bullet text');
});
// ═══════════════════════════════════════════════════════════════════════════
// (d) Indented variants
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== parseTaskPlanMustHaves: indented variants ===');
{
test('parseTaskPlanMustHaves: indented variants', () => {
const content = `## Must-Haves
- [ ] Indented unchecked item
@ -73,21 +69,20 @@ console.log('\n=== parseTaskPlanMustHaves: indented variants ===');
- Plain indented item
`;
const result = parseTaskPlanMustHaves(content);
assertEq(result.length, 3, 'should return 3 items');
assertEq(result[0].text, 'Indented unchecked item', 'indented unchecked text');
assertEq(result[0].checked, false, 'indented unchecked state');
assertEq(result[1].text, 'Indented checked item', 'indented checked text');
assertEq(result[1].checked, true, 'indented checked state');
assertEq(result[2].text, 'Plain indented item', 'indented plain text');
assertEq(result[2].checked, false, 'indented plain state');
}
assert.deepStrictEqual(result.length, 3, 'should return 3 items');
assert.deepStrictEqual(result[0].text, 'Indented unchecked item', 'indented unchecked text');
assert.deepStrictEqual(result[0].checked, false, 'indented unchecked state');
assert.deepStrictEqual(result[1].text, 'Indented checked item', 'indented checked text');
assert.deepStrictEqual(result[1].checked, true, 'indented checked state');
assert.deepStrictEqual(result[2].text, 'Plain indented item', 'indented plain text');
assert.deepStrictEqual(result[2].checked, false, 'indented plain state');
});
// ═══════════════════════════════════════════════════════════════════════════
// (e) Mixed checkbox states in one section
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== parseTaskPlanMustHaves: mixed states ===');
{
test('parseTaskPlanMustHaves: mixed states', () => {
const content = `## Must-Haves
- [ ] Unchecked one
@ -97,20 +92,19 @@ console.log('\n=== parseTaskPlanMustHaves: mixed states ===');
- [ ] Another unchecked
`;
const result = parseTaskPlanMustHaves(content);
assertEq(result.length, 5, 'should return 5 items');
assertEq(result[0].checked, false, 'first is unchecked');
assertEq(result[1].checked, true, 'second is checked');
assertEq(result[2].checked, true, 'third is checked (uppercase)');
assertEq(result[3].checked, false, 'fourth (plain) is unchecked');
assertEq(result[4].checked, false, 'fifth is unchecked');
}
assert.deepStrictEqual(result.length, 5, 'should return 5 items');
assert.deepStrictEqual(result[0].checked, false, 'first is unchecked');
assert.deepStrictEqual(result[1].checked, true, 'second is checked');
assert.deepStrictEqual(result[2].checked, true, 'third is checked (uppercase)');
assert.deepStrictEqual(result[3].checked, false, 'fourth (plain) is unchecked');
assert.deepStrictEqual(result[4].checked, false, 'fifth is unchecked');
});
// ═══════════════════════════════════════════════════════════════════════════
// (f) Missing Must-Haves section → empty array
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== parseTaskPlanMustHaves: missing section ===');
{
test('parseTaskPlanMustHaves: missing section', () => {
const content = `# T01: Some Task
## Description
@ -122,16 +116,15 @@ Some description here.
- Run tests
`;
const result = parseTaskPlanMustHaves(content);
assertEq(result.length, 0, 'returns empty array when section missing');
assertTrue(Array.isArray(result), 'result is an array');
}
assert.deepStrictEqual(result.length, 0, 'returns empty array when section missing');
assert.ok(Array.isArray(result), 'result is an array');
});
// ═══════════════════════════════════════════════════════════════════════════
// (g) Empty Must-Haves section → empty array
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== parseTaskPlanMustHaves: empty section ===');
{
test('parseTaskPlanMustHaves: empty section', () => {
const content = `## Must-Haves
## Verification
@ -139,15 +132,14 @@ console.log('\n=== parseTaskPlanMustHaves: empty section ===');
- Run tests
`;
const result = parseTaskPlanMustHaves(content);
assertEq(result.length, 0, 'returns empty array when section is empty');
}
assert.deepStrictEqual(result.length, 0, 'returns empty array when section is empty');
});
// ═══════════════════════════════════════════════════════════════════════════
// (h) Content with YAML frontmatter
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== parseTaskPlanMustHaves: YAML frontmatter ===');
{
test('parseTaskPlanMustHaves: YAML frontmatter', () => {
const content = `---
estimated_steps: 5
estimated_files: 3
@ -161,16 +153,16 @@ estimated_files: 3
- [x] Checked must-have after frontmatter
`;
const result = parseTaskPlanMustHaves(content);
assertEq(result.length, 2, 'frontmatter does not pollute results');
assertEq(result[0].text, 'Real must-have after frontmatter', 'first item text correct');
assertEq(result[0].checked, false, 'first item unchecked');
assertEq(result[1].text, 'Checked must-have after frontmatter', 'second item text correct');
assertEq(result[1].checked, true, 'second item checked');
}
assert.deepStrictEqual(result.length, 2, 'frontmatter does not pollute results');
assert.deepStrictEqual(result[0].text, 'Real must-have after frontmatter', 'first item text correct');
assert.deepStrictEqual(result[0].checked, false, 'first item unchecked');
assert.deepStrictEqual(result[1].text, 'Checked must-have after frontmatter', 'second item text correct');
assert.deepStrictEqual(result[1].checked, true, 'second item checked');
});
// Verify frontmatter content is not misinterpreted as must-haves
console.log('\n=== parseTaskPlanMustHaves: frontmatter-only content ===');
{
test('parseTaskPlanMustHaves: frontmatter-only content', () => {
const content = `---
estimated_steps: 5
estimated_files: 3
@ -183,15 +175,14 @@ estimated_files: 3
No must-haves section here.
`;
const result = parseTaskPlanMustHaves(content);
assertEq(result.length, 0, 'frontmatter-only content returns empty array');
}
assert.deepStrictEqual(result.length, 0, 'frontmatter-only content returns empty array');
});
// ═══════════════════════════════════════════════════════════════════════════
// (i) Real task plan format (based on S01/T01-PLAN.md structure)
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== parseTaskPlanMustHaves: real task plan format ===');
{
test('parseTaskPlanMustHaves: real task plan format', () => {
const content = `---
estimated_steps: 5
estimated_files: 3
@ -239,40 +230,37 @@ Add the \`completing-milestone\` phase to the GSD state machine.
- \`agent/extensions/gsd/types.ts\` — Phase union includes \`'completing-milestone'\`
`;
const result = parseTaskPlanMustHaves(content);
assertEq(result.length, 5, 'real plan has 5 must-haves');
assertTrue(result[0].text.includes('`Phase` type includes'), 'first must-have text matches');
assertTrue(result[1].text.includes('`deriveState` returns'), 'second must-have text matches');
assertEq(result[0].checked, false, 'all real must-haves are unchecked');
assertEq(result[4].checked, false, 'last real must-have is unchecked');
assertTrue(result[4].text.includes('multi-milestone'), 'last must-have references multi-milestone');
}
assert.deepStrictEqual(result.length, 5, 'real plan has 5 must-haves');
assert.ok(result[0].text.includes('`Phase` type includes'), 'first must-have text matches');
assert.ok(result[1].text.includes('`deriveState` returns'), 'second must-have text matches');
assert.deepStrictEqual(result[0].checked, false, 'all real must-haves are unchecked');
assert.deepStrictEqual(result[4].checked, false, 'last real must-have is unchecked');
assert.ok(result[4].text.includes('multi-milestone'), 'last must-have references multi-milestone');
});
// ═══════════════════════════════════════════════════════════════════════════
// Edge cases
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== parseTaskPlanMustHaves: empty string ===');
{
test('parseTaskPlanMustHaves: empty string', () => {
const result = parseTaskPlanMustHaves('');
assertEq(result.length, 0, 'empty string returns empty array');
}
assert.deepStrictEqual(result.length, 0, 'empty string returns empty array');
});
console.log('\n=== parseTaskPlanMustHaves: must-haves with inline code and backticks ===');
{
test('parseTaskPlanMustHaves: must-haves with inline code and backticks', () => {
const content = `## Must-Haves
- [ ] \`functionName\` is exported from \`module.ts\`
- [x] Returns \`Array<{ text: string }>\` with correct extraction
`;
const result = parseTaskPlanMustHaves(content);
assertEq(result.length, 2, 'handles backtick content');
assertTrue(result[0].text.includes('`functionName`'), 'preserves backticks in text');
assertEq(result[0].checked, false, 'backtick item unchecked');
assertEq(result[1].checked, true, 'backtick item checked');
}
assert.deepStrictEqual(result.length, 2, 'handles backtick content');
assert.ok(result[0].text.includes('`functionName`'), 'preserves backticks in text');
assert.deepStrictEqual(result[0].checked, false, 'backtick item unchecked');
assert.deepStrictEqual(result[1].checked, true, 'backtick item checked');
});
console.log('\n=== parseTaskPlanMustHaves: asterisk bullets ===');
{
test('parseTaskPlanMustHaves: asterisk bullets', () => {
const content = `## Must-Haves
* [ ] Asterisk unchecked
@ -280,12 +268,11 @@ console.log('\n=== parseTaskPlanMustHaves: asterisk bullets ===');
* Plain asterisk
`;
const result = parseTaskPlanMustHaves(content);
assertEq(result.length, 3, 'handles asterisk bullets');
assertEq(result[0].checked, false, 'asterisk unchecked');
assertEq(result[1].checked, true, 'asterisk checked');
assertEq(result[2].checked, false, 'plain asterisk unchecked');
}
assert.deepStrictEqual(result.length, 3, 'handles asterisk bullets');
assert.deepStrictEqual(result[0].checked, false, 'asterisk unchecked');
assert.deepStrictEqual(result[1].checked, true, 'asterisk checked');
assert.deepStrictEqual(result[2].checked, false, 'plain asterisk unchecked');
});
// ═══════════════════════════════════════════════════════════════════════════
report();

View file

@ -19,9 +19,8 @@ import { shouldUseWorktreeIsolation } from "../auto.ts";
import { getIsolationMode } from "../preferences.ts";
import { getActiveAutoWorktreeContext } from "../auto-worktree.ts";
import { invalidateAllCaches } from "../cache.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
// --- Preferences helpers (same pattern as doctor-git.test.ts K001) ---
@ -38,77 +37,77 @@ function removeRunnerPreferences(): void {
// --- Tests ---
// Test 1: shouldUseWorktreeIsolation returns false for none
console.log("Test 1: shouldUseWorktreeIsolation returns false for none");
test('shouldUseWorktreeIsolation returns false for none', () => {
try {
writeRunnerPreferences("none");
invalidateAllCaches();
assertEq(shouldUseWorktreeIsolation(), false, "shouldUseWorktreeIsolation() with none prefs");
assert.deepStrictEqual(shouldUseWorktreeIsolation(), false, "shouldUseWorktreeIsolation() with none prefs");
} finally {
removeRunnerPreferences();
invalidateAllCaches();
}
});
// Test 2: shouldUseWorktreeIsolation returns false for branch
console.log("Test 2: shouldUseWorktreeIsolation returns false for branch");
test('shouldUseWorktreeIsolation returns false for branch', () => {
try {
writeRunnerPreferences("branch");
invalidateAllCaches();
assertEq(shouldUseWorktreeIsolation(), false, "shouldUseWorktreeIsolation() with branch prefs");
assert.deepStrictEqual(shouldUseWorktreeIsolation(), false, "shouldUseWorktreeIsolation() with branch prefs");
} finally {
removeRunnerPreferences();
invalidateAllCaches();
}
});
// Test 3: shouldUseWorktreeIsolation returns true for worktree
console.log("Test 3: shouldUseWorktreeIsolation returns true for worktree");
test('shouldUseWorktreeIsolation returns true for worktree', () => {
try {
writeRunnerPreferences("worktree");
invalidateAllCaches();
assertEq(shouldUseWorktreeIsolation(), true, "shouldUseWorktreeIsolation() with worktree prefs");
assert.deepStrictEqual(shouldUseWorktreeIsolation(), true, "shouldUseWorktreeIsolation() with worktree prefs");
} finally {
removeRunnerPreferences();
invalidateAllCaches();
}
});
// Test 4: shouldUseWorktreeIsolation returns true for no prefs (default)
// Skip if global prefs exist — they override the default and this test
// cannot control ~/.gsd/preferences.md.
const globalPrefsExist = existsSync(join(homedir(), ".gsd", "preferences.md"))
|| existsSync(join(homedir(), ".gsd", "PREFERENCES.md"));
if (!globalPrefsExist) {
console.log("Test 4: shouldUseWorktreeIsolation returns true for no prefs (default)");
try {
removeRunnerPreferences(); // ensure no prefs file
invalidateAllCaches();
assertEq(shouldUseWorktreeIsolation(), true, "shouldUseWorktreeIsolation() with no prefs (default worktree)");
} finally {
invalidateAllCaches();
}
} else {
console.log("Test 4: SKIPPED — global prefs file exists, cannot test bare default");
}
// Test 5: getIsolationMode returns "none" with none prefs
console.log("Test 5: getIsolationMode returns 'none' with none prefs");
test('shouldUseWorktreeIsolation returns true for no prefs (default)', () => {
const globalPrefsExist = existsSync(join(homedir(), ".gsd", "preferences.md"))
|| existsSync(join(homedir(), ".gsd", "PREFERENCES.md"));
if (!globalPrefsExist) {
try {
removeRunnerPreferences(); // ensure no prefs file
invalidateAllCaches();
assert.deepStrictEqual(shouldUseWorktreeIsolation(), true, "shouldUseWorktreeIsolation() with no prefs (default worktree)");
} finally {
invalidateAllCaches();
}
} else {
}
});
test('getIsolationMode returns "none" with none prefs', () => {
try {
writeRunnerPreferences("none");
invalidateAllCaches();
assertEq(getIsolationMode(), "none", "getIsolationMode() with none prefs");
assert.deepStrictEqual(getIsolationMode(), "none", "getIsolationMode() with none prefs");
} finally {
removeRunnerPreferences();
invalidateAllCaches();
}
});
// Test 6: getActiveAutoWorktreeContext returns null at baseline
console.log("Test 6: getActiveAutoWorktreeContext returns null at baseline");
assertEq(getActiveAutoWorktreeContext(), null, "getActiveAutoWorktreeContext() returns null without enterAutoWorktree()");
test('getActiveAutoWorktreeContext returns null at baseline', () => {
assert.deepStrictEqual(getActiveAutoWorktreeContext(), null, "getActiveAutoWorktreeContext() returns null without enterAutoWorktree()");
});
// Test 7: System prompt worktree block absent without active worktree
console.log("Test 7: System prompt worktree block absent without active worktree");
{
const ctx = getActiveAutoWorktreeContext();
assertTrue(ctx === null, "getActiveAutoWorktreeContext() null confirms system prompt worktree block will not be injected");
}
report();
test('Test 7: System prompt worktree block absent without active worktree', () => {
const ctx = getActiveAutoWorktreeContext();
assert.ok(ctx === null, "getActiveAutoWorktreeContext() null confirms system prompt worktree block will not be injected");
});