625 lines
20 KiB
TypeScript
625 lines
20 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { test } from "node:test";
|
|
import {
|
|
migrateFromMarkdown,
|
|
parseDecisionsTable,
|
|
parseRequirementsSections,
|
|
} from "../md-importer.ts";
|
|
import {
|
|
_getAdapter,
|
|
closeDatabase,
|
|
getActiveDecisions,
|
|
getDecisionById,
|
|
getRequirementById,
|
|
openDatabase,
|
|
} from "../sf-db.ts";
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Fixtures
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const DECISIONS_MD = `# Decisions Register
|
|
|
|
| # | When | Scope | Decision | Choice | Rationale | Revisable? |
|
|
|---|------|-------|----------|--------|-----------|------------|
|
|
| D001 | M001 | library | SQLite library | better-sqlite3 | Sync API | No |
|
|
| D002 | M001 | arch | DB location | .sf/sf.db | Derived state | No |
|
|
| D010 | M001/S01 | library | Provider strategy (amends D001) | node:sqlite fallback | Zero deps | No |
|
|
| D020 | M001/S02 | library | Importer approach (amends D010) | Direct parse | Simple | Yes |
|
|
`;
|
|
|
|
const REQUIREMENTS_MD = `# Requirements
|
|
|
|
## Active
|
|
|
|
### R001 — SQLite DB layer
|
|
- Class: core-capability
|
|
- Status: active
|
|
- Description: A SQLite database with typed wrappers
|
|
- Why it matters: Foundation for storage
|
|
- Source: user
|
|
- Primary owning slice: M001/S01
|
|
- Supporting slices: none
|
|
- Validation: unmapped
|
|
- Notes: WAL mode enabled
|
|
|
|
### R002 — Graceful fallback
|
|
- Class: failure-visibility
|
|
- Status: active
|
|
- Description: Falls back to markdown if SQLite unavailable
|
|
- Why it matters: Must not break on exotic platforms
|
|
- Source: user
|
|
- Primary owning slice: M001/S01
|
|
- Supporting slices: M001/S03
|
|
- Validation: unmapped
|
|
- Notes: Transparent fallback
|
|
|
|
## Validated
|
|
|
|
### R017 — Sub-5ms query latency
|
|
- Validated by: M001/S01
|
|
- Proof: 50 decisions queried in 0.62ms
|
|
|
|
## Deferred
|
|
|
|
### R030 — Vector search
|
|
- Class: differentiator
|
|
- Status: deferred
|
|
- Description: Rust crate for embeddings
|
|
- Why it matters: Semantic retrieval
|
|
- Source: user
|
|
- Primary owning slice: none
|
|
- Supporting slices: none
|
|
- Validation: unmapped
|
|
- Notes: Deferred to M002
|
|
|
|
## Out of Scope
|
|
|
|
### R040 — Web UI
|
|
- Class: anti-feature
|
|
- Status: out-of-scope
|
|
- Description: No web interface for DB
|
|
- Why it matters: Prevents scope creep
|
|
- Source: user
|
|
- Primary owning slice: none
|
|
- Supporting slices: none
|
|
- Validation: n/a
|
|
- Notes: Excluded in PRD
|
|
`;
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Helpers
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function createFixtureTree(baseDir: string): void {
|
|
const sf = path.join(baseDir, ".sf");
|
|
fs.mkdirSync(sf, { recursive: true });
|
|
fs.writeFileSync(path.join(sf, "DECISIONS.md"), DECISIONS_MD);
|
|
fs.writeFileSync(path.join(sf, "REQUIREMENTS.md"), REQUIREMENTS_MD);
|
|
fs.writeFileSync(
|
|
path.join(sf, "PROJECT.md"),
|
|
"# Test Project\nA test project.",
|
|
);
|
|
|
|
// Create milestone hierarchy
|
|
const m001 = path.join(sf, "milestones", "M001");
|
|
fs.mkdirSync(m001, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(m001, "M001-ROADMAP.md"),
|
|
"# M001 Roadmap\nTest roadmap content.",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(m001, "M001-CONTEXT.md"),
|
|
"# M001 Context\nTest context.",
|
|
);
|
|
|
|
// Create slice
|
|
const s01 = path.join(m001, "slices", "S01");
|
|
fs.mkdirSync(s01, { recursive: true });
|
|
fs.writeFileSync(path.join(s01, "S01-PLAN.md"), "# S01 Plan\nTest plan.");
|
|
fs.writeFileSync(
|
|
path.join(s01, "S01-SUMMARY.md"),
|
|
"# S01 Summary\nTest summary.",
|
|
);
|
|
|
|
// Create tasks
|
|
const tasks = path.join(s01, "tasks");
|
|
fs.mkdirSync(tasks, { recursive: true });
|
|
fs.writeFileSync(path.join(tasks, "T01-PLAN.md"), "# T01 Plan\nTask plan.");
|
|
fs.writeFileSync(
|
|
path.join(tasks, "T01-SUMMARY.md"),
|
|
"# T01 Summary\nTask summary.",
|
|
);
|
|
}
|
|
|
|
function cleanupDir(dir: string): void {
|
|
try {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
} catch {
|
|
// best effort
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// md-importer: parseDecisionsTable
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
test("md-importer: parseDecisionsTable", () => {
|
|
const decisions = parseDecisionsTable(DECISIONS_MD);
|
|
assert.deepStrictEqual(decisions.length, 4, "should parse 4 decisions");
|
|
assert.deepStrictEqual(
|
|
decisions[0].id,
|
|
"D001",
|
|
"first decision should be D001",
|
|
);
|
|
assert.deepStrictEqual(
|
|
decisions[0].decision,
|
|
"SQLite library",
|
|
"D001 decision text",
|
|
);
|
|
assert.deepStrictEqual(decisions[0].choice, "better-sqlite3", "D001 choice");
|
|
assert.deepStrictEqual(decisions[0].scope, "library", "D001 scope");
|
|
assert.deepStrictEqual(decisions[0].revisable, "No", "D001 revisable");
|
|
});
|
|
|
|
test("md-importer: supersession detection", () => {
|
|
const decisions = parseDecisionsTable(DECISIONS_MD);
|
|
|
|
// D010 amends D001 → D001.superseded_by = D010
|
|
const d001 = decisions.find((d) => d.id === "D001");
|
|
assert.deepStrictEqual(
|
|
d001?.superseded_by,
|
|
"D010",
|
|
"D001 should be superseded by D010",
|
|
);
|
|
|
|
// D020 amends D010 → D010.superseded_by = D020
|
|
const d010 = decisions.find((d) => d.id === "D010");
|
|
assert.deepStrictEqual(
|
|
d010?.superseded_by,
|
|
"D020",
|
|
"D010 should be superseded by D020",
|
|
);
|
|
|
|
// D002 is not amended
|
|
const d002 = decisions.find((d) => d.id === "D002");
|
|
assert.deepStrictEqual(
|
|
d002?.superseded_by,
|
|
null,
|
|
"D002 should not be superseded",
|
|
);
|
|
|
|
// D020 is the latest in chain, not superseded
|
|
const d020 = decisions.find((d) => d.id === "D020");
|
|
assert.deepStrictEqual(
|
|
d020?.superseded_by,
|
|
null,
|
|
"D020 should not be superseded",
|
|
);
|
|
});
|
|
|
|
test("md-importer: malformed/empty rows skipped", () => {
|
|
const malformedInput = `# Decisions
|
|
|
|
| # | When | Scope | Decision | Choice | Rationale | Revisable? |
|
|
|---|------|-------|----------|--------|-----------|------------|
|
|
| D001 | M001 | lib | Pick lib | sqlite | Fast | No |
|
|
| not-a-decision | bad | x | y | z | w | q |
|
|
| | | | | | | |
|
|
| D003 | M001 | arch | Config | JSON | Simple | Yes |
|
|
`;
|
|
const decisions = parseDecisionsTable(malformedInput);
|
|
assert.deepStrictEqual(
|
|
decisions.length,
|
|
2,
|
|
"should skip rows without D-prefix IDs",
|
|
);
|
|
assert.deepStrictEqual(decisions[0].id, "D001", "first valid row");
|
|
assert.deepStrictEqual(
|
|
decisions[1].id,
|
|
"D003",
|
|
"second valid row (skipping malformed)",
|
|
);
|
|
});
|
|
|
|
test("md-importer: made_by backward compatibility (old 7-column format)", () => {
|
|
const decisions = parseDecisionsTable(DECISIONS_MD);
|
|
// Old format has no Made By column — should default to 'agent'
|
|
for (const d of decisions) {
|
|
assert.deepStrictEqual(
|
|
d.made_by,
|
|
"agent",
|
|
`${d.id} made_by defaults to agent for legacy format`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("md-importer: made_by column parsing (new 8-column format)", () => {
|
|
const newFormatMd = `# Decisions Register
|
|
|
|
| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |
|
|
|---|------|-------|----------|--------|-----------|------------|---------|
|
|
| D001 | M001 | library | SQLite library | better-sqlite3 | Sync API | No | human |
|
|
| D002 | M001 | arch | DB location | .sf/sf.db | Derived state | No | agent |
|
|
| D003 | M002 | impl | Config format | JSON | Simple | Yes | collaborative |
|
|
| D004 | M002 | impl | Cache strategy | LRU | Predictable | No | bogus |
|
|
`;
|
|
const decisions = parseDecisionsTable(newFormatMd);
|
|
assert.deepStrictEqual(
|
|
decisions.length,
|
|
4,
|
|
"should parse 4 decisions with new format",
|
|
);
|
|
assert.deepStrictEqual(decisions[0].made_by, "human", "D001 made_by = human");
|
|
assert.deepStrictEqual(decisions[1].made_by, "agent", "D002 made_by = agent");
|
|
assert.deepStrictEqual(
|
|
decisions[2].made_by,
|
|
"collaborative",
|
|
"D003 made_by = collaborative",
|
|
);
|
|
assert.deepStrictEqual(
|
|
decisions[3].made_by,
|
|
"agent",
|
|
"D004 invalid made_by defaults to agent",
|
|
);
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// md-importer: parseRequirementsSections
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
test("md-importer: parseRequirementsSections", () => {
|
|
const reqs = parseRequirementsSections(REQUIREMENTS_MD);
|
|
assert.deepStrictEqual(reqs.length, 5, "should parse 5 unique requirements");
|
|
|
|
const r001 = reqs.find((r) => r.id === "R001");
|
|
assert.ok(!!r001, "R001 should exist");
|
|
assert.deepStrictEqual(r001?.class, "core-capability", "R001 class");
|
|
assert.deepStrictEqual(r001?.status, "active", "R001 status");
|
|
assert.deepStrictEqual(
|
|
r001?.description,
|
|
"A SQLite database with typed wrappers",
|
|
"R001 description",
|
|
);
|
|
assert.deepStrictEqual(r001?.why, "Foundation for storage", "R001 why");
|
|
assert.deepStrictEqual(r001?.source, "user", "R001 source");
|
|
assert.deepStrictEqual(r001?.primary_owner, "M001/S01", "R001 primary_owner");
|
|
assert.deepStrictEqual(
|
|
r001?.supporting_slices,
|
|
"none",
|
|
"R001 supporting_slices",
|
|
);
|
|
assert.deepStrictEqual(r001?.validation, "unmapped", "R001 validation");
|
|
assert.deepStrictEqual(r001?.notes, "WAL mode enabled", "R001 notes");
|
|
assert.ok(
|
|
r001?.full_content?.includes("### R001") ?? false,
|
|
"R001 full_content should have heading",
|
|
);
|
|
|
|
// Validated section — R017 (abbreviated format with "Validated by" / "Proof" bullets)
|
|
const r017 = reqs.find((r) => r.id === "R017");
|
|
assert.ok(!!r017, "R017 should exist");
|
|
assert.deepStrictEqual(
|
|
r017?.status,
|
|
"validated",
|
|
"R017 status from validated section",
|
|
);
|
|
assert.deepStrictEqual(
|
|
r017?.validation,
|
|
"M001/S01",
|
|
'R017 validation (from "Validated by" bullet)',
|
|
);
|
|
assert.deepStrictEqual(
|
|
r017?.notes,
|
|
"50 decisions queried in 0.62ms",
|
|
'R017 notes (from "Proof" bullet)',
|
|
);
|
|
|
|
// Deferred requirement
|
|
const r030 = reqs.find((r) => r.id === "R030");
|
|
assert.deepStrictEqual(
|
|
r030?.status,
|
|
"deferred",
|
|
"R030 status should be deferred",
|
|
);
|
|
assert.deepStrictEqual(r030?.class, "differentiator", "R030 class");
|
|
assert.deepStrictEqual(
|
|
r030?.description,
|
|
"Rust crate for embeddings",
|
|
"R030 description",
|
|
);
|
|
|
|
// Out of scope
|
|
const r040 = reqs.find((r) => r.id === "R040");
|
|
assert.deepStrictEqual(
|
|
r040?.status,
|
|
"out-of-scope",
|
|
"R040 status should be out-of-scope",
|
|
);
|
|
assert.deepStrictEqual(r040?.class, "anti-feature", "R040 class");
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// md-importer: migrateFromMarkdown orchestrator
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
test("md-importer: migrateFromMarkdown orchestrator", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sf-import-test-"));
|
|
createFixtureTree(tmpDir);
|
|
|
|
try {
|
|
openDatabase(":memory:");
|
|
const result = migrateFromMarkdown(tmpDir);
|
|
|
|
assert.deepStrictEqual(result.decisions, 4, "should import 4 decisions");
|
|
assert.deepStrictEqual(
|
|
result.requirements,
|
|
5,
|
|
"should import 5 requirements",
|
|
);
|
|
assert.ok(result.artifacts > 0, "should import some artifacts");
|
|
|
|
// Verify decisions queryable
|
|
const d001 = getDecisionById("D001");
|
|
assert.ok(!!d001, "D001 should be queryable");
|
|
assert.deepStrictEqual(
|
|
d001?.superseded_by,
|
|
"D010",
|
|
"D001 superseded_by should be D010",
|
|
);
|
|
|
|
// Verify requirements queryable
|
|
const r001 = getRequirementById("R001");
|
|
assert.ok(!!r001, "R001 should be queryable");
|
|
assert.deepStrictEqual(r001?.status, "active", "R001 status from DB");
|
|
|
|
// Verify active views
|
|
const activeD = getActiveDecisions();
|
|
assert.deepStrictEqual(
|
|
activeD.length,
|
|
2,
|
|
"should have 2 active decisions (D002, D020)",
|
|
);
|
|
|
|
// Verify artifacts table
|
|
const adapter = _getAdapter();
|
|
const artifacts = adapter
|
|
?.prepare("SELECT count(*) as c FROM artifacts")
|
|
.get();
|
|
assert.ok((artifacts?.c as number) > 0, "artifacts table should have rows");
|
|
|
|
// Verify hierarchy correctness
|
|
const roadmap = adapter
|
|
?.prepare("SELECT * FROM artifacts WHERE artifact_type = :type")
|
|
.get({ ":type": "ROADMAP" });
|
|
assert.ok(!!roadmap, "ROADMAP artifact should exist");
|
|
assert.deepStrictEqual(
|
|
roadmap?.milestone_id,
|
|
"M001",
|
|
"ROADMAP should be in M001",
|
|
);
|
|
|
|
const taskPlan = adapter
|
|
?.prepare(
|
|
"SELECT * FROM artifacts WHERE task_id = :taskId AND artifact_type = :type",
|
|
)
|
|
.get({
|
|
":taskId": "T01",
|
|
":type": "PLAN",
|
|
});
|
|
assert.ok(!!taskPlan, "T01-PLAN artifact should exist");
|
|
|
|
closeDatabase();
|
|
} finally {
|
|
cleanupDir(tmpDir);
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// md-importer: idempotent re-import
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
test("md-importer: idempotent re-import", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sf-idemp-test-"));
|
|
createFixtureTree(tmpDir);
|
|
|
|
try {
|
|
openDatabase(":memory:");
|
|
const r1 = migrateFromMarkdown(tmpDir);
|
|
const r2 = migrateFromMarkdown(tmpDir);
|
|
|
|
assert.deepStrictEqual(
|
|
r1.decisions,
|
|
r2.decisions,
|
|
"double import should produce same decision count",
|
|
);
|
|
assert.deepStrictEqual(
|
|
r1.requirements,
|
|
r2.requirements,
|
|
"double import should produce same requirement count",
|
|
);
|
|
assert.deepStrictEqual(
|
|
r1.artifacts,
|
|
r2.artifacts,
|
|
"double import should produce same artifact count",
|
|
);
|
|
|
|
// Verify no duplicates
|
|
const adapter = _getAdapter();
|
|
const dc = adapter?.prepare("SELECT count(*) as c FROM decisions").get()
|
|
?.c as number;
|
|
const rc = adapter?.prepare("SELECT count(*) as c FROM requirements").get()
|
|
?.c as number;
|
|
const ac = adapter?.prepare("SELECT count(*) as c FROM artifacts").get()
|
|
?.c as number;
|
|
|
|
assert.deepStrictEqual(
|
|
dc,
|
|
r1.decisions,
|
|
"DB decision count matches import count",
|
|
);
|
|
assert.deepStrictEqual(
|
|
rc,
|
|
r1.requirements,
|
|
"DB requirement count matches import count",
|
|
);
|
|
assert.deepStrictEqual(
|
|
ac,
|
|
r1.artifacts,
|
|
"DB artifact count matches import count",
|
|
);
|
|
|
|
closeDatabase();
|
|
} finally {
|
|
cleanupDir(tmpDir);
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// md-importer: missing file graceful handling
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
test("md-importer: missing file handling", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sf-empty-test-"));
|
|
// Create empty .sf/ with no files
|
|
fs.mkdirSync(path.join(tmpDir, ".sf"), { recursive: true });
|
|
|
|
try {
|
|
openDatabase(":memory:");
|
|
const result = migrateFromMarkdown(tmpDir);
|
|
|
|
assert.deepStrictEqual(
|
|
result.decisions,
|
|
0,
|
|
"missing DECISIONS.md → 0 decisions",
|
|
);
|
|
assert.deepStrictEqual(
|
|
result.requirements,
|
|
0,
|
|
"missing REQUIREMENTS.md → 0 requirements",
|
|
);
|
|
assert.deepStrictEqual(result.artifacts, 0, "empty tree → 0 artifacts");
|
|
|
|
closeDatabase();
|
|
} finally {
|
|
cleanupDir(tmpDir);
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// md-importer: schema v1→v2 migration on existing DBs
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
test("md-importer: schema v1→v2 migration", () => {
|
|
// This test verifies that opening a fresh DB auto-migrates to current schema version
|
|
openDatabase(":memory:");
|
|
const adapter = _getAdapter();
|
|
const version = adapter
|
|
?.prepare("SELECT MAX(version) as v FROM schema_version")
|
|
.get();
|
|
assert.deepStrictEqual(
|
|
version?.v,
|
|
21,
|
|
"new DB should be at schema version 21",
|
|
);
|
|
|
|
// Artifacts table should exist
|
|
const tableCheck = adapter
|
|
?.prepare(
|
|
"SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='artifacts'",
|
|
)
|
|
.get();
|
|
assert.deepStrictEqual(tableCheck?.c, 1, "artifacts table should exist");
|
|
|
|
closeDatabase();
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// md-importer: round-trip fidelity
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
test("md-importer: round-trip fidelity", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sf-roundtrip-test-"));
|
|
createFixtureTree(tmpDir);
|
|
|
|
try {
|
|
openDatabase(":memory:");
|
|
migrateFromMarkdown(tmpDir);
|
|
|
|
// Round-trip: verify imported field values match source
|
|
const d002 = getDecisionById("D002");
|
|
assert.deepStrictEqual(
|
|
d002?.when_context,
|
|
"M001",
|
|
"D002 when_context round-trip",
|
|
);
|
|
assert.deepStrictEqual(d002?.scope, "arch", "D002 scope round-trip");
|
|
assert.deepStrictEqual(
|
|
d002?.decision,
|
|
"DB location",
|
|
"D002 decision round-trip",
|
|
);
|
|
assert.deepStrictEqual(d002?.choice, ".sf/sf.db", "D002 choice round-trip");
|
|
assert.deepStrictEqual(
|
|
d002?.rationale,
|
|
"Derived state",
|
|
"D002 rationale round-trip",
|
|
);
|
|
|
|
const r002 = getRequirementById("R002");
|
|
assert.deepStrictEqual(
|
|
r002?.class,
|
|
"failure-visibility",
|
|
"R002 class round-trip",
|
|
);
|
|
assert.deepStrictEqual(
|
|
r002?.description,
|
|
"Falls back to markdown if SQLite unavailable",
|
|
"R002 description round-trip",
|
|
);
|
|
assert.deepStrictEqual(
|
|
r002?.why,
|
|
"Must not break on exotic platforms",
|
|
"R002 why round-trip",
|
|
);
|
|
assert.deepStrictEqual(
|
|
r002?.primary_owner,
|
|
"M001/S01",
|
|
"R002 primary_owner round-trip",
|
|
);
|
|
assert.deepStrictEqual(
|
|
r002?.supporting_slices,
|
|
"M001/S03",
|
|
"R002 supporting_slices round-trip",
|
|
);
|
|
assert.deepStrictEqual(
|
|
r002?.notes,
|
|
"Transparent fallback",
|
|
"R002 notes round-trip",
|
|
);
|
|
assert.deepStrictEqual(
|
|
r002?.validation,
|
|
"unmapped",
|
|
"R002 validation round-trip",
|
|
);
|
|
|
|
// Verify artifact content is stored
|
|
const adapter = _getAdapter();
|
|
const project = adapter
|
|
?.prepare("SELECT * FROM artifacts WHERE path = :path")
|
|
.get({ ":path": "PROJECT.md" });
|
|
assert.ok(
|
|
(project?.full_content as string)?.includes("Test Project"),
|
|
"PROJECT.md content round-trip",
|
|
);
|
|
|
|
closeDatabase();
|
|
} finally {
|
|
cleanupDir(tmpDir);
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|