singularity-forge/packages/mcp-server/src/readers/graph.test.ts
ace-pm 9d739dfa5d Rename GSD→SF: complete rebrand from fork origin
- All gsdDir/gsdRoot/gsdHome → sfDir/sfRootDir/sfHome
- GSDWorkspace* → SFWorkspace* interfaces
- bootstrapGsdProject → bootstrapProject
- runGSDDoctor → runSFDoctor
- GsdClient → SfClient, gsd-client.ts → sf-client.ts
- .gsd/ → .sf/ in all tests, docs, docker, native, vscode
- Auto-migration: headless detects .gsd/ → renames to .sf/
- Deleted gsd-phase-state.ts backward-compat re-export
- Renamed bin/gsd-from-source → bin/sf-from-source
- Updated mintlify docs, github workflows, docker configs
2026-04-15 18:33:47 +02:00

604 lines
22 KiB
TypeScript

// SF MCP Server — knowledge graph reader tests
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomBytes } from 'node:crypto';
import {
buildGraph,
writeGraph,
writeSnapshot,
graphStatus,
graphQuery,
graphDiff,
} from './graph.js';
import type { KnowledgeGraph } from './graph.js';
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
function tmpProject(): string {
const dir = join(tmpdir(), `sf-graph-test-${randomBytes(4).toString('hex')}`);
mkdirSync(dir, { recursive: true });
return dir;
}
function writeFixture(base: string, relPath: string, content: string): void {
const full = join(base, relPath);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, content, 'utf-8');
}
function makeProjectWithArtifacts(projectDir: string): void {
writeFixture(projectDir, '.sf/STATE.md', [
'# SF State',
'',
'**Active Milestone:** M001: Auth System',
'**Active Slice:** S01: Login flow',
'**Phase:** execution',
'',
'## Milestone Registry',
'',
'- 🔄 **M001:** Auth System',
'',
'## Next Action',
'',
'Execute T01 in S01.',
].join('\n'));
writeFixture(projectDir, '.sf/KNOWLEDGE.md', [
'# Project Knowledge',
'',
'## Rules',
'',
'| # | Scope | Rule | Why | Added |',
'|---|-------|------|-----|-------|',
'| K001 | auth | Hash passwords with bcrypt | Security requirement | manual |',
'| K002 | db | Use transactions for multi-table | Data consistency | auto |',
'',
'## Patterns',
'',
'| # | Pattern | Where | Notes |',
'|---|---------|-------|-------|',
'| P001 | Singleton services | services/ | Prevents duplication |',
'',
'## Lessons Learned',
'',
'| # | What Happened | Root Cause | Fix | Scope |',
'|---|--------------|------------|-----|-------|',
'| L001 | CI tests failed | Env diff | Added setup script | testing |',
].join('\n'));
writeFixture(projectDir, '.sf/milestones/M001/M001-ROADMAP.md', [
'# M001: Auth System',
'',
'## Vision',
'',
'Build authentication for the platform.',
'',
'## Slice Overview',
'',
'| ID | Slice | Risk | Depends | Done | After this |',
'|----|-------|------|---------|------|------------|',
'| S01 | Login flow | low | — | 🔄 | Users can log in |',
].join('\n'));
writeFixture(projectDir, '.sf/milestones/M001/slices/S01/S01-PLAN.md', [
'# S01: Login flow',
'',
'## Tasks',
'',
'- [ ] **T01: Implement login endpoint** — Core auth logic',
'- [ ] **T02: Add session management** — Keep users logged in',
].join('\n'));
}
// ---------------------------------------------------------------------------
// LEARNINGS.md fixture helpers
// ---------------------------------------------------------------------------
function writeLearningsFixture(projectDir: string, milestoneId: string, content: string): void {
writeFixture(projectDir, `.sf/milestones/${milestoneId}/${milestoneId}-LEARNINGS.md`, content);
}
const SAMPLE_LEARNINGS = `---
phase: "M001"
phase_name: "User Auth"
project: "my-project"
generated: "2026-04-15T10:00:00Z"
counts:
decisions: 2
lessons: 1
patterns: 1
surprises: 1
missing_artifacts: []
---
# Learnings: User Auth
## Decisions
- Use JWT for stateless auth across services.
Source: M001-PLAN.md/Architecture
- Store refresh tokens in HTTP-only cookies only.
Source: M001-PLAN.md/Security
## Lessons
- Integration tests need a real DB — mocks missed migration bugs.
Source: M001-SUMMARY.md/Testing
## Patterns
- Repository pattern abstracts DB access and simplifies testing.
Source: M001-PLAN.md/Design
## Surprises
- Token expiry edge case caused silent auth failures in prod.
Source: M001-SUMMARY.md/Issues
`;
// ---------------------------------------------------------------------------
// buildGraph tests
// ---------------------------------------------------------------------------
describe('buildGraph', () => {
let projectDir: string;
before(() => {
projectDir = tmpProject();
makeProjectWithArtifacts(projectDir);
});
after(() => rmSync(projectDir, { recursive: true, force: true }));
it('returns nodeCount > 0 for a project with artifacts', async () => {
const graph = await buildGraph(projectDir);
assert.ok(graph.nodes.length > 0, `Expected nodes, got ${graph.nodes.length}`);
});
it('returns edgeCount >= 0 (valid graph structure)', async () => {
const graph = await buildGraph(projectDir);
assert.ok(graph.edges.length >= 0);
});
it('includes builtAt ISO timestamp', async () => {
const graph = await buildGraph(projectDir);
assert.ok(typeof graph.builtAt === 'string');
assert.ok(!isNaN(Date.parse(graph.builtAt)));
});
it('skips unparseable artifact and does not throw', async () => {
const badProject = tmpProject();
// Write a corrupt/minimal STATE.md that is technically valid but empty
writeFixture(badProject, '.sf/STATE.md', 'not valid sf state at all \0\0\0');
// Should not throw
const graph = await buildGraph(badProject);
assert.ok(graph.nodes.length >= 0);
rmSync(badProject, { recursive: true, force: true });
});
it('returns empty graph for project with no .sf/ directory', async () => {
const emptyProject = tmpProject();
const graph = await buildGraph(emptyProject);
assert.ok(graph.nodes.length >= 0); // no throw
assert.equal(typeof graph.builtAt, 'string');
rmSync(emptyProject, { recursive: true, force: true });
});
it('nodes have required fields: id, label, type, confidence', async () => {
const graph = await buildGraph(projectDir);
for (const node of graph.nodes) {
assert.ok(typeof node.id === 'string', 'node.id must be string');
assert.ok(typeof node.label === 'string', 'node.label must be string');
assert.ok(typeof node.type === 'string', 'node.type must be string');
assert.ok(
node.confidence === 'EXTRACTED' ||
node.confidence === 'INFERRED' ||
node.confidence === 'AMBIGUOUS',
`Invalid confidence: ${node.confidence}`,
);
}
});
});
// ---------------------------------------------------------------------------
// buildGraph — LEARNINGS.md parsing tests
// ---------------------------------------------------------------------------
describe('buildGraph — LEARNINGS.md parsing', () => {
let projectDir: string;
beforeEach(() => {
projectDir = tmpProject();
// Create minimal milestone directory so parseMilestoneFiles finds it
mkdirSync(join(projectDir, '.sf', 'milestones', 'M001'), { recursive: true });
writeLearningsFixture(projectDir, 'M001', SAMPLE_LEARNINGS);
});
afterEach(() => rmSync(projectDir, { recursive: true, force: true }));
it('extracts decision nodes from ## Decisions section', async () => {
const graph = await buildGraph(projectDir);
const decisions = graph.nodes.filter((n) => n.type === 'decision' || (n.type === 'rule' && n.id.startsWith('decision:')));
// Decisions should be extracted with a 'decision' type (or similar existing type)
const decisionNodes = graph.nodes.filter((n) => n.id.includes('decision:M001'));
assert.ok(decisionNodes.length >= 2, `Expected >= 2 decision nodes, got ${decisionNodes.length}`);
});
it('extracts lesson nodes from ## Lessons section', async () => {
const graph = await buildGraph(projectDir);
const lessonNodes = graph.nodes.filter((n) => n.id.includes('lesson:M001'));
assert.ok(lessonNodes.length >= 1, `Expected >= 1 lesson node, got ${lessonNodes.length}`);
assert.ok(lessonNodes.every((n) => n.type === 'lesson'), 'All lesson nodes must have type "lesson"');
});
it('extracts pattern nodes from ## Patterns section', async () => {
const graph = await buildGraph(projectDir);
const patternNodes = graph.nodes.filter((n) => n.id.includes('pattern:M001'));
assert.ok(patternNodes.length >= 1, `Expected >= 1 pattern node, got ${patternNodes.length}`);
assert.ok(patternNodes.every((n) => n.type === 'pattern'), 'All pattern nodes must have type "pattern"');
});
it('maps surprises to lesson nodes', async () => {
const graph = await buildGraph(projectDir);
// Surprises should be mapped to lesson type since no "surprise" NodeType exists
const surpriseNodes = graph.nodes.filter((n) => n.id.includes('surprise:M001'));
assert.ok(surpriseNodes.length >= 1, `Expected >= 1 surprise node, got ${surpriseNodes.length}`);
assert.ok(surpriseNodes.every((n) => n.type === 'lesson'), 'Surprises must be mapped to type "lesson"');
});
it('node labels contain the learning text', async () => {
const graph = await buildGraph(projectDir);
const hasJwtDecision = graph.nodes.some((n) =>
n.label.toLowerCase().includes('jwt') || n.description?.toLowerCase().includes('jwt'),
);
assert.ok(hasJwtDecision, 'Expected a node describing the JWT decision');
});
it('node description includes source attribution', async () => {
const graph = await buildGraph(projectDir);
const learningNodes = graph.nodes.filter((n) =>
n.id.includes(':M001:') || n.id.match(/:(decision|lesson|pattern|surprise):M001/),
);
const withSource = learningNodes.filter((n) => n.description?.includes('Source:') || n.description?.includes('M001-PLAN'));
assert.ok(withSource.length > 0, 'Expected at least one node with source attribution in description');
});
it('adds relates_to edge from learning node to milestone node', async () => {
const graph = await buildGraph(projectDir);
const edgesToMilestone = graph.edges.filter(
(e) => e.to === 'milestone:M001' || e.from === 'milestone:M001',
);
// At least one learning node should relate to the milestone
const learningEdges = graph.edges.filter(
(e) => (e.from.includes('M001') && (e.type === 'relates_to' || e.type === 'contains')) ||
(e.to.includes('M001') && e.type === 'relates_to'),
);
assert.ok(learningEdges.length > 0 || edgesToMilestone.length > 0,
'Expected edges connecting learning nodes to milestone');
});
it('skips LEARNINGS.md gracefully when file is malformed', async () => {
const badProject = tmpProject();
mkdirSync(join(badProject, '.sf', 'milestones', 'M002'), { recursive: true });
writeLearningsFixture(badProject, 'M002', '\0\0\0 not valid yaml or markdown \0\0\0');
// Must not throw
const graph = await buildGraph(badProject);
assert.ok(graph.nodes.length >= 0);
assert.equal(typeof graph.builtAt, 'string');
rmSync(badProject, { recursive: true, force: true });
});
it('produces no learning nodes when all sections are empty', async () => {
const emptyProject = tmpProject();
mkdirSync(join(emptyProject, '.sf', 'milestones', 'M003'), { recursive: true });
writeLearningsFixture(emptyProject, 'M003', `---
phase: "M003"
phase_name: "Empty"
project: "test"
generated: "2026-04-15T10:00:00Z"
counts:
decisions: 0
lessons: 0
patterns: 0
surprises: 0
missing_artifacts: []
---
# Learnings: Empty
## Decisions
## Lessons
## Patterns
## Surprises
`);
const graph = await buildGraph(emptyProject);
const learningNodes = graph.nodes.filter((n) =>
n.id.includes('decision:M003') ||
n.id.includes('lesson:M003') ||
n.id.includes('pattern:M003') ||
n.id.includes('surprise:M003'),
);
assert.equal(learningNodes.length, 0, 'Empty sections should produce no nodes');
rmSync(emptyProject, { recursive: true, force: true });
});
it('does not crash when LEARNINGS.md is missing entirely', async () => {
const noLearningsProject = tmpProject();
mkdirSync(join(noLearningsProject, '.sf', 'milestones', 'M004'), { recursive: true });
// No LEARNINGS.md file written
const graph = await buildGraph(noLearningsProject);
assert.ok(graph.nodes.length >= 0);
rmSync(noLearningsProject, { recursive: true, force: true });
});
});
// ---------------------------------------------------------------------------
// writeGraph tests
// ---------------------------------------------------------------------------
describe('writeGraph', () => {
let projectDir: string;
let graph: KnowledgeGraph;
before(async () => {
projectDir = tmpProject();
makeProjectWithArtifacts(projectDir);
graph = await buildGraph(projectDir);
});
after(() => rmSync(projectDir, { recursive: true, force: true }));
it('creates graph.json in .sf/graphs/ after writeGraph()', async () => {
const sfRoot = join(projectDir, '.sf');
await writeGraph(sfRoot, graph);
const graphPath = join(sfRoot, 'graphs', 'graph.json');
assert.ok(existsSync(graphPath), `Expected ${graphPath} to exist`);
});
it('write is atomic — no temp file remains after writeGraph()', async () => {
const sfRoot = join(projectDir, '.sf');
await writeGraph(sfRoot, graph);
const tmpPath = join(sfRoot, 'graphs', 'graph.tmp.json');
assert.ok(!existsSync(tmpPath), 'Temp file should not exist after successful write');
});
it('written graph.json is valid JSON with nodes and edges', async () => {
const sfRoot = join(projectDir, '.sf');
await writeGraph(sfRoot, graph);
const raw = readFileSync(join(sfRoot, 'graphs', 'graph.json'), 'utf-8');
const parsed = JSON.parse(raw) as KnowledgeGraph;
assert.ok(Array.isArray(parsed.nodes));
assert.ok(Array.isArray(parsed.edges));
assert.ok(typeof parsed.builtAt === 'string');
});
});
// ---------------------------------------------------------------------------
// graphStatus tests
// ---------------------------------------------------------------------------
describe('graphStatus', () => {
let projectDir: string;
beforeEach(() => {
projectDir = tmpProject();
});
afterEach(() => rmSync(projectDir, { recursive: true, force: true }));
it('returns { exists: false } when no graph.json exists', async () => {
const status = await graphStatus(projectDir);
assert.equal(status.exists, false);
});
it('returns { exists: true, nodeCount, edgeCount, ageHours } when graph exists', async () => {
makeProjectWithArtifacts(projectDir);
const sfRoot = join(projectDir, '.sf');
const graph = await buildGraph(projectDir);
await writeGraph(sfRoot, graph);
const status = await graphStatus(projectDir);
assert.equal(status.exists, true);
assert.ok(typeof status.nodeCount === 'number');
assert.ok(typeof status.edgeCount === 'number');
assert.ok(typeof status.ageHours === 'number');
assert.ok(status.ageHours >= 0);
});
it('stale = false for a freshly built graph', async () => {
makeProjectWithArtifacts(projectDir);
const sfRoot = join(projectDir, '.sf');
const graph = await buildGraph(projectDir);
await writeGraph(sfRoot, graph);
const status = await graphStatus(projectDir);
assert.equal(status.stale, false);
});
it('stale = true for a graph older than 24h (builtAt backdated)', async () => {
makeProjectWithArtifacts(projectDir);
const sfRoot = join(projectDir, '.sf');
mkdirSync(join(sfRoot, 'graphs'), { recursive: true });
// Write a graph with a builtAt 25 hours ago
const oldGraph: KnowledgeGraph = {
nodes: [],
edges: [],
builtAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
};
writeFileSync(
join(sfRoot, 'graphs', 'graph.json'),
JSON.stringify(oldGraph),
'utf-8',
);
const status = await graphStatus(projectDir);
assert.equal(status.exists, true);
assert.equal(status.stale, true);
});
});
// ---------------------------------------------------------------------------
// graphQuery tests
// ---------------------------------------------------------------------------
describe('graphQuery', () => {
let projectDir: string;
before(async () => {
projectDir = tmpProject();
makeProjectWithArtifacts(projectDir);
const sfRoot = join(projectDir, '.sf');
const graph = await buildGraph(projectDir);
await writeGraph(sfRoot, graph);
});
after(() => rmSync(projectDir, { recursive: true, force: true }));
it('returns matching nodes for a known term', async () => {
const result = await graphQuery(projectDir, 'auth');
assert.ok(Array.isArray(result.nodes));
// Should match nodes with 'auth' in label or description
assert.ok(result.nodes.length > 0, 'Expected at least one match for "auth"');
});
it('returns empty array for a term that matches nothing', async () => {
const result = await graphQuery(projectDir, 'xxxxxxnotfound999zzz');
assert.ok(Array.isArray(result.nodes));
assert.equal(result.nodes.length, 0);
});
it('search is case-insensitive', async () => {
const lower = await graphQuery(projectDir, 'auth');
const upper = await graphQuery(projectDir, 'AUTH');
assert.deepEqual(
lower.nodes.map((n) => n.id).sort(),
upper.nodes.map((n) => n.id).sort(),
);
});
it('budget trims AMBIGUOUS edges first', async () => {
const sfRoot = join(projectDir, '.sf');
// Write a graph with mixed confidence edges
const mixedGraph: KnowledgeGraph = {
builtAt: new Date().toISOString(),
nodes: [
{ id: 'n1', label: 'seed node budget', type: 'milestone', confidence: 'EXTRACTED' },
{ id: 'n2', label: 'connected via AMBIGUOUS', type: 'task', confidence: 'AMBIGUOUS' },
{ id: 'n3', label: 'connected via INFERRED', type: 'task', confidence: 'INFERRED' },
],
edges: [
{ from: 'n1', to: 'n2', type: 'contains', confidence: 'AMBIGUOUS' },
{ from: 'n1', to: 'n3', type: 'contains', confidence: 'INFERRED' },
],
};
await writeGraph(sfRoot, mixedGraph);
// With a very small budget, AMBIGUOUS edges should be trimmed first
const result = await graphQuery(projectDir, 'seed node budget', 10);
// At minimum, the seed node itself should be present
assert.ok(result.nodes.some((n) => n.id === 'n1'), 'Seed node should be in result');
// Restore the original graph
const originalGraph = await buildGraph(projectDir);
await writeGraph(sfRoot, originalGraph);
});
});
// ---------------------------------------------------------------------------
// writeSnapshot + graphDiff tests
// ---------------------------------------------------------------------------
describe('graphDiff', () => {
let projectDir: string;
beforeEach(async () => {
projectDir = tmpProject();
makeProjectWithArtifacts(projectDir);
const sfRoot = join(projectDir, '.sf');
const graph = await buildGraph(projectDir);
await writeGraph(sfRoot, graph);
});
afterEach(() => rmSync(projectDir, { recursive: true, force: true }));
it('returns empty diff when comparing graph to itself (snapshot = current)', async () => {
const sfRoot = join(projectDir, '.sf');
await writeSnapshot(sfRoot);
const diff = await graphDiff(projectDir);
assert.ok(Array.isArray(diff.nodes.added));
assert.ok(Array.isArray(diff.nodes.removed));
assert.ok(Array.isArray(diff.nodes.changed));
assert.equal(diff.nodes.added.length, 0);
assert.equal(diff.nodes.removed.length, 0);
});
it('returns added nodes when a new node appears after snapshot', async () => {
const sfRoot = join(projectDir, '.sf');
// Take snapshot of the original graph
await writeSnapshot(sfRoot);
// Now write a graph with an extra node
const extraGraph: KnowledgeGraph = {
builtAt: new Date().toISOString(),
nodes: [
{ id: 'brand-new-node', label: 'New Feature', type: 'milestone', confidence: 'EXTRACTED' },
],
edges: [],
};
await writeGraph(sfRoot, extraGraph);
const diff = await graphDiff(projectDir);
assert.ok(diff.nodes.added.includes('brand-new-node'), 'new node should be in added');
});
it('returns removed nodes when a node disappears after snapshot', async () => {
const sfRoot = join(projectDir, '.sf');
// Create snapshot with a node that won't exist in current graph
const snapshotGraph: KnowledgeGraph = {
builtAt: new Date().toISOString(),
nodes: [
{ id: 'old-node-to-be-removed', label: 'Old', type: 'task', confidence: 'EXTRACTED' },
],
edges: [],
};
writeFileSync(
join(sfRoot, 'graphs', '.last-build-snapshot.json'),
JSON.stringify({ ...snapshotGraph, snapshotAt: new Date().toISOString() }),
'utf-8',
);
// Current graph.json has no such node
const diff = await graphDiff(projectDir);
assert.ok(diff.nodes.removed.includes('old-node-to-be-removed'), 'old node should be in removed');
});
it('returns empty diff structure when no snapshot exists', async () => {
// No snapshot file — diff should be empty/meaningful
const diff = await graphDiff(projectDir);
assert.ok(Array.isArray(diff.nodes.added));
assert.ok(Array.isArray(diff.nodes.removed));
assert.ok(Array.isArray(diff.nodes.changed));
assert.ok(Array.isArray(diff.edges.added));
assert.ok(Array.isArray(diff.edges.removed));
});
it('writeSnapshot creates .last-build-snapshot.json with snapshotAt', async () => {
const sfRoot = join(projectDir, '.sf');
await writeSnapshot(sfRoot);
const snapshotPath = join(sfRoot, 'graphs', '.last-build-snapshot.json');
assert.ok(existsSync(snapshotPath));
const raw = readFileSync(snapshotPath, 'utf-8');
const parsed = JSON.parse(raw) as KnowledgeGraph & { snapshotAt: string };
assert.ok(typeof parsed.snapshotAt === 'string');
assert.ok(!isNaN(Date.parse(parsed.snapshotAt)));
});
});