From b24594d79f5d557c7cf1a99a7919195c2d3c2c92 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 24 Mar 2026 23:34:52 -0400 Subject: [PATCH] refactor: migrate D-G test files from createTestContext to node:test (#2418) --- .../gsd/tests/dashboard-budget.test.ts | 419 +++++---- .../extensions/gsd/tests/db-writer.test.ts | 814 +++++++++--------- .../gsd/tests/derive-state-crossval.test.ts | 151 ++-- .../gsd/tests/derive-state-db.test.ts | 335 ++++--- .../gsd/tests/derive-state-deps.test.ts | 179 ++-- .../extensions/gsd/tests/derive-state.test.ts | 419 +++++---- .../gsd/tests/doctor-enhancements.test.ts | 129 ++- .../tests/doctor-environment-worktree.test.ts | 53 +- .../gsd/tests/doctor-environment.test.ts | 169 ++-- .../extensions/gsd/tests/doctor-git.test.ts | 249 +++--- .../gsd/tests/doctor-proactive.test.ts | 190 ++-- .../gsd/tests/doctor-runtime.test.ts | 165 ++-- .../extensions/gsd/tests/doctor.test.ts | 236 +++-- .../gsd/tests/ensure-db-open.test.ts | 234 +++-- ...ature-branch-lifecycle-integration.test.ts | 105 ++- .../extensions/gsd/tests/flag-file-db.test.ts | 72 +- .../gsd/tests/freeform-decisions.test.ts | 336 ++++---- .../extensions/gsd/tests/git-locale.test.ts | 40 +- .../extensions/gsd/tests/git-service.test.ts | 673 ++++++--------- .../extensions/gsd/tests/gsd-db.test.ts | 579 ++++++------- .../extensions/gsd/tests/gsd-inspect.test.ts | 203 +++-- .../extensions/gsd/tests/gsd-recover.test.ts | 184 ++-- .../extensions/gsd/tests/gsd-tools.test.ts | 473 +++++----- 23 files changed, 2928 insertions(+), 3479 deletions(-) diff --git a/src/resources/extensions/gsd/tests/dashboard-budget.test.ts b/src/resources/extensions/gsd/tests/dashboard-budget.test.ts index bedb4a1f8..a9a14873c 100644 --- a/src/resources/extensions/gsd/tests/dashboard-budget.test.ts +++ b/src/resources/extensions/gsd/tests/dashboard-budget.test.ts @@ -1,3 +1,5 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; /** * Tests for dashboard budget indicator rendering. * @@ -18,10 +20,6 @@ import { getProjectTotals, formatTokenCount, } from "../metrics.js"; -import { createTestContext } from './test-helpers.ts'; - -const { assertEq, assertTrue, assertMatch, assertNoMatch, report } = createTestContext(); - // ─── Test helpers ───────────────────────────────────────────────────────────── function makeUnit(overrides: Partial = {}): UnitMetrics { @@ -102,245 +100,230 @@ function renderModelContextWindow(units: UnitMetrics[], modelName: string): stri // ─── Completed section: budget indicators ───────────────────────────────────── -console.log("\n=== Completed section: truncation + continue-here markers ==="); +describe('dashboard-budget', () => { + test('Completed section: truncation + continue-here markers', () => { + // Unit with truncation and continue-here — both markers appear + const ledgerUnits = [ + makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 3, continueHereFired: true }), + ]; + const markers = renderCompletedBudgetMarkers( + { type: "execute-task", id: "M001/S01/T01" }, + ledgerUnits, + ); + assert.match(markers, /▼3/, "completed: shows ▼3 for 3 truncation sections"); + assert.match(markers, /→ wrap-up/, "completed: shows → wrap-up when continueHereFired"); + }); -{ - // Unit with truncation and continue-here — both markers appear - const ledgerUnits = [ - makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 3, continueHereFired: true }), - ]; - const markers = renderCompletedBudgetMarkers( - { type: "execute-task", id: "M001/S01/T01" }, - ledgerUnits, - ); - assertMatch(markers, /▼3/, "completed: shows ▼3 for 3 truncation sections"); - assertMatch(markers, /→ wrap-up/, "completed: shows → wrap-up when continueHereFired"); -} + { + // Unit with truncation only — no wrap-up marker + const ledgerUnits = [ + makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 5, continueHereFired: false }), + ]; + const markers = renderCompletedBudgetMarkers( + { type: "execute-task", id: "M001/S01/T01" }, + ledgerUnits, + ); + assert.match(markers, /▼5/, "completed: shows ▼5 truncation only"); + assert.doesNotMatch(markers, /wrap-up/, "completed: no wrap-up when continueHereFired=false"); + } -{ - // Unit with truncation only — no wrap-up marker - const ledgerUnits = [ - makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 5, continueHereFired: false }), - ]; - const markers = renderCompletedBudgetMarkers( - { type: "execute-task", id: "M001/S01/T01" }, - ledgerUnits, - ); - assertMatch(markers, /▼5/, "completed: shows ▼5 truncation only"); - assertNoMatch(markers, /wrap-up/, "completed: no wrap-up when continueHereFired=false"); -} + { + // Unit with continue-here only — no truncation marker + const ledgerUnits = [ + makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 0, continueHereFired: true }), + ]; + const markers = renderCompletedBudgetMarkers( + { type: "execute-task", id: "M001/S01/T01" }, + ledgerUnits, + ); + assert.doesNotMatch(markers, /▼/, "completed: no ▼ when truncationSections=0"); + assert.match(markers, /→ wrap-up/, "completed: shows → wrap-up"); + } -{ - // Unit with continue-here only — no truncation marker - const ledgerUnits = [ - makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 0, continueHereFired: true }), - ]; - const markers = renderCompletedBudgetMarkers( - { type: "execute-task", id: "M001/S01/T01" }, - ledgerUnits, - ); - assertNoMatch(markers, /▼/, "completed: no ▼ when truncationSections=0"); - assertMatch(markers, /→ wrap-up/, "completed: shows → wrap-up"); -} + // ─── Completed section: missing ledger match ────────────────────────────────── -// ─── Completed section: missing ledger match ────────────────────────────────── + test('Completed section: missing ledger match', () => { + // Completed unit with no matching ledger entry — no crash, no markers + const ledgerUnits = [ + makeUnit({ type: "execute-task", id: "M001/S01/T99", truncationSections: 3 }), + ]; + const markers = renderCompletedBudgetMarkers( + { type: "execute-task", id: "M001/S01/T01" }, + ledgerUnits, + ); + assert.deepStrictEqual(markers, "", "missing match: empty markers when no ledger entry matches"); + }); -console.log("\n=== Completed section: missing ledger match ==="); + { + // Empty ledger — no crash, no markers + const markers = renderCompletedBudgetMarkers( + { type: "execute-task", id: "M001/S01/T01" }, + [], + ); + assert.deepStrictEqual(markers, "", "empty ledger: empty markers"); + } -{ - // Completed unit with no matching ledger entry — no crash, no markers - const ledgerUnits = [ - makeUnit({ type: "execute-task", id: "M001/S01/T99", truncationSections: 3 }), - ]; - const markers = renderCompletedBudgetMarkers( - { type: "execute-task", id: "M001/S01/T01" }, - ledgerUnits, - ); - assertEq(markers, "", "missing match: empty markers when no ledger entry matches"); -} + // ─── Completed section: retry handling (last entry wins) ────────────────────── -{ - // Empty ledger — no crash, no markers - const markers = renderCompletedBudgetMarkers( - { type: "execute-task", id: "M001/S01/T01" }, - [], - ); - assertEq(markers, "", "empty ledger: empty markers"); -} + test('Completed section: retry handling', () => { + // Two ledger entries for same unit (retry) — last entry wins + const ledgerUnits = [ + makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 1 }), + makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 7 }), + ]; + const markers = renderCompletedBudgetMarkers( + { type: "execute-task", id: "M001/S01/T01" }, + ledgerUnits, + ); + assert.match(markers, /▼7/, "retry: last entry's truncation count (7) wins over first (1)"); + assert.doesNotMatch(markers, /▼1/, "retry: first entry's count (1) is not shown"); + }); -// ─── Completed section: retry handling (last entry wins) ────────────────────── + // ─── By Model section: context window display ───────────────────────────────── -console.log("\n=== Completed section: retry handling ==="); + test('By Model section: context window', () => { + // Model with context window — shows formatted token count + const units = [ + makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 200000 }), + ]; + const label = renderModelContextWindow(units, "claude-sonnet-4-20250514"); + assert.deepStrictEqual(label, "[200.0k]", "by model: shows [200.0k] for 200000 context window"); + }); -{ - // Two ledger entries for same unit (retry) — last entry wins - const ledgerUnits = [ - makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 1 }), - makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 7 }), - ]; - const markers = renderCompletedBudgetMarkers( - { type: "execute-task", id: "M001/S01/T01" }, - ledgerUnits, - ); - assertMatch(markers, /▼7/, "retry: last entry's truncation count (7) wins over first (1)"); - assertNoMatch(markers, /▼1/, "retry: first entry's count (1) is not shown"); -} + { + // Model without context window — no label + const units = [ + makeUnit({ model: "claude-sonnet-4-20250514" }), + ]; + const label = renderModelContextWindow(units, "claude-sonnet-4-20250514"); + assert.deepStrictEqual(label, null, "by model: null when no contextWindowTokens"); + } -// ─── By Model section: context window display ───────────────────────────────── + { + // Multiple models — each gets its own context window + const units = [ + makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 200000, cost: 0.05 }), + makeUnit({ model: "claude-opus-4-20250514", contextWindowTokens: 200000, cost: 0.30 }), + ]; + const sonnetLabel = renderModelContextWindow(units, "claude-sonnet-4-20250514"); + const opusLabel = renderModelContextWindow(units, "claude-opus-4-20250514"); + assert.deepStrictEqual(sonnetLabel, "[200.0k]", "by model multi: sonnet has context window"); + assert.deepStrictEqual(opusLabel, "[200.0k]", "by model multi: opus has context window"); + } -console.log("\n=== By Model section: context window ==="); + // ─── By Model section: single model visibility ─────────────────────────────── -{ - // Model with context window — shows formatted token count - const units = [ - makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 200000 }), - ]; - const label = renderModelContextWindow(units, "claude-sonnet-4-20250514"); - assertEq(label, "[200.0k]", "by model: shows [200.0k] for 200000 context window"); -} + test('By Model section: single model visibility', () => { + // With guard changed to >= 1, single model aggregation should produce results + const units = [ + makeUnit({ model: "claude-sonnet-4-20250514" }), + ]; + const models = aggregateByModel(units); + assert.ok(models.length >= 1, "single model: aggregateByModel returns >= 1 entry"); + assert.deepStrictEqual(models.length, 1, "single model: exactly 1 model aggregate"); + assert.deepStrictEqual(models[0].model, "claude-sonnet-4-20250514", "single model: correct model name"); + // The guard `models.length >= 1` (changed from > 1) means this section now renders + assert.ok(models.length >= 1, "single model: passes >= 1 guard (section will render)"); + }); -{ - // Model without context window — no label - const units = [ - makeUnit({ model: "claude-sonnet-4-20250514" }), - ]; - const label = renderModelContextWindow(units, "claude-sonnet-4-20250514"); - assertEq(label, null, "by model: null when no contextWindowTokens"); -} + // ─── Cost & Usage: aggregate budget line ────────────────────────────────────── -{ - // Multiple models — each gets its own context window - const units = [ - makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 200000, cost: 0.05 }), - makeUnit({ model: "claude-opus-4-20250514", contextWindowTokens: 200000, cost: 0.30 }), - ]; - const sonnetLabel = renderModelContextWindow(units, "claude-sonnet-4-20250514"); - const opusLabel = renderModelContextWindow(units, "claude-opus-4-20250514"); - assertEq(sonnetLabel, "[200.0k]", "by model multi: sonnet has context window"); - assertEq(opusLabel, "[200.0k]", "by model multi: opus has context window"); -} + test('Cost & Usage: aggregate budget line', () => { + // Units with truncation and continue-here — both stats appear + const units = [ + makeUnit({ truncationSections: 3, continueHereFired: true }), + makeUnit({ truncationSections: 2, continueHereFired: false }), + makeUnit({ truncationSections: 1, continueHereFired: true }), + ]; + const line = renderCostBudgetLine(units); + assert.ok(line !== null, "cost budget: line rendered when budget data exists"); + assert.match(line!, /6 sections truncated/, "cost budget: shows total truncation count (3+2+1=6)"); + assert.match(line!, /2 continue-here fired/, "cost budget: shows continue-here count"); + }); -// ─── By Model section: single model visibility ─────────────────────────────── + { + // Only truncation, no continue-here + const units = [ + makeUnit({ truncationSections: 4, continueHereFired: false }), + ]; + const line = renderCostBudgetLine(units); + assert.ok(line !== null, "cost budget truncation-only: line rendered"); + assert.match(line!, /4 sections truncated/, "cost budget truncation-only: shows count"); + assert.doesNotMatch(line!, /continue-here/, "cost budget truncation-only: no continue-here text"); + } -console.log("\n=== By Model section: single model visibility ==="); + { + // Only continue-here, no truncation + const units = [ + makeUnit({ truncationSections: 0, continueHereFired: true }), + ]; + const line = renderCostBudgetLine(units); + assert.ok(line !== null, "cost budget continue-only: line rendered"); + assert.doesNotMatch(line!, /truncated/, "cost budget continue-only: no truncation text"); + assert.match(line!, /1 continue-here fired/, "cost budget continue-only: shows count"); + } -{ - // With guard changed to >= 1, single model aggregation should produce results - const units = [ - makeUnit({ model: "claude-sonnet-4-20250514" }), - ]; - const models = aggregateByModel(units); - assertTrue(models.length >= 1, "single model: aggregateByModel returns >= 1 entry"); - assertEq(models.length, 1, "single model: exactly 1 model aggregate"); - assertEq(models[0].model, "claude-sonnet-4-20250514", "single model: correct model name"); - // The guard `models.length >= 1` (changed from > 1) means this section now renders - assertTrue(models.length >= 1, "single model: passes >= 1 guard (section will render)"); -} + // ─── Backward compat: no budget fields ──────────────────────────────────────── -// ─── Cost & Usage: aggregate budget line ────────────────────────────────────── + test('Backward compat: no budget data', () => { + // Old-format units without budget fields — no indicators anywhere + const oldUnits = [ + makeUnit(), // no budget fields + makeUnit({ id: "M001/S01/T02" }), + ]; -console.log("\n=== Cost & Usage: aggregate budget line ==="); + // Completed section: no markers + const markers = renderCompletedBudgetMarkers( + { type: "execute-task", id: "M001/S01/T01" }, + oldUnits, + ); + assert.doesNotMatch(markers, /▼/, "backward compat completed: no truncation marker"); + assert.doesNotMatch(markers, /wrap-up/, "backward compat completed: no wrap-up marker"); + assert.deepStrictEqual(markers, "", "backward compat completed: empty markers string"); -{ - // Units with truncation and continue-here — both stats appear - const units = [ - makeUnit({ truncationSections: 3, continueHereFired: true }), - makeUnit({ truncationSections: 2, continueHereFired: false }), - makeUnit({ truncationSections: 1, continueHereFired: true }), - ]; - const line = renderCostBudgetLine(units); - assertTrue(line !== null, "cost budget: line rendered when budget data exists"); - assertMatch(line!, /6 sections truncated/, "cost budget: shows total truncation count (3+2+1=6)"); - assertMatch(line!, /2 continue-here fired/, "cost budget: shows continue-here count"); -} + // By Model section: no context window label + const label = renderModelContextWindow(oldUnits, "claude-sonnet-4-20250514"); + assert.deepStrictEqual(label, null, "backward compat by-model: no context window label"); -{ - // Only truncation, no continue-here - const units = [ - makeUnit({ truncationSections: 4, continueHereFired: false }), - ]; - const line = renderCostBudgetLine(units); - assertTrue(line !== null, "cost budget truncation-only: line rendered"); - assertMatch(line!, /4 sections truncated/, "cost budget truncation-only: shows count"); - assertNoMatch(line!, /continue-here/, "cost budget truncation-only: no continue-here text"); -} + // Cost & Usage: no budget line + const line = renderCostBudgetLine(oldUnits); + assert.deepStrictEqual(line, null, "backward compat cost: no budget summary line"); -{ - // Only continue-here, no truncation - const units = [ - makeUnit({ truncationSections: 0, continueHereFired: true }), - ]; - const line = renderCostBudgetLine(units); - assertTrue(line !== null, "cost budget continue-only: line rendered"); - assertNoMatch(line!, /truncated/, "cost budget continue-only: no truncation text"); - assertMatch(line!, /1 continue-here fired/, "cost budget continue-only: shows count"); -} + // Aggregation still works + const totals = getProjectTotals(oldUnits); + assert.deepStrictEqual(totals.totalTruncationSections, 0, "backward compat: truncation total = 0"); + assert.deepStrictEqual(totals.continueHereFiredCount, 0, "backward compat: continueHere count = 0"); + assert.deepStrictEqual(totals.units, 2, "backward compat: unit count correct"); + }); -// ─── Backward compat: no budget fields ──────────────────────────────────────── + // ─── Edge cases ─────────────────────────────────────────────────────────────── -console.log("\n=== Backward compat: no budget data ==="); + test('Edge cases', () => { + // formatTokenCount for context window values + assert.deepStrictEqual(formatTokenCount(200000), "200.0k", "format: 200000 → 200.0k"); + assert.deepStrictEqual(formatTokenCount(128000), "128.0k", "format: 128000 → 128.0k"); + assert.deepStrictEqual(formatTokenCount(1000000), "1.00M", "format: 1000000 → 1.00M"); + assert.deepStrictEqual(formatTokenCount(32000), "32.0k", "format: 32000 → 32.0k"); + }); -{ - // Old-format units without budget fields — no indicators anywhere - const oldUnits = [ - makeUnit(), // no budget fields - makeUnit({ id: "M001/S01/T02" }), - ]; + { + // Completed unit key includes type — different types don't collide + const ledgerUnits = [ + makeUnit({ type: "research-slice", id: "M001/S01", truncationSections: 2 }), + makeUnit({ type: "plan-slice", id: "M001/S01", truncationSections: 5 }), + ]; + const researchMarkers = renderCompletedBudgetMarkers( + { type: "research-slice", id: "M001/S01" }, + ledgerUnits, + ); + const planMarkers = renderCompletedBudgetMarkers( + { type: "plan-slice", id: "M001/S01" }, + ledgerUnits, + ); + assert.match(researchMarkers, /▼2/, "type-keying: research unit gets its own truncation count"); + assert.match(planMarkers, /▼5/, "type-keying: plan unit gets its own truncation count"); + } - // Completed section: no markers - const markers = renderCompletedBudgetMarkers( - { type: "execute-task", id: "M001/S01/T01" }, - oldUnits, - ); - assertNoMatch(markers, /▼/, "backward compat completed: no truncation marker"); - assertNoMatch(markers, /wrap-up/, "backward compat completed: no wrap-up marker"); - assertEq(markers, "", "backward compat completed: empty markers string"); + // ─── Summary ────────────────────────────────────────────────────────────────── - // By Model section: no context window label - const label = renderModelContextWindow(oldUnits, "claude-sonnet-4-20250514"); - assertEq(label, null, "backward compat by-model: no context window label"); - - // Cost & Usage: no budget line - const line = renderCostBudgetLine(oldUnits); - assertEq(line, null, "backward compat cost: no budget summary line"); - - // Aggregation still works - const totals = getProjectTotals(oldUnits); - assertEq(totals.totalTruncationSections, 0, "backward compat: truncation total = 0"); - assertEq(totals.continueHereFiredCount, 0, "backward compat: continueHere count = 0"); - assertEq(totals.units, 2, "backward compat: unit count correct"); -} - -// ─── Edge cases ─────────────────────────────────────────────────────────────── - -console.log("\n=== Edge cases ==="); - -{ - // formatTokenCount for context window values - assertEq(formatTokenCount(200000), "200.0k", "format: 200000 → 200.0k"); - assertEq(formatTokenCount(128000), "128.0k", "format: 128000 → 128.0k"); - assertEq(formatTokenCount(1000000), "1.00M", "format: 1000000 → 1.00M"); - assertEq(formatTokenCount(32000), "32.0k", "format: 32000 → 32.0k"); -} - -{ - // Completed unit key includes type — different types don't collide - const ledgerUnits = [ - makeUnit({ type: "research-slice", id: "M001/S01", truncationSections: 2 }), - makeUnit({ type: "plan-slice", id: "M001/S01", truncationSections: 5 }), - ]; - const researchMarkers = renderCompletedBudgetMarkers( - { type: "research-slice", id: "M001/S01" }, - ledgerUnits, - ); - const planMarkers = renderCompletedBudgetMarkers( - { type: "plan-slice", id: "M001/S01" }, - ledgerUnits, - ); - assertMatch(researchMarkers, /▼2/, "type-keying: research unit gets its own truncation count"); - assertMatch(planMarkers, /▼5/, "type-keying: plan unit gets its own truncation count"); -} - -// ─── Summary ────────────────────────────────────────────────────────────────── - -report(); +}); diff --git a/src/resources/extensions/gsd/tests/db-writer.test.ts b/src/resources/extensions/gsd/tests/db-writer.test.ts index fbde354a0..fa8f7170d 100644 --- a/src/resources/extensions/gsd/tests/db-writer.test.ts +++ b/src/resources/extensions/gsd/tests/db-writer.test.ts @@ -1,4 +1,5 @@ -import { createTestContext } from './test-helpers.ts'; +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import * as path from 'node:path'; import * as os from 'node:os'; import * as fs from 'node:fs'; @@ -26,8 +27,6 @@ import { } from '../db-writer.ts'; import type { Decision, Requirement } from '../types.ts'; -const { assertEq, assertTrue, assertMatch, report } = createTestContext(); - // ═══════════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════════ @@ -151,462 +150,433 @@ const SAMPLE_REQUIREMENTS: Requirement[] = [ // Round-Trip Tests: Decisions // ═══════════════════════════════════════════════════════════════════════════ -console.log('\n── generateDecisionsMd round-trip ──'); +describe('db-writer', () => { + test('generateDecisionsMd round-trip', () => { + const md = generateDecisionsMd(SAMPLE_DECISIONS); + const parsed = parseDecisionsTable(md); -{ - const md = generateDecisionsMd(SAMPLE_DECISIONS); - const parsed = parseDecisionsTable(md); + assert.deepStrictEqual(parsed.length, SAMPLE_DECISIONS.length, 'decisions count matches'); - assertEq(parsed.length, SAMPLE_DECISIONS.length, 'decisions count matches'); + for (let i = 0; i < SAMPLE_DECISIONS.length; i++) { + const orig = SAMPLE_DECISIONS[i]; + const rt = parsed[i]; + assert.deepStrictEqual(rt.id, orig.id, `decision ${orig.id} id round-trips`); + assert.deepStrictEqual(rt.when_context, orig.when_context, `decision ${orig.id} when_context round-trips`); + assert.deepStrictEqual(rt.scope, orig.scope, `decision ${orig.id} scope round-trips`); + assert.deepStrictEqual(rt.decision, orig.decision, `decision ${orig.id} decision round-trips`); + assert.deepStrictEqual(rt.choice, orig.choice, `decision ${orig.id} choice round-trips`); + assert.deepStrictEqual(rt.rationale, orig.rationale, `decision ${orig.id} rationale round-trips`); + assert.deepStrictEqual(rt.revisable, orig.revisable, `decision ${orig.id} revisable round-trips`); + assert.deepStrictEqual(rt.made_by, orig.made_by, `decision ${orig.id} made_by round-trips`); + } + }); - for (let i = 0; i < SAMPLE_DECISIONS.length; i++) { - const orig = SAMPLE_DECISIONS[i]; - const rt = parsed[i]; - assertEq(rt.id, orig.id, `decision ${orig.id} id round-trips`); - assertEq(rt.when_context, orig.when_context, `decision ${orig.id} when_context round-trips`); - assertEq(rt.scope, orig.scope, `decision ${orig.id} scope round-trips`); - assertEq(rt.decision, orig.decision, `decision ${orig.id} decision round-trips`); - assertEq(rt.choice, orig.choice, `decision ${orig.id} choice round-trips`); - assertEq(rt.rationale, orig.rationale, `decision ${orig.id} rationale round-trips`); - assertEq(rt.revisable, orig.revisable, `decision ${orig.id} revisable round-trips`); - assertEq(rt.made_by, orig.made_by, `decision ${orig.id} made_by round-trips`); - } -} + test('generateDecisionsMd format', () => { + const md = generateDecisionsMd(SAMPLE_DECISIONS); + assert.ok(md.startsWith('# Decisions Register\n'), 'starts with H1 header'); + assert.ok(md.includes(' -{ - const tmpDir = makeTmpDir(); - const dbPath = path.join(tmpDir, '.gsd', 'gsd.db'); - const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md'); - openDatabase(dbPath); + | # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By | + |---|------|-------|----------|--------|-----------|------------|---------| + | D001 | M001 | arch | Use REST API | REST | Simpler | Yes | human | + `; - // Pre-populate with canonical table format - const tableContent = `# Decisions Register + fs.writeFileSync(mdPath, tableContent, 'utf-8'); - + try { + const result = await saveDecisionToDb({ + scope: 'testing', + decision: 'Use Vitest', + choice: 'Vitest', + rationale: 'Fast', + when_context: 'M001', + }, tmpDir); -| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By | -|---|------|-------|----------|--------|-----------|------------|---------| -| D001 | M001 | arch | Use REST API | REST | Simpler | Yes | human | -`; + // The pre-existing table decision was NOT in DB, so it won't appear after regen. + // But the new decision should be there. + assert.deepStrictEqual(result.id, 'D001', 'gets D001 since DB was empty'); - fs.writeFileSync(mdPath, tableContent, 'utf-8'); + const afterContent = fs.readFileSync(mdPath, 'utf-8'); + // Table-format file gets fully regenerated — this is the normal path + assert.ok( + afterContent.includes('# Decisions Register'), + 'table-format file still has header after save', + ); + assert.ok( + afterContent.includes('Use Vitest'), + 'new decision present in regenerated table', + ); + } finally { + closeDatabase(); + cleanupDir(tmpDir); + } + }); - try { - const result = await saveDecisionToDb({ - scope: 'testing', - decision: 'Use Vitest', - choice: 'Vitest', - rationale: 'Fast', - when_context: 'M001', - }, tmpDir); + test('saveDecisionToDb with no existing DECISIONS.md creates table', async () => { + const tmpDir = makeTmpDir(); + const dbPath = path.join(tmpDir, '.gsd', 'gsd.db'); + const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md'); + openDatabase(dbPath); - // The pre-existing table decision was NOT in DB, so it won't appear after regen. - // But the new decision should be there. - assertEq(result.id, 'D001', 'gets D001 since DB was empty'); + // No DECISIONS.md exists at all + assert.ok(!fs.existsSync(mdPath), 'DECISIONS.md does not exist initially'); - const afterContent = fs.readFileSync(mdPath, 'utf-8'); - // Table-format file gets fully regenerated — this is the normal path - assertTrue( - afterContent.includes('# Decisions Register'), - 'table-format file still has header after save', - ); - assertTrue( - afterContent.includes('Use Vitest'), - 'new decision present in regenerated table', - ); - } finally { - closeDatabase(); - cleanupDir(tmpDir); - } -} + try { + const result = await saveDecisionToDb({ + scope: 'arch', + decision: 'Brand new decision', + choice: 'Option A', + rationale: 'Best fit', + }, tmpDir); -console.log('\n── saveDecisionToDb with no existing DECISIONS.md creates table ──'); + assert.deepStrictEqual(result.id, 'D001', 'first decision gets D001'); + assert.ok(fs.existsSync(mdPath), 'DECISIONS.md created'); -{ - const tmpDir = makeTmpDir(); - const dbPath = path.join(tmpDir, '.gsd', 'gsd.db'); - const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md'); - openDatabase(dbPath); + const content = fs.readFileSync(mdPath, 'utf-8'); + assert.ok(content.includes('# Decisions Register'), 'new file has header'); + assert.ok(content.includes('Brand new decision'), 'new file has decision'); + } finally { + closeDatabase(); + cleanupDir(tmpDir); + } + }); - // No DECISIONS.md exists at all - assertTrue(!fs.existsSync(mdPath), 'DECISIONS.md does not exist initially'); + // ═══════════════════════════════════════════════════════════════════════════ - try { - const result = await saveDecisionToDb({ - scope: 'arch', - decision: 'Brand new decision', - choice: 'Option A', - rationale: 'Best fit', - }, tmpDir); - - assertEq(result.id, 'D001', 'first decision gets D001'); - assertTrue(fs.existsSync(mdPath), 'DECISIONS.md created'); - - const content = fs.readFileSync(mdPath, 'utf-8'); - assertTrue(content.includes('# Decisions Register'), 'new file has header'); - assertTrue(content.includes('Brand new decision'), 'new file has decision'); - } finally { - closeDatabase(); - cleanupDir(tmpDir); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ - -report(); +}); diff --git a/src/resources/extensions/gsd/tests/git-locale.test.ts b/src/resources/extensions/gsd/tests/git-locale.test.ts index d4e95704a..ef668e1de 100644 --- a/src/resources/extensions/gsd/tests/git-locale.test.ts +++ b/src/resources/extensions/gsd/tests/git-locale.test.ts @@ -1,3 +1,5 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; /** * Regression tests for #1997: git locale not forced to C. * @@ -13,10 +15,6 @@ import { execFileSync } from "node:child_process"; import { GIT_NO_PROMPT_ENV } from "../git-constants.ts"; import { nativeAddAllWithExclusions } from "../native-git-bridge.ts"; import { RUNTIME_EXCLUSION_PATHS } from "../git-service.ts"; -import { createTestContext } from "./test-helpers.ts"; - -const { assertEq, assertTrue, report } = createTestContext(); - function git(cwd: string, ...args: string[]): string { return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); } @@ -39,27 +37,24 @@ function createFile(base: string, relPath: string, content: string): void { writeFileSync(full, content); } -async function main(): Promise { +describe('git-locale', async () => { // ─── GIT_NO_PROMPT_ENV includes LC_ALL=C ───────────────────────────── - console.log("\n=== GIT_NO_PROMPT_ENV includes LC_ALL=C ==="); - assertEq( + assert.deepStrictEqual( GIT_NO_PROMPT_ENV.LC_ALL, "C", "GIT_NO_PROMPT_ENV must set LC_ALL to 'C' to force English git output" ); - assertTrue( + assert.ok( "GIT_TERMINAL_PROMPT" in GIT_NO_PROMPT_ENV, "GIT_NO_PROMPT_ENV still contains GIT_TERMINAL_PROMPT" ); // ─── nativeAddAllWithExclusions: non-English locale does not throw ─── - console.log("\n=== nativeAddAllWithExclusions: non-English locale does not throw ==="); - - { + test('nativeAddAllWithExclusions: non-English locale does not throw', () => { // Simulate what happens on a German system: .gsd is gitignored, // exclusion pathspecs trigger an advisory warning exit code 1. // With LC_ALL=C the English stderr guard should match and suppress. @@ -89,22 +84,20 @@ async function main(): Promise { if (origLang !== undefined) process.env.LANG = origLang; else delete process.env.LANG; - assertTrue( + assert.ok( !threw, "nativeAddAllWithExclusions must not throw on non-English locale when .gsd is gitignored (#1997)" ); const staged = git(repo, "diff", "--cached", "--name-only"); - assertTrue(staged.includes("src/app.ts"), "real file staged despite German locale"); + assert.ok(staged.includes("src/app.ts"), "real file staged despite German locale"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── nativeMergeSquash: env is passed (merge-squash stderr is English) ─ - console.log("\n=== nativeMergeSquash fallback uses GIT_NO_PROMPT_ENV ==="); - - { + test('nativeMergeSquash fallback uses GIT_NO_PROMPT_ENV', () => { // We verify indirectly: the source code must pass env: GIT_NO_PROMPT_ENV. // Read the source and check for the pattern. This is a static check. const src = readFileSync( @@ -114,20 +107,13 @@ async function main(): Promise { // Find the nativeMergeSquash function and check it uses GIT_NO_PROMPT_ENV const fnStart = src.indexOf("export function nativeMergeSquash"); - assertTrue(fnStart !== -1, "nativeMergeSquash function exists in source"); + assert.ok(fnStart !== -1, "nativeMergeSquash function exists in source"); const fnBody = src.slice(fnStart, src.indexOf("\nexport function", fnStart + 1)); const hasEnv = fnBody.includes("env: GIT_NO_PROMPT_ENV"); - assertTrue( + assert.ok( hasEnv, "nativeMergeSquash fallback must pass env: GIT_NO_PROMPT_ENV to execFileSync (#1997)" ); - } - - report(); -} - -main().catch((error) => { - console.error(error); - process.exit(1); + }); }); diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index d824606db..0cfd47386 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -1,3 +1,5 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, symlinkSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; @@ -20,174 +22,170 @@ import { type TaskCommitContext, } from "../git-service.ts"; import { nativeAddAllWithExclusions } from "../native-git-bridge.ts"; -import { createTestContext } from './test-helpers.ts'; - -const { assertEq, assertTrue, report } = createTestContext(); function run(command: string, cwd: string): string { return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); } -async function main(): Promise { +describe('git-service', async () => { // ─── inferCommitType ─────────────────────────────────────────────────── - console.log("\n=== inferCommitType ==="); - assertEq( + assert.deepStrictEqual( inferCommitType("Implement user authentication"), "feat", "generic feature title → feat" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Add dashboard page"), "feat", "add-style title → feat" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Fix login redirect bug"), "fix", "title with 'fix' → fix" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Bug in session handling"), "fix", "title with 'bug' → fix" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Hotfix for production crash"), "fix", "title with 'hotfix' → fix" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Patch memory leak"), "fix", "title with 'patch' → fix" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Refactor state management"), "refactor", "title with 'refactor' → refactor" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Restructure project layout"), "refactor", "title with 'restructure' → refactor" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Reorganize module imports"), "refactor", "title with 'reorganize' → refactor" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Update API documentation"), "docs", "title with 'documentation' → docs" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Add doc for setup guide"), "docs", "title with 'doc' → docs" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Add unit tests for auth"), "test", "title with 'tests' → test" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Testing infrastructure setup"), "test", "title with 'testing' → test" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Chore: update dependencies"), "chore", "title with 'chore' → chore" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Cleanup unused imports"), "chore", "title with 'cleanup' → chore" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Clean up stale branches"), "chore", "title with 'clean up' → chore" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Archive old milestones"), "chore", "title with 'archive' → chore" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Remove deprecated endpoints"), "chore", "title with 'remove' → chore" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Delete temp files"), "chore", "title with 'delete' → chore" ); // Mixed keywords — first match wins - assertEq( + assert.deepStrictEqual( inferCommitType("Fix and refactor the login module"), "fix", "mixed keywords → first match wins (fix before refactor)" ); - assertEq( + assert.deepStrictEqual( inferCommitType("Refactor test utilities"), "refactor", "mixed keywords → first match wins (refactor before test)" ); // Unknown / unrecognized title → feat - assertEq( + assert.deepStrictEqual( inferCommitType("Build the new pipeline"), "feat", "unrecognized title → feat" ); - assertEq( + assert.deepStrictEqual( inferCommitType(""), "feat", "empty title → feat" ); // Word boundary: "testify" should NOT match "test" - assertEq( + assert.deepStrictEqual( inferCommitType("Testify integration"), "feat", "'testify' does not match 'test' — word boundary prevents partial match" ); // "documentary" should NOT match "doc" (word boundary) - assertEq( + assert.deepStrictEqual( inferCommitType("Documentary style UI"), "feat", "'documentary' does not match 'doc' — word boundary prevents partial match" ); // "prefix" should NOT match "fix" (word boundary) - assertEq( + assert.deepStrictEqual( inferCommitType("Add prefix to all IDs"), "feat", "'prefix' does not match 'fix' — word boundary prevents partial match" @@ -195,15 +193,14 @@ async function main(): Promise { // ─── inferCommitType with oneLiner ────────────────────────────────────── - console.log("\n=== inferCommitType with oneLiner ==="); - assertEq( + assert.deepStrictEqual( inferCommitType("implement dashboard", "Fixed rendering bug in sidebar"), "fix", "one-liner with 'fixed' overrides generic title → fix" ); - assertEq( + assert.deepStrictEqual( inferCommitType("add search", "Optimized query performance with caching"), "perf", "one-liner with 'performance' and 'caching' → perf" @@ -211,29 +208,27 @@ async function main(): Promise { // ─── buildTaskCommitMessage ───────────────────────────────────────────── - console.log("\n=== buildTaskCommitMessage ==="); - - { + test('buildTaskCommitMessage', () => { const msg = buildTaskCommitMessage({ taskId: "S01/T02", taskTitle: "implement user authentication", oneLiner: "Added JWT-based auth with refresh token rotation", keyFiles: ["src/auth.ts", "src/middleware/jwt.ts"], }); - assertTrue(msg.startsWith("feat(S01/T02):"), "message starts with type(scope)"); - assertTrue(msg.includes("JWT-based auth"), "message includes one-liner content"); - assertTrue(msg.includes("- src/auth.ts"), "message body includes key files"); - assertTrue(msg.includes("- src/middleware/jwt.ts"), "message body includes second key file"); - } + assert.ok(msg.startsWith("feat(S01/T02):"), "message starts with type(scope)"); + assert.ok(msg.includes("JWT-based auth"), "message includes one-liner content"); + assert.ok(msg.includes("- src/auth.ts"), "message body includes key files"); + assert.ok(msg.includes("- src/middleware/jwt.ts"), "message body includes second key file"); + }); { const msg = buildTaskCommitMessage({ taskId: "S02/T01", taskTitle: "fix login redirect bug", }); - assertTrue(msg.startsWith("fix(S02/T01):"), "infers fix type from title"); - assertTrue(msg.includes("fix login redirect bug"), "uses task title when no one-liner"); - assertTrue(!msg.includes("\n"), "no body when no key files"); + assert.ok(msg.startsWith("fix(S02/T01):"), "infers fix type from title"); + assert.ok(msg.includes("fix login redirect bug"), "uses task title when no one-liner"); + assert.ok(!msg.includes("\n"), "no body when no key files"); } { @@ -242,14 +237,13 @@ async function main(): Promise { taskTitle: "add tests", oneLiner: "Unit tests for auth module with coverage", }); - assertTrue(msg.startsWith("test(S01/T03):"), "infers test type"); + assert.ok(msg.startsWith("test(S01/T03):"), "infers test type"); } // ─── RUNTIME_EXCLUSION_PATHS ─────────────────────────────────────────── - console.log("\n=== RUNTIME_EXCLUSION_PATHS ==="); - assertEq( + assert.deepStrictEqual( RUNTIME_EXCLUSION_PATHS.length, 13, "exactly 13 runtime exclusion paths" @@ -271,24 +265,23 @@ async function main(): Promise { ".gsd/DISCUSSION-MANIFEST.json", ]; - assertEq( + assert.deepStrictEqual( [...RUNTIME_EXCLUSION_PATHS], expectedPaths, "paths match expected set in order" ); - assertTrue( + assert.ok( RUNTIME_EXCLUSION_PATHS.includes(".gsd/activity/"), "includes .gsd/activity/" ); - assertTrue( + assert.ok( RUNTIME_EXCLUSION_PATHS.includes(".gsd/STATE.md"), "includes .gsd/STATE.md" ); // ─── runGit ──────────────────────────────────────────────────────────── - console.log("\n=== runGit ==="); const tempDir = mkdtempSync(join(tmpdir(), "gsd-git-service-test-")); run("git init -b main", tempDir); @@ -297,11 +290,11 @@ async function main(): Promise { // runGit should work on a valid repo const branch = runGit(tempDir, ["branch", "--show-current"]); - assertEq(branch, "main", "runGit returns current branch"); + assert.deepStrictEqual(branch, "main", "runGit returns current branch"); // runGit allowFailure returns empty string on failure const result = runGit(tempDir, ["log", "--oneline"], { allowFailure: true }); - assertEq(result, "", "runGit allowFailure returns empty on error (no commits yet)"); + assert.deepStrictEqual(result, "", "runGit allowFailure returns empty on error (no commits yet)"); // runGit throws on failure without allowFailure let threw = false; @@ -309,22 +302,21 @@ async function main(): Promise { runGit(tempDir, ["log", "--oneline"]); } catch (e) { threw = true; - assertTrue( + assert.ok( (e as Error).message.includes("git log --oneline failed"), "error message includes command and path" ); } - assertTrue(threw, "runGit throws without allowFailure on error"); + assert.ok(threw, "runGit throws without allowFailure on error"); // ─── Type exports compile check ──────────────────────────────────────── - console.log("\n=== Type exports ==="); // These are compile-time checks — if we got here, the types import fine const _prefs: GitPreferences = { auto_push: true, remote: "origin" }; const _opts: CommitOptions = { message: "test" }; - assertTrue(true, "GitPreferences type exported and usable"); - assertTrue(true, "CommitOptions type exported and usable"); + assert.ok(true, "GitPreferences type exported and usable"); + assert.ok(true, "CommitOptions type exported and usable"); // Cleanup T01 temp dir rmSync(tempDir, { recursive: true, force: true }); @@ -351,9 +343,7 @@ async function main(): Promise { // ─── GitServiceImpl: smart staging ───────────────────────────────────── - console.log("\n=== GitServiceImpl: smart staging ==="); - - { + test('GitServiceImpl: smart staging', () => { const repo = initTempRepo(); const svc = new GitServiceImpl(repo); @@ -370,34 +360,32 @@ async function main(): Promise { const result = svc.commit({ message: "test: smart staging" }); - assertEq(result, "test: smart staging", "commit returns the commit message"); + assert.deepStrictEqual(result, "test: smart staging", "commit returns the commit message"); // Verify only src/code.ts is in the commit const showStat = run("git show --stat --format= HEAD", repo); - assertTrue(showStat.includes("src/code.ts"), "src/code.ts is in the commit"); - assertTrue(!showStat.includes(".gsd/activity"), ".gsd/activity/ excluded from commit"); - assertTrue(!showStat.includes(".gsd/runtime"), ".gsd/runtime/ excluded from commit"); - assertTrue(!showStat.includes("STATE.md"), ".gsd/STATE.md excluded from commit"); - assertTrue(!showStat.includes("auto.lock"), ".gsd/auto.lock excluded from commit"); - assertTrue(!showStat.includes("metrics.json"), ".gsd/metrics.json excluded from commit"); - assertTrue(!showStat.includes(".gsd/worktrees"), ".gsd/worktrees/ excluded from commit"); + assert.ok(showStat.includes("src/code.ts"), "src/code.ts is in the commit"); + assert.ok(!showStat.includes(".gsd/activity"), ".gsd/activity/ excluded from commit"); + assert.ok(!showStat.includes(".gsd/runtime"), ".gsd/runtime/ excluded from commit"); + assert.ok(!showStat.includes("STATE.md"), ".gsd/STATE.md excluded from commit"); + assert.ok(!showStat.includes("auto.lock"), ".gsd/auto.lock excluded from commit"); + assert.ok(!showStat.includes("metrics.json"), ".gsd/metrics.json excluded from commit"); + assert.ok(!showStat.includes(".gsd/worktrees"), ".gsd/worktrees/ excluded from commit"); // Verify runtime files are still untracked // git status --short may collapse to "?? .gsd/" or show individual files // Use --untracked-files=all to force individual listing const statusOut = run("git status --short --untracked-files=all", repo); - assertTrue(statusOut.includes(".gsd/activity/"), "activity still untracked after commit"); - assertTrue(statusOut.includes(".gsd/runtime/"), "runtime still untracked after commit"); - assertTrue(statusOut.includes(".gsd/STATE.md"), "STATE.md still untracked after commit"); + assert.ok(statusOut.includes(".gsd/activity/"), "activity still untracked after commit"); + assert.ok(statusOut.includes(".gsd/runtime/"), "runtime still untracked after commit"); + assert.ok(statusOut.includes(".gsd/STATE.md"), "STATE.md still untracked after commit"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── GitServiceImpl: smart staging excludes tracked runtime files ────── - console.log("\n=== GitServiceImpl: smart staging excludes tracked runtime files ==="); - - { + test('GitServiceImpl: smart staging excludes tracked runtime files', () => { // Reproduces the real bug: .gsd/ runtime files that are already tracked // (in the git index) must be excluded from staging even when .gsd/ is // in .gitignore. The old pathspec-exclude approach failed silently in @@ -427,9 +415,9 @@ async function main(): Promise { // Verify runtime files are tracked (precondition) const tracked = run("git ls-files .gsd/", repo); - assertTrue(tracked.includes("metrics.json"), "precondition: metrics.json tracked"); - assertTrue(tracked.includes("completed-units.json"), "precondition: completed-units.json tracked"); - assertTrue(tracked.includes("activity/log.jsonl"), "precondition: activity log tracked"); + assert.ok(tracked.includes("metrics.json"), "precondition: metrics.json tracked"); + assert.ok(tracked.includes("completed-units.json"), "precondition: completed-units.json tracked"); + assert.ok(tracked.includes("activity/log.jsonl"), "precondition: activity log tracked"); // Now modify both runtime and real files createFile(repo, ".gsd/metrics.json", '{"version":2}'); @@ -440,15 +428,15 @@ async function main(): Promise { // autoCommit should commit real.ts. The first call also runs auto-cleanup // which removes runtime files from the index via a dedicated commit. const msg = svc.autoCommit("execute-task", "M001/S01/T01"); - assertTrue(msg !== null, "autoCommit produces a commit"); + assert.ok(msg !== null, "autoCommit produces a commit"); const show = run("git show --stat HEAD", repo); - assertTrue(show.includes("src/real.ts"), "real files are committed"); + assert.ok(show.includes("src/real.ts"), "real files are committed"); // After the commit, runtime files must no longer be in the git index. // They remain on disk but are untracked (protected by .gitignore). const trackedAfter = run("git ls-files .gsd/", repo); - assertEq(trackedAfter, "", "no .gsd/ runtime files remain in the index"); + assert.deepStrictEqual(trackedAfter, "", "no .gsd/ runtime files remain in the index"); // Verify a second autoCommit with changed runtime files does NOT stage them createFile(repo, ".gsd/metrics.json", '{"version":3}'); @@ -456,37 +444,33 @@ async function main(): Promise { createFile(repo, "src/real.ts", "third version"); const msg2 = svc.autoCommit("execute-task", "M001/S01/T02"); - assertTrue(msg2 !== null, "second autoCommit produces a commit"); + assert.ok(msg2 !== null, "second autoCommit produces a commit"); const show2 = run("git show --stat HEAD", repo); - assertTrue(show2.includes("src/real.ts"), "real files committed in second commit"); - assertTrue(!show2.includes("metrics"), "metrics.json not in second commit"); - assertTrue(!show2.includes("completed-units"), "completed-units.json not in second commit"); - assertTrue(!show2.includes("activity"), "activity not in second commit"); + assert.ok(show2.includes("src/real.ts"), "real files committed in second commit"); + assert.ok(!show2.includes("metrics"), "metrics.json not in second commit"); + assert.ok(!show2.includes("completed-units"), "completed-units.json not in second commit"); + assert.ok(!show2.includes("activity"), "activity not in second commit"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── GitServiceImpl: autoCommit on clean repo ────────────────────────── - console.log("\n=== GitServiceImpl: autoCommit ==="); - - { + test('GitServiceImpl: autoCommit', () => { const repo = initTempRepo(); const svc = new GitServiceImpl(repo); // Clean repo — autoCommit should return null const cleanResult = svc.autoCommit("task", "T01"); - assertEq(cleanResult, null, "autoCommit on clean repo returns null"); + assert.deepStrictEqual(cleanResult, null, "autoCommit on clean repo returns null"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── GitServiceImpl: autoCommit on dirty repo ────────────────────────── - console.log("\n=== GitServiceImpl: autoCommit on dirty repo ==="); - - { + test('GitServiceImpl: autoCommit on dirty repo', () => { const repo = initTempRepo(); const svc = new GitServiceImpl(repo); @@ -494,10 +478,10 @@ async function main(): Promise { // Without task context, autoCommit uses generic chore message const msg = svc.autoCommit("task", "T01"); - assertEq(msg, "chore(T01): auto-commit after task", "autoCommit returns generic format without task context"); + assert.deepStrictEqual(msg, "chore(T01): auto-commit after task", "autoCommit returns generic format without task context"); const log = run("git log --oneline -1", repo); - assertTrue(log.includes("chore(T01): auto-commit after task"), "generic commit message is in git log"); + assert.ok(log.includes("chore(T01): auto-commit after task"), "generic commit message is in git log"); // With task context, autoCommit uses meaningful message createFile(repo, "src/auth.ts", "export function login() {}"); @@ -507,18 +491,16 @@ async function main(): Promise { oneLiner: "Added JWT-based auth with refresh token rotation", keyFiles: ["src/auth.ts"], }); - assertTrue(msg2 !== null, "autoCommit with task context returns a message"); - assertTrue(msg2!.startsWith("feat(S01/T02):"), "meaningful commit uses feat type and scope"); - assertTrue(msg2!.includes("JWT-based auth"), "meaningful commit includes one-liner content"); + assert.ok(msg2 !== null, "autoCommit with task context returns a message"); + assert.ok(msg2!.startsWith("feat(S01/T02):"), "meaningful commit uses feat type and scope"); + assert.ok(msg2!.includes("JWT-based auth"), "meaningful commit includes one-liner content"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── GitServiceImpl: empty-after-staging guard ───────────────────────── - console.log("\n=== GitServiceImpl: empty-after-staging guard ==="); - - { + test('GitServiceImpl: empty-after-staging guard', () => { const repo = initTempRepo(); const svc = new GitServiceImpl(repo); @@ -526,20 +508,18 @@ async function main(): Promise { createFile(repo, ".gsd/activity/x.jsonl", "data"); const result = svc.autoCommit("task", "T02"); - assertEq(result, null, "autoCommit returns null when only runtime files are dirty"); + assert.deepStrictEqual(result, null, "autoCommit returns null when only runtime files are dirty"); // Verify no new commit was created (should still be at init commit) const logCount = run("git rev-list --count HEAD", repo); - assertEq(logCount, "1", "no new commit created when only runtime files changed"); + assert.deepStrictEqual(logCount, "1", "no new commit created when only runtime files changed"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── GitServiceImpl: autoCommit with extraExclusions ─────────────────── - console.log("\n=== GitServiceImpl: autoCommit with extraExclusions ==="); - - { + test('GitServiceImpl: autoCommit with extraExclusions', () => { const repo = initTempRepo(); const svc = new GitServiceImpl(repo); @@ -549,21 +529,19 @@ async function main(): Promise { // Auto-commit with .gsd/ excluded (simulates pre-switch) const msg = svc.autoCommit("pre-switch", "main", [".gsd/"]); - assertEq(msg, "chore(main): auto-commit after pre-switch", "pre-switch autoCommit with .gsd/ exclusion commits"); + assert.deepStrictEqual(msg, "chore(main): auto-commit after pre-switch", "pre-switch autoCommit with .gsd/ exclusion commits"); // Verify .gsd/ file was NOT committed const show = run("git show --stat HEAD", repo); - assertTrue(!show.includes("ROADMAP"), ".gsd/ files excluded from pre-switch auto-commit"); - assertTrue(show.includes("feature.ts"), "non-.gsd/ files included in pre-switch auto-commit"); + assert.ok(!show.includes("ROADMAP"), ".gsd/ files excluded from pre-switch auto-commit"); + assert.ok(show.includes("feature.ts"), "non-.gsd/ files included in pre-switch auto-commit"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── GitServiceImpl: autoCommit extraExclusions — only .gsd/ dirty ──── - console.log("\n=== GitServiceImpl: autoCommit extraExclusions — only .gsd/ dirty ==="); - - { + test('GitServiceImpl: autoCommit extraExclusions — only .gsd/ dirty', () => { const repo = initTempRepo(); const svc = new GitServiceImpl(repo); @@ -573,25 +551,23 @@ async function main(): Promise { // Auto-commit with .gsd/ excluded — nothing else to commit const result = svc.autoCommit("pre-switch", "main", [".gsd/"]); - assertEq(result, null, "autoCommit returns null when only .gsd/ files are dirty and excluded"); + assert.deepStrictEqual(result, null, "autoCommit returns null when only .gsd/ files are dirty and excluded"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── GitServiceImpl: commit returns null when nothing staged ─────────── - console.log("\n=== GitServiceImpl: commit empty ==="); - - { + test('GitServiceImpl: commit empty', () => { const repo = initTempRepo(); const svc = new GitServiceImpl(repo); // Nothing dirty, commit should return null const result = svc.commit({ message: "should not commit" }); - assertEq(result, null, "commit returns null when nothing to stage"); + assert.deepStrictEqual(result, null, "commit returns null when nothing to stage"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── Helper: create repo for branch tests ──────────────────────────── @@ -608,36 +584,32 @@ async function main(): Promise { // ─── getCurrentBranch ──────────────────────────────────────────────── - console.log("\n=== Branch queries ==="); - - { + test('Branch queries', () => { const repo = initBranchTestRepo(); const svc = new GitServiceImpl(repo); - assertEq(svc.getCurrentBranch(), "main", "getCurrentBranch returns main on main branch"); + assert.deepStrictEqual(svc.getCurrentBranch(), "main", "getCurrentBranch returns main on main branch"); run("git checkout -b gsd/M001/S01", repo); - assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "getCurrentBranch returns slice branch name"); + assert.deepStrictEqual(svc.getCurrentBranch(), "gsd/M001/S01", "getCurrentBranch returns slice branch name"); run("git checkout -b feature/foo", repo); - assertEq(svc.getCurrentBranch(), "feature/foo", "getCurrentBranch returns feature branch name"); + assert.deepStrictEqual(svc.getCurrentBranch(), "feature/foo", "getCurrentBranch returns feature branch name"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── getMainBranch ──────────────────────────────────────────────────── - console.log("\n=== getMainBranch ==="); - - { + test('getMainBranch', () => { const repo = initBranchTestRepo(); const svc = new GitServiceImpl(repo); // Basic case: repo has "main" branch - assertEq(svc.getMainBranch(), "main", "getMainBranch returns main when main exists"); + assert.deepStrictEqual(svc.getMainBranch(), "main", "getMainBranch returns main when main exists"); rmSync(repo, { recursive: true, force: true }); - } + }); { // master-only repo @@ -650,7 +622,7 @@ async function main(): Promise { run('git commit -m "init"', repo); const svc = new GitServiceImpl(repo); - assertEq(svc.getMainBranch(), "master", "getMainBranch returns master when only master exists"); + assert.deepStrictEqual(svc.getMainBranch(), "master", "getMainBranch returns master when only master exists"); rmSync(repo, { recursive: true, force: true }); } @@ -661,9 +633,7 @@ async function main(): Promise { // ─── createSnapshot: prefs enabled ───────────────────────────────────── - console.log("\n=== createSnapshot: enabled ==="); - - { + test('createSnapshot: enabled', () => { const repo = initBranchTestRepo(); const svc = new GitServiceImpl(repo, { snapshots: true }); @@ -677,16 +647,14 @@ async function main(): Promise { // Verify ref exists under refs/gsd/snapshots/ const refs = run("git for-each-ref refs/gsd/snapshots/", repo); - assertTrue(refs.includes("refs/gsd/snapshots/gsd/M001/S01/"), "snapshot ref created under refs/gsd/snapshots/"); + assert.ok(refs.includes("refs/gsd/snapshots/gsd/M001/S01/"), "snapshot ref created under refs/gsd/snapshots/"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── createSnapshot: prefs disabled ──────────────────────────────────── - console.log("\n=== createSnapshot: disabled ==="); - - { + test('createSnapshot: disabled', () => { const repo = initBranchTestRepo(); const svc = new GitServiceImpl(repo, { snapshots: false }); @@ -698,16 +666,14 @@ async function main(): Promise { svc.createSnapshot("gsd/M001/S01"); const refs = run("git for-each-ref refs/gsd/snapshots/", repo); - assertEq(refs, "", "no snapshot ref created when prefs.snapshots is false"); + assert.deepStrictEqual(refs, "", "no snapshot ref created when prefs.snapshots is false"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── runPreMergeCheck: pass ──────────────────────────────────────────── - console.log("\n=== runPreMergeCheck: pass ==="); - - { + test('runPreMergeCheck: pass', () => { const repo = initBranchTestRepo(); // Create package.json with passing test script createFile(repo, "package.json", JSON.stringify({ @@ -720,17 +686,15 @@ async function main(): Promise { const svc = new GitServiceImpl(repo, { pre_merge_check: true }); const result: PreMergeCheckResult = svc.runPreMergeCheck(); - assertEq(result.passed, true, "runPreMergeCheck returns passed:true when tests pass"); - assertTrue(!result.skipped, "runPreMergeCheck is not skipped when enabled"); + assert.deepStrictEqual(result.passed, true, "runPreMergeCheck returns passed:true when tests pass"); + assert.ok(!result.skipped, "runPreMergeCheck is not skipped when enabled"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── runPreMergeCheck: fail ──────────────────────────────────────────── - console.log("\n=== runPreMergeCheck: fail ==="); - - { + test('runPreMergeCheck: fail', () => { const repo = initBranchTestRepo(); // Create package.json with failing test script createFile(repo, "package.json", JSON.stringify({ @@ -743,17 +707,15 @@ async function main(): Promise { const svc = new GitServiceImpl(repo, { pre_merge_check: true }); const result: PreMergeCheckResult = svc.runPreMergeCheck(); - assertEq(result.passed, false, "runPreMergeCheck returns passed:false when tests fail"); - assertTrue(!result.skipped, "runPreMergeCheck is not skipped when enabled"); + assert.deepStrictEqual(result.passed, false, "runPreMergeCheck returns passed:false when tests fail"); + assert.ok(!result.skipped, "runPreMergeCheck is not skipped when enabled"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── runPreMergeCheck: disabled ──────────────────────────────────────── - console.log("\n=== runPreMergeCheck: disabled ==="); - - { + test('runPreMergeCheck: disabled', () => { const repo = initBranchTestRepo(); createFile(repo, "package.json", JSON.stringify({ name: "test-disabled", @@ -765,98 +727,86 @@ async function main(): Promise { const svc = new GitServiceImpl(repo, { pre_merge_check: false }); const result: PreMergeCheckResult = svc.runPreMergeCheck(); - assertEq(result.skipped, true, "runPreMergeCheck skipped when pre_merge_check is false"); - assertEq(result.passed, true, "runPreMergeCheck returns passed:true when skipped (no block)"); + assert.deepStrictEqual(result.skipped, true, "runPreMergeCheck skipped when pre_merge_check is false"); + assert.deepStrictEqual(result.passed, true, "runPreMergeCheck returns passed:true when skipped (no block)"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── runPreMergeCheck: custom command ────────────────────────────────── - console.log("\n=== runPreMergeCheck: custom command ==="); - - { + test('runPreMergeCheck: custom command', () => { const repo = initBranchTestRepo(); // Custom command string overrides auto-detection const svc = new GitServiceImpl(repo, { pre_merge_check: 'node -e "process.exit(0)"' }); const result: PreMergeCheckResult = svc.runPreMergeCheck(); - assertEq(result.passed, true, "runPreMergeCheck passes with custom command that exits 0"); - assertTrue(!result.skipped, "custom command is not skipped"); + assert.deepStrictEqual(result.passed, true, "runPreMergeCheck passes with custom command that exits 0"); + assert.ok(!result.skipped, "custom command is not skipped"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── VALID_BRANCH_NAME regex ────────────────────────────────────────── - console.log("\n=== VALID_BRANCH_NAME regex ==="); - - { + test('VALID_BRANCH_NAME regex', () => { // Valid branch names - assertTrue(VALID_BRANCH_NAME.test("main"), "VALID_BRANCH_NAME accepts 'main'"); - assertTrue(VALID_BRANCH_NAME.test("master"), "VALID_BRANCH_NAME accepts 'master'"); - assertTrue(VALID_BRANCH_NAME.test("develop"), "VALID_BRANCH_NAME accepts 'develop'"); - assertTrue(VALID_BRANCH_NAME.test("feature/foo"), "VALID_BRANCH_NAME accepts 'feature/foo'"); - assertTrue(VALID_BRANCH_NAME.test("release-1.0"), "VALID_BRANCH_NAME accepts 'release-1.0'"); - assertTrue(VALID_BRANCH_NAME.test("my_branch"), "VALID_BRANCH_NAME accepts 'my_branch'"); - assertTrue(VALID_BRANCH_NAME.test("v2.0.1"), "VALID_BRANCH_NAME accepts 'v2.0.1'"); + assert.ok(VALID_BRANCH_NAME.test("main"), "VALID_BRANCH_NAME accepts 'main'"); + assert.ok(VALID_BRANCH_NAME.test("master"), "VALID_BRANCH_NAME accepts 'master'"); + assert.ok(VALID_BRANCH_NAME.test("develop"), "VALID_BRANCH_NAME accepts 'develop'"); + assert.ok(VALID_BRANCH_NAME.test("feature/foo"), "VALID_BRANCH_NAME accepts 'feature/foo'"); + assert.ok(VALID_BRANCH_NAME.test("release-1.0"), "VALID_BRANCH_NAME accepts 'release-1.0'"); + assert.ok(VALID_BRANCH_NAME.test("my_branch"), "VALID_BRANCH_NAME accepts 'my_branch'"); + assert.ok(VALID_BRANCH_NAME.test("v2.0.1"), "VALID_BRANCH_NAME accepts 'v2.0.1'"); // Invalid / injection attempts - assertTrue(!VALID_BRANCH_NAME.test("main; rm -rf /"), "VALID_BRANCH_NAME rejects shell injection"); - assertTrue(!VALID_BRANCH_NAME.test("main && echo pwned"), "VALID_BRANCH_NAME rejects && injection"); - assertTrue(!VALID_BRANCH_NAME.test(""), "VALID_BRANCH_NAME rejects empty string"); - assertTrue(!VALID_BRANCH_NAME.test("branch name"), "VALID_BRANCH_NAME rejects spaces"); - assertTrue(!VALID_BRANCH_NAME.test("branch`cmd`"), "VALID_BRANCH_NAME rejects backticks"); - assertTrue(!VALID_BRANCH_NAME.test("branch$(cmd)"), "VALID_BRANCH_NAME rejects $() subshell"); - } + assert.ok(!VALID_BRANCH_NAME.test("main; rm -rf /"), "VALID_BRANCH_NAME rejects shell injection"); + assert.ok(!VALID_BRANCH_NAME.test("main && echo pwned"), "VALID_BRANCH_NAME rejects && injection"); + assert.ok(!VALID_BRANCH_NAME.test(""), "VALID_BRANCH_NAME rejects empty string"); + assert.ok(!VALID_BRANCH_NAME.test("branch name"), "VALID_BRANCH_NAME rejects spaces"); + assert.ok(!VALID_BRANCH_NAME.test("branch`cmd`"), "VALID_BRANCH_NAME rejects backticks"); + assert.ok(!VALID_BRANCH_NAME.test("branch$(cmd)"), "VALID_BRANCH_NAME rejects $() subshell"); + }); // ─── getMainBranch: configured main_branch preference ────────────────── - console.log("\n=== getMainBranch: configured main_branch ==="); - - { + test('getMainBranch: configured main_branch', () => { const repo = initBranchTestRepo(); const svc = new GitServiceImpl(repo, { main_branch: "trunk" }); - assertEq(svc.getMainBranch(), "trunk", "getMainBranch returns configured main_branch preference"); + assert.deepStrictEqual(svc.getMainBranch(), "trunk", "getMainBranch returns configured main_branch preference"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── getMainBranch: falls back to auto-detection when not set ────────── - console.log("\n=== getMainBranch: fallback to auto-detection ==="); - - { + test('getMainBranch: fallback to auto-detection', () => { const repo = initBranchTestRepo(); const svc = new GitServiceImpl(repo, {}); - assertEq(svc.getMainBranch(), "main", "getMainBranch falls back to auto-detection when main_branch not set"); + assert.deepStrictEqual(svc.getMainBranch(), "main", "getMainBranch falls back to auto-detection when main_branch not set"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── getMainBranch: ignores invalid branch names ─────────────────────── - console.log("\n=== getMainBranch: ignores invalid branch name ==="); - - { + test('getMainBranch: ignores invalid branch name', () => { const repo = initBranchTestRepo(); const svc = new GitServiceImpl(repo, { main_branch: "main; rm -rf /" }); - assertEq(svc.getMainBranch(), "main", "getMainBranch ignores invalid branch name and falls back to auto-detection"); + assert.deepStrictEqual(svc.getMainBranch(), "main", "getMainBranch ignores invalid branch name and falls back to auto-detection"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── PreMergeCheckResult type export compile check ───────────────────── - console.log("\n=== PreMergeCheckResult type export ==="); - - { + test('PreMergeCheckResult type export', () => { const _checkResult: PreMergeCheckResult = { passed: true, skipped: false }; - assertTrue(true, "PreMergeCheckResult type exported and usable"); - } + assert.ok(true, "PreMergeCheckResult type exported and usable"); + }); // ═══════════════════════════════════════════════════════════════════════ // Integration branch — feature-branch workflow support @@ -864,82 +814,70 @@ async function main(): Promise { // ─── writeIntegrationBranch / readIntegrationBranch: round-trip ──────── - console.log("\n=== Integration branch: write and read ==="); - - { + test('Integration branch: write and read', () => { const repo = initBranchTestRepo(); // Initially no integration branch - assertEq(readIntegrationBranch(repo, "M001"), null, "readIntegrationBranch returns null when no metadata"); + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), null, "readIntegrationBranch returns null when no metadata"); // Write integration branch writeIntegrationBranch(repo, "M001", "f-123-new-thing"); - assertEq(readIntegrationBranch(repo, "M001"), "f-123-new-thing", "readIntegrationBranch returns written branch"); + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "f-123-new-thing", "readIntegrationBranch returns written branch"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── writeIntegrationBranch: updates when branch changes (#300) ────── - console.log("\n=== Integration branch: updates on branch change ==="); - - { + test('Integration branch: updates on branch change', () => { const repo = initBranchTestRepo(); writeIntegrationBranch(repo, "M001", "f-123-first"); writeIntegrationBranch(repo, "M001", "f-456-second"); // updates to new branch (#300) - assertEq(readIntegrationBranch(repo, "M001"), "f-456-second", "second write updates integration branch to new value"); + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "f-456-second", "second write updates integration branch to new value"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── writeIntegrationBranch: same branch is idempotent ───────────────── - console.log("\n=== Integration branch: same branch is idempotent ==="); - - { + test('Integration branch: same branch is idempotent', () => { const repo = initBranchTestRepo(); writeIntegrationBranch(repo, "M001", "f-123-first"); writeIntegrationBranch(repo, "M001", "f-123-first"); // same branch — no-op - assertEq(readIntegrationBranch(repo, "M001"), "f-123-first", "same branch write is idempotent"); + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "f-123-first", "same branch write is idempotent"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── writeIntegrationBranch: rejects slice branches ─────────────────── - console.log("\n=== Integration branch: rejects slice branches ==="); - - { + test('Integration branch: rejects slice branches', () => { const repo = initBranchTestRepo(); writeIntegrationBranch(repo, "M001", "gsd/M001/S01"); - assertEq(readIntegrationBranch(repo, "M001"), null, "slice branches are not recorded as integration branch"); + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), null, "slice branches are not recorded as integration branch"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── writeIntegrationBranch: rejects invalid branch names ───────────── - console.log("\n=== Integration branch: rejects invalid names ==="); - - { + test('Integration branch: rejects invalid names', () => { const repo = initBranchTestRepo(); writeIntegrationBranch(repo, "M001", "bad; rm -rf /"); - assertEq(readIntegrationBranch(repo, "M001"), null, "invalid branch name is not recorded"); + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), null, "invalid branch name is not recorded"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── getMainBranch: uses integration branch when milestone set ──────── - console.log("\n=== getMainBranch: integration branch from milestone metadata ==="); - - { + test('getMainBranch: integration branch from milestone metadata', () => { const repo = initBranchTestRepo(); // Create a feature branch @@ -951,20 +889,18 @@ async function main(): Promise { // Without milestone set, getMainBranch returns "main" const svc = new GitServiceImpl(repo); - assertEq(svc.getMainBranch(), "main", "getMainBranch returns main when no milestone set"); + assert.deepStrictEqual(svc.getMainBranch(), "main", "getMainBranch returns main when no milestone set"); // With milestone set, getMainBranch returns the integration branch svc.setMilestoneId("M001"); - assertEq(svc.getMainBranch(), "f-123-feature", "getMainBranch returns integration branch when milestone set"); + assert.deepStrictEqual(svc.getMainBranch(), "f-123-feature", "getMainBranch returns integration branch when milestone set"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── getMainBranch: main_branch pref still takes priority ───────────── - console.log("\n=== getMainBranch: main_branch pref overrides integration branch ==="); - - { + test('getMainBranch: main_branch pref overrides integration branch', () => { const repo = initBranchTestRepo(); run("git checkout -b f-123-feature", repo); @@ -976,16 +912,14 @@ async function main(): Promise { // Explicit preference still wins const svc = new GitServiceImpl(repo, { main_branch: "trunk" }); svc.setMilestoneId("M001"); - assertEq(svc.getMainBranch(), "trunk", "main_branch preference overrides integration branch"); + assert.deepStrictEqual(svc.getMainBranch(), "trunk", "main_branch preference overrides integration branch"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── getMainBranch: falls back when integration branch deleted ──────── - console.log("\n=== getMainBranch: fallback when integration branch deleted ==="); - - { + test('getMainBranch: fallback when integration branch deleted', () => { const repo = initBranchTestRepo(); // Write metadata pointing to a branch that doesn't exist @@ -993,75 +927,67 @@ async function main(): Promise { const svc = new GitServiceImpl(repo); svc.setMilestoneId("M001"); - assertEq(svc.getMainBranch(), "main", "getMainBranch falls back to main when integration branch no longer exists"); + assert.deepStrictEqual(svc.getMainBranch(), "main", "getMainBranch falls back to main when integration branch no longer exists"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── resolveMilestoneIntegrationBranch: recorded branch wins when it exists ─── - console.log("\n=== Integration branch: resolver prefers recorded branch ==="); - - { + test('Integration branch: resolver prefers recorded branch', () => { const repo = initBranchTestRepo(); run("git checkout -b feature/live", repo); run("git checkout main", repo); writeIntegrationBranch(repo, "M001", "feature/live"); const resolved = resolveMilestoneIntegrationBranch(repo, "M001"); - assertEq(resolved.status, "recorded", "resolver reports recorded branch when metadata branch exists"); - assertEq(resolved.recordedBranch, "feature/live", "resolver includes recorded branch"); - assertEq(resolved.effectiveBranch, "feature/live", "resolver uses recorded branch as effective branch"); + assert.deepStrictEqual(resolved.status, "recorded", "resolver reports recorded branch when metadata branch exists"); + assert.deepStrictEqual(resolved.recordedBranch, "feature/live", "resolver includes recorded branch"); + assert.deepStrictEqual(resolved.effectiveBranch, "feature/live", "resolver uses recorded branch as effective branch"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── resolveMilestoneIntegrationBranch: falls back to detected default ──────── - console.log("\n=== Integration branch: resolver falls back to detected default ==="); - - { + test('Integration branch: resolver falls back to detected default', () => { const repo = initBranchTestRepo(); writeIntegrationBranch(repo, "M001", "deleted-branch"); const resolved = resolveMilestoneIntegrationBranch(repo, "M001"); - assertEq(resolved.status, "fallback", "resolver reports fallback when recorded branch is stale"); - assertEq(resolved.recordedBranch, "deleted-branch", "resolver preserves stale recorded branch for diagnostics"); - assertEq(resolved.effectiveBranch, "main", "resolver falls back to detected default branch"); - assertTrue( + assert.deepStrictEqual(resolved.status, "fallback", "resolver reports fallback when recorded branch is stale"); + assert.deepStrictEqual(resolved.recordedBranch, "deleted-branch", "resolver preserves stale recorded branch for diagnostics"); + assert.deepStrictEqual(resolved.effectiveBranch, "main", "resolver falls back to detected default branch"); + assert.ok( resolved.reason.includes("deleted-branch") && resolved.reason.includes("main"), "resolver reason mentions stale recorded branch and fallback branch", ); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── resolveMilestoneIntegrationBranch: configured main_branch is fallback ───── - console.log("\n=== Integration branch: resolver uses configured fallback branch ==="); - - { + test('Integration branch: resolver uses configured fallback branch', () => { const repo = initBranchTestRepo(); run("git checkout -b trunk", repo); run("git checkout main", repo); writeIntegrationBranch(repo, "M001", "deleted-branch"); const resolved = resolveMilestoneIntegrationBranch(repo, "M001", { main_branch: "trunk" }); - assertEq(resolved.status, "fallback", "resolver reports fallback when using configured main_branch"); - assertEq(resolved.effectiveBranch, "trunk", "resolver prefers configured main_branch as fallback"); - assertTrue( + assert.deepStrictEqual(resolved.status, "fallback", "resolver reports fallback when using configured main_branch"); + assert.deepStrictEqual(resolved.effectiveBranch, "trunk", "resolver prefers configured main_branch as fallback"); + assert.ok( resolved.reason.includes("deleted-branch") && resolved.reason.includes("trunk"), "configured fallback reason mentions stale branch and configured branch", ); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── Per-milestone isolation: different milestones, different targets ── - console.log("\n=== Integration branch: per-milestone isolation ==="); - - { + test('Integration branch: per-milestone isolation', () => { const repo = initBranchTestRepo(); run("git checkout -b feature-a", repo); @@ -1074,37 +1000,33 @@ async function main(): Promise { const svc = new GitServiceImpl(repo); svc.setMilestoneId("M001"); - assertEq(svc.getMainBranch(), "feature-a", "M001 integration branch is feature-a"); + assert.deepStrictEqual(svc.getMainBranch(), "feature-a", "M001 integration branch is feature-a"); svc.setMilestoneId("M002"); - assertEq(svc.getMainBranch(), "feature-b", "M002 integration branch is feature-b"); + assert.deepStrictEqual(svc.getMainBranch(), "feature-b", "M002 integration branch is feature-b"); svc.setMilestoneId(null); - assertEq(svc.getMainBranch(), "main", "no milestone set → falls back to main"); + assert.deepStrictEqual(svc.getMainBranch(), "main", "no milestone set → falls back to main"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── Backward compatibility: no metadata → existing behavior ────────── - console.log("\n=== Integration branch: backward compat ==="); - - { + test('Integration branch: backward compat', () => { const repo = initBranchTestRepo(); const svc = new GitServiceImpl(repo); // Set milestone but no metadata file exists svc.setMilestoneId("M001"); - assertEq(svc.getMainBranch(), "main", "backward compat: no metadata file → falls back to main"); + assert.deepStrictEqual(svc.getMainBranch(), "main", "backward compat: no metadata file → falls back to main"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── untrackRuntimeFiles: removes tracked runtime files from index ─── - console.log("\n=== untrackRuntimeFiles ==="); - - { + test('untrackRuntimeFiles', async () => { const { untrackRuntimeFiles } = await import("../gitignore.ts"); const repo = mkdtempSync(join(tmpdir(), "gsd-untrack-")); run("git init -b main", repo); @@ -1125,38 +1047,36 @@ async function main(): Promise { // Precondition: runtime files are tracked const trackedBefore = run("git ls-files .gsd/", repo); - assertTrue(trackedBefore.includes("completed-units.json"), "untrack: precondition — completed-units tracked"); - assertTrue(trackedBefore.includes("metrics.json"), "untrack: precondition — metrics tracked"); + assert.ok(trackedBefore.includes("completed-units.json"), "untrack: precondition — completed-units tracked"); + assert.ok(trackedBefore.includes("metrics.json"), "untrack: precondition — metrics tracked"); // Run untrackRuntimeFiles untrackRuntimeFiles(repo); // Runtime files should be removed from the index const trackedAfter = run("git ls-files .gsd/", repo); - assertEq(trackedAfter, "", "untrack: all runtime files removed from index"); + assert.deepStrictEqual(trackedAfter, "", "untrack: all runtime files removed from index"); // Non-runtime files remain tracked const srcTracked = run("git ls-files src.ts", repo); - assertTrue(srcTracked.includes("src.ts"), "untrack: non-runtime files remain tracked"); + assert.ok(srcTracked.includes("src.ts"), "untrack: non-runtime files remain tracked"); // Files still exist on disk - assertTrue(existsSync(join(repo, ".gsd", "completed-units.json")), + assert.ok(existsSync(join(repo, ".gsd", "completed-units.json")), "untrack: completed-units.json still on disk"); - assertTrue(existsSync(join(repo, ".gsd", "metrics.json")), + assert.ok(existsSync(join(repo, ".gsd", "metrics.json")), "untrack: metrics.json still on disk"); // Idempotent — running again doesn't error untrackRuntimeFiles(repo); - assertTrue(true, "untrack: second call is idempotent (no error)"); + assert.ok(true, "untrack: second call is idempotent (no error)"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── smartStage excludes runtime files but allows milestone artifacts ── - console.log("\n=== smartStage excludes runtime files, allows milestone artifacts ==="); - - { + test('smartStage excludes runtime files, allows milestone artifacts', () => { const repo = mkdtempSync(join(tmpdir(), "gsd-smart-stage-excludes-")); run("git init -b main", repo); run("git config user.email test@test.com", repo); @@ -1178,71 +1098,65 @@ async function main(): Promise { // smartStage excludes only runtime paths, not all of .gsd/ (#1326) const svc = new GitServiceImpl(repo); const msg = svc.commit({ message: "test commit" }); - assertTrue(msg !== null, "smartStage: commit succeeds"); + assert.ok(msg !== null, "smartStage: commit succeeds"); const committed = run("git show --name-only HEAD", repo); - assertTrue(committed.includes("src.ts"), "smartStage: source files ARE in commit"); + assert.ok(committed.includes("src.ts"), "smartStage: source files ARE in commit"); // Runtime files should NOT be committed - assertTrue(!committed.includes(".gsd/STATE.md"), "smartStage: STATE.md excluded (runtime)"); - assertTrue(!committed.includes(".gsd/runtime/"), "smartStage: runtime/ excluded"); - assertTrue(!committed.includes(".gsd/activity/"), "smartStage: activity/ excluded"); + assert.ok(!committed.includes(".gsd/STATE.md"), "smartStage: STATE.md excluded (runtime)"); + assert.ok(!committed.includes(".gsd/runtime/"), "smartStage: runtime/ excluded"); + assert.ok(!committed.includes(".gsd/activity/"), "smartStage: activity/ excluded"); // Milestone artifacts SHOULD be committed when not gitignored (#1326) - assertTrue(committed.includes(".gsd/milestones/"), "smartStage: milestone artifacts ARE committed"); + assert.ok(committed.includes(".gsd/milestones/"), "smartStage: milestone artifacts ARE committed"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── writeIntegrationBranch: no commit (metadata in external storage) ── - console.log("\n=== writeIntegrationBranch: no commit ==="); - - { + test('writeIntegrationBranch: no commit', () => { const repo = initBranchTestRepo(); const commitsBefore = run("git rev-list --count HEAD", repo); writeIntegrationBranch(repo, "M001", "f-123-new-thing"); // File should still be written to disk - assertEq(readIntegrationBranch(repo, "M001"), "f-123-new-thing", + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "f-123-new-thing", "writeIntegrationBranch: metadata file exists on disk"); // No commit — .gsd/ is managed externally const commitsAfter = run("git rev-list --count HEAD", repo); - assertEq(commitsBefore, commitsAfter, + assert.deepStrictEqual(commitsBefore, commitsAfter, "writeIntegrationBranch: no git commit created for integration branch"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── ensureGitignore: always adds .gsd to gitignore ────────────────── - console.log("\n=== ensureGitignore: adds .gsd entry ==="); - - { + test('ensureGitignore: adds .gsd entry', async () => { const { ensureGitignore } = await import("../gitignore.ts"); const repo = mkdtempSync(join(tmpdir(), "gsd-gitignore-external-state-")); // Should add .gsd to gitignore (external state dir is a symlink) const modified = ensureGitignore(repo); - assertTrue(modified, "ensureGitignore: gitignore was modified"); + assert.ok(modified, "ensureGitignore: gitignore was modified"); const { readFileSync } = await import("node:fs"); const content = readFileSync(join(repo, ".gitignore"), "utf-8"); const lines = content.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#")); - assertTrue(lines.includes(".gsd"), "ensureGitignore: .gitignore contains .gsd"); + assert.ok(lines.includes(".gsd"), "ensureGitignore: .gitignore contains .gsd"); // Idempotent — calling again doesn't add duplicates const modified2 = ensureGitignore(repo); - assertTrue(!modified2, "ensureGitignore: second call is idempotent"); + assert.ok(!modified2, "ensureGitignore: second call is idempotent"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── nativeAddAllWithExclusions: symlinked .gsd fallback ─────────────── - console.log("\n=== nativeAddAllWithExclusions: symlinked .gsd fallback ==="); - - { + test('nativeAddAllWithExclusions: symlinked .gsd fallback', () => { // When .gsd is a symlink, git rejects `:!.gsd/...` pathspecs with // "fatal: pathspec '...' is beyond a symbolic link". The fix falls // back to plain `git add -A`, which respects .gitignore. @@ -1271,22 +1185,20 @@ async function main(): Promise { threw = true; console.error(" unexpected error:", e); } - assertTrue(!threw, "nativeAddAllWithExclusions does not throw with symlinked .gsd"); + assert.ok(!threw, "nativeAddAllWithExclusions does not throw with symlinked .gsd"); // Verify the real file was staged const staged = run("git diff --cached --name-only", repo); - assertTrue(staged.includes("src/app.ts"), "real file staged despite symlinked .gsd"); - assertTrue(!staged.includes(".gsd"), ".gsd content not staged"); + assert.ok(staged.includes("src/app.ts"), "real file staged despite symlinked .gsd"); + assert.ok(!staged.includes(".gsd"), ".gsd content not staged"); rmSync(repo, { recursive: true, force: true }); rmSync(externalGsd, { recursive: true, force: true }); - } + }); // ─── nativeAddAllWithExclusions: non-symlinked .gsd still works ─────── - console.log("\n=== nativeAddAllWithExclusions: non-symlinked .gsd still works ==="); - - { + test('nativeAddAllWithExclusions: non-symlinked .gsd still works', () => { // Verify the normal (non-symlink) case still works with pathspec exclusions const repo = initTempRepo(); @@ -1300,96 +1212,91 @@ async function main(): Promise { } catch { threw = true; } - assertTrue(!threw, "nativeAddAllWithExclusions works with normal .gsd directory"); + assert.ok(!threw, "nativeAddAllWithExclusions works with normal .gsd directory"); const staged = run("git diff --cached --name-only", repo); - assertTrue(staged.includes("src/code.ts"), "real file staged with normal .gsd"); + assert.ok(staged.includes("src/code.ts"), "real file staged with normal .gsd"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── MergeConflictError: constructor fields ─────────────────────────────── - console.log("\n=== MergeConflictError: constructor fields ==="); - { + test('MergeConflictError: constructor fields', () => { const err = new MergeConflictError( ["src/foo.ts", "src/bar.ts"], "squash", "gsd/M001/S01", "main", ); - assertEq(err.conflictedFiles, ["src/foo.ts", "src/bar.ts"], "MergeConflictError.conflictedFiles populated"); - assertEq(err.strategy, "squash", "MergeConflictError.strategy set"); - assertEq(err.branch, "gsd/M001/S01", "MergeConflictError.branch set"); - assertEq(err.mainBranch, "main", "MergeConflictError.mainBranch set"); - assertEq(err.name, "MergeConflictError", "MergeConflictError.name is MergeConflictError"); - assertTrue(err.message.includes("src/foo.ts"), "MergeConflictError message lists conflicted files"); - assertTrue(err.message.toLowerCase().includes("squash"), "MergeConflictError message mentions strategy"); - assertTrue(err instanceof MergeConflictError, "MergeConflictError is an instanceof MergeConflictError"); - assertTrue(err instanceof Error, "MergeConflictError is an Error instance"); - } + assert.deepStrictEqual(err.conflictedFiles, ["src/foo.ts", "src/bar.ts"], "MergeConflictError.conflictedFiles populated"); + assert.deepStrictEqual(err.strategy, "squash", "MergeConflictError.strategy set"); + assert.deepStrictEqual(err.branch, "gsd/M001/S01", "MergeConflictError.branch set"); + assert.deepStrictEqual(err.mainBranch, "main", "MergeConflictError.mainBranch set"); + assert.deepStrictEqual(err.name, "MergeConflictError", "MergeConflictError.name is MergeConflictError"); + assert.ok(err.message.includes("src/foo.ts"), "MergeConflictError message lists conflicted files"); + assert.ok(err.message.toLowerCase().includes("squash"), "MergeConflictError message mentions strategy"); + assert.ok(err instanceof MergeConflictError, "MergeConflictError is an instanceof MergeConflictError"); + assert.ok(err instanceof Error, "MergeConflictError is an Error instance"); + }); // ─── Integration branch: rejects gsd/quick/* branches ──────────────────── - console.log("\n=== Integration branch: rejects gsd/quick/* branches ==="); - { + test('Integration branch: rejects gsd/quick/* branches', () => { const repo = initBranchTestRepo(); writeIntegrationBranch(repo, "M001", "gsd/quick/1234-some-task"); - assertEq(readIntegrationBranch(repo, "M001"), null, "gsd/quick/* branches are not recorded as integration branch"); + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), null, "gsd/quick/* branches are not recorded as integration branch"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── Integration branch: resolver returns missing when no metadata ──────── - console.log("\n=== Integration branch: resolver returns missing when no metadata ==="); - { + test('Integration branch: resolver returns missing when no metadata', () => { const repo = initBranchTestRepo(); // No writeIntegrationBranch call — no metadata file exists const resolved = resolveMilestoneIntegrationBranch(repo, "M999"); - assertEq(resolved.status, "missing", "resolver reports missing when no metadata file"); - assertEq(resolved.recordedBranch, null, "resolver recordedBranch is null when no metadata"); - assertEq(resolved.effectiveBranch, null, "resolver effectiveBranch is null when no metadata"); - assertTrue(resolved.reason.includes("M999"), "resolver reason mentions the milestone ID"); + assert.deepStrictEqual(resolved.status, "missing", "resolver reports missing when no metadata file"); + assert.deepStrictEqual(resolved.recordedBranch, null, "resolver recordedBranch is null when no metadata"); + assert.deepStrictEqual(resolved.effectiveBranch, null, "resolver effectiveBranch is null when no metadata"); + assert.ok(resolved.reason.includes("M999"), "resolver reason mentions the milestone ID"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── Integration branch: resolver missing when both recorded and configured branches gone ─── - console.log("\n=== Integration branch: resolver missing when both recorded and configured branches gone ==="); - { + test('Integration branch: resolver missing when both recorded and configured branches gone', () => { const repo = initBranchTestRepo(); // Record a branch that doesn't exist writeIntegrationBranch(repo, "M001", "deleted-feature"); // configured main_branch also doesn't exist const resolved = resolveMilestoneIntegrationBranch(repo, "M001", { main_branch: "nonexistent-branch" }); - assertEq(resolved.status, "missing", "resolver reports missing when recorded branch and configured main_branch both absent"); - assertEq(resolved.recordedBranch, "deleted-feature", "resolver preserves stale recorded branch"); - assertEq(resolved.effectiveBranch, null, "resolver effectiveBranch is null when no safe fallback"); - assertTrue( + assert.deepStrictEqual(resolved.status, "missing", "resolver reports missing when recorded branch and configured main_branch both absent"); + assert.deepStrictEqual(resolved.recordedBranch, "deleted-feature", "resolver preserves stale recorded branch"); + assert.deepStrictEqual(resolved.effectiveBranch, null, "resolver effectiveBranch is null when no safe fallback"); + assert.ok( resolved.reason.includes("deleted-feature") && resolved.reason.includes("nonexistent-branch"), "reason mentions both stale branch and unavailable configured branch", ); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── buildTaskCommitMessage: issueNumber appends Resolves trailer ───────── - console.log("\n=== buildTaskCommitMessage: issueNumber appends Resolves trailer ==="); - { + test('buildTaskCommitMessage: issueNumber appends Resolves trailer', () => { const msg = buildTaskCommitMessage({ taskId: "S01/T03", taskTitle: "fix login redirect", issueNumber: 42, }); - assertTrue(msg.includes("Resolves #42"), "buildTaskCommitMessage includes Resolves #N trailer when issueNumber is set"); - assertTrue(msg.startsWith("fix(S01/T03):"), "buildTaskCommitMessage infers fix type"); - } + assert.ok(msg.includes("Resolves #42"), "buildTaskCommitMessage includes Resolves #N trailer when issueNumber is set"); + assert.ok(msg.startsWith("fix(S01/T03):"), "buildTaskCommitMessage infers fix type"); + }); { // No issueNumber — no Resolves trailer @@ -1397,29 +1304,26 @@ async function main(): Promise { taskId: "S01/T04", taskTitle: "add dashboard widget", }); - assertTrue(!msg.includes("Resolves"), "buildTaskCommitMessage omits Resolves trailer when issueNumber is absent"); + assert.ok(!msg.includes("Resolves"), "buildTaskCommitMessage omits Resolves trailer when issueNumber is absent"); } // ─── runPreMergeCheck: skips when no package.json ──────────────────────── - console.log("\n=== runPreMergeCheck: skips when no package.json ==="); - { + test('runPreMergeCheck: skips when no package.json', () => { const repo = initBranchTestRepo(); // No package.json created — auto-detect should skip gracefully const svc = new GitServiceImpl(repo, { pre_merge_check: true }); const result: PreMergeCheckResult = svc.runPreMergeCheck(); - assertEq(result.passed, true, "runPreMergeCheck passes when no package.json (skip)"); - assertEq(result.skipped, true, "runPreMergeCheck skips when no package.json found"); + assert.deepStrictEqual(result.passed, true, "runPreMergeCheck passes when no package.json (skip)"); + assert.deepStrictEqual(result.skipped, true, "runPreMergeCheck skips when no package.json found"); rmSync(repo, { recursive: true, force: true }); - } + }); // ─── autoCommit: symlinked .gsd does NOT stage milestone artifacts (#2247) ── - console.log("\n=== autoCommit: symlinked .gsd does NOT stage milestone artifacts (#2247) ==="); - - { + test('autoCommit: symlinked .gsd does NOT stage milestone artifacts (#2247)', () => { // When .gsd is a symlink (external state project), .gsd/ files live outside // the repo by design. smartStage() must NOT force-stage them into git — the // .gitignore exclusion is correct and intentional. @@ -1448,21 +1352,14 @@ async function main(): Promise { const svc = new GitServiceImpl(repo); const msg = svc.autoCommit("complete-milestone", "M009"); - assertTrue(msg !== null, "symlink autoCommit: commit succeeds"); + assert.ok(msg !== null, "symlink autoCommit: commit succeeds"); const committed = run("git show --name-only HEAD", repo); - assertTrue(committed.includes("src/feature.ts"), "symlink autoCommit: source file committed"); - assertTrue(!committed.includes(".gsd/milestones/"), + assert.ok(committed.includes("src/feature.ts"), "symlink autoCommit: source file committed"); + assert.ok(!committed.includes(".gsd/milestones/"), "symlink autoCommit: .gsd/milestones/ files are NOT staged (external state stays external)"); try { rmSync(repo, { recursive: true, force: true }); } catch {} try { rmSync(externalGsd, { recursive: true, force: true }); } catch {} - } - - report(); -} - -main().catch((error) => { - console.error(error); - process.exit(1); + }); }); diff --git a/src/resources/extensions/gsd/tests/gsd-db.test.ts b/src/resources/extensions/gsd/tests/gsd-db.test.ts index 73d24159e..0046b3e3f 100644 --- a/src/resources/extensions/gsd/tests/gsd-db.test.ts +++ b/src/resources/extensions/gsd/tests/gsd-db.test.ts @@ -1,4 +1,5 @@ -import { createTestContext } from './test-helpers.ts'; +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -18,8 +19,6 @@ import { _resetProvider, } from '../gsd-db.ts'; -const { assertEq, assertTrue, report } = createTestContext(); - // ═══════════════════════════════════════════════════════════════════════════ // Helper: create a temp file path for file-backed DB tests // ═══════════════════════════════════════════════════════════════════════════ @@ -47,314 +46,306 @@ function cleanup(dbPath: string): void { // gsd-db tests // ═══════════════════════════════════════════════════════════════════════════ -console.log('\n=== gsd-db: provider detection ==='); -{ - const provider = getDbProvider(); - assertTrue(provider !== null, 'provider should be non-null'); - assertTrue( - provider === 'node:sqlite' || provider === 'better-sqlite3', - `provider should be a known name, got: ${provider}`, - ); -} - -console.log('\n=== gsd-db: fresh DB schema init (memory) ==='); -{ - const ok = openDatabase(':memory:'); - assertTrue(ok, 'openDatabase should return true'); - assertTrue(isDbAvailable(), 'isDbAvailable should be true after open'); - - // Check schema_version table - const adapter = _getAdapter()!; - const version = adapter.prepare('SELECT MAX(version) as version FROM schema_version').get(); - assertEq(version?.['version'], 10, 'schema version should be 10'); - - // Check tables exist by querying them - const dRows = adapter.prepare('SELECT count(*) as cnt FROM decisions').get(); - assertEq(dRows?.['cnt'], 0, 'decisions table should exist and be empty'); - - const rRows = adapter.prepare('SELECT count(*) as cnt FROM requirements').get(); - assertEq(rRows?.['cnt'], 0, 'requirements table should exist and be empty'); - - closeDatabase(); - assertTrue(!isDbAvailable(), 'isDbAvailable should be false after close'); -} - -console.log('\n=== gsd-db: double-init idempotency ==='); -{ - const dbPath = tempDbPath(); - openDatabase(dbPath); - - // Insert a decision so we can verify it survives re-init - insertDecision({ - id: 'D001', - when_context: 'test', - scope: 'global', - decision: 'test decision', - choice: 'option A', - rationale: 'because', - revisable: 'yes', - made_by: 'agent', - superseded_by: null, +describe('gsd-db', () => { + test('gsd-db: provider detection', () => { + const provider = getDbProvider(); + assert.ok(provider !== null, 'provider should be non-null'); + assert.ok( + provider === 'node:sqlite' || provider === 'better-sqlite3', + `provider should be a known name, got: ${provider}`, + ); }); - closeDatabase(); + test('gsd-db: fresh DB schema init (memory)', () => { + const ok = openDatabase(':memory:'); + assert.ok(ok, 'openDatabase should return true'); + assert.ok(isDbAvailable(), 'isDbAvailable should be true after open'); - // Re-open same DB — schema init should be idempotent - openDatabase(dbPath); - const d = getDecisionById('D001'); - assertTrue(d !== null, 'decision should survive re-init'); - assertEq(d?.id, 'D001', 'decision ID preserved after re-init'); + // Check schema_version table + const adapter = _getAdapter()!; + const version = adapter.prepare('SELECT MAX(version) as version FROM schema_version').get(); + assert.deepStrictEqual(version?.['version'], 10, 'schema version should be 10'); - // Schema version should still be 1 (not duplicated) - const adapter = _getAdapter()!; - const versions = adapter.prepare('SELECT count(*) as cnt FROM schema_version').get(); - assertEq(versions?.['cnt'], 1, 'schema_version should have exactly 1 row after double-init'); + // Check tables exist by querying them + const dRows = adapter.prepare('SELECT count(*) as cnt FROM decisions').get(); + assert.deepStrictEqual(dRows?.['cnt'], 0, 'decisions table should exist and be empty'); - cleanup(dbPath); -} + const rRows = adapter.prepare('SELECT count(*) as cnt FROM requirements').get(); + assert.deepStrictEqual(rRows?.['cnt'], 0, 'requirements table should exist and be empty'); -console.log('\n=== gsd-db: insert + get decision ==='); -{ - openDatabase(':memory:'); - insertDecision({ - id: 'D042', - when_context: 'during sprint 3', - scope: 'M001/S02', - decision: 'use SQLite for storage', - choice: 'node:sqlite', - rationale: 'built-in, zero deps', - revisable: 'yes, if perf insufficient', - made_by: 'agent', - superseded_by: null, + closeDatabase(); + assert.ok(!isDbAvailable(), 'isDbAvailable should be false after close'); }); - const d = getDecisionById('D042'); - assertTrue(d !== null, 'should find inserted decision'); - assertEq(d?.id, 'D042', 'decision id'); - assertEq(d?.scope, 'M001/S02', 'decision scope'); - assertEq(d?.choice, 'node:sqlite', 'decision choice'); - assertTrue(typeof d?.seq === 'number' && d.seq > 0, 'seq should be auto-assigned positive number'); - assertEq(d?.superseded_by, null, 'superseded_by should be null'); + test('gsd-db: double-init idempotency', () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); - // Non-existent - const missing = getDecisionById('D999'); - assertEq(missing, null, 'non-existent decision returns null'); - - closeDatabase(); -} - -console.log('\n=== gsd-db: insert + get requirement ==='); -{ - openDatabase(':memory:'); - insertRequirement({ - id: 'R007', - class: 'functional', - status: 'active', - description: 'System must persist decisions', - why: 'decisions inform future agents', - source: 'M001-CONTEXT', - primary_owner: 'S01', - supporting_slices: 'S02, S03', - validation: 'insert and query roundtrip', - notes: 'high priority', - full_content: 'Full text of requirement...', - superseded_by: null, - }); - - const r = getRequirementById('R007'); - assertTrue(r !== null, 'should find inserted requirement'); - assertEq(r?.id, 'R007', 'requirement id'); - assertEq(r?.class, 'functional', 'requirement class'); - assertEq(r?.status, 'active', 'requirement status'); - assertEq(r?.primary_owner, 'S01', 'requirement primary_owner'); - assertEq(r?.superseded_by, null, 'superseded_by should be null'); - - // Non-existent - const missing = getRequirementById('R999'); - assertEq(missing, null, 'non-existent requirement returns null'); - - closeDatabase(); -} - -console.log('\n=== gsd-db: active_decisions view excludes superseded ==='); -{ - openDatabase(':memory:'); - - insertDecision({ - id: 'D001', - when_context: 'early', - scope: 'global', - decision: 'use JSON files', - choice: 'JSON', - rationale: 'simple', - revisable: 'yes', - made_by: 'agent', - superseded_by: 'D002', // superseded! - }); - - insertDecision({ - id: 'D002', - when_context: 'later', - scope: 'global', - decision: 'use SQLite', - choice: 'SQLite', - rationale: 'better querying', - revisable: 'yes', - made_by: 'agent', - superseded_by: null, // active - }); - - insertDecision({ - id: 'D003', - when_context: 'same time', - scope: 'local', - decision: 'use WAL mode', - choice: 'WAL', - rationale: 'concurrent reads', - revisable: 'no', - made_by: 'agent', - superseded_by: null, // active - }); - - const active = getActiveDecisions(); - assertEq(active.length, 2, 'active_decisions should return 2 (not the superseded one)'); - const ids = active.map(d => d.id).sort(); - assertEq(ids, ['D002', 'D003'], 'active decisions should be D002 and D003'); - - // Verify D001 is still in the raw table - const d1 = getDecisionById('D001'); - assertTrue(d1 !== null, 'superseded decision still exists in raw table'); - assertEq(d1?.superseded_by, 'D002', 'superseded_by is set'); - - closeDatabase(); -} - -console.log('\n=== gsd-db: active_requirements view excludes superseded ==='); -{ - openDatabase(':memory:'); - - insertRequirement({ - id: 'R001', - class: 'functional', - status: 'active', - description: 'old requirement', - why: 'was needed', - source: 'M001', - primary_owner: 'S01', - supporting_slices: '', - validation: 'test', - notes: '', - full_content: '', - superseded_by: 'R002', // superseded! - }); - - insertRequirement({ - id: 'R002', - class: 'functional', - status: 'active', - description: 'new requirement', - why: 'replaces R001', - source: 'M001', - primary_owner: 'S01', - supporting_slices: '', - validation: 'test', - notes: '', - full_content: '', - superseded_by: null, // active - }); - - const active = getActiveRequirements(); - assertEq(active.length, 1, 'active_requirements should return 1'); - assertEq(active[0]?.id, 'R002', 'only R002 should be active'); - - // R001 still in raw table - const r1 = getRequirementById('R001'); - assertTrue(r1 !== null, 'superseded requirement still in raw table'); - - closeDatabase(); -} - -console.log('\n=== gsd-db: WAL mode on file-backed DB ==='); -{ - const dbPath = tempDbPath(); - openDatabase(dbPath); - - const adapter = _getAdapter()!; - const mode = adapter.prepare('PRAGMA journal_mode').get(); - assertEq(mode?.['journal_mode'], 'wal', 'journal_mode should be wal for file-backed DB'); - - cleanup(dbPath); -} - -console.log('\n=== gsd-db: transaction rollback on error ==='); -{ - openDatabase(':memory:'); - - // Insert a decision normally - insertDecision({ - id: 'D010', - when_context: 'test', - scope: 'test', - decision: 'test', - choice: 'test', - rationale: 'test', - revisable: 'test', - made_by: 'agent', - superseded_by: null, - }); - - // Try a transaction that fails — the insert inside should be rolled back - let threw = false; - try { - transaction(() => { - insertDecision({ - id: 'D011', - when_context: 'should be rolled back', - scope: 'test', - decision: 'test', - choice: 'test', - rationale: 'test', - revisable: 'test', - made_by: 'agent', - superseded_by: null, - }); - throw new Error('intentional failure'); + // Insert a decision so we can verify it survives re-init + insertDecision({ + id: 'D001', + when_context: 'test', + scope: 'global', + decision: 'test decision', + choice: 'option A', + rationale: 'because', + revisable: 'yes', + made_by: 'agent', + superseded_by: null, }); - } catch (err) { - if ((err as Error).message === 'intentional failure') { - threw = true; + + closeDatabase(); + + // Re-open same DB — schema init should be idempotent + openDatabase(dbPath); + const d = getDecisionById('D001'); + assert.ok(d !== null, 'decision should survive re-init'); + assert.deepStrictEqual(d?.id, 'D001', 'decision ID preserved after re-init'); + + // Schema version should still be 1 (not duplicated) + const adapter = _getAdapter()!; + const versions = adapter.prepare('SELECT count(*) as cnt FROM schema_version').get(); + assert.deepStrictEqual(versions?.['cnt'], 1, 'schema_version should have exactly 1 row after double-init'); + + cleanup(dbPath); + }); + + test('gsd-db: insert + get decision', () => { + openDatabase(':memory:'); + insertDecision({ + id: 'D042', + when_context: 'during sprint 3', + scope: 'M001/S02', + decision: 'use SQLite for storage', + choice: 'node:sqlite', + rationale: 'built-in, zero deps', + revisable: 'yes, if perf insufficient', + made_by: 'agent', + superseded_by: null, + }); + + const d = getDecisionById('D042'); + assert.ok(d !== null, 'should find inserted decision'); + assert.deepStrictEqual(d?.id, 'D042', 'decision id'); + assert.deepStrictEqual(d?.scope, 'M001/S02', 'decision scope'); + assert.deepStrictEqual(d?.choice, 'node:sqlite', 'decision choice'); + assert.ok(typeof d?.seq === 'number' && d.seq > 0, 'seq should be auto-assigned positive number'); + assert.deepStrictEqual(d?.superseded_by, null, 'superseded_by should be null'); + + // Non-existent + const missing = getDecisionById('D999'); + assert.deepStrictEqual(missing, null, 'non-existent decision returns null'); + + closeDatabase(); + }); + + test('gsd-db: insert + get requirement', () => { + openDatabase(':memory:'); + insertRequirement({ + id: 'R007', + class: 'functional', + status: 'active', + description: 'System must persist decisions', + why: 'decisions inform future agents', + source: 'M001-CONTEXT', + primary_owner: 'S01', + supporting_slices: 'S02, S03', + validation: 'insert and query roundtrip', + notes: 'high priority', + full_content: 'Full text of requirement...', + superseded_by: null, + }); + + const r = getRequirementById('R007'); + assert.ok(r !== null, 'should find inserted requirement'); + assert.deepStrictEqual(r?.id, 'R007', 'requirement id'); + assert.deepStrictEqual(r?.class, 'functional', 'requirement class'); + assert.deepStrictEqual(r?.status, 'active', 'requirement status'); + assert.deepStrictEqual(r?.primary_owner, 'S01', 'requirement primary_owner'); + assert.deepStrictEqual(r?.superseded_by, null, 'superseded_by should be null'); + + // Non-existent + const missing = getRequirementById('R999'); + assert.deepStrictEqual(missing, null, 'non-existent requirement returns null'); + + closeDatabase(); + }); + + test('gsd-db: active_decisions view excludes superseded', () => { + openDatabase(':memory:'); + + insertDecision({ + id: 'D001', + when_context: 'early', + scope: 'global', + decision: 'use JSON files', + choice: 'JSON', + rationale: 'simple', + revisable: 'yes', + made_by: 'agent', + superseded_by: 'D002', // superseded! + }); + + insertDecision({ + id: 'D002', + when_context: 'later', + scope: 'global', + decision: 'use SQLite', + choice: 'SQLite', + rationale: 'better querying', + revisable: 'yes', + made_by: 'agent', + superseded_by: null, // active + }); + + insertDecision({ + id: 'D003', + when_context: 'same time', + scope: 'local', + decision: 'use WAL mode', + choice: 'WAL', + rationale: 'concurrent reads', + revisable: 'no', + made_by: 'agent', + superseded_by: null, // active + }); + + const active = getActiveDecisions(); + assert.deepStrictEqual(active.length, 2, 'active_decisions should return 2 (not the superseded one)'); + const ids = active.map(d => d.id).sort(); + assert.deepStrictEqual(ids, ['D002', 'D003'], 'active decisions should be D002 and D003'); + + // Verify D001 is still in the raw table + const d1 = getDecisionById('D001'); + assert.ok(d1 !== null, 'superseded decision still exists in raw table'); + assert.deepStrictEqual(d1?.superseded_by, 'D002', 'superseded_by is set'); + + closeDatabase(); + }); + + test('gsd-db: active_requirements view excludes superseded', () => { + openDatabase(':memory:'); + + insertRequirement({ + id: 'R001', + class: 'functional', + status: 'active', + description: 'old requirement', + why: 'was needed', + source: 'M001', + primary_owner: 'S01', + supporting_slices: '', + validation: 'test', + notes: '', + full_content: '', + superseded_by: 'R002', // superseded! + }); + + insertRequirement({ + id: 'R002', + class: 'functional', + status: 'active', + description: 'new requirement', + why: 'replaces R001', + source: 'M001', + primary_owner: 'S01', + supporting_slices: '', + validation: 'test', + notes: '', + full_content: '', + superseded_by: null, // active + }); + + const active = getActiveRequirements(); + assert.deepStrictEqual(active.length, 1, 'active_requirements should return 1'); + assert.deepStrictEqual(active[0]?.id, 'R002', 'only R002 should be active'); + + // R001 still in raw table + const r1 = getRequirementById('R001'); + assert.ok(r1 !== null, 'superseded requirement still in raw table'); + + closeDatabase(); + }); + + test('gsd-db: WAL mode on file-backed DB', () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); + + const adapter = _getAdapter()!; + const mode = adapter.prepare('PRAGMA journal_mode').get(); + assert.deepStrictEqual(mode?.['journal_mode'], 'wal', 'journal_mode should be wal for file-backed DB'); + + cleanup(dbPath); + }); + + test('gsd-db: transaction rollback on error', () => { + openDatabase(':memory:'); + + // Insert a decision normally + insertDecision({ + id: 'D010', + when_context: 'test', + scope: 'test', + decision: 'test', + choice: 'test', + rationale: 'test', + revisable: 'test', + made_by: 'agent', + superseded_by: null, + }); + + // Try a transaction that fails — the insert inside should be rolled back + let threw = false; + try { + transaction(() => { + insertDecision({ + id: 'D011', + when_context: 'should be rolled back', + scope: 'test', + decision: 'test', + choice: 'test', + rationale: 'test', + revisable: 'test', + made_by: 'agent', + superseded_by: null, + }); + throw new Error('intentional failure'); + }); + } catch (err) { + if ((err as Error).message === 'intentional failure') { + threw = true; + } } - } - assertTrue(threw, 'transaction should re-throw the error'); - const d11 = getDecisionById('D011'); - assertEq(d11, null, 'D011 should be rolled back (not found)'); + assert.ok(threw, 'transaction should re-throw the error'); + const d11 = getDecisionById('D011'); + assert.deepStrictEqual(d11, null, 'D011 should be rolled back (not found)'); - // D010 should still be there - const d10 = getDecisionById('D010'); - assertTrue(d10 !== null, 'D010 should survive the failed transaction'); + // D010 should still be there + const d10 = getDecisionById('D010'); + assert.ok(d10 !== null, 'D010 should survive the failed transaction'); - closeDatabase(); -} + closeDatabase(); + }); -console.log('\n=== gsd-db: query wrappers return null/empty when DB unavailable ==='); -{ - // Ensure DB is closed - closeDatabase(); - assertTrue(!isDbAvailable(), 'DB should not be available'); + test('gsd-db: query wrappers return null/empty when DB unavailable', () => { + // Ensure DB is closed + closeDatabase(); + assert.ok(!isDbAvailable(), 'DB should not be available'); - const d = getDecisionById('D001'); - assertEq(d, null, 'getDecisionById returns null when DB closed'); + const d = getDecisionById('D001'); + assert.deepStrictEqual(d, null, 'getDecisionById returns null when DB closed'); - const r = getRequirementById('R001'); - assertEq(r, null, 'getRequirementById returns null when DB closed'); + const r = getRequirementById('R001'); + assert.deepStrictEqual(r, null, 'getRequirementById returns null when DB closed'); - const ad = getActiveDecisions(); - assertEq(ad, [], 'getActiveDecisions returns [] when DB closed'); + const ad = getActiveDecisions(); + assert.deepStrictEqual(ad, [], 'getActiveDecisions returns [] when DB closed'); - const ar = getActiveRequirements(); - assertEq(ar, [], 'getActiveRequirements returns [] when DB closed'); -} + const ar = getActiveRequirements(); + assert.deepStrictEqual(ar, [], 'getActiveRequirements returns [] when DB closed'); + }); -// ─── Final Report ────────────────────────────────────────────────────────── -report(); + // ─── Final Report ────────────────────────────────────────────────────────── + +}); diff --git a/src/resources/extensions/gsd/tests/gsd-inspect.test.ts b/src/resources/extensions/gsd/tests/gsd-inspect.test.ts index 947313c09..418a2c432 100644 --- a/src/resources/extensions/gsd/tests/gsd-inspect.test.ts +++ b/src/resources/extensions/gsd/tests/gsd-inspect.test.ts @@ -1,125 +1,114 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; // gsd-inspect — Tests for /gsd inspect output formatting // // Tests the pure formatInspectOutput function with known data. -import { createTestContext } from './test-helpers.ts'; import { formatInspectOutput, type InspectData } from '../commands-inspect.ts'; -const { assertEq, assertTrue, assertMatch, report } = createTestContext(); +describe('gsd-inspect', () => { + test('full output formatting', () => { + const data: InspectData = { + schemaVersion: 2, + counts: { decisions: 12, requirements: 8, artifacts: 3 }, + recentDecisions: [ + { id: "D012", decision: "Use SQLite for persistence", choice: "node:sqlite with fallback" }, + { id: "D011", decision: "Markdown dual-write", choice: "DB-first then regenerate" }, + ], + recentRequirements: [ + { id: "R015", status: "active", description: "Commands register via pi.registerCommand" }, + { id: "R014", status: "active", description: "DB writes use upsert pattern" }, + ], + }; -// ── formats output with schema version, counts, and recent entries ── -console.log("# === gsd-inspect: full output formatting ==="); -{ - const data: InspectData = { - schemaVersion: 2, - counts: { decisions: 12, requirements: 8, artifacts: 3 }, - recentDecisions: [ - { id: "D012", decision: "Use SQLite for persistence", choice: "node:sqlite with fallback" }, - { id: "D011", decision: "Markdown dual-write", choice: "DB-first then regenerate" }, - ], - recentRequirements: [ - { id: "R015", status: "active", description: "Commands register via pi.registerCommand" }, - { id: "R014", status: "active", description: "DB writes use upsert pattern" }, - ], - }; + const output = formatInspectOutput(data); - const output = formatInspectOutput(data); + assert.match(output, /=== GSD Database Inspect ===/, "contains header"); + assert.match(output, /Schema version: 2/, "contains schema version"); + assert.match(output, /Decisions:\s+12/, "contains decisions count"); + assert.match(output, /Requirements:\s+8/, "contains requirements count"); + assert.match(output, /Artifacts:\s+3/, "contains artifacts count"); + assert.match(output, /Recent decisions:/, "contains recent decisions header"); + assert.match(output, /D012: Use SQLite for persistence → node:sqlite with fallback/, "contains D012 entry"); + assert.match(output, /D011: Markdown dual-write → DB-first then regenerate/, "contains D011 entry"); + assert.match(output, /Recent requirements:/, "contains recent requirements header"); + assert.match(output, /R015 \[active\]: Commands register via pi\.registerCommand/, "contains R015 entry"); + assert.match(output, /R014 \[active\]: DB writes use upsert pattern/, "contains R014 entry"); + }); - assertMatch(output, /=== GSD Database Inspect ===/, "contains header"); - assertMatch(output, /Schema version: 2/, "contains schema version"); - assertMatch(output, /Decisions:\s+12/, "contains decisions count"); - assertMatch(output, /Requirements:\s+8/, "contains requirements count"); - assertMatch(output, /Artifacts:\s+3/, "contains artifacts count"); - assertMatch(output, /Recent decisions:/, "contains recent decisions header"); - assertMatch(output, /D012: Use SQLite for persistence → node:sqlite with fallback/, "contains D012 entry"); - assertMatch(output, /D011: Markdown dual-write → DB-first then regenerate/, "contains D011 entry"); - assertMatch(output, /Recent requirements:/, "contains recent requirements header"); - assertMatch(output, /R015 \[active\]: Commands register via pi\.registerCommand/, "contains R015 entry"); - assertMatch(output, /R014 \[active\]: DB writes use upsert pattern/, "contains R014 entry"); -} + test('empty data', () => { + const data: InspectData = { + schemaVersion: 1, + counts: { decisions: 0, requirements: 0, artifacts: 0 }, + recentDecisions: [], + recentRequirements: [], + }; -// ── handles zero counts and no recent entries ── -console.log("# === gsd-inspect: empty data ==="); -{ - const data: InspectData = { - schemaVersion: 1, - counts: { decisions: 0, requirements: 0, artifacts: 0 }, - recentDecisions: [], - recentRequirements: [], - }; + const output = formatInspectOutput(data); - const output = formatInspectOutput(data); + assert.match(output, /Schema version: 1/, "contains schema version 1"); + assert.match(output, /Decisions:\s+0/, "zero decisions"); + assert.match(output, /Requirements:\s+0/, "zero requirements"); + assert.match(output, /Artifacts:\s+0/, "zero artifacts"); + assert.ok(!output.includes("Recent decisions:"), "no recent decisions section when empty"); + assert.ok(!output.includes("Recent requirements:"), "no recent requirements section when empty"); + }); - assertMatch(output, /Schema version: 1/, "contains schema version 1"); - assertMatch(output, /Decisions:\s+0/, "zero decisions"); - assertMatch(output, /Requirements:\s+0/, "zero requirements"); - assertMatch(output, /Artifacts:\s+0/, "zero artifacts"); - assertTrue(!output.includes("Recent decisions:"), "no recent decisions section when empty"); - assertTrue(!output.includes("Recent requirements:"), "no recent requirements section when empty"); -} + test('null schema version', () => { + const data: InspectData = { + schemaVersion: null, + counts: { decisions: 0, requirements: 0, artifacts: 0 }, + recentDecisions: [], + recentRequirements: [], + }; -// ── handles null schema version ── -console.log("# === gsd-inspect: null schema version ==="); -{ - const data: InspectData = { - schemaVersion: null, - counts: { decisions: 0, requirements: 0, artifacts: 0 }, - recentDecisions: [], - recentRequirements: [], - }; + const output = formatInspectOutput(data); + assert.match(output, /Schema version: unknown/, "null version shows as unknown"); + }); - const output = formatInspectOutput(data); - assertMatch(output, /Schema version: unknown/, "null version shows as unknown"); -} + test('five recent entries', () => { + const data: InspectData = { + schemaVersion: 2, + counts: { decisions: 5, requirements: 5, artifacts: 0 }, + recentDecisions: [ + { id: "D005", decision: "Dec 5", choice: "C5" }, + { id: "D004", decision: "Dec 4", choice: "C4" }, + { id: "D003", decision: "Dec 3", choice: "C3" }, + { id: "D002", decision: "Dec 2", choice: "C2" }, + { id: "D001", decision: "Dec 1", choice: "C1" }, + ], + recentRequirements: [ + { id: "R005", status: "active", description: "Req 5" }, + { id: "R004", status: "done", description: "Req 4" }, + { id: "R003", status: "active", description: "Req 3" }, + { id: "R002", status: "active", description: "Req 2" }, + { id: "R001", status: "done", description: "Req 1" }, + ], + }; -// ── formats up to 5 recent entries ── -console.log("# === gsd-inspect: five recent entries ==="); -{ - const data: InspectData = { - schemaVersion: 2, - counts: { decisions: 5, requirements: 5, artifacts: 0 }, - recentDecisions: [ - { id: "D005", decision: "Dec 5", choice: "C5" }, - { id: "D004", decision: "Dec 4", choice: "C4" }, - { id: "D003", decision: "Dec 3", choice: "C3" }, - { id: "D002", decision: "Dec 2", choice: "C2" }, - { id: "D001", decision: "Dec 1", choice: "C1" }, - ], - recentRequirements: [ - { id: "R005", status: "active", description: "Req 5" }, - { id: "R004", status: "done", description: "Req 4" }, - { id: "R003", status: "active", description: "Req 3" }, - { id: "R002", status: "active", description: "Req 2" }, - { id: "R001", status: "done", description: "Req 1" }, - ], - }; + const output = formatInspectOutput(data); - const output = formatInspectOutput(data); + for (let i = 1; i <= 5; i++) { + assert.match(output, new RegExp(`D00${i}: Dec ${i} → C${i}`), `contains D00${i}`); + } + for (let i = 1; i <= 5; i++) { + assert.match(output, new RegExp(`R00${i}`), `contains R00${i}`); + } + assert.match(output, /\[active\]/, "contains active status"); + assert.match(output, /\[done\]/, "contains done status"); + }); - for (let i = 1; i <= 5; i++) { - assertMatch(output, new RegExp(`D00${i}: Dec ${i} → C${i}`), `contains D00${i}`); - } - for (let i = 1; i <= 5; i++) { - assertMatch(output, new RegExp(`R00${i}`), `contains R00${i}`); - } - assertMatch(output, /\[active\]/, "contains active status"); - assertMatch(output, /\[done\]/, "contains done status"); -} + test('output format', () => { + const data: InspectData = { + schemaVersion: 2, + counts: { decisions: 1, requirements: 1, artifacts: 0 }, + recentDecisions: [{ id: "D001", decision: "Test", choice: "Yes" }], + recentRequirements: [{ id: "R001", status: "active", description: "Test req" }], + }; -// ── output is multiline text (not JSON) ── -console.log("# === gsd-inspect: output format ==="); -{ - const data: InspectData = { - schemaVersion: 2, - counts: { decisions: 1, requirements: 1, artifacts: 0 }, - recentDecisions: [{ id: "D001", decision: "Test", choice: "Yes" }], - recentRequirements: [{ id: "R001", status: "active", description: "Test req" }], - }; - - const output = formatInspectOutput(data); - const lines = output.split("\n"); - assertTrue(lines.length > 5, "output has multiple lines"); - assertTrue(!output.startsWith("{"), "output is not JSON"); -} - -report(); + const output = formatInspectOutput(data); + const lines = output.split("\n"); + assert.ok(lines.length > 5, "output has multiple lines"); + assert.ok(!output.startsWith("{"), "output is not JSON"); + }); +}); diff --git a/src/resources/extensions/gsd/tests/gsd-recover.test.ts b/src/resources/extensions/gsd/tests/gsd-recover.test.ts index 0f4df9cb7..4ee0a9c6f 100644 --- a/src/resources/extensions/gsd/tests/gsd-recover.test.ts +++ b/src/resources/extensions/gsd/tests/gsd-recover.test.ts @@ -1,3 +1,5 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; // gsd-recover.test.ts — Tests for the `gsd recover` recovery logic. // Verifies: populate DB → clear hierarchy → recover from markdown → state matches. @@ -22,10 +24,6 @@ import { } from '../gsd-db.ts'; import { migrateHierarchyToDb } from '../md-importer.ts'; import { deriveStateFromDb, invalidateStateCache } from '../state.ts'; -import { createTestContext } from './test-helpers.ts'; - -const { assertEq, assertTrue, report } = createTestContext(); - // ─── Fixture Helpers ─────────────────────────────────────────────────────── function createFixtureBase(): string { @@ -148,10 +146,8 @@ function clearHierarchyTables(): void { // ─── Tests ──────────────────────────────────────────────────────────────── -async function main() { - // ─── Test (a): Full recovery round-trip ───────────────────────────────── - console.log('\n=== recover: full round-trip (populate → clear → recover → verify) ==='); - { +describe('gsd-recover', async () => { + test('full round-trip (populate, clear, recover, verify)', async () => { const base = createFixtureBase(); try { // Set up markdown fixtures @@ -163,14 +159,14 @@ async function main() { // Step 1: Open DB and populate from markdown openDatabase(':memory:'); const counts1 = migrateHierarchyToDb(base); - assertEq(counts1.milestones, 1, 'round-trip: initial migration — 1 milestone'); - assertEq(counts1.slices, 2, 'round-trip: initial migration — 2 slices'); - assertTrue(counts1.tasks >= 5, 'round-trip: initial migration — at least 5 tasks'); + assert.deepStrictEqual(counts1.milestones, 1, 'round-trip: initial migration - 1 milestone'); + assert.deepStrictEqual(counts1.slices, 2, 'round-trip: initial migration - 2 slices'); + assert.ok(counts1.tasks >= 5, 'round-trip: initial migration - at least 5 tasks'); // Step 2: Capture state from DB before clearing invalidateStateCache(); const stateBefore = await deriveStateFromDb(base); - assertTrue(stateBefore.activeMilestone !== null, 'round-trip: state before has active milestone'); + assert.ok(stateBefore.activeMilestone !== null, 'round-trip: state before has active milestone'); const milestonesBefore = getAllMilestones(); const slicesBefore = getMilestoneSlices('M001'); const s01TasksBefore = getSliceTasks('M001', 'S01'); @@ -179,30 +175,30 @@ async function main() { // Step 3: Clear hierarchy tables clearHierarchyTables(); const milestonesAfterClear = getAllMilestones(); - assertEq(milestonesAfterClear.length, 0, 'round-trip: milestones cleared'); + assert.deepStrictEqual(milestonesAfterClear.length, 0, 'round-trip: milestones cleared'); // Step 4: Recover from markdown const counts2 = migrateHierarchyToDb(base); - assertEq(counts2.milestones, counts1.milestones, 'round-trip: recovery milestone count matches'); - assertEq(counts2.slices, counts1.slices, 'round-trip: recovery slice count matches'); - assertEq(counts2.tasks, counts1.tasks, 'round-trip: recovery task count matches'); + assert.deepStrictEqual(counts2.milestones, counts1.milestones, 'round-trip: recovery milestone count matches'); + assert.deepStrictEqual(counts2.slices, counts1.slices, 'round-trip: recovery slice count matches'); + assert.deepStrictEqual(counts2.tasks, counts1.tasks, 'round-trip: recovery task count matches'); // Step 5: Verify state matches invalidateStateCache(); const stateAfter = await deriveStateFromDb(base); - assertEq(stateAfter.phase, stateBefore.phase, 'round-trip: phase matches'); - assertEq( + assert.deepStrictEqual(stateAfter.phase, stateBefore.phase, 'round-trip: phase matches'); + assert.deepStrictEqual( stateAfter.activeMilestone?.id, stateBefore.activeMilestone?.id, 'round-trip: active milestone ID matches', ); - assertEq( + assert.deepStrictEqual( stateAfter.activeSlice?.id, stateBefore.activeSlice?.id, 'round-trip: active slice ID matches', ); - assertEq( + assert.deepStrictEqual( stateAfter.activeTask?.id, stateBefore.activeTask?.id, 'round-trip: active task ID matches', @@ -210,32 +206,30 @@ async function main() { // Verify row-level data matches const milestonesAfter = getAllMilestones(); - assertEq(milestonesAfter.length, milestonesBefore.length, 'round-trip: milestone row count'); - assertEq(milestonesAfter[0]?.id, milestonesBefore[0]?.id, 'round-trip: milestone ID'); - assertEq(milestonesAfter[0]?.title, milestonesBefore[0]?.title, 'round-trip: milestone title'); + assert.deepStrictEqual(milestonesAfter.length, milestonesBefore.length, 'round-trip: milestone row count'); + assert.deepStrictEqual(milestonesAfter[0]?.id, milestonesBefore[0]?.id, 'round-trip: milestone ID'); + assert.deepStrictEqual(milestonesAfter[0]?.title, milestonesBefore[0]?.title, 'round-trip: milestone title'); const slicesAfter = getMilestoneSlices('M001'); - assertEq(slicesAfter.length, slicesBefore.length, 'round-trip: slice row count'); - assertEq(slicesAfter[0]?.id, slicesBefore[0]?.id, 'round-trip: S01 ID'); - assertEq(slicesAfter[0]?.status, slicesBefore[0]?.status, 'round-trip: S01 status'); - assertEq(slicesAfter[1]?.id, slicesBefore[1]?.id, 'round-trip: S02 ID'); + assert.deepStrictEqual(slicesAfter.length, slicesBefore.length, 'round-trip: slice row count'); + assert.deepStrictEqual(slicesAfter[0]?.id, slicesBefore[0]?.id, 'round-trip: S01 ID'); + assert.deepStrictEqual(slicesAfter[0]?.status, slicesBefore[0]?.status, 'round-trip: S01 status'); + assert.deepStrictEqual(slicesAfter[1]?.id, slicesBefore[1]?.id, 'round-trip: S02 ID'); const s01TasksAfter = getSliceTasks('M001', 'S01'); - assertEq(s01TasksAfter.length, s01TasksBefore.length, 'round-trip: S01 task count'); + assert.deepStrictEqual(s01TasksAfter.length, s01TasksBefore.length, 'round-trip: S01 task count'); const s02TasksAfter = getSliceTasks('M001', 'S02'); - assertEq(s02TasksAfter.length, s02TasksBefore.length, 'round-trip: S02 task count'); + assert.deepStrictEqual(s02TasksAfter.length, s02TasksBefore.length, 'round-trip: S02 task count'); closeDatabase(); } finally { closeDatabase(); cleanup(base); } - } + }); - // ─── Test (a2): v8 planning columns populated after recovery ─────────── - console.log('\n=== recover: v8 planning columns populated ==='); - { + test('v8 planning columns populated', async () => { const base = createFixtureBase(); try { writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_M001); @@ -248,75 +242,70 @@ async function main() { // Milestone planning columns const milestone = getMilestone('M001'); - assertTrue(milestone !== null, 'v8: milestone exists'); - assertEq(milestone!.vision, 'Test recovery round-trip.', 'v8: milestone vision populated'); - assertTrue(milestone!.success_criteria.length >= 2, 'v8: milestone success_criteria has entries'); - assertEq(milestone!.success_criteria[0], 'All recovery tests pass', 'v8: first success criterion'); - assertTrue(milestone!.boundary_map_markdown.includes('Boundary Map'), 'v8: boundary_map_markdown populated'); - assertTrue(milestone!.boundary_map_markdown.includes('S01'), 'v8: boundary_map_markdown has S01'); + assert.ok(milestone !== null, 'v8: milestone exists'); + assert.deepStrictEqual(milestone!.vision, 'Test recovery round-trip.', 'v8: milestone vision populated'); + assert.ok(milestone!.success_criteria.length >= 2, 'v8: milestone success_criteria has entries'); + assert.deepStrictEqual(milestone!.success_criteria[0], 'All recovery tests pass', 'v8: first success criterion'); + assert.ok(milestone!.boundary_map_markdown.includes('Boundary Map'), 'v8: boundary_map_markdown populated'); + assert.ok(milestone!.boundary_map_markdown.includes('S01'), 'v8: boundary_map_markdown has S01'); // Tool-only fields left empty per D004 - assertEq(milestone!.key_risks.length, 0, 'v8: key_risks left empty (tool-only per D004)'); - assertEq(milestone!.requirement_coverage, '', 'v8: requirement_coverage left empty (tool-only per D004)'); + assert.deepStrictEqual(milestone!.key_risks.length, 0, 'v8: key_risks left empty (tool-only per D004)'); + assert.deepStrictEqual(milestone!.requirement_coverage, '', 'v8: requirement_coverage left empty (tool-only per D004)'); // Slice planning columns const sliceS01 = getSlice('M001', 'S01'); - assertTrue(sliceS01 !== null, 'v8: slice S01 exists'); - assertEq(sliceS01!.goal, 'Setup fixtures.', 'v8: S01 goal populated'); + assert.ok(sliceS01 !== null, 'v8: slice S01 exists'); + assert.deepStrictEqual(sliceS01!.goal, 'Setup fixtures.', 'v8: S01 goal populated'); const sliceS02 = getSlice('M001', 'S02'); - assertTrue(sliceS02 !== null, 'v8: slice S02 exists'); - assertEq(sliceS02!.goal, 'Build core.', 'v8: S02 goal populated'); + assert.ok(sliceS02 !== null, 'v8: slice S02 exists'); + assert.deepStrictEqual(sliceS02!.goal, 'Build core.', 'v8: S02 goal populated'); // Slice tool-only fields left empty per D004 - assertEq(sliceS01!.proof_level, '', 'v8: S01 proof_level left empty (tool-only per D004)'); + assert.deepStrictEqual(sliceS01!.proof_level, '', 'v8: S01 proof_level left empty (tool-only per D004)'); - // Task planning columns — S01/T01 + // Task planning columns - S01/T01 const taskS01T01 = getTask('M001', 'S01', 'T01'); - assertTrue(taskS01T01 !== null, 'v8: task S01/T01 exists'); - assertTrue(taskS01T01!.files.length >= 2, 'v8: S01/T01 files populated'); - assertTrue(taskS01T01!.files.includes('init.ts'), 'v8: S01/T01 files includes init.ts'); - assertTrue(taskS01T01!.files.includes('config.ts'), 'v8: S01/T01 files includes config.ts'); - assertEq(taskS01T01!.verify, '`node test-init.ts`', 'v8: S01/T01 verify populated'); + assert.ok(taskS01T01 !== null, 'v8: task S01/T01 exists'); + assert.ok(taskS01T01!.files.length >= 2, 'v8: S01/T01 files populated'); + assert.ok(taskS01T01!.files.includes('init.ts'), 'v8: S01/T01 files includes init.ts'); + assert.ok(taskS01T01!.files.includes('config.ts'), 'v8: S01/T01 files includes config.ts'); + assert.deepStrictEqual(taskS01T01!.verify, '`node test-init.ts`', 'v8: S01/T01 verify populated'); - // Task planning columns — S02/T02 + // Task planning columns - S02/T02 const taskS02T02 = getTask('M001', 'S02', 'T02'); - assertTrue(taskS02T02 !== null, 'v8: task S02/T02 exists'); - assertTrue(taskS02T02!.files.length >= 2, 'v8: S02/T02 files populated'); - assertTrue(taskS02T02!.files.includes('test-core.ts'), 'v8: S02/T02 files includes test-core.ts'); - assertEq(taskS02T02!.verify, '`npm test`', 'v8: S02/T02 verify populated'); + assert.ok(taskS02T02 !== null, 'v8: task S02/T02 exists'); + assert.ok(taskS02T02!.files.length >= 2, 'v8: S02/T02 files populated'); + assert.ok(taskS02T02!.files.includes('test-core.ts'), 'v8: S02/T02 files includes test-core.ts'); + assert.deepStrictEqual(taskS02T02!.verify, '`npm test`', 'v8: S02/T02 verify populated'); - // Task with no Files/Verify — not applicable since all fixtures now have them, - // but confirm a task from S02 has correct data const taskS02T03 = getTask('M001', 'S02', 'T03'); - assertTrue(taskS02T03 !== null, 'v8: task S02/T03 exists'); - assertTrue(taskS02T03!.files.includes('polish.ts'), 'v8: S02/T03 files includes polish.ts'); - assertEq(taskS02T03!.verify, '`node test-polish.ts`', 'v8: S02/T03 verify populated'); + assert.ok(taskS02T03 !== null, 'v8: task S02/T03 exists'); + assert.ok(taskS02T03!.files.includes('polish.ts'), 'v8: S02/T03 files includes polish.ts'); + assert.deepStrictEqual(taskS02T03!.verify, '`node test-polish.ts`', 'v8: S02/T03 verify populated'); // Diagnostic: v8 planning columns queryable via SQL const db = _getAdapter()!; const milestoneRow = db.prepare("SELECT vision, success_criteria, boundary_map_markdown FROM milestones WHERE id = 'M001'").get() as any; - assertTrue(milestoneRow.vision.length > 0, 'v8-diag: vision column queryable'); - assertTrue(milestoneRow.boundary_map_markdown.length > 0, 'v8-diag: boundary_map_markdown column queryable'); + assert.ok(milestoneRow.vision.length > 0, 'v8-diag: vision column queryable'); + assert.ok(milestoneRow.boundary_map_markdown.length > 0, 'v8-diag: boundary_map_markdown column queryable'); const sliceRow = db.prepare("SELECT goal FROM slices WHERE milestone_id = 'M001' AND id = 'S01'").get() as any; - assertTrue(sliceRow.goal.length > 0, 'v8-diag: goal column queryable'); + assert.ok(sliceRow.goal.length > 0, 'v8-diag: goal column queryable'); const taskRow = db.prepare("SELECT files, verify FROM tasks WHERE milestone_id = 'M001' AND slice_id = 'S01' AND id = 'T01'").get() as any; - assertTrue(taskRow.files.length > 2, 'v8-diag: files column queryable (JSON array)'); - assertTrue(taskRow.verify.length > 0, 'v8-diag: verify column queryable'); + assert.ok(taskRow.files.length > 2, 'v8-diag: files column queryable (JSON array)'); + assert.ok(taskRow.verify.length > 0, 'v8-diag: verify column queryable'); closeDatabase(); } finally { closeDatabase(); cleanup(base); } - } + }); - - // ─── Test (b): Idempotent recovery — double recover ──────────────────── - console.log('\n=== recover: idempotent — double recovery produces same state ==='); - { + test('idempotent - double recovery produces same state', async () => { const base = createFixtureBase(); try { writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_M001); @@ -337,18 +326,18 @@ async function main() { invalidateStateCache(); const state2 = await deriveStateFromDb(base); - assertEq(state2.phase, state1.phase, 'idempotent: phase matches'); - assertEq( + assert.deepStrictEqual(state2.phase, state1.phase, 'idempotent: phase matches'); + assert.deepStrictEqual( state2.activeMilestone?.id, state1.activeMilestone?.id, 'idempotent: active milestone matches', ); - assertEq( + assert.deepStrictEqual( state2.activeSlice?.id, state1.activeSlice?.id, 'idempotent: active slice matches', ); - assertEq( + assert.deepStrictEqual( state2.activeTask?.id, state1.activeTask?.id, 'idempotent: active task matches', @@ -359,11 +348,9 @@ async function main() { closeDatabase(); cleanup(base); } - } + }); - // ─── Test (c): Recovery preserves non-hierarchy data ─────────────────── - console.log('\n=== recover: preserves decisions/requirements ==='); - { + test('preserves decisions/requirements', async () => { const base = createFixtureBase(); try { writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_M001); @@ -402,35 +389,33 @@ async function main() { // Verify decisions and requirements survived const decisions = db.prepare('SELECT * FROM decisions').all(); - assertEq(decisions.length, 1, 'preserve: decision survives clear'); - assertEq((decisions[0] as any).id, 'D001', 'preserve: decision ID intact'); + assert.deepStrictEqual(decisions.length, 1, 'preserve: decision survives clear'); + assert.deepStrictEqual((decisions[0] as any).id, 'D001', 'preserve: decision ID intact'); const requirements = db.prepare('SELECT * FROM requirements').all(); - assertEq(requirements.length, 1, 'preserve: requirement survives clear'); - assertEq((requirements[0] as any).id, 'R001', 'preserve: requirement ID intact'); + assert.deepStrictEqual(requirements.length, 1, 'preserve: requirement survives clear'); + assert.deepStrictEqual((requirements[0] as any).id, 'R001', 'preserve: requirement ID intact'); // Recover hierarchy migrateHierarchyToDb(base); const milestones = getAllMilestones(); - assertTrue(milestones.length > 0, 'preserve: milestones recovered after clear'); + assert.ok(milestones.length > 0, 'preserve: milestones recovered after clear'); // Verify non-hierarchy data still intact after recovery const decisionsAfter = db.prepare('SELECT * FROM decisions').all(); - assertEq(decisionsAfter.length, 1, 'preserve: decision still present after recovery'); + assert.deepStrictEqual(decisionsAfter.length, 1, 'preserve: decision still present after recovery'); closeDatabase(); } finally { closeDatabase(); cleanup(base); } - } + }); - // ─── Test (d): Recovery from empty markdown dir ──────────────────────── - console.log('\n=== recover: empty milestones dir ==='); - { + test('empty milestones dir', async () => { const base = createFixtureBase(); try { - // No milestones written — just the empty dir + // No milestones written - just the empty dir openDatabase(':memory:'); // Pre-populate to simulate existing state @@ -439,24 +424,17 @@ async function main() { // Clear and recover from empty clearHierarchyTables(); const counts = migrateHierarchyToDb(base); - assertEq(counts.milestones, 0, 'empty: zero milestones recovered'); - assertEq(counts.slices, 0, 'empty: zero slices recovered'); - assertEq(counts.tasks, 0, 'empty: zero tasks recovered'); + assert.deepStrictEqual(counts.milestones, 0, 'empty: zero milestones recovered'); + assert.deepStrictEqual(counts.slices, 0, 'empty: zero slices recovered'); + assert.deepStrictEqual(counts.tasks, 0, 'empty: zero tasks recovered'); const all = getAllMilestones(); - assertEq(all.length, 0, 'empty: no milestones in DB after recovery'); + assert.deepStrictEqual(all.length, 0, 'empty: no milestones in DB after recovery'); closeDatabase(); } finally { closeDatabase(); cleanup(base); } - } - - report(); -} - -main().catch((error) => { - console.error(error); - process.exit(1); + }); }); diff --git a/src/resources/extensions/gsd/tests/gsd-tools.test.ts b/src/resources/extensions/gsd/tests/gsd-tools.test.ts index 12f8b4168..ef1dedd11 100644 --- a/src/resources/extensions/gsd/tests/gsd-tools.test.ts +++ b/src/resources/extensions/gsd/tests/gsd-tools.test.ts @@ -1,9 +1,10 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; // gsd-tools — Structured LLM tool tests // // Tests the three registered tools: gsd_decision_save, gsd_requirement_update, gsd_summary_save. // Each tool is tested via direct function invocation against an in-memory DB. -import { createTestContext } from './test-helpers.ts'; import * as path from 'node:path'; import * as os from 'node:os'; import * as fs from 'node:fs'; @@ -25,8 +26,6 @@ import { } from '../db-writer.ts'; import type { Requirement } from '../types.ts'; -const { assertEq, assertTrue, assertMatch, report } = createTestContext(); - // ═══════════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════════ @@ -46,281 +45,249 @@ function cleanupDir(dir: string): void { /** * Simulate tool execute by calling the underlying DB functions directly. * The actual tool registration happens in index.ts; here we test the - * execute logic pattern: check DB → call writer → return result. + * execute logic pattern: check DB -> call writer -> return result. */ -// ═══════════════════════════════════════════════════════════════════════════ -// gsd_decision_save tool tests -// ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n── gsd_decision_save ──'); - -{ - const tmpDir = makeTmpDir(); - try { - const dbPath = path.join(tmpDir, '.gsd', 'gsd.db'); - openDatabase(dbPath); - assertTrue(isDbAvailable(), 'DB should be available after open'); - - // (a) Decision tool creates DB row + returns new ID - const result = await saveDecisionToDb( - { - scope: 'architecture', - decision: 'Use SQLite for metadata', - choice: 'SQLite', - rationale: 'Sync API fits the CLI model', - revisable: 'Yes', - when_context: 'M001', - }, - tmpDir, - ); - - assertEq(result.id, 'D001', 'First decision should be D001'); - - // Verify DB row exists - const row = getDecisionById('D001'); - assertTrue(row !== null, 'Decision D001 should exist in DB'); - assertEq(row!.scope, 'architecture', 'Decision scope should match'); - assertEq(row!.decision, 'Use SQLite for metadata', 'Decision text should match'); - assertEq(row!.choice, 'SQLite', 'Decision choice should match'); - - // Verify DECISIONS.md was generated - const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md'); - assertTrue(fs.existsSync(mdPath), 'DECISIONS.md should be created'); - const mdContent = fs.readFileSync(mdPath, 'utf-8'); - assertTrue(mdContent.includes('D001'), 'DECISIONS.md should contain D001'); - assertTrue(mdContent.includes('SQLite'), 'DECISIONS.md should contain choice'); - - // (e) Decision tool auto-assigns correct next ID - const result2 = await saveDecisionToDb( - { - scope: 'testing', - decision: 'Test runner', - choice: 'vitest', - rationale: 'Fast and ESM-native', - }, - tmpDir, - ); - assertEq(result2.id, 'D002', 'Second decision should be D002'); - - const result3 = await saveDecisionToDb( - { - scope: 'CI', - decision: 'CI platform', - choice: 'GitHub Actions', - rationale: 'Integrated with repo', - }, - tmpDir, - ); - assertEq(result3.id, 'D003', 'Third decision should be D003'); - - closeDatabase(); - } finally { - cleanupDir(tmpDir); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// gsd_requirement_update tool tests -// ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n── gsd_requirement_update ──'); - -{ - const tmpDir = makeTmpDir(); - try { - const dbPath = path.join(tmpDir, '.gsd', 'gsd.db'); - openDatabase(dbPath); - - // Seed a requirement - const seedReq: Requirement = { - id: 'R001', - class: 'functional', - status: 'active', - description: 'Must support SQLite storage', - why: 'Structured data needs', - source: 'design', - primary_owner: 'S03', - supporting_slices: '', - validation: '', - notes: '', - full_content: '', - superseded_by: null, - }; - upsertRequirement(seedReq); - - // (b) Requirement update tool modifies existing requirement - await updateRequirementInDb( - 'R001', - { status: 'validated', validation: 'Unit tests pass', notes: 'Verified in S06' }, - tmpDir, - ); - - const updated = getRequirementById('R001'); - assertTrue(updated !== null, 'R001 should still exist'); - assertEq(updated!.status, 'validated', 'Status should be updated'); - assertEq(updated!.validation, 'Unit tests pass', 'Validation should be updated'); - assertEq(updated!.notes, 'Verified in S06', 'Notes should be updated'); - // Original fields preserved - assertEq(updated!.description, 'Must support SQLite storage', 'Description should be preserved'); - assertEq(updated!.primary_owner, 'S03', 'Primary owner should be preserved'); - - // Verify REQUIREMENTS.md was generated - const mdPath = path.join(tmpDir, '.gsd', 'REQUIREMENTS.md'); - assertTrue(fs.existsSync(mdPath), 'REQUIREMENTS.md should be created'); - const mdContent = fs.readFileSync(mdPath, 'utf-8'); - assertTrue(mdContent.includes('R001'), 'REQUIREMENTS.md should contain R001'); - assertTrue(mdContent.includes('validated'), 'REQUIREMENTS.md should reflect updated status'); - - // Updating non-existent requirement throws - let threwForMissing = false; +describe('gsd-tools', () => { + test('gsd_decision_save', async () => { + const tmpDir = makeTmpDir(); try { - await updateRequirementInDb('R999', { status: 'deferred' }, tmpDir); - } catch (err) { - threwForMissing = true; - assertTrue( - (err as Error).message.includes('R999'), - 'Error should mention the missing requirement ID', + const dbPath = path.join(tmpDir, '.gsd', 'gsd.db'); + openDatabase(dbPath); + assert.ok(isDbAvailable(), 'DB should be available after open'); + + // (a) Decision tool creates DB row + returns new ID + const result = await saveDecisionToDb( + { + scope: 'architecture', + decision: 'Use SQLite for metadata', + choice: 'SQLite', + rationale: 'Sync API fits the CLI model', + revisable: 'Yes', + when_context: 'M001', + }, + tmpDir, ); + + assert.deepStrictEqual(result.id, 'D001', 'First decision should be D001'); + + // Verify DB row exists + const row = getDecisionById('D001'); + assert.ok(row !== null, 'Decision D001 should exist in DB'); + assert.deepStrictEqual(row!.scope, 'architecture', 'Decision scope should match'); + assert.deepStrictEqual(row!.decision, 'Use SQLite for metadata', 'Decision text should match'); + assert.deepStrictEqual(row!.choice, 'SQLite', 'Decision choice should match'); + + // Verify DECISIONS.md was generated + const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md'); + assert.ok(fs.existsSync(mdPath), 'DECISIONS.md should be created'); + const mdContent = fs.readFileSync(mdPath, 'utf-8'); + assert.ok(mdContent.includes('D001'), 'DECISIONS.md should contain D001'); + assert.ok(mdContent.includes('SQLite'), 'DECISIONS.md should contain choice'); + + // (e) Decision tool auto-assigns correct next ID + const result2 = await saveDecisionToDb( + { + scope: 'testing', + decision: 'Test runner', + choice: 'vitest', + rationale: 'Fast and ESM-native', + }, + tmpDir, + ); + assert.deepStrictEqual(result2.id, 'D002', 'Second decision should be D002'); + + const result3 = await saveDecisionToDb( + { + scope: 'CI', + decision: 'CI platform', + choice: 'GitHub Actions', + rationale: 'Integrated with repo', + }, + tmpDir, + ); + assert.deepStrictEqual(result3.id, 'D003', 'Third decision should be D003'); + + closeDatabase(); + } finally { + cleanupDir(tmpDir); } - assertTrue(threwForMissing, 'Should throw for non-existent requirement'); + }); - closeDatabase(); - } finally { - cleanupDir(tmpDir); - } -} + test('gsd_requirement_update', async () => { + const tmpDir = makeTmpDir(); + try { + const dbPath = path.join(tmpDir, '.gsd', 'gsd.db'); + openDatabase(dbPath); -// ═══════════════════════════════════════════════════════════════════════════ -// gsd_summary_save tool tests -// ═══════════════════════════════════════════════════════════════════════════ + // Seed a requirement + const seedReq: Requirement = { + id: 'R001', + class: 'functional', + status: 'active', + description: 'Must support SQLite storage', + why: 'Structured data needs', + source: 'design', + primary_owner: 'S03', + supporting_slices: '', + validation: '', + notes: '', + full_content: '', + superseded_by: null, + }; + upsertRequirement(seedReq); -console.log('\n── gsd_summary_save ──'); + // (b) Requirement update tool modifies existing requirement + await updateRequirementInDb( + 'R001', + { status: 'validated', validation: 'Unit tests pass', notes: 'Verified in S06' }, + tmpDir, + ); -{ - const tmpDir = makeTmpDir(); - try { - const dbPath = path.join(tmpDir, '.gsd', 'gsd.db'); - openDatabase(dbPath); + const updated = getRequirementById('R001'); + assert.ok(updated !== null, 'R001 should still exist'); + assert.deepStrictEqual(updated!.status, 'validated', 'Status should be updated'); + assert.deepStrictEqual(updated!.validation, 'Unit tests pass', 'Validation should be updated'); + assert.deepStrictEqual(updated!.notes, 'Verified in S06', 'Notes should be updated'); + // Original fields preserved + assert.deepStrictEqual(updated!.description, 'Must support SQLite storage', 'Description should be preserved'); + assert.deepStrictEqual(updated!.primary_owner, 'S03', 'Primary owner should be preserved'); - // (c) Summary tool creates artifact row - await saveArtifactToDb( - { - path: 'milestones/M001/slices/S01/S01-SUMMARY.md', - artifact_type: 'SUMMARY', - content: '# S01 Summary\n\nThis is a test summary.', - milestone_id: 'M001', - slice_id: 'S01', - }, - tmpDir, - ); + // Verify REQUIREMENTS.md was generated + const mdPath = path.join(tmpDir, '.gsd', 'REQUIREMENTS.md'); + assert.ok(fs.existsSync(mdPath), 'REQUIREMENTS.md should be created'); + const mdContent = fs.readFileSync(mdPath, 'utf-8'); + assert.ok(mdContent.includes('R001'), 'REQUIREMENTS.md should contain R001'); + assert.ok(mdContent.includes('validated'), 'REQUIREMENTS.md should reflect updated status'); - // Verify artifact in DB - const adapter = _getAdapter(); - assertTrue(adapter !== null, 'Adapter should be available'); - const rows = adapter!.prepare( - "SELECT * FROM artifacts WHERE path = 'milestones/M001/slices/S01/S01-SUMMARY.md'", - ).all(); - assertEq(rows.length, 1, 'Should have 1 artifact row'); - assertEq(rows[0]['artifact_type'] as string, 'SUMMARY', 'Artifact type should be SUMMARY'); - assertEq(rows[0]['milestone_id'] as string, 'M001', 'Milestone ID should match'); - assertEq(rows[0]['slice_id'] as string, 'S01', 'Slice ID should match'); + // Updating non-existent requirement throws + let threwForMissing = false; + try { + await updateRequirementInDb('R999', { status: 'deferred' }, tmpDir); + } catch (err) { + threwForMissing = true; + assert.ok( + (err as Error).message.includes('R999'), + 'Error should mention the missing requirement ID', + ); + } + assert.ok(threwForMissing, 'Should throw for non-existent requirement'); - // Verify file was written to disk - const filePath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'S01-SUMMARY.md'); - assertTrue(fs.existsSync(filePath), 'Summary file should be written to disk'); - const fileContent = fs.readFileSync(filePath, 'utf-8'); - assertTrue(fileContent.includes('S01 Summary'), 'File should contain summary content'); + closeDatabase(); + } finally { + cleanupDir(tmpDir); + } + }); - // Test milestone-level artifact (no slice_id) - await saveArtifactToDb( - { - path: 'milestones/M001/M001-CONTEXT.md', - artifact_type: 'CONTEXT', - content: '# M001 Context\n\nContext notes.', - milestone_id: 'M001', - }, - tmpDir, - ); + test('gsd_summary_save', async () => { + const tmpDir = makeTmpDir(); + try { + const dbPath = path.join(tmpDir, '.gsd', 'gsd.db'); + openDatabase(dbPath); - const mFilePath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md'); - assertTrue(fs.existsSync(mFilePath), 'Milestone-level artifact file should be created'); + // (c) Summary tool creates artifact row + await saveArtifactToDb( + { + path: 'milestones/M001/slices/S01/S01-SUMMARY.md', + artifact_type: 'SUMMARY', + content: '# S01 Summary\n\nThis is a test summary.', + milestone_id: 'M001', + slice_id: 'S01', + }, + tmpDir, + ); - // Test task-level artifact - await saveArtifactToDb( - { - path: 'milestones/M001/slices/S01/tasks/T01-SUMMARY.md', - artifact_type: 'SUMMARY', - content: '# T01 Summary\n\nTask summary.', - milestone_id: 'M001', - slice_id: 'S01', - task_id: 'T01', - }, - tmpDir, - ); + // Verify artifact in DB + const adapter = _getAdapter(); + assert.ok(adapter !== null, 'Adapter should be available'); + const rows = adapter!.prepare( + "SELECT * FROM artifacts WHERE path = 'milestones/M001/slices/S01/S01-SUMMARY.md'", + ).all(); + assert.deepStrictEqual(rows.length, 1, 'Should have 1 artifact row'); + assert.deepStrictEqual(rows[0]['artifact_type'] as string, 'SUMMARY', 'Artifact type should be SUMMARY'); + assert.deepStrictEqual(rows[0]['milestone_id'] as string, 'M001', 'Milestone ID should match'); + assert.deepStrictEqual(rows[0]['slice_id'] as string, 'S01', 'Slice ID should match'); - const tFilePath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks', 'T01-SUMMARY.md'); - assertTrue(fs.existsSync(tFilePath), 'Task-level artifact file should be created'); + // Verify file was written to disk + const filePath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'S01-SUMMARY.md'); + assert.ok(fs.existsSync(filePath), 'Summary file should be written to disk'); + const fileContent = fs.readFileSync(filePath, 'utf-8'); + assert.ok(fileContent.includes('S01 Summary'), 'File should contain summary content'); - closeDatabase(); - } finally { - cleanupDir(tmpDir); - } -} + // Test milestone-level artifact (no slice_id) + await saveArtifactToDb( + { + path: 'milestones/M001/M001-CONTEXT.md', + artifact_type: 'CONTEXT', + content: '# M001 Context\n\nContext notes.', + milestone_id: 'M001', + }, + tmpDir, + ); -// ═══════════════════════════════════════════════════════════════════════════ -// DB unavailable error paths -// ═══════════════════════════════════════════════════════════════════════════ + const mFilePath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md'); + assert.ok(fs.existsSync(mFilePath), 'Milestone-level artifact file should be created'); -console.log('\n── DB unavailable error paths ──'); + // Test task-level artifact + await saveArtifactToDb( + { + path: 'milestones/M001/slices/S01/tasks/T01-SUMMARY.md', + artifact_type: 'SUMMARY', + content: '# T01 Summary\n\nTask summary.', + milestone_id: 'M001', + slice_id: 'S01', + task_id: 'T01', + }, + tmpDir, + ); -{ - // (d) All tools return isError when DB unavailable - // Close any open DB and don't open a new one - try { closeDatabase(); } catch { /* already closed */ } + const tFilePath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks', 'T01-SUMMARY.md'); + assert.ok(fs.existsSync(tFilePath), 'Task-level artifact file should be created'); - // isDbAvailable() should return false - assertTrue(!isDbAvailable(), 'DB should be unavailable after close'); + closeDatabase(); + } finally { + cleanupDir(tmpDir); + } + }); - // nextDecisionId degrades gracefully - const fallbackId = await nextDecisionId(); - assertEq(fallbackId, 'D001', 'nextDecisionId should return D001 when DB unavailable'); -} + test('DB unavailable error paths', async () => { + // (d) All tools return isError when DB unavailable + // Close any open DB and don't open a new one + try { closeDatabase(); } catch { /* already closed */ } -// ═══════════════════════════════════════════════════════════════════════════ -// Tool result format verification -// ═══════════════════════════════════════════════════════════════════════════ + // isDbAvailable() should return false + assert.ok(!isDbAvailable(), 'DB should be unavailable after close'); -console.log('\n── Tool result format ──'); + // nextDecisionId degrades gracefully + const fallbackId = await nextDecisionId(); + assert.deepStrictEqual(fallbackId, 'D001', 'nextDecisionId should return D001 when DB unavailable'); + }); -{ - const tmpDir = makeTmpDir(); - try { - const dbPath = path.join(tmpDir, '.gsd', 'gsd.db'); - openDatabase(dbPath); + test('Tool result format', async () => { + const tmpDir = makeTmpDir(); + try { + const dbPath = path.join(tmpDir, '.gsd', 'gsd.db'); + openDatabase(dbPath); - // Verify result follows AgentToolResult interface: {content: [{type: "text", text}], details} - const result = await saveDecisionToDb( - { - scope: 'format-test', - decision: 'Test format', - choice: 'TypeBox', - rationale: 'Schema validation', - }, - tmpDir, - ); + // Verify result follows AgentToolResult interface: {content: [{type: "text", text}], details} + const result = await saveDecisionToDb( + { + scope: 'format-test', + decision: 'Test format', + choice: 'TypeBox', + rationale: 'Schema validation', + }, + tmpDir, + ); - // The saveDecisionToDb returns {id} — the tool wrapping adds the AgentToolResult shape. - // Verify the raw function returns the expected shape. - assertTrue(typeof result.id === 'string', 'saveDecisionToDb should return {id: string}'); - assertMatch(result.id, /^D\d{3}$/, 'ID should match DXXX pattern'); + // The saveDecisionToDb returns {id} - the tool wrapping adds the AgentToolResult shape. + // Verify the raw function returns the expected shape. + assert.ok(typeof result.id === 'string', 'saveDecisionToDb should return {id: string}'); + assert.match(result.id, /^D\d{3}$/, 'ID should match DXXX pattern'); - closeDatabase(); - } finally { - cleanupDir(tmpDir); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ - -report(); + closeDatabase(); + } finally { + cleanupDir(tmpDir); + } + }); +});