Add under-anything knowledge dashboard

This commit is contained in:
qiaoxinjiu
2026-05-27 15:40:32 +08:00
commit e31a75d2bb
565 changed files with 143063 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
{
"name": "@understand-anything/core",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./search": {
"types": "./dist/search.d.ts",
"default": "./dist/search.js"
},
"./types": {
"types": "./dist/types.d.ts",
"default": "./dist/types.js"
},
"./schema": {
"types": "./dist/schema.d.ts",
"default": "./dist/schema.js"
},
"./languages": {
"types": "./dist/languages/index.d.ts",
"default": "./dist/languages/index.js"
}
},
"scripts": {
"build": "tsc",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "3.2.4",
"typescript": "^5.7.0",
"vitest": "^3.1.0"
},
"dependencies": {
"fuse.js": "^7.1.0",
"ignore": "^7.0.5",
"tree-sitter-c-sharp": "^0.23.1",
"tree-sitter-cpp": "^0.23.4",
"tree-sitter-go": "^0.25.0",
"tree-sitter-java": "^0.23.5",
"tree-sitter-javascript": "^0.25.0",
"tree-sitter-php": "^0.23.11",
"tree-sitter-python": "^0.25.0",
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "^0.24.0",
"tree-sitter-typescript": "^0.23.2",
"web-tree-sitter": "^0.26.6",
"yaml": "^2.8.3",
"zod": "^4.3.6"
}
}

View File

@@ -0,0 +1,183 @@
import { describe, it, expect } from "vitest";
import { classifyUpdate } from "../change-classifier.js";
import type { ChangeAnalysis } from "../fingerprint.js";
function makeAnalysis(overrides: Partial<ChangeAnalysis> = {}): ChangeAnalysis {
return {
fileChanges: [],
newFiles: [],
deletedFiles: [],
structurallyChangedFiles: [],
cosmeticOnlyFiles: [],
unchangedFiles: [],
...overrides,
};
}
describe("classifyUpdate", () => {
it("returns SKIP when all files are unchanged", () => {
const analysis = makeAnalysis({
unchangedFiles: ["src/a.ts", "src/b.ts"],
});
const decision = classifyUpdate(analysis, 50);
expect(decision.action).toBe("SKIP");
expect(decision.filesToReanalyze).toHaveLength(0);
expect(decision.rerunArchitecture).toBe(false);
expect(decision.rerunTour).toBe(false);
});
it("returns SKIP when all changes are cosmetic", () => {
const analysis = makeAnalysis({
cosmeticOnlyFiles: ["src/a.ts", "src/b.ts"],
});
const decision = classifyUpdate(analysis, 50);
expect(decision.action).toBe("SKIP");
expect(decision.reason).toContain("cosmetic-only");
});
it("returns PARTIAL_UPDATE for a few structural changes", () => {
const analysis = makeAnalysis({
structurallyChangedFiles: ["src/a.ts", "src/b.ts"],
newFiles: ["src/c.ts"],
cosmeticOnlyFiles: ["src/d.ts"],
});
// src/ already exists in the project, so adding src/c.ts is not a directory change
const allKnownFiles = ["src/a.ts", "src/b.ts", "src/d.ts", "lib/util.ts"];
const decision = classifyUpdate(analysis, 50, allKnownFiles);
expect(decision.action).toBe("PARTIAL_UPDATE");
expect(decision.filesToReanalyze).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
expect(decision.rerunArchitecture).toBe(false);
expect(decision.rerunTour).toBe(false);
});
it("returns ARCHITECTURE_UPDATE when >10 structural files", () => {
const files = Array.from({ length: 12 }, (_, i) => `src/file${i}.ts`);
const analysis = makeAnalysis({
structurallyChangedFiles: files,
});
const decision = classifyUpdate(analysis, 50);
expect(decision.action).toBe("ARCHITECTURE_UPDATE");
expect(decision.rerunArchitecture).toBe(true);
expect(decision.rerunTour).toBe(true);
});
it("returns ARCHITECTURE_UPDATE when new directories appear", () => {
const analysis = makeAnalysis({
structurallyChangedFiles: ["src/existing.ts"],
newFiles: ["newdir/file.ts"],
});
const allKnownFiles = ["src/existing.ts", "src/other.ts", "lib/util.ts"];
const decision = classifyUpdate(analysis, 50, allKnownFiles);
expect(decision.action).toBe("ARCHITECTURE_UPDATE");
expect(decision.rerunArchitecture).toBe(true);
});
it("returns ARCHITECTURE_UPDATE when directories are deleted", () => {
const analysis = makeAnalysis({
structurallyChangedFiles: ["src/existing.ts"],
deletedFiles: ["olddir/removed.ts"],
});
const allKnownFiles = ["src/existing.ts", "src/other.ts"];
const decision = classifyUpdate(analysis, 50, allKnownFiles);
expect(decision.action).toBe("ARCHITECTURE_UPDATE");
expect(decision.rerunArchitecture).toBe(true);
});
it("does NOT trigger ARCHITECTURE_UPDATE for new file in existing directory", () => {
const analysis = makeAnalysis({
newFiles: ["src/newfile.ts"],
});
// src/ is already known via other files in the project
const allKnownFiles = ["src/a.ts", "src/b.ts", "lib/util.ts"];
const decision = classifyUpdate(analysis, 50, allKnownFiles);
expect(decision.action).toBe("PARTIAL_UPDATE");
expect(decision.rerunArchitecture).toBe(false);
});
it("triggers ARCHITECTURE_UPDATE for new file in genuinely new directory", () => {
const analysis = makeAnalysis({
newFiles: ["brand-new-pkg/index.ts"],
});
// allKnownFiles only contains src/ and lib/ — no brand-new-pkg/
const allKnownFiles = ["src/a.ts", "src/b.ts", "lib/util.ts"];
const decision = classifyUpdate(analysis, 50, allKnownFiles);
expect(decision.action).toBe("ARCHITECTURE_UPDATE");
expect(decision.rerunArchitecture).toBe(true);
});
it("returns FULL_UPDATE when >30 structural files", () => {
const files = Array.from({ length: 35 }, (_, i) => `src/file${i}.ts`);
const analysis = makeAnalysis({
structurallyChangedFiles: files,
});
const decision = classifyUpdate(analysis, 100);
expect(decision.action).toBe("FULL_UPDATE");
expect(decision.rerunArchitecture).toBe(true);
expect(decision.rerunTour).toBe(true);
});
it("returns FULL_UPDATE when >50% of project is structurally changed", () => {
const files = Array.from({ length: 6 }, (_, i) => `src/file${i}.ts`);
const analysis = makeAnalysis({
structurallyChangedFiles: files,
});
// 6 out of 10 files = 60%
const decision = classifyUpdate(analysis, 10);
expect(decision.action).toBe("FULL_UPDATE");
});
it("includes new and structural files in filesToReanalyze for PARTIAL", () => {
const analysis = makeAnalysis({
structurallyChangedFiles: ["src/modified.ts"],
newFiles: ["src/added.ts"],
deletedFiles: ["src/removed.ts"],
});
const decision = classifyUpdate(analysis, 50);
expect(decision.filesToReanalyze).toContain("src/modified.ts");
expect(decision.filesToReanalyze).toContain("src/added.ts");
// Deleted files shouldn't be re-analyzed
expect(decision.filesToReanalyze).not.toContain("src/removed.ts");
});
it("handles empty analysis (no changes at all)", () => {
const analysis = makeAnalysis();
const decision = classifyUpdate(analysis, 50);
expect(decision.action).toBe("SKIP");
expect(decision.reason).toContain("No changes detected");
});
it("counts deleted files toward structural total", () => {
// 8 structural + 3 deleted = 11 total structural > 10 threshold
const analysis = makeAnalysis({
structurallyChangedFiles: Array.from({ length: 8 }, (_, i) => `src/file${i}.ts`),
deletedFiles: ["src/old1.ts", "src/old2.ts", "src/old3.ts"],
});
const decision = classifyUpdate(analysis, 50);
expect(decision.action).toBe("ARCHITECTURE_UPDATE");
});
});

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { normalizeNodeId } from "../analyzer/normalize-graph.js";
describe("normalizeNodeId — domain types", () => {
it("normalizes domain node IDs", () => {
const result = normalizeNodeId("domain:order-management", {
type: "domain",
name: "Order Management",
});
expect(result).toBe("domain:order-management");
});
it("normalizes flow node IDs", () => {
const result = normalizeNodeId("flow:create-order", {
type: "flow",
name: "Create Order",
});
expect(result).toBe("flow:create-order");
});
it("normalizes step node IDs with filePath", () => {
const result = normalizeNodeId("step:create-order:validate", {
type: "step",
name: "Validate",
filePath: "src/validators/order.ts",
});
expect(result).toBe("step:create-order:src/validators/order.ts:validate");
});
it("normalizes step node IDs without filePath", () => {
const result = normalizeNodeId("step:validate", {
type: "step",
name: "Validate",
});
expect(result).toBe("step:validate");
});
it("normalizes bare step name with filePath", () => {
const result = normalizeNodeId("validate", {
type: "step",
name: "Validate",
filePath: "src/validators/order.ts",
});
expect(result).toBe("step:src/validators/order.ts:validate");
});
});

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { saveDomainGraph, loadDomainGraph } from "../persistence/index.js";
import type { KnowledgeGraph } from "../types.js";
const testRoot = join(tmpdir(), "ua-domain-persist-test");
const domainGraph: KnowledgeGraph = {
version: "1.0.0",
project: {
name: "test",
languages: ["typescript"],
frameworks: [],
description: "test",
analyzedAt: "2026-04-01T00:00:00.000Z",
gitCommitHash: "abc123",
},
nodes: [
{
id: "domain:orders",
type: "domain",
name: "Orders",
summary: "Order management",
tags: [],
complexity: "moderate",
},
],
edges: [],
layers: [],
tour: [],
};
describe("domain graph persistence", () => {
beforeEach(() => {
if (existsSync(testRoot)) rmSync(testRoot, { recursive: true });
mkdirSync(testRoot, { recursive: true });
});
afterEach(() => {
if (existsSync(testRoot)) rmSync(testRoot, { recursive: true });
});
it("saves and loads domain graph", () => {
saveDomainGraph(testRoot, domainGraph);
const loaded = loadDomainGraph(testRoot);
expect(loaded).not.toBeNull();
expect(loaded!.nodes[0].id).toBe("domain:orders");
});
it("returns null when no domain graph exists", () => {
const loaded = loadDomainGraph(testRoot);
expect(loaded).toBeNull();
});
it("saves to domain-graph.json, not knowledge-graph.json", () => {
saveDomainGraph(testRoot, domainGraph);
const domainPath = join(testRoot, ".understand-anything", "domain-graph.json");
const structuralPath = join(testRoot, ".understand-anything", "knowledge-graph.json");
expect(existsSync(domainPath)).toBe(true);
expect(existsSync(structuralPath)).toBe(false);
});
});

View File

@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest";
import { validateGraph } from "../schema.js";
import type { KnowledgeGraph } from "../types.js";
const domainGraph: KnowledgeGraph = {
version: "1.0.0",
project: {
name: "test-project",
languages: ["typescript"],
frameworks: [],
description: "A test project",
analyzedAt: "2026-04-01T00:00:00.000Z",
gitCommitHash: "abc123",
},
nodes: [
{
id: "domain:order-management",
type: "domain",
name: "Order Management",
summary: "Handles order lifecycle",
tags: ["core"],
complexity: "complex",
},
{
id: "flow:create-order",
type: "flow",
name: "Create Order",
summary: "Customer submits a new order",
tags: ["write-path"],
complexity: "moderate",
domainMeta: {
entryPoint: "POST /api/orders",
entryType: "http",
},
},
{
id: "step:create-order:validate",
type: "step",
name: "Validate Input",
summary: "Checks request body",
tags: ["validation"],
complexity: "simple",
filePath: "src/validators/order.ts",
lineRange: [10, 30],
},
],
edges: [
{
source: "domain:order-management",
target: "flow:create-order",
type: "contains_flow",
direction: "forward",
weight: 1.0,
},
{
source: "flow:create-order",
target: "step:create-order:validate",
type: "flow_step",
direction: "forward",
weight: 0.1,
},
],
layers: [],
tour: [],
};
describe("domain graph types", () => {
it("validates a domain graph with domain/flow/step node types", () => {
const result = validateGraph(domainGraph);
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(result.data!.nodes).toHaveLength(3);
expect(result.data!.edges).toHaveLength(2);
});
it("validates contains_flow edge type", () => {
const result = validateGraph(domainGraph);
expect(result.success).toBe(true);
expect(result.data!.edges[0].type).toBe("contains_flow");
});
it("validates flow_step edge type", () => {
const result = validateGraph(domainGraph);
expect(result.success).toBe(true);
expect(result.data!.edges[1].type).toBe("flow_step");
});
it("validates cross_domain edge type", () => {
const graph = structuredClone(domainGraph);
graph.nodes.push({
id: "domain:logistics",
type: "domain",
name: "Logistics",
summary: "Handles shipping",
tags: [],
complexity: "moderate",
});
graph.edges.push({
source: "domain:order-management",
target: "domain:logistics",
type: "cross_domain",
direction: "forward",
description: "Triggers on order confirmed",
weight: 0.6,
});
const result = validateGraph(graph);
expect(result.success).toBe(true);
});
it("normalizes domain type aliases", () => {
const graph = structuredClone(domainGraph);
(graph.nodes[0] as any).type = "business_domain";
(graph.nodes[1] as any).type = "business_flow";
(graph.nodes[2] as any).type = "business_step";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].type).toBe("domain");
expect(result.data!.nodes[1].type).toBe("flow");
expect(result.data!.nodes[2].type).toBe("step");
});
it("normalizes domain edge type aliases", () => {
const graph = structuredClone(domainGraph);
(graph.edges[0] as any).type = "has_flow";
(graph.edges[1] as any).type = "next_step";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges[0].type).toBe("contains_flow");
expect(result.data!.edges[1].type).toBe("flow_step");
});
it("preserves domainMeta on nodes through validation", () => {
const result = validateGraph(domainGraph);
expect(result.success).toBe(true);
const flowNode = result.data!.nodes.find((n) => n.id === "flow:create-order");
expect((flowNode as any).domainMeta).toEqual({
entryPoint: "POST /api/orders",
entryType: "http",
});
});
});

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { SemanticSearchEngine, cosineSimilarity } from "../embedding-search.js";
import type { GraphNode } from "../types.js";
const nodes: GraphNode[] = [
{ id: "n1", type: "file", name: "auth.ts", summary: "Authentication module", tags: ["auth"], complexity: "moderate" },
{ id: "n2", type: "file", name: "db.ts", summary: "Database connection", tags: ["db"], complexity: "simple" },
{ id: "n3", type: "function", name: "login", summary: "User login handler", tags: ["auth", "login"], complexity: "moderate" },
];
// Simple unit vectors for testing
const embeddings: Record<string, number[]> = {
n1: [1, 0, 0, 0],
n2: [0, 1, 0, 0],
n3: [0.9, 0, 0.1, 0],
};
describe("embedding-search", () => {
describe("cosineSimilarity", () => {
it("returns 1 for identical vectors", () => {
expect(cosineSimilarity([1, 0, 0], [1, 0, 0])).toBeCloseTo(1);
});
it("returns 0 for orthogonal vectors", () => {
expect(cosineSimilarity([1, 0, 0], [0, 1, 0])).toBeCloseTo(0);
});
it("returns high similarity for similar vectors", () => {
const sim = cosineSimilarity([1, 0, 0], [0.9, 0.1, 0]);
expect(sim).toBeGreaterThan(0.9);
});
it("handles zero vectors", () => {
expect(cosineSimilarity([0, 0, 0], [1, 0, 0])).toBe(0);
});
});
describe("SemanticSearchEngine", () => {
it("returns results sorted by similarity", () => {
const engine = new SemanticSearchEngine(nodes, embeddings);
const queryEmbedding = [1, 0, 0, 0]; // most similar to n1 and n3
const results = engine.search(queryEmbedding);
expect(results[0].nodeId).toBe("n1");
});
it("respects limit parameter", () => {
const engine = new SemanticSearchEngine(nodes, embeddings);
const results = engine.search([1, 0, 0, 0], { limit: 2 });
expect(results).toHaveLength(2);
});
it("respects threshold parameter", () => {
const engine = new SemanticSearchEngine(nodes, embeddings);
const results = engine.search([1, 0, 0, 0], { threshold: 0.5 });
// n2 has 0 similarity, should be filtered out
const ids = results.map((r) => r.nodeId);
expect(ids).not.toContain("n2");
});
it("filters by node type", () => {
const engine = new SemanticSearchEngine(nodes, embeddings);
const results = engine.search([1, 0, 0, 0], { types: ["function"] });
expect(results.every((r) => {
const node = nodes.find((n) => n.id === r.nodeId);
return node?.type === "function";
})).toBe(true);
});
it("returns empty for nodes without embeddings", () => {
const engine = new SemanticSearchEngine(nodes, {});
const results = engine.search([1, 0, 0, 0]);
expect(results).toHaveLength(0);
});
it("hasEmbeddings returns true when embeddings exist", () => {
const engine = new SemanticSearchEngine(nodes, embeddings);
expect(engine.hasEmbeddings()).toBe(true);
});
it("hasEmbeddings returns false when empty", () => {
const engine = new SemanticSearchEngine(nodes, {});
expect(engine.hasEmbeddings()).toBe(false);
});
it("addEmbedding updates the search index", () => {
const engine = new SemanticSearchEngine(nodes, {});
expect(engine.hasEmbeddings()).toBe(false);
engine.addEmbedding("n1", [1, 0, 0, 0]);
expect(engine.hasEmbeddings()).toBe(true);
});
});
});

View File

@@ -0,0 +1,427 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { StructuralAnalysis } from "../types.js";
import {
contentHash,
extractFileFingerprint,
compareFingerprints,
analyzeChanges,
type FileFingerprint,
type FingerprintStore,
} from "../fingerprint.js";
// Mock fs and path for analyzeChanges
vi.mock("node:fs", () => ({
readFileSync: vi.fn(),
existsSync: vi.fn(),
}));
import { readFileSync, existsSync } from "node:fs";
const mockedReadFileSync = vi.mocked(readFileSync);
const mockedExistsSync = vi.mocked(existsSync);
beforeEach(() => {
vi.clearAllMocks();
});
describe("contentHash", () => {
it("produces consistent SHA-256 hashes", () => {
const hash1 = contentHash("hello world");
const hash2 = contentHash("hello world");
expect(hash1).toBe(hash2);
expect(hash1).toMatch(/^[a-f0-9]{64}$/);
});
it("produces different hashes for different content", () => {
expect(contentHash("hello")).not.toBe(contentHash("world"));
});
});
describe("extractFileFingerprint", () => {
it("extracts function fingerprints from analysis", () => {
const analysis: StructuralAnalysis = {
functions: [
{ name: "main", lineRange: [1, 20], params: ["config", "options"], returnType: "void" },
{ name: "helper", lineRange: [22, 30], params: [], returnType: "string" },
],
classes: [],
imports: [],
exports: [{ name: "main", lineNumber: 1 }],
};
const fp = extractFileFingerprint("src/index.ts", "const x = 1;\n".repeat(30), analysis);
expect(fp.filePath).toBe("src/index.ts");
expect(fp.functions).toHaveLength(2);
expect(fp.functions[0]).toEqual({
name: "main",
params: ["config", "options"],
returnType: "void",
exported: true,
lineCount: 20,
});
expect(fp.functions[1]).toEqual({
name: "helper",
params: [],
returnType: "string",
exported: false,
lineCount: 9,
});
});
it("extracts class fingerprints", () => {
const analysis: StructuralAnalysis = {
functions: [],
classes: [
{ name: "MyClass", lineRange: [1, 50], methods: ["doStuff", "init"], properties: ["name"] },
],
imports: [],
exports: [{ name: "MyClass", lineNumber: 1 }],
};
const fp = extractFileFingerprint("src/my-class.ts", "x\n".repeat(50), analysis);
expect(fp.classes).toHaveLength(1);
expect(fp.classes[0]).toEqual({
name: "MyClass",
methods: ["doStuff", "init"],
properties: ["name"],
exported: true,
lineCount: 50,
});
});
it("extracts import and export fingerprints", () => {
const analysis: StructuralAnalysis = {
functions: [],
classes: [],
imports: [
{ source: "./utils", specifiers: ["format", "parse"], lineNumber: 1 },
{ source: "node:fs", specifiers: ["readFileSync"], lineNumber: 2 },
],
exports: [{ name: "main", lineNumber: 5 }, { name: "default", lineNumber: 10 }],
};
const fp = extractFileFingerprint("src/index.ts", "x\n", analysis);
expect(fp.imports).toHaveLength(2);
expect(fp.imports[0]).toEqual({ source: "./utils", specifiers: ["format", "parse"] });
expect(fp.exports).toEqual(["main", "default"]);
});
it("computes content hash and total lines", () => {
const content = "line1\nline2\nline3\n";
const analysis: StructuralAnalysis = {
functions: [],
classes: [],
imports: [],
exports: [],
};
const fp = extractFileFingerprint("src/empty.ts", content, analysis);
expect(fp.contentHash).toBe(contentHash(content));
expect(fp.totalLines).toBe(4); // 3 lines + trailing newline = 4 elements
});
});
describe("compareFingerprints", () => {
const baseFp: FileFingerprint = {
filePath: "src/index.ts",
contentHash: "abc123",
functions: [
{ name: "main", params: ["config"], returnType: "void", exported: true, lineCount: 20 },
],
classes: [],
imports: [{ source: "./utils", specifiers: ["format"] }],
exports: ["main"],
totalLines: 30,
hasStructuralAnalysis: true,
};
it("returns NONE when content hash is identical", () => {
const result = compareFingerprints(baseFp, { ...baseFp });
expect(result.changeLevel).toBe("NONE");
expect(result.details).toHaveLength(0);
});
it("returns COSMETIC when content changed but structure is identical", () => {
const newFp = { ...baseFp, contentHash: "different_hash" };
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("COSMETIC");
expect(result.details).toContain("internal logic changed (no structural impact)");
});
it("detects new functions", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
functions: [
...baseFp.functions,
{ name: "newFunc", params: [], exported: false, lineCount: 10 },
],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("new function: newFunc");
});
it("detects removed functions", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
functions: [],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("removed function: main");
});
it("detects parameter changes", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
functions: [
{ name: "main", params: ["config", "options"], returnType: "void", exported: true, lineCount: 20 },
],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("params changed: main");
});
it("detects export status changes", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
functions: [
{ name: "main", params: ["config"], returnType: "void", exported: false, lineCount: 20 },
],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("export status changed: main");
});
it("detects significant size changes (>50%)", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
functions: [
{ name: "main", params: ["config"], returnType: "void", exported: true, lineCount: 60 },
],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details.some((d) => d.includes("significant size change"))).toBe(true);
});
it("detects import changes", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
imports: [{ source: "./helpers", specifiers: ["doStuff"] }],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("imports changed");
});
it("detects export list changes", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
exports: ["main", "helper"],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("exports changed");
});
it("detects new and removed classes", () => {
const withClass: FileFingerprint = {
...baseFp,
contentHash: "different",
classes: [{ name: "MyClass", methods: ["init"], properties: [], exported: true, lineCount: 30 }],
hasStructuralAnalysis: true,
};
const result = compareFingerprints(baseFp, withClass);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("new class: MyClass");
});
it("detects class method changes", () => {
const oldFp: FileFingerprint = {
...baseFp,
classes: [{ name: "Foo", methods: ["a", "b"], properties: [], exported: true, lineCount: 30 }],
hasStructuralAnalysis: true,
};
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
classes: [{ name: "Foo", methods: ["a", "c"], properties: [], exported: true, lineCount: 30 }],
hasStructuralAnalysis: true,
};
const result = compareFingerprints(oldFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("methods changed: Foo");
});
it("does NOT mutate input arrays (sort must use spread-copy)", () => {
const oldFp: FileFingerprint = {
...baseFp,
classes: [{ name: "Foo", methods: ["b", "a"], properties: ["y", "x"], exported: true, lineCount: 30 }],
imports: [{ source: "./utils", specifiers: ["z", "a"] }],
hasStructuralAnalysis: true,
};
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
classes: [{ name: "Foo", methods: ["b", "a"], properties: ["y", "x"], exported: true, lineCount: 30 }],
imports: [{ source: "./utils", specifiers: ["z", "a"] }],
hasStructuralAnalysis: true,
};
// Snapshot original order before comparison
const oldMethodsBefore = [...oldFp.classes[0].methods];
const oldPropertiesBefore = [...oldFp.classes[0].properties];
const oldSpecifiersBefore = [...oldFp.imports[0].specifiers];
const newMethodsBefore = [...newFp.classes[0].methods];
const newPropertiesBefore = [...newFp.classes[0].properties];
const newSpecifiersBefore = [...newFp.imports[0].specifiers];
compareFingerprints(oldFp, newFp);
// Arrays must remain in their original order (not sorted in-place)
expect(oldFp.classes[0].methods).toEqual(oldMethodsBefore);
expect(oldFp.classes[0].properties).toEqual(oldPropertiesBefore);
expect(oldFp.imports[0].specifiers).toEqual(oldSpecifiersBefore);
expect(newFp.classes[0].methods).toEqual(newMethodsBefore);
expect(newFp.classes[0].properties).toEqual(newPropertiesBefore);
expect(newFp.imports[0].specifiers).toEqual(newSpecifiersBefore);
});
it("classifies as STRUCTURAL when hasStructuralAnalysis is false (no tree-sitter)", () => {
const oldFp: FileFingerprint = {
filePath: "config.yaml",
contentHash: "hash_old",
functions: [],
classes: [],
imports: [],
exports: [],
totalLines: 10,
hasStructuralAnalysis: false,
};
const newFp: FileFingerprint = {
filePath: "config.yaml",
contentHash: "hash_new",
functions: [],
classes: [],
imports: [],
exports: [],
totalLines: 12,
hasStructuralAnalysis: false,
};
const result = compareFingerprints(oldFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("no structural analysis available — conservative classification");
});
});
describe("analyzeChanges", () => {
const mockRegistry = {
analyzeFile: vi.fn(),
} as any;
const existingStore: FingerprintStore = {
version: "1.0.0",
gitCommitHash: "abc123",
generatedAt: "2026-01-01T00:00:00.000Z",
files: {
"src/index.ts": {
filePath: "src/index.ts",
contentHash: "hash_a",
functions: [{ name: "main", params: [], exported: true, lineCount: 20 }],
classes: [],
imports: [],
exports: ["main"],
totalLines: 30,
hasStructuralAnalysis: true,
},
"src/utils.ts": {
filePath: "src/utils.ts",
contentHash: "hash_b",
functions: [],
classes: [],
imports: [],
exports: [],
totalLines: 10,
hasStructuralAnalysis: true,
},
},
};
it("classifies new files as STRUCTURAL", () => {
mockedExistsSync.mockReturnValue(true);
mockedReadFileSync.mockReturnValue("new content");
mockRegistry.analyzeFile.mockReturnValue({
functions: [],
classes: [],
imports: [],
exports: [],
});
const result = analyzeChanges("/project", ["src/new-file.ts"], existingStore, mockRegistry);
expect(result.newFiles).toContain("src/new-file.ts");
expect(result.fileChanges[0].changeLevel).toBe("STRUCTURAL");
});
it("classifies deleted files as STRUCTURAL", () => {
mockedExistsSync.mockReturnValue(false);
const result = analyzeChanges("/project", ["src/utils.ts"], existingStore, mockRegistry);
expect(result.deletedFiles).toContain("src/utils.ts");
expect(result.fileChanges[0].changeLevel).toBe("STRUCTURAL");
});
it("classifies unchanged content as NONE", () => {
mockedExistsSync.mockReturnValue(true);
// Return content that produces the same hash
const content = "test content";
const hash = contentHash(content);
const store: FingerprintStore = {
...existingStore,
files: {
"src/index.ts": {
...existingStore.files["src/index.ts"],
contentHash: hash,
},
},
};
mockedReadFileSync.mockReturnValue(content);
mockRegistry.analyzeFile.mockReturnValue({
functions: [{ name: "main", lineRange: [1, 20], params: [] }],
classes: [],
imports: [],
exports: [{ name: "main", lineNumber: 1 }],
});
const result = analyzeChanges("/project", ["src/index.ts"], store, mockRegistry);
expect(result.unchangedFiles).toContain("src/index.ts");
});
it("ignores deleted files not in the store", () => {
mockedExistsSync.mockReturnValue(false);
const result = analyzeChanges("/project", ["src/unknown.ts"], existingStore, mockRegistry);
expect(result.deletedFiles).toHaveLength(0);
expect(result.fileChanges).toHaveLength(0);
});
});

View File

@@ -0,0 +1,123 @@
import { describe, it, expect } from "vitest";
import { FrameworkRegistry } from "../languages/framework-registry.js";
import { djangoConfig } from "../languages/frameworks/django.js";
import { reactConfig } from "../languages/frameworks/react.js";
describe("FrameworkRegistry", () => {
it("registers and retrieves a framework config by id", () => {
const registry = new FrameworkRegistry();
registry.register(djangoConfig);
expect(registry.getById("django")?.displayName).toBe("Django");
});
it("retrieves frameworks for a language", () => {
const registry = new FrameworkRegistry();
registry.register(djangoConfig);
registry.register(reactConfig);
const pythonFrameworks = registry.getForLanguage("python");
expect(pythonFrameworks).toHaveLength(1);
expect(pythonFrameworks[0].id).toBe("django");
});
it("returns empty array for unknown language", () => {
const registry = new FrameworkRegistry();
registry.register(djangoConfig);
expect(registry.getForLanguage("haskell")).toEqual([]);
});
describe("detectFrameworks", () => {
it("detects Django from requirements.txt", () => {
const registry = new FrameworkRegistry();
registry.register(djangoConfig);
const detected = registry.detectFrameworks({
"requirements.txt": "django==4.2\ncelery==5.3\n",
});
expect(detected).toHaveLength(1);
expect(detected[0].id).toBe("django");
});
it("detects React from package.json", () => {
const registry = new FrameworkRegistry();
registry.register(reactConfig);
const detected = registry.detectFrameworks({
"package.json": '{"dependencies": {"react": "^18.2.0", "react-dom": "^18.2.0"}}',
});
expect(detected).toHaveLength(1);
expect(detected[0].id).toBe("react");
});
it("detection is case-insensitive", () => {
const registry = new FrameworkRegistry();
registry.register(djangoConfig);
const detected = registry.detectFrameworks({
"requirements.txt": "Django==4.2\n",
});
expect(detected).toHaveLength(1);
});
it("returns empty array when no frameworks match", () => {
const registry = new FrameworkRegistry();
registry.register(djangoConfig);
const detected = registry.detectFrameworks({
"requirements.txt": "requests==2.31\n",
});
expect(detected).toEqual([]);
});
it("returns empty array for empty manifests", () => {
const registry = new FrameworkRegistry();
registry.register(djangoConfig);
expect(registry.detectFrameworks({})).toEqual([]);
});
it("does not duplicate detected frameworks", () => {
const registry = new FrameworkRegistry();
registry.register(djangoConfig);
const detected = registry.detectFrameworks({
"requirements.txt": "django==4.2\ndjango==4.2\n",
"pyproject.toml": '[project]\ndependencies = ["django>=4.0"]',
});
expect(detected).toHaveLength(1);
});
});
it("returns frameworks for all listed languages (cross-language)", () => {
const registry = FrameworkRegistry.createDefault();
// React lists both typescript and javascript
const tsFrameworks = registry.getForLanguage("typescript");
const jsFrameworks = registry.getForLanguage("javascript");
expect(tsFrameworks.some((f) => f.id === "react")).toBe(true);
expect(jsFrameworks.some((f) => f.id === "react")).toBe(true);
});
it("does not duplicate on re-registration", () => {
const registry = new FrameworkRegistry();
registry.register(djangoConfig);
registry.register(djangoConfig);
expect(registry.getForLanguage("python")).toHaveLength(1);
});
it("getForLanguage returns a copy, not the internal array", () => {
const registry = new FrameworkRegistry();
registry.register(djangoConfig);
const result = registry.getForLanguage("python");
result.push(reactConfig);
expect(registry.getForLanguage("python")).toHaveLength(1);
});
describe("createDefault", () => {
it("registers all 10 built-in framework configs", () => {
const registry = FrameworkRegistry.createDefault();
expect(registry.getAllFrameworks()).toHaveLength(10);
});
it("includes frameworks for multiple languages", () => {
const registry = FrameworkRegistry.createDefault();
expect(registry.getForLanguage("python").length).toBeGreaterThanOrEqual(3);
expect(registry.getForLanguage("typescript").length).toBeGreaterThanOrEqual(2);
expect(registry.getForLanguage("java").length).toBeGreaterThanOrEqual(1);
expect(registry.getForLanguage("ruby").length).toBeGreaterThanOrEqual(1);
expect(registry.getForLanguage("go").length).toBeGreaterThanOrEqual(1);
});
});
});

View File

@@ -0,0 +1,155 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createIgnoreFilter, DEFAULT_IGNORE_PATTERNS } from "../ignore-filter";
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("IgnoreFilter", () => {
let testDir: string;
beforeEach(() => {
testDir = join(tmpdir(), `ignore-filter-test-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
mkdirSync(join(testDir, ".understand-anything"), { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
describe("DEFAULT_IGNORE_PATTERNS", () => {
it("contains node_modules", () => {
expect(DEFAULT_IGNORE_PATTERNS).toContain("node_modules/");
});
it("contains .git", () => {
expect(DEFAULT_IGNORE_PATTERNS).toContain(".git/");
});
it("contains obj for .NET", () => {
expect(DEFAULT_IGNORE_PATTERNS).toContain("obj/");
});
it("does not contain bin (used by Node/Ruby CLI launchers)", () => {
expect(DEFAULT_IGNORE_PATTERNS).not.toContain("bin/");
});
it("contains build output directories", () => {
expect(DEFAULT_IGNORE_PATTERNS).toContain("dist/");
expect(DEFAULT_IGNORE_PATTERNS).toContain("build/");
expect(DEFAULT_IGNORE_PATTERNS).toContain("out/");
expect(DEFAULT_IGNORE_PATTERNS).toContain("coverage/");
});
});
describe("createIgnoreFilter with no user file", () => {
it("ignores files matching default patterns", () => {
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("node_modules/foo/bar.js")).toBe(true);
expect(filter.isIgnored("dist/index.js")).toBe(true);
expect(filter.isIgnored(".git/config")).toBe(true);
expect(filter.isIgnored("obj/Release/net8.0/app.dll")).toBe(true);
});
it("does not ignore source files", () => {
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("src/index.ts")).toBe(false);
expect(filter.isIgnored("README.md")).toBe(false);
expect(filter.isIgnored("package.json")).toBe(false);
});
it("ignores lock files", () => {
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("pnpm-lock.yaml")).toBe(true);
expect(filter.isIgnored("package-lock.json")).toBe(true);
expect(filter.isIgnored("yarn.lock")).toBe(true);
});
it("ignores binary/asset files", () => {
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("logo.png")).toBe(true);
expect(filter.isIgnored("font.woff2")).toBe(true);
expect(filter.isIgnored("doc.pdf")).toBe(true);
});
it("ignores generated files", () => {
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("bundle.min.js")).toBe(true);
expect(filter.isIgnored("style.min.css")).toBe(true);
expect(filter.isIgnored("source.map")).toBe(true);
});
it("ignores IDE directories", () => {
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored(".idea/workspace.xml")).toBe(true);
expect(filter.isIgnored(".vscode/settings.json")).toBe(true);
});
});
describe("createIgnoreFilter with user .understandignore", () => {
it("reads patterns from .understand-anything/.understandignore", () => {
writeFileSync(
join(testDir, ".understand-anything", ".understandignore"),
"# Exclude tests\n__tests__/\n*.test.ts\n"
);
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("__tests__/foo.test.ts")).toBe(true);
expect(filter.isIgnored("src/utils.test.ts")).toBe(true);
expect(filter.isIgnored("src/utils.ts")).toBe(false);
});
it("reads patterns from project root .understandignore", () => {
writeFileSync(
join(testDir, ".understandignore"),
"docs/\n"
);
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("docs/README.md")).toBe(true);
expect(filter.isIgnored("src/index.ts")).toBe(false);
});
it("handles # comments and blank lines", () => {
writeFileSync(
join(testDir, ".understand-anything", ".understandignore"),
"# This is a comment\n\n\nfixtures/\n\n# Another comment\n"
);
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("fixtures/data.json")).toBe(true);
expect(filter.isIgnored("src/index.ts")).toBe(false);
});
it("supports ! negation to override defaults", () => {
writeFileSync(
join(testDir, ".understand-anything", ".understandignore"),
"!dist/\n"
);
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("dist/index.js")).toBe(false);
});
it("supports ** recursive matching", () => {
writeFileSync(
join(testDir, ".understand-anything", ".understandignore"),
"**/snapshots/\n"
);
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("src/components/snapshots/Button.snap")).toBe(true);
expect(filter.isIgnored("snapshots/foo.snap")).toBe(true);
});
it("merges .understand-anything/ and root .understandignore", () => {
writeFileSync(
join(testDir, ".understand-anything", ".understandignore"),
"__tests__/\n"
);
writeFileSync(
join(testDir, ".understandignore"),
"fixtures/\n"
);
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("__tests__/foo.ts")).toBe(true);
expect(filter.isIgnored("fixtures/data.json")).toBe(true);
expect(filter.isIgnored("src/index.ts")).toBe(false);
});
});
});

View File

@@ -0,0 +1,162 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { generateStarterIgnoreFile } from "../ignore-generator";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("generateStarterIgnoreFile", () => {
let testDir: string;
beforeEach(() => {
testDir = join(tmpdir(), `ignore-gen-test-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it("includes a header comment explaining the file", () => {
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain(".understandignore");
expect(content).toContain("same as .gitignore");
expect(content).toContain("Built-in defaults");
});
it("all suggestions are commented out", () => {
mkdirSync(join(testDir, "__tests__"), { recursive: true });
mkdirSync(join(testDir, "docs"), { recursive: true });
const content = generateStarterIgnoreFile(testDir);
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
expect(lines).toHaveLength(0);
});
it("suggests __tests__ when directory exists", () => {
mkdirSync(join(testDir, "__tests__"), { recursive: true });
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# __tests__/");
});
it("suggests docs when directory exists", () => {
mkdirSync(join(testDir, "docs"), { recursive: true });
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# docs/");
});
it("suggests test and tests when they exist", () => {
mkdirSync(join(testDir, "test"), { recursive: true });
mkdirSync(join(testDir, "tests"), { recursive: true });
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# test/");
expect(content).toContain("# tests/");
});
it("suggests fixtures when directory exists", () => {
mkdirSync(join(testDir, "fixtures"), { recursive: true });
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# fixtures/");
});
it("suggests examples when directory exists", () => {
mkdirSync(join(testDir, "examples"), { recursive: true });
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# examples/");
});
it("suggests .storybook when directory exists", () => {
mkdirSync(join(testDir, ".storybook"), { recursive: true });
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# .storybook/");
});
it("suggests migrations when directory exists", () => {
mkdirSync(join(testDir, "migrations"), { recursive: true });
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# migrations/");
});
it("suggests scripts when directory exists", () => {
mkdirSync(join(testDir, "scripts"), { recursive: true });
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# scripts/");
});
it("always includes generic test file suggestions", () => {
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# *.snap");
expect(content).toContain("# *.test.*");
expect(content).toContain("# *.spec.*");
});
it("does not suggest directories that don't exist", () => {
const content = generateStarterIgnoreFile(testDir);
expect(content).not.toContain("# __tests__/");
expect(content).not.toContain("# .storybook/");
expect(content).not.toContain("# fixtures/");
});
describe(".gitignore integration", () => {
it("includes .gitignore patterns not covered by defaults", () => {
writeFileSync(join(testDir, ".gitignore"), ".env\nsecrets/\n*.pyc\n");
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("From .gitignore");
expect(content).toContain("# .env");
expect(content).toContain("# secrets/");
expect(content).toContain("# *.pyc");
});
it("excludes .gitignore patterns already in defaults", () => {
writeFileSync(join(testDir, ".gitignore"), "node_modules/\ndist/\n.env\n");
const content = generateStarterIgnoreFile(testDir);
// .env is not in defaults, should appear
expect(content).toContain("# .env");
// node_modules/ and dist/ are in defaults, should not appear in .gitignore section
const gitignoreSection = content.split("From .gitignore")[1]?.split("---")[0] ?? "";
expect(gitignoreSection).not.toContain("node_modules");
expect(gitignoreSection).not.toContain("dist");
});
it("skips .gitignore comments and blank lines", () => {
writeFileSync(join(testDir, ".gitignore"), "# a comment\n\n.env\n \n");
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# .env");
// Should not include the original comment as a pattern
const gitignoreSection = content.split("From .gitignore")[1]?.split("---")[0] ?? "";
expect(gitignoreSection).not.toContain("a comment");
});
it("handles .gitignore with trailing-slash normalization for defaults", () => {
// "dist" without trailing slash should still match "dist/" default
writeFileSync(join(testDir, ".gitignore"), "dist\ncoverage\n.env\n");
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("From .gitignore");
// Extract lines between the .gitignore header and the next section header
const lines = content.split("\n");
const headerIdx = lines.findIndex((l) => l.includes("From .gitignore"));
const nextSectionIdx = lines.findIndex((l, i) => i > headerIdx && l.startsWith("# ---"));
const sectionLines = lines.slice(headerIdx + 1, nextSectionIdx === -1 ? undefined : nextSectionIdx);
const patterns = sectionLines.filter((l) => l.startsWith("# ") && !l.startsWith("# ---")).map((l) => l.slice(2));
expect(patterns).toContain(".env");
expect(patterns).not.toContain("dist");
expect(patterns).not.toContain("coverage");
});
it("omits .gitignore section when no .gitignore exists", () => {
const content = generateStarterIgnoreFile(testDir);
expect(content).not.toContain("From .gitignore");
});
it("omits .gitignore section when all patterns are covered by defaults", () => {
writeFileSync(join(testDir, ".gitignore"), "node_modules/\ndist/\n*.lock\n");
const content = generateStarterIgnoreFile(testDir);
expect(content).not.toContain("From .gitignore");
});
it("all .gitignore suggestions are commented out", () => {
writeFileSync(join(testDir, ".gitignore"), ".env\nsecrets/\n*.pyc\n");
const content = generateStarterIgnoreFile(testDir);
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
expect(lines).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,157 @@
import { describe, it, expect } from "vitest";
import {
buildLanguageLessonPrompt,
parseLanguageLessonResponse,
detectLanguageConcepts,
} from "../analyzer/language-lesson.js";
import type { GraphNode, GraphEdge } from "../types.js";
import { typescriptConfig } from "../languages/configs/typescript.js";
const sampleNode: GraphNode = {
id: "function:auth:verifyToken",
type: "function",
name: "verifyToken",
filePath: "src/auth/verify.ts",
lineRange: [10, 35],
summary: "Verifies JWT tokens and extracts user payload using async/await",
tags: ["auth", "jwt", "async"],
complexity: "moderate",
};
const sampleEdges: GraphEdge[] = [
{
source: "function:auth:verifyToken",
target: "file:src/config.ts",
type: "reads_from",
direction: "forward",
weight: 0.6,
},
{
source: "file:src/middleware.ts",
target: "function:auth:verifyToken",
type: "calls",
direction: "forward",
weight: 0.8,
},
];
describe("language-lesson", () => {
describe("buildLanguageLessonPrompt", () => {
it("includes the node name and summary", () => {
const prompt = buildLanguageLessonPrompt(
sampleNode,
sampleEdges,
"typescript",
);
expect(prompt).toContain("verifyToken");
expect(prompt).toContain("JWT tokens");
});
it("includes the target language", () => {
const prompt = buildLanguageLessonPrompt(
sampleNode,
sampleEdges,
"typescript",
typescriptConfig,
);
expect(prompt).toContain("TypeScript");
});
it("includes relationship context", () => {
const prompt = buildLanguageLessonPrompt(
sampleNode,
sampleEdges,
"typescript",
);
expect(prompt).toContain("reads_from");
});
it("requests JSON output", () => {
const prompt = buildLanguageLessonPrompt(
sampleNode,
sampleEdges,
"typescript",
);
expect(prompt).toContain("JSON");
});
});
describe("parseLanguageLessonResponse", () => {
it("parses a valid response", () => {
const response = JSON.stringify({
languageNotes:
"Uses async/await for non-blocking token verification.",
concepts: [
{
name: "async/await",
explanation:
"The function uses async/await to handle asynchronous JWT verification.",
},
],
});
const result = parseLanguageLessonResponse(response);
expect(result.languageNotes).toBe(
"Uses async/await for non-blocking token verification.",
);
expect(result.concepts).toHaveLength(1);
expect(result.concepts[0].name).toBe("async/await");
expect(result.concepts[0].explanation).toContain("async/await");
});
it("extracts JSON from code blocks", () => {
const response = `Here is the analysis:
\`\`\`json
{
"languageNotes": "TypeScript generics used here.",
"concepts": [
{ "name": "generics", "explanation": "Type parameters enable reuse." }
]
}
\`\`\``;
const result = parseLanguageLessonResponse(response);
expect(result.languageNotes).toBe("TypeScript generics used here.");
expect(result.concepts).toHaveLength(1);
expect(result.concepts[0].name).toBe("generics");
});
it("returns empty result for invalid response", () => {
const result = parseLanguageLessonResponse("");
expect(result).toEqual({ languageNotes: "", concepts: [] });
});
});
describe("detectLanguageConcepts", () => {
it("detects async patterns from tags", () => {
const concepts = detectLanguageConcepts(sampleNode, "typescript");
expect(concepts).toContain("async/await");
});
it("detects middleware pattern", () => {
const middlewareNode: GraphNode = {
id: "function:middleware:auth",
type: "function",
name: "authMiddleware",
filePath: "src/middleware/auth.ts",
summary: "Express middleware for authentication",
tags: ["middleware", "auth"],
complexity: "moderate",
};
const concepts = detectLanguageConcepts(middlewareNode, "typescript");
expect(concepts).toContain("middleware pattern");
});
it("returns empty for nodes with no detectable concepts", () => {
const plainNode: GraphNode = {
id: "file:src/config.ts",
type: "file",
name: "config.ts",
filePath: "src/config.ts",
summary: "Exports configuration values from environment variables",
tags: ["config"],
complexity: "simple",
};
const concepts = detectLanguageConcepts(plainNode, "typescript");
expect(concepts).toEqual([]);
});
});
});

View File

@@ -0,0 +1,198 @@
import { describe, it, expect } from "vitest";
import { LanguageRegistry } from "../languages/language-registry.js";
import { StrictLanguageConfigSchema } from "../languages/types.js";
import { typescriptConfig } from "../languages/configs/typescript.js";
import { pythonConfig } from "../languages/configs/python.js";
describe("LanguageRegistry", () => {
it("registers and retrieves a language config by id", () => {
const registry = new LanguageRegistry();
registry.register(typescriptConfig);
expect(registry.getById("typescript")).toEqual(typescriptConfig);
});
it("retrieves config by file extension", () => {
const registry = new LanguageRegistry();
registry.register(typescriptConfig);
expect(registry.getByExtension(".ts")?.id).toBe("typescript");
expect(registry.getByExtension(".tsx")?.id).toBe("typescript");
});
it("retrieves config for a file path", () => {
const registry = new LanguageRegistry();
registry.register(typescriptConfig);
registry.register(pythonConfig);
expect(registry.getForFile("src/index.ts")?.id).toBe("typescript");
expect(registry.getForFile("app/models.py")?.id).toBe("python");
});
it("returns null for unknown extensions", () => {
const registry = new LanguageRegistry();
registry.register(typescriptConfig);
expect(registry.getByExtension(".xyz")).toBeNull();
expect(registry.getForFile("file.unknown")).toBeNull();
});
it("returns null for files without extensions and no filename match", () => {
const registry = new LanguageRegistry();
expect(registry.getForFile("SOMEFILE")).toBeNull();
});
it("lists all registered languages", () => {
const registry = new LanguageRegistry();
registry.register(typescriptConfig);
registry.register(pythonConfig);
const all = registry.getAllLanguages();
expect(all).toHaveLength(2);
expect(all.map(c => c.id)).toContain("typescript");
expect(all.map(c => c.id)).toContain("python");
});
describe("createDefault", () => {
it("registers all 40 built-in language configs", () => {
const registry = LanguageRegistry.createDefault();
const all = registry.getAllLanguages();
expect(all.length).toBe(40);
});
it("maps all expected extensions", () => {
const registry = LanguageRegistry.createDefault();
expect(registry.getByExtension(".ts")?.id).toBe("typescript");
expect(registry.getByExtension(".py")?.id).toBe("python");
expect(registry.getByExtension(".go")?.id).toBe("go");
expect(registry.getByExtension(".rs")?.id).toBe("rust");
expect(registry.getByExtension(".java")?.id).toBe("java");
expect(registry.getByExtension(".rb")?.id).toBe("ruby");
expect(registry.getByExtension(".php")?.id).toBe("php");
expect(registry.getByExtension(".swift")?.id).toBe("swift");
expect(registry.getByExtension(".kt")?.id).toBe("kotlin");
expect(registry.getByExtension(".cs")?.id).toBe("csharp");
expect(registry.getByExtension(".cpp")?.id).toBe("cpp");
expect(registry.getByExtension(".c")?.id).toBe("c");
expect(registry.getByExtension(".h")?.id).toBe("c");
expect(registry.getByExtension(".lua")?.id).toBe("lua");
expect(registry.getByExtension(".js")?.id).toBe("javascript");
});
it("has no duplicate extension mappings across configs", () => {
const registry = LanguageRegistry.createDefault();
const all = registry.getAllLanguages();
const allExtensions: string[] = [];
for (const config of all) {
allExtensions.push(...config.extensions);
}
const unique = new Set(allExtensions);
expect(unique.size).toBe(allExtensions.length);
});
it("every config has at least one concept", () => {
const registry = LanguageRegistry.createDefault();
for (const config of registry.getAllLanguages()) {
expect(config.concepts.length).toBeGreaterThan(0);
}
});
});
describe("Non-code language configs", () => {
it("detects all non-code file types via extension", () => {
const registry = LanguageRegistry.createDefault();
const expectations: [string, string][] = [
["README.md", "markdown"],
["config.yaml", "yaml"],
["package.json", "json"],
["config.toml", "toml"],
[".env", "env"],
["pom.xml", "xml"],
["Dockerfile", "dockerfile"],
["schema.sql", "sql"],
["schema.graphql", "graphql"],
["types.proto", "protobuf"],
["main.tf", "terraform"],
["Makefile", "makefile"],
["deploy.sh", "shell"],
["index.html", "html"],
["styles.css", "css"],
["data.csv", "csv"],
["deploy.ps1", "powershell"],
];
for (const [file, expectedId] of expectations) {
const config = registry.getForFile(file);
expect(config?.id, `${file} should be detected as ${expectedId}`).toBe(expectedId);
}
});
it("detects filename-based configs (Dockerfile, Makefile, Jenkinsfile)", () => {
const registry = LanguageRegistry.createDefault();
expect(registry.getForFile("Dockerfile")?.id).toBe("dockerfile");
expect(registry.getForFile("Makefile")?.id).toBe("makefile");
expect(registry.getForFile("Jenkinsfile")?.id).toBe("jenkinsfile");
expect(registry.getForFile("src/Dockerfile")?.id).toBe("dockerfile");
expect(registry.getForFile("build/Makefile")?.id).toBe("makefile");
});
it("detects filename-based configs for docker-compose", () => {
const registry = LanguageRegistry.createDefault();
expect(registry.getForFile("docker-compose.yml")?.id).toBe("docker-compose");
expect(registry.getForFile("docker-compose.yaml")?.id).toBe("docker-compose");
expect(registry.getForFile("compose.yml")?.id).toBe("docker-compose");
});
it("detects .env file variants", () => {
const registry = LanguageRegistry.createDefault();
expect(registry.getForFile(".env")?.id).toBe("env");
expect(registry.getForFile(".env.local")?.id).toBe("env");
expect(registry.getForFile(".env.production")?.id).toBe("env");
});
});
describe("StrictLanguageConfigSchema refinement", () => {
it("rejects configs with empty extensions AND no filenames", () => {
const result = StrictLanguageConfigSchema.safeParse({
id: "empty-lang",
displayName: "Empty",
extensions: [],
concepts: ["nothing"],
filePatterns: { entryPoints: [], barrels: [], tests: [], config: [] },
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain("at least one extension or filename");
}
});
it("rejects configs with empty extensions AND empty filenames", () => {
const result = StrictLanguageConfigSchema.safeParse({
id: "empty-lang",
displayName: "Empty",
extensions: [],
filenames: [],
concepts: ["nothing"],
filePatterns: { entryPoints: [], barrels: [], tests: [], config: [] },
});
expect(result.success).toBe(false);
});
it("accepts configs with extensions but no filenames", () => {
const result = StrictLanguageConfigSchema.safeParse({
id: "ext-lang",
displayName: "ExtLang",
extensions: [".ext"],
concepts: ["something"],
filePatterns: { entryPoints: [], barrels: [], tests: [], config: [] },
});
expect(result.success).toBe(true);
});
it("accepts configs with filenames but empty extensions", () => {
const result = StrictLanguageConfigSchema.safeParse({
id: "filename-lang",
displayName: "FilenameLang",
extensions: [],
filenames: ["Specialfile"],
concepts: ["something"],
filePatterns: { entryPoints: [], barrels: [], tests: [], config: [] },
});
expect(result.success).toBe(true);
});
});
});

View File

@@ -0,0 +1,188 @@
import { describe, it, expect } from "vitest";
import {
detectLayers,
buildLayerDetectionPrompt,
parseLayerDetectionResponse,
applyLLMLayers,
} from "../analyzer/layer-detector.js";
import type { KnowledgeGraph, GraphNode } from "../types.js";
const makeNode = (
overrides: Partial<GraphNode> & { id: string; name: string },
): GraphNode => ({
type: "file",
summary: "",
tags: [],
complexity: "simple",
...overrides,
});
const makeGraph = (nodes: GraphNode[]): KnowledgeGraph => ({
version: "1.0.0",
project: {
name: "test-project",
languages: ["typescript"],
frameworks: [],
description: "A test project",
analyzedAt: new Date().toISOString(),
gitCommitHash: "abc123",
},
nodes,
edges: [],
layers: [],
tour: [],
});
describe("detectLayers", () => {
it("detects API/routes layer from file paths", () => {
const graph = makeGraph([
makeNode({ id: "f1", name: "users.ts", filePath: "src/routes/users.ts" }),
makeNode({ id: "f2", name: "auth.ts", filePath: "src/controllers/auth.ts" }),
makeNode({ id: "f3", name: "health.ts", filePath: "src/api/health.ts" }),
]);
const layers = detectLayers(graph);
const apiLayer = layers.find((l) => l.name === "API Layer");
expect(apiLayer).toBeDefined();
expect(apiLayer!.nodeIds).toContain("f1");
expect(apiLayer!.nodeIds).toContain("f2");
expect(apiLayer!.nodeIds).toContain("f3");
});
it("detects Data layer from model/entity/repository paths", () => {
const graph = makeGraph([
makeNode({ id: "f1", name: "User.ts", filePath: "src/models/User.ts" }),
makeNode({ id: "f2", name: "Post.ts", filePath: "src/entity/Post.ts" }),
makeNode({ id: "f3", name: "UserRepo.ts", filePath: "src/repository/UserRepo.ts" }),
]);
const layers = detectLayers(graph);
const dataLayer = layers.find((l) => l.name === "Data Layer");
expect(dataLayer).toBeDefined();
expect(dataLayer!.nodeIds).toContain("f1");
expect(dataLayer!.nodeIds).toContain("f2");
expect(dataLayer!.nodeIds).toContain("f3");
});
it("puts unmatched file nodes in Core layer", () => {
const graph = makeGraph([
makeNode({ id: "f1", name: "main.ts", filePath: "src/main.ts" }),
makeNode({ id: "f2", name: "app.ts", filePath: "src/app.ts" }),
]);
const layers = detectLayers(graph);
const coreLayer = layers.find((l) => l.name === "Core");
expect(coreLayer).toBeDefined();
expect(coreLayer!.nodeIds).toContain("f1");
expect(coreLayer!.nodeIds).toContain("f2");
});
it("assigns unique kebab-case IDs to each layer", () => {
const graph = makeGraph([
makeNode({ id: "f1", name: "users.ts", filePath: "src/routes/users.ts" }),
makeNode({ id: "f2", name: "User.ts", filePath: "src/models/User.ts" }),
makeNode({ id: "f3", name: "main.ts", filePath: "src/main.ts" }),
]);
const layers = detectLayers(graph);
const ids = layers.map((l) => l.id);
// All IDs should start with "layer:"
for (const id of ids) {
expect(id).toMatch(/^layer:/);
}
// All IDs should be unique
expect(new Set(ids).size).toBe(ids.length);
});
it("only assigns file-type nodes, ignoring functions and classes", () => {
const graph = makeGraph([
makeNode({ id: "f1", name: "users.ts", type: "file", filePath: "src/routes/users.ts" }),
makeNode({ id: "fn1", name: "getUser", type: "function", filePath: "src/routes/users.ts" }),
makeNode({ id: "c1", name: "UserController", type: "class", filePath: "src/routes/users.ts" }),
]);
const layers = detectLayers(graph);
const allNodeIds = layers.flatMap((l) => l.nodeIds);
expect(allNodeIds).toContain("f1");
expect(allNodeIds).not.toContain("fn1");
expect(allNodeIds).not.toContain("c1");
});
});
describe("buildLayerDetectionPrompt", () => {
it("contains file paths and mentions JSON in the prompt", () => {
const graph = makeGraph([
makeNode({ id: "f1", name: "index.ts", filePath: "src/index.ts" }),
makeNode({ id: "f2", name: "app.ts", filePath: "src/app.ts" }),
]);
const prompt = buildLayerDetectionPrompt(graph);
expect(prompt).toContain("src/index.ts");
expect(prompt).toContain("src/app.ts");
expect(prompt).toContain("JSON");
});
});
describe("parseLayerDetectionResponse", () => {
it("parses a valid JSON response", () => {
const response = JSON.stringify([
{
name: "API",
description: "Handles HTTP requests",
filePatterns: ["src/routes/", "src/controllers/"],
},
{
name: "Data",
description: "Database models and queries",
filePatterns: ["src/models/"],
},
]);
const result = parseLayerDetectionResponse(response);
expect(result).not.toBeNull();
expect(result!.length).toBe(2);
expect(result![0].name).toBe("API");
expect(result![0].filePatterns).toEqual(["src/routes/", "src/controllers/"]);
});
it("parses JSON wrapped in markdown fences", () => {
const response = `Here are the layers:
\`\`\`json
[
{ "name": "UI", "description": "Frontend components", "filePatterns": ["src/components/"] }
]
\`\`\``;
const result = parseLayerDetectionResponse(response);
expect(result).not.toBeNull();
expect(result!.length).toBe(1);
expect(result![0].name).toBe("UI");
});
it("returns null for invalid/unparseable input", () => {
expect(parseLayerDetectionResponse("not json at all")).toBeNull();
expect(parseLayerDetectionResponse("{}")).toBeNull();
expect(parseLayerDetectionResponse("")).toBeNull();
});
});
describe("applyLLMLayers", () => {
it("assigns file nodes to LLM-provided layers and puts unmatched in Other", () => {
const graph = makeGraph([
makeNode({ id: "f1", name: "users.ts", filePath: "src/routes/users.ts" }),
makeNode({ id: "f2", name: "User.ts", filePath: "src/models/User.ts" }),
makeNode({ id: "f3", name: "main.ts", filePath: "src/main.ts" }),
]);
const llmLayers = [
{ name: "API", description: "HTTP endpoints", filePatterns: ["src/routes/"] },
{ name: "Data", description: "Models", filePatterns: ["src/models/"] },
];
const layers = applyLLMLayers(graph, llmLayers);
const apiLayer = layers.find((l) => l.name === "API");
expect(apiLayer).toBeDefined();
expect(apiLayer!.nodeIds).toContain("f1");
const dataLayer = layers.find((l) => l.name === "Data");
expect(dataLayer).toBeDefined();
expect(dataLayer!.nodeIds).toContain("f2");
const otherLayer = layers.find((l) => l.name === "Other");
expect(otherLayer).toBeDefined();
expect(otherLayer!.nodeIds).toContain("f3");
});
});

View File

@@ -0,0 +1,498 @@
import { describe, it, expect } from "vitest";
import {
normalizeNodeId,
normalizeComplexity,
normalizeBatchOutput,
} from "../analyzer/normalize-graph.js";
import { validateGraph } from "../schema.js";
describe("normalizeNodeId", () => {
it("passes through a correct file ID unchanged", () => {
expect(
normalizeNodeId("file:src/index.ts", { type: "file" }),
).toBe("file:src/index.ts");
});
it("passes through a correct func ID unchanged", () => {
expect(
normalizeNodeId("func:src/utils.ts:formatDate", { type: "function" }),
).toBe("func:src/utils.ts:formatDate");
});
it("passes through a correct class ID unchanged", () => {
expect(
normalizeNodeId("class:src/models/User.ts:User", { type: "class" }),
).toBe("class:src/models/User.ts:User");
});
it("fixes double-prefixed IDs", () => {
expect(
normalizeNodeId("file:file:src/foo.ts", { type: "file" }),
).toBe("file:src/foo.ts");
});
it("strips project-name prefix when valid prefix follows", () => {
expect(
normalizeNodeId("my-project:file:src/foo.ts", { type: "file" }),
).toBe("file:src/foo.ts");
});
it("strips project-name prefix and adds correct prefix for bare path", () => {
expect(
normalizeNodeId("my-project:src/foo.ts", { type: "file" }),
).toBe("file:src/foo.ts");
});
it("adds file: prefix to bare paths", () => {
expect(
normalizeNodeId("frontend/src/utils/constants.ts", { type: "file" }),
).toBe("file:frontend/src/utils/constants.ts");
});
it("reconstructs func ID from filePath and name for bare paths", () => {
expect(
normalizeNodeId("formatDate", {
type: "function",
filePath: "src/utils.ts",
name: "formatDate",
}),
).toBe("func:src/utils.ts:formatDate");
});
it("reconstructs class ID from filePath and name for bare paths", () => {
expect(
normalizeNodeId("User", {
type: "class",
filePath: "src/models/User.ts",
name: "User",
}),
).toBe("class:src/models/User.ts:User");
});
it("trims whitespace", () => {
expect(
normalizeNodeId(" file:src/foo.ts ", { type: "file" }),
).toBe("file:src/foo.ts");
});
it("handles module: and concept: prefixes", () => {
expect(
normalizeNodeId("module:auth", { type: "module" }),
).toBe("module:auth");
expect(
normalizeNodeId("concept:caching", { type: "concept" }),
).toBe("concept:caching");
});
it("handles project-name prefix before a valid non-code prefix", () => {
expect(
normalizeNodeId("my-project:service:docker-compose.yml", {
type: "file",
}),
).toBe("service:docker-compose.yml");
});
it("returns empty string for empty input", () => {
expect(normalizeNodeId("", { type: "file" })).toBe("");
});
it("falls back to untouched ID for unknown node type", () => {
expect(normalizeNodeId("some-id", { type: "widget" as any })).toBe("some-id");
});
it("passes through non-code type IDs unchanged", () => {
expect(normalizeNodeId("config:tsconfig.json", { type: "config" })).toBe("config:tsconfig.json");
expect(normalizeNodeId("document:README.md", { type: "document" })).toBe("document:README.md");
expect(normalizeNodeId("service:docker-compose.yml", { type: "service" })).toBe("service:docker-compose.yml");
expect(normalizeNodeId("table:migrations/001.sql:users", { type: "table" })).toBe("table:migrations/001.sql:users");
expect(normalizeNodeId("endpoint:src/routes.ts:GET /api/users", { type: "endpoint" })).toBe("endpoint:src/routes.ts:GET /api/users");
expect(normalizeNodeId("pipeline:.github/workflows/ci.yml", { type: "pipeline" })).toBe("pipeline:.github/workflows/ci.yml");
expect(normalizeNodeId("schema:schema.graphql", { type: "schema" })).toBe("schema:schema.graphql");
expect(normalizeNodeId("resource:main.tf", { type: "resource" })).toBe("resource:main.tf");
});
it("adds prefix for bare paths with non-code types", () => {
expect(normalizeNodeId("tsconfig.json", { type: "config" })).toBe("config:tsconfig.json");
expect(normalizeNodeId("README.md", { type: "document" })).toBe("document:README.md");
});
it("strips project-name prefix from non-code type IDs", () => {
expect(normalizeNodeId("my-project:config:tsconfig.json", { type: "config" })).toBe("config:tsconfig.json");
});
});
describe("normalizeComplexity", () => {
it("passes through valid values unchanged", () => {
expect(normalizeComplexity("simple")).toBe("simple");
expect(normalizeComplexity("moderate")).toBe("moderate");
expect(normalizeComplexity("complex")).toBe("complex");
});
it("maps 'low' to 'simple'", () => {
expect(normalizeComplexity("low")).toBe("simple");
});
it("maps 'high' to 'complex'", () => {
expect(normalizeComplexity("high")).toBe("complex");
});
it("maps 'medium' to 'moderate'", () => {
expect(normalizeComplexity("medium")).toBe("moderate");
});
it("maps other aliases from upstream COMPLEXITY_ALIASES", () => {
expect(normalizeComplexity("easy")).toBe("simple");
expect(normalizeComplexity("hard")).toBe("complex");
expect(normalizeComplexity("difficult")).toBe("complex");
expect(normalizeComplexity("intermediate")).toBe("moderate");
});
it("is case-insensitive", () => {
expect(normalizeComplexity("LOW")).toBe("simple");
expect(normalizeComplexity("High")).toBe("complex");
expect(normalizeComplexity("MODERATE")).toBe("moderate");
});
it("maps numeric 1-3 to simple", () => {
expect(normalizeComplexity(1)).toBe("simple");
expect(normalizeComplexity(3)).toBe("simple");
});
it("maps numeric 4-6 to moderate", () => {
expect(normalizeComplexity(4)).toBe("moderate");
expect(normalizeComplexity(6)).toBe("moderate");
});
it("maps numeric 7-10 to complex", () => {
expect(normalizeComplexity(7)).toBe("complex");
expect(normalizeComplexity(10)).toBe("complex");
});
it("defaults free-text to moderate", () => {
expect(normalizeComplexity("detailed")).toBe("moderate");
expect(normalizeComplexity("very complex with many deps")).toBe("moderate");
});
it("defaults undefined/null to moderate", () => {
expect(normalizeComplexity(undefined)).toBe("moderate");
expect(normalizeComplexity(null)).toBe("moderate");
});
it("defaults zero and negative numbers to moderate", () => {
expect(normalizeComplexity(0)).toBe("moderate");
expect(normalizeComplexity(-5)).toBe("moderate");
});
});
describe("normalizeBatchOutput", () => {
it("normalizes IDs and numeric complexity, rewrites edges", () => {
const result = normalizeBatchOutput({
nodes: [
{
id: "file:src/good.ts",
type: "file",
name: "good.ts",
filePath: "src/good.ts",
summary: "A good file",
tags: ["util"],
complexity: "simple",
},
{
id: "my-project:file:src/bad.ts",
type: "file",
name: "bad.ts",
filePath: "src/bad.ts",
summary: "Project-prefixed",
tags: ["api"],
complexity: "simple",
},
{
id: "src/bare.ts",
type: "file",
name: "bare.ts",
filePath: "src/bare.ts",
summary: "Bare path",
tags: [],
complexity: 4,
},
],
edges: [
{
source: "file:src/good.ts",
target: "my-project:file:src/bad.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
{
source: "src/bare.ts",
target: "file:src/good.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
],
});
expect(result.nodes).toHaveLength(3);
expect(result.nodes[0].id).toBe("file:src/good.ts");
expect(result.nodes[1].id).toBe("file:src/bad.ts");
expect(result.nodes[2].id).toBe("file:src/bare.ts");
// Only numeric complexity is fixed here; string aliases are upstream's job
expect(result.nodes[2].complexity).toBe("moderate");
// Edges should be rewritten through the ID map
expect(result.edges).toHaveLength(2);
expect(result.edges[0].source).toBe("file:src/good.ts");
expect(result.edges[0].target).toBe("file:src/bad.ts");
expect(result.edges[1].source).toBe("file:src/bare.ts");
expect(result.stats.idsFixed).toBe(2);
expect(result.stats.complexityFixed).toBe(1); // only the numeric one
expect(result.stats.edgesRewritten).toBe(2);
expect(result.stats.danglingEdgesDropped).toBe(0);
});
it("drops dangling edges after normalization", () => {
const result = normalizeBatchOutput({
nodes: [
{
id: "file:src/a.ts",
type: "file",
name: "a.ts",
summary: "File A",
tags: [],
complexity: "simple",
},
],
edges: [
{
source: "file:src/a.ts",
target: "file:src/nonexistent.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
],
});
expect(result.edges).toHaveLength(0);
expect(result.stats.danglingEdgesDropped).toBe(1);
expect(result.stats.droppedEdges).toHaveLength(1);
expect(result.stats.droppedEdges[0]).toEqual({
source: "file:src/a.ts",
target: "file:src/nonexistent.ts",
type: "imports",
reason: "missing-target",
});
});
it("deduplicates nodes keeping last occurrence", () => {
const result = normalizeBatchOutput({
nodes: [
{
id: "file:src/a.ts",
type: "file",
name: "a.ts",
summary: "First version",
tags: [],
complexity: "simple",
},
{
id: "file:src/a.ts",
type: "file",
name: "a.ts",
summary: "Second version",
tags: ["updated"],
complexity: "complex",
},
],
edges: [],
});
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].summary).toBe("Second version");
});
it("deduplicates edges after ID rewriting", () => {
const result = normalizeBatchOutput({
nodes: [
{
id: "file:src/a.ts",
type: "file",
name: "a.ts",
summary: "A",
tags: [],
complexity: "simple",
},
{
id: "file:src/b.ts",
type: "file",
name: "b.ts",
summary: "B",
tags: [],
complexity: "simple",
},
],
edges: [
{
source: "file:src/a.ts",
target: "file:src/b.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
{
source: "proj:file:src/a.ts",
target: "file:src/b.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
],
});
// Both edges resolve to the same source after normalization — deduplicated
expect(result.edges).toHaveLength(1);
});
it("returns accurate stats", () => {
const result = normalizeBatchOutput({
nodes: [
{
id: "file:src/ok.ts",
type: "file",
name: "ok.ts",
summary: "OK",
tags: [],
complexity: "simple",
},
{
id: "proj:file:src/fix.ts",
type: "file",
name: "fix.ts",
summary: "Needs fix",
tags: [],
complexity: 2,
},
],
edges: [
{
source: "proj:file:src/fix.ts",
target: "file:src/ok.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
{
source: "file:src/ok.ts",
target: "file:src/gone.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
],
});
expect(result.stats.idsFixed).toBe(1);
expect(result.stats.complexityFixed).toBe(1);
expect(result.stats.edgesRewritten).toBe(1);
expect(result.stats.danglingEdgesDropped).toBe(1);
expect(result.edges).toHaveLength(1);
});
it("resolves edge endpoints with different malformed variants than node IDs", () => {
const result = normalizeBatchOutput({
nodes: [
{
id: "src/bare.ts",
type: "file",
name: "bare.ts",
filePath: "src/bare.ts",
summary: "Bare",
tags: [],
complexity: "simple",
},
{
id: "file:src/target.ts",
type: "file",
name: "target.ts",
filePath: "src/target.ts",
summary: "Target",
tags: [],
complexity: "simple",
},
],
edges: [
{
source: "my-project:file:src/bare.ts",
target: "file:src/target.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
],
});
expect(result.edges).toHaveLength(1);
expect(result.edges[0].source).toBe("file:src/bare.ts");
expect(result.edges[0].target).toBe("file:src/target.ts");
});
});
describe("normalizeBatchOutput integration", () => {
it("produces output that passes validateGraph after wrapping", () => {
const result = normalizeBatchOutput({
nodes: [
{
id: "my-project:file:src/index.ts",
type: "file",
name: "index.ts",
filePath: "src/index.ts",
summary: "Entry point",
tags: ["entry"],
complexity: 3,
},
{
id: "src/utils.ts",
type: "file",
name: "utils.ts",
filePath: "src/utils.ts",
summary: "Utilities",
tags: [],
complexity: "simple",
},
],
edges: [
{
source: "my-project:file:src/index.ts",
target: "src/utils.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
],
});
const graph = {
version: "1.0.0",
project: {
name: "test",
languages: ["typescript"],
frameworks: [],
description: "Test project",
analyzedAt: new Date().toISOString(),
gitCommitHash: "abc123",
},
nodes: result.nodes,
edges: result.edges,
layers: [],
tour: [],
};
const validation = validateGraph(graph);
expect(validation.success).toBe(true);
expect(validation.data?.nodes).toHaveLength(2);
expect(validation.data?.edges).toHaveLength(1);
});
});

View File

@@ -0,0 +1,629 @@
import { describe, it, expect } from "vitest";
import { MarkdownParser } from "../plugins/parsers/markdown-parser.js";
import { YAMLConfigParser } from "../plugins/parsers/yaml-parser.js";
import { JSONConfigParser, stripJsoncSyntax } from "../plugins/parsers/json-parser.js";
import { TOMLParser } from "../plugins/parsers/toml-parser.js";
import { EnvParser } from "../plugins/parsers/env-parser.js";
import { DockerfileParser } from "../plugins/parsers/dockerfile-parser.js";
import { SQLParser } from "../plugins/parsers/sql-parser.js";
import { GraphQLParser } from "../plugins/parsers/graphql-parser.js";
import { ProtobufParser } from "../plugins/parsers/protobuf-parser.js";
import { TerraformParser } from "../plugins/parsers/terraform-parser.js";
import { MakefileParser } from "../plugins/parsers/makefile-parser.js";
import { ShellParser } from "../plugins/parsers/shell-parser.js";
import { registerAllParsers } from "../plugins/parsers/index.js";
import { PluginRegistry } from "../plugins/registry.js";
describe("MarkdownParser", () => {
const parser = new MarkdownParser();
it("extracts heading sections", () => {
const content = "# Title\n\nIntro\n\n## Section A\n\nContent A\n\n### Subsection\n\nContent B";
const result = parser.analyzeFile("README.md", content);
expect(result.sections).toHaveLength(3);
expect(result.sections![0]).toMatchObject({ name: "Title", level: 1 });
expect(result.sections![1]).toMatchObject({ name: "Section A", level: 2 });
expect(result.sections![2]).toMatchObject({ name: "Subsection", level: 3 });
});
it("extracts YAML front matter as imports", () => {
const content = "---\ntitle: Test\ntags: [a, b]\n---\n# Content";
const result = parser.analyzeFile("post.md", content);
expect(result.imports).toHaveLength(0);
});
it("extracts file references", () => {
const content = "See [guide](./docs/guide.md) and ![img](./assets/logo.png)";
const refs = parser.extractReferences!("README.md", content);
expect(refs).toHaveLength(2);
expect(refs[0]).toMatchObject({ target: "./docs/guide.md", referenceType: "file" });
expect(refs[1]).toMatchObject({ target: "./assets/logo.png", referenceType: "image" });
});
it("skips external URLs in references", () => {
const content = "[link](https://example.com) and [local](./file.md)";
const refs = parser.extractReferences!("README.md", content);
expect(refs).toHaveLength(1);
expect(refs[0].target).toBe("./file.md");
});
it("returns empty sections for empty content", () => {
const result = parser.analyzeFile("empty.md", "");
expect(result.sections).toHaveLength(0);
});
it("ignores headings inside fenced code blocks", () => {
// Regression: lines inside ``` blocks that look like shell comments
// (`# install`, `# build`) used to register as level-1 sections.
const content = [
"# Real Title",
"",
"Some intro.",
"",
"```bash",
"# install",
"npm install",
"# build",
"npm run build",
"```",
"",
"## Real Section",
].join("\n");
const result = parser.analyzeFile("README.md", content);
expect(result.sections!.map((s) => s.name)).toEqual(["Real Title", "Real Section"]);
});
it("re-enters heading detection after the fence closes", () => {
const content = [
"```",
"# fake",
"```",
"# After fence",
].join("\n");
const result = parser.analyzeFile("doc.md", content);
expect(result.sections!.map((s) => s.name)).toEqual(["After fence"]);
});
});
describe("YAMLConfigParser", () => {
const parser = new YAMLConfigParser();
it("extracts top-level key sections", () => {
const content = "name: my-app\nversion: 1.0\nservices:\n web:\n image: node\n db:\n image: postgres";
const result = parser.analyzeFile("config.yaml", content);
expect(result.sections).toBeDefined();
expect(result.sections!.length).toBeGreaterThanOrEqual(3);
expect(result.sections!.map(s => s.name)).toContain("name");
expect(result.sections!.map(s => s.name)).toContain("services");
});
it("handles invalid YAML gracefully", () => {
const content = "invalid: yaml: content: [[[";
const result = parser.analyzeFile("broken.yaml", content);
expect(result.sections).toBeDefined();
});
it("declares yaml-flavored special formats so the registry can route them here", () => {
// Regression: docker-compose / kubernetes / github-actions / openapi
// were tagged with non-`yaml` ids by LanguageRegistry, so the parser
// never matched and the file got zero structural extraction.
expect(parser.languages).toEqual(expect.arrayContaining([
"yaml", "kubernetes", "docker-compose", "github-actions", "openapi",
]));
});
it("recognizes quoted top-level keys (e.g. GitHub Actions `\"on\"`)", () => {
const content = '"on":\n push:\n branches: [main]\nname: ci\n';
const result = parser.analyzeFile(".github/workflows/ci.yml", content);
expect(result.sections!.map((s) => s.name)).toEqual(expect.arrayContaining(["on", "name"]));
});
it("emits one section per entry for array-root YAML documents", () => {
const content = "- name: alpha\n port: 80\n- name: beta\n port: 443\n";
const result = parser.analyzeFile("list.yaml", content);
expect(result.sections!.map((s) => s.name)).toEqual(["alpha", "beta"]);
});
});
describe("JSONConfigParser", () => {
const parser = new JSONConfigParser();
it("extracts top-level key sections", () => {
const content = '{\n "name": "my-app",\n "version": "1.0",\n "dependencies": {}\n}';
const result = parser.analyzeFile("package.json", content);
expect(result.sections).toBeDefined();
expect(result.sections!.map(s => s.name)).toContain("name");
expect(result.sections!.map(s => s.name)).toContain("dependencies");
});
it("extracts $ref references", () => {
const content = '{\n "$ref": "./common.json#/defs/User"\n}';
const refs = parser.extractReferences!("schema.json", content);
expect(refs).toHaveLength(1);
expect(refs[0]).toMatchObject({ target: "./common.json#/defs/User", referenceType: "schema" });
});
it("skips internal $ref references", () => {
const content = '{\n "$ref": "#/definitions/User"\n}';
const refs = parser.extractReferences!("schema.json", content);
expect(refs).toHaveLength(0);
});
it("handles invalid JSON gracefully", () => {
const content = "not json at all";
const result = parser.analyzeFile("broken.json", content);
expect(result.sections).toHaveLength(0);
});
it("declares json plus the JSON-flavored special formats as supported languages", () => {
expect(parser.languages).toEqual(["json", "jsonc", "json-schema", "openapi"]);
});
it("parses .jsonc files with line and block comments", () => {
const content = [
"{",
" // top-level comment",
' "name": "wrangler",',
" /* block",
" comment */",
' "main": "src/index.ts",',
' "compatibility_date": "2024-01-01",',
"}", // trailing comma above
].join("\n");
const result = parser.analyzeFile("wrangler.jsonc", content);
const names = result.sections!.map((s) => s.name);
expect(names).toEqual(["name", "main", "compatibility_date"]);
});
it("preserves comment-like sequences inside string values", () => {
const content = '{\n "url": "https://example.com//path",\n "note": "/* not a comment */"\n}';
const result = parser.analyzeFile("config.jsonc", content);
expect(result.sections!.map((s) => s.name)).toEqual(["url", "note"]);
});
});
describe("stripJsoncSyntax", () => {
it("strips line comments", () => {
expect(stripJsoncSyntax('{"a": 1} // tail')).toBe('{"a": 1} ');
});
it("strips block comments", () => {
expect(stripJsoncSyntax('{/* x */ "a": 1}')).toBe('{ "a": 1}');
});
it("strips trailing commas before } and ]", () => {
expect(stripJsoncSyntax('{"a": 1,}')).toBe('{"a": 1}');
expect(stripJsoncSyntax('[1, 2,]')).toBe('[1, 2]');
});
it("does not strip // inside strings", () => {
expect(stripJsoncSyntax('{"u": "http://x"}')).toBe('{"u": "http://x"}');
});
it("handles escaped quotes inside strings", () => {
expect(stripJsoncSyntax('{"q": "say \\"hi\\""}')).toBe('{"q": "say \\"hi\\""}');
});
it("leaves plain JSON unchanged", () => {
const plain = '{"a": 1, "b": [2, 3]}';
expect(stripJsoncSyntax(plain)).toBe(plain);
});
});
describe("TOMLParser", () => {
const parser = new TOMLParser();
it("extracts section headers", () => {
const content = "[package]\nname = \"my-app\"\n\n[dependencies]\nfoo = \"1.0\"\n\n[[bin]]\nname = \"cli\"";
const result = parser.analyzeFile("Cargo.toml", content);
expect(result.sections).toBeDefined();
expect(result.sections!.length).toBe(3);
expect(result.sections![0].name).toBe("package");
expect(result.sections![1].name).toBe("dependencies");
expect(result.sections![2].name).toBe("[[bin]]");
});
});
describe("EnvParser", () => {
const parser = new EnvParser();
it("extracts variable names", () => {
const content = "# Database config\nDB_HOST=localhost\nDB_PORT=5432\n\n# API\nAPI_KEY=secret123";
const result = parser.analyzeFile(".env", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!).toHaveLength(3);
expect(result.definitions!.map(d => d.name)).toEqual(["DB_HOST", "DB_PORT", "API_KEY"]);
});
it("skips comments and empty lines", () => {
const content = "# comment\n\nVAR=value";
const result = parser.analyzeFile(".env", content);
expect(result.definitions!).toHaveLength(1);
});
});
describe("DockerfileParser", () => {
const parser = new DockerfileParser();
it("extracts FROM stages", () => {
const content = "FROM node:22-slim AS builder\nRUN npm install\n\nFROM node:22-slim AS runner\nCOPY --from=builder /app /app\nEXPOSE 3000";
const result = parser.analyzeFile("Dockerfile", content);
expect(result.services).toBeDefined();
expect(result.services!).toHaveLength(2);
expect(result.services![0]).toMatchObject({ name: "builder", image: "node:22-slim" });
expect(result.services![1]).toMatchObject({ name: "runner", image: "node:22-slim" });
});
it("extracts EXPOSE ports", () => {
const content = "FROM node:22\nEXPOSE 3000 8080\nCMD [\"node\", \"server.js\"]";
const result = parser.analyzeFile("Dockerfile", content);
expect(result.services![0].ports).toContain(3000);
expect(result.services![0].ports).toContain(8080);
});
it("extracts steps", () => {
const content = "FROM node:22\nWORKDIR /app\nCOPY . .\nRUN npm install\nCMD [\"node\", \"start\"]";
const result = parser.analyzeFile("Dockerfile", content);
expect(result.steps).toBeDefined();
expect(result.steps!.length).toBe(5);
});
});
describe("SQLParser", () => {
const parser = new SQLParser();
it("extracts CREATE TABLE definitions with columns", () => {
const content = `CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
);
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER,
title TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);`;
const result = parser.analyzeFile("schema.sql", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!).toHaveLength(2);
expect(result.definitions![0]).toMatchObject({ name: "users", kind: "table" });
expect(result.definitions![0].fields).toContain("id");
expect(result.definitions![0].fields).toContain("name");
expect(result.definitions![0].fields).toContain("email");
expect(result.definitions![1]).toMatchObject({ name: "posts", kind: "table" });
});
it("extracts CREATE VIEW", () => {
const content = "CREATE VIEW active_users AS SELECT * FROM users WHERE active = true;";
const result = parser.analyzeFile("views.sql", content);
expect(result.definitions!.some(d => d.name === "active_users" && d.kind === "view")).toBe(true);
});
it("extracts CREATE INDEX", () => {
const content = "CREATE UNIQUE INDEX idx_users_email ON users(email);";
const result = parser.analyzeFile("indexes.sql", content);
expect(result.definitions!.some(d => d.name === "idx_users_email" && d.kind === "index")).toBe(true);
});
});
describe("GraphQLParser", () => {
const parser = new GraphQLParser();
it("extracts type definitions", () => {
const content = `type User {
id: ID!
name: String!
email: String!
}
type Post {
id: ID!
title: String!
author: User!
}`;
const result = parser.analyzeFile("schema.graphql", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!).toHaveLength(2);
expect(result.definitions![0]).toMatchObject({ name: "User", kind: "type" });
expect(result.definitions![0].fields).toContain("id");
expect(result.definitions![0].fields).toContain("name");
expect(result.definitions![1]).toMatchObject({ name: "Post", kind: "type" });
});
it("extracts Query/Mutation endpoints", () => {
const content = `type Query {
users: [User!]!
user(id: ID!): User
}
type Mutation {
createUser(name: String!): User!
}`;
const result = parser.analyzeFile("schema.graphql", content);
expect(result.endpoints).toBeDefined();
expect(result.endpoints!.length).toBeGreaterThanOrEqual(3);
expect(result.endpoints!.some(e => e.method === "Query" && e.path === "users")).toBe(true);
expect(result.endpoints!.some(e => e.method === "Mutation" && e.path === "createUser")).toBe(true);
});
it("extracts enum definitions", () => {
const content = "enum Role {\n ADMIN\n USER\n GUEST\n}";
const result = parser.analyzeFile("schema.graphql", content);
expect(result.definitions!.some(d => d.name === "Role" && d.kind === "enum")).toBe(true);
});
});
describe("ProtobufParser", () => {
const parser = new ProtobufParser();
it("extracts message definitions with fields", () => {
const content = `message User {
string name = 1;
int32 age = 2;
repeated string emails = 3;
}`;
const result = parser.analyzeFile("user.proto", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!).toHaveLength(1);
expect(result.definitions![0]).toMatchObject({ name: "User", kind: "message" });
expect(result.definitions![0].fields).toContain("name");
expect(result.definitions![0].fields).toContain("age");
expect(result.definitions![0].fields).toContain("emails");
});
it("extracts enum definitions", () => {
const content = "enum Status {\n UNKNOWN = 0;\n ACTIVE = 1;\n INACTIVE = 2;\n}";
const result = parser.analyzeFile("status.proto", content);
expect(result.definitions!.some(d => d.name === "Status" && d.kind === "enum")).toBe(true);
expect(result.definitions![0].fields).toContain("UNKNOWN");
expect(result.definitions![0].fields).toContain("ACTIVE");
});
it("extracts service RPC methods", () => {
const content = `service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc CreateUser (CreateUserRequest) returns (User);
}`;
const result = parser.analyzeFile("service.proto", content);
expect(result.endpoints).toBeDefined();
expect(result.endpoints!).toHaveLength(2);
expect(result.endpoints![0]).toMatchObject({ method: "rpc", path: "UserService.GetUser" });
expect(result.endpoints![1]).toMatchObject({ method: "rpc", path: "UserService.CreateUser" });
});
});
describe("TerraformParser", () => {
const parser = new TerraformParser();
it("extracts resource blocks", () => {
const content = `resource "aws_s3_bucket" "main" {
bucket = "my-bucket"
}
resource "aws_iam_role" "lambda" {
name = "lambda-role"
}`;
const result = parser.analyzeFile("main.tf", content);
expect(result.resources).toBeDefined();
expect(result.resources!).toHaveLength(2);
expect(result.resources![0]).toMatchObject({ name: "aws_s3_bucket.main", kind: "aws_s3_bucket" });
expect(result.resources![1]).toMatchObject({ name: "aws_iam_role.lambda", kind: "aws_iam_role" });
});
it("extracts data blocks", () => {
const content = 'data "aws_ami" "ubuntu" {\n most_recent = true\n}';
const result = parser.analyzeFile("data.tf", content);
expect(result.resources!.some(r => r.name === "data.aws_ami.ubuntu")).toBe(true);
});
it("extracts module blocks", () => {
const content = 'module "vpc" {\n source = "./modules/vpc"\n}';
const result = parser.analyzeFile("modules.tf", content);
expect(result.resources!.some(r => r.name === "module.vpc" && r.kind === "module")).toBe(true);
});
it("extracts variables and outputs", () => {
const content = 'variable "region" {\n default = "us-east-1"\n}\n\noutput "bucket_arn" {\n value = aws_s3_bucket.main.arn\n}';
const result = parser.analyzeFile("variables.tf", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!.some(d => d.name === "region" && d.kind === "variable")).toBe(true);
expect(result.definitions!.some(d => d.name === "bucket_arn" && d.kind === "output")).toBe(true);
});
});
describe("MakefileParser", () => {
const parser = new MakefileParser();
it("extracts make targets", () => {
const content = "build:\n\tgo build -o bin/app\n\ntest:\n\tgo test ./...\n\nclean:\n\trm -rf bin/";
const result = parser.analyzeFile("Makefile", content);
expect(result.steps).toBeDefined();
expect(result.steps!).toHaveLength(3);
expect(result.steps!.map(s => s.name)).toEqual(["build", "test", "clean"]);
});
it("does not confuse variable assignments with targets", () => {
const content = "CC := gcc\nCFLAGS := -Wall\n\nbuild:\n\t$(CC) $(CFLAGS) main.c";
const result = parser.analyzeFile("Makefile", content);
expect(result.steps!).toHaveLength(1);
expect(result.steps![0].name).toBe("build");
});
});
describe("ShellParser", () => {
const parser = new ShellParser();
it("extracts function definitions", () => {
const content = "#!/bin/bash\n\ngreet() {\n echo \"Hello $1\"\n}\n\nfunction cleanup {\n rm -rf tmp/\n}";
const result = parser.analyzeFile("script.sh", content);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("greet");
expect(result.functions[1].name).toBe("cleanup");
});
it("extracts source references", () => {
const content = "#!/bin/bash\nsource ./lib/utils.sh\n. ./lib/config.sh";
const refs = parser.extractReferences!("script.sh", content);
expect(refs).toHaveLength(2);
expect(refs[0]).toMatchObject({ target: "./lib/utils.sh", referenceType: "file" });
expect(refs[1]).toMatchObject({ target: "./lib/config.sh", referenceType: "file" });
});
});
// --- Edge case tests ---
describe("SQLParser edge cases", () => {
const parser = new SQLParser();
it("handles CREATE TABLE IF NOT EXISTS", () => {
const content = "CREATE TABLE IF NOT EXISTS users (id INT);";
const result = parser.analyzeFile("schema.sql", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!).toHaveLength(1);
expect(result.definitions![0]).toMatchObject({ name: "users", kind: "table" });
expect(result.definitions![0].fields).toContain("id");
});
it("handles CREATE OR REPLACE VIEW", () => {
const content = "CREATE OR REPLACE VIEW active AS SELECT * FROM users;";
const result = parser.analyzeFile("views.sql", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!.some(d => d.name === "active" && d.kind === "view")).toBe(true);
});
});
describe("GraphQLParser edge cases", () => {
const parser = new GraphQLParser();
it("extracts input type definitions", () => {
const content = "input CreateUserInput {\n name: String!\n email: String!\n}";
const result = parser.analyzeFile("schema.graphql", content);
expect(result.definitions).toBeDefined();
const inputDef = result.definitions!.find(d => d.name === "CreateUserInput");
expect(inputDef).toBeDefined();
expect(inputDef!.kind).toBe("input");
expect(inputDef!.fields).toContain("name");
});
});
describe("MakefileParser edge cases", () => {
const parser = new MakefileParser();
it("does not extract .PHONY as a target", () => {
const content = ".PHONY: build test\n\nbuild:\n\tgo build\n\ntest:\n\tgo test";
const result = parser.analyzeFile("Makefile", content);
expect(result.steps).toBeDefined();
const targetNames = result.steps!.map(s => s.name);
expect(targetNames).not.toContain(".PHONY");
expect(targetNames).toContain("build");
expect(targetNames).toContain("test");
});
});
describe("ShellParser edge cases", () => {
const parser = new ShellParser();
it("handles function with opening brace on next line", () => {
const content = "greet()\n{\n echo \"Hello\"\n}";
const result = parser.analyzeFile("script.sh", content);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("greet");
expect(result.functions[0].lineRange[1]).toBeGreaterThan(result.functions[0].lineRange[0]);
});
it("rejects function-like patterns that lack an opening brace", () => {
// Regression: pre-2.6.2 the regex matched `name() echo hi` (POSIX
// one-liner) and `usage()` strings appearing in heredocs as if they
// were function definitions.
const content = [
"name() echo hi",
"say_usage() # comment, no brace",
"real_func() {",
" echo real",
"}",
].join("\n");
const result = parser.analyzeFile("script.sh", content);
expect(result.functions.map((f) => f.name)).toEqual(["real_func"]);
});
it("declares jenkinsfile so Groovy-flavored CI configs are routed here", () => {
expect(parser.languages).toEqual(expect.arrayContaining(["shell", "jenkinsfile"]));
});
});
describe("TOMLParser edge cases", () => {
const parser = new TOMLParser();
it("returns empty sections for empty string", () => {
const result = parser.analyzeFile("empty.toml", "");
expect(result.sections).toBeDefined();
expect(result.sections).toHaveLength(0);
});
it("returns empty sections for garbage text", () => {
const result = parser.analyzeFile("garbage.toml", "this is not toml at all\nrandom garbage 123");
expect(result.sections).toBeDefined();
expect(result.sections).toHaveLength(0);
});
});
describe("DockerfileParser edge cases", () => {
const parser = new DockerfileParser();
it("assigns EXPOSE ports to the correct stage in multi-stage build", () => {
const content = "FROM node:22 AS builder\nRUN npm install\n\nFROM node:22-slim AS runner\nCOPY --from=builder /app /app\nEXPOSE 3000 8080\nCMD [\"node\", \"server.js\"]";
const result = parser.analyzeFile("Dockerfile", content);
expect(result.services).toBeDefined();
expect(result.services!).toHaveLength(2);
// Ports should be on the runner stage (second stage), not the builder
expect(result.services![0].ports).toHaveLength(0); // builder has no EXPOSE
expect(result.services![1].ports).toContain(3000);
expect(result.services![1].ports).toContain(8080);
});
it("includes lineRange for each stage", () => {
const content = "FROM node:22 AS builder\nRUN npm install\n\nFROM node:22-slim AS runner\nCOPY . .\nCMD [\"node\", \"start\"]";
const result = parser.analyzeFile("Dockerfile", content);
expect(result.services).toBeDefined();
expect(result.services!).toHaveLength(2);
expect(result.services![0].lineRange).toBeDefined();
expect(result.services![0].lineRange![0]).toBe(1);
expect(result.services![1].lineRange).toBeDefined();
expect(result.services![1].lineRange![0]).toBe(4);
});
});
describe("EnvParser edge cases", () => {
const parser = new EnvParser();
it("does not handle export VAR=value syntax", () => {
const content = "export DB_HOST=localhost\nAPI_KEY=secret";
const result = parser.analyzeFile(".env", content);
// The `export` prefix is not handled — only plain KEY=value is parsed
const names = result.definitions!.map(d => d.name);
expect(names).toContain("API_KEY");
expect(names).not.toContain("DB_HOST");
});
});
describe("registerAllParsers", () => {
it("registers all 12 parsers with a PluginRegistry", () => {
const registry = new PluginRegistry();
registerAllParsers(registry);
expect(registry.getPlugins()).toHaveLength(12);
expect(registry.getSupportedLanguages()).toContain("markdown");
expect(registry.getSupportedLanguages()).toContain("yaml");
expect(registry.getSupportedLanguages()).toContain("json");
expect(registry.getSupportedLanguages()).toContain("toml");
expect(registry.getSupportedLanguages()).toContain("env");
expect(registry.getSupportedLanguages()).toContain("dockerfile");
expect(registry.getSupportedLanguages()).toContain("sql");
expect(registry.getSupportedLanguages()).toContain("graphql");
expect(registry.getSupportedLanguages()).toContain("protobuf");
expect(registry.getSupportedLanguages()).toContain("terraform");
expect(registry.getSupportedLanguages()).toContain("makefile");
expect(registry.getSupportedLanguages()).toContain("shell");
});
});

View File

@@ -0,0 +1,115 @@
import { describe, it, expect } from "vitest";
import {
parsePluginConfig,
serializePluginConfig,
type PluginConfig,
DEFAULT_PLUGIN_CONFIG,
} from "../plugins/discovery.js";
describe("plugin-discovery", () => {
describe("parsePluginConfig", () => {
it("parses valid config JSON", () => {
const json = JSON.stringify({
plugins: [
{ name: "tree-sitter", enabled: true, languages: ["typescript", "javascript"] },
{ name: "python-ast", enabled: false, languages: ["python"] },
],
});
const config = parsePluginConfig(json);
expect(config.plugins).toHaveLength(2);
expect(config.plugins[0].name).toBe("tree-sitter");
expect(config.plugins[1].enabled).toBe(false);
});
it("returns default config for invalid JSON", () => {
const config = parsePluginConfig("not json");
expect(config).toEqual(DEFAULT_PLUGIN_CONFIG);
});
it("returns default config for empty string", () => {
const config = parsePluginConfig("");
expect(config).toEqual(DEFAULT_PLUGIN_CONFIG);
});
it("filters out entries missing required fields", () => {
const json = JSON.stringify({
plugins: [
{ name: "valid", enabled: true, languages: ["typescript"] },
{ enabled: true, languages: ["python"] }, // missing name
{ name: "no-langs", enabled: true }, // missing languages
],
});
const config = parsePluginConfig(json);
expect(config.plugins).toHaveLength(1);
expect(config.plugins[0].name).toBe("valid");
});
it("defaults enabled to true when omitted", () => {
const json = JSON.stringify({
plugins: [
{ name: "tree-sitter", languages: ["typescript"] },
],
});
const config = parsePluginConfig(json);
expect(config.plugins[0].enabled).toBe(true);
});
it("returns default config when plugins field is not an array", () => {
const json = JSON.stringify({
plugins: "not an array",
});
const config = parsePluginConfig(json);
expect(config).toEqual(DEFAULT_PLUGIN_CONFIG);
});
it("returns default config when plugins field is missing", () => {
const json = JSON.stringify({
someOtherField: "value",
});
const config = parsePluginConfig(json);
expect(config).toEqual(DEFAULT_PLUGIN_CONFIG);
});
});
describe("DEFAULT_PLUGIN_CONFIG", () => {
it("includes tree-sitter as enabled by default", () => {
expect(DEFAULT_PLUGIN_CONFIG.plugins).toHaveLength(1);
expect(DEFAULT_PLUGIN_CONFIG.plugins[0].name).toBe("tree-sitter");
expect(DEFAULT_PLUGIN_CONFIG.plugins[0].enabled).toBe(true);
});
});
describe("serializePluginConfig", () => {
it("serializes plugin config to formatted JSON", () => {
const config: PluginConfig = {
plugins: [
{
name: "tree-sitter",
enabled: true,
languages: ["typescript", "javascript"],
},
],
};
const json = serializePluginConfig(config);
expect(json).toContain('"name": "tree-sitter"');
expect(json).toContain('"enabled": true');
expect(json).toContain('"languages"');
});
it("serializes config with options field", () => {
const config: PluginConfig = {
plugins: [
{
name: "custom-plugin",
enabled: true,
languages: ["python"],
options: { strict: true, timeout: 5000 },
},
],
};
const json = serializePluginConfig(config);
expect(json).toContain('"options"');
expect(json).toContain('"strict": true');
});
});
});

View File

@@ -0,0 +1,228 @@
import { describe, it, expect } from "vitest";
import { PluginRegistry } from "../plugins/registry.js";
import { registerAllParsers } from "../plugins/parsers/index.js";
import type { AnalyzerPlugin, StructuralAnalysis, ImportResolution } from "../types.js";
const emptyAnalysis: StructuralAnalysis = {
functions: [],
classes: [],
imports: [],
exports: [],
};
function createMockPlugin(name: string, languages: string[]): AnalyzerPlugin {
return {
name,
languages,
analyzeFile: () => ({ ...emptyAnalysis }),
resolveImports: () => [],
};
}
describe("PluginRegistry", () => {
it("registers a plugin", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("test", ["typescript"]);
registry.register(plugin);
expect(registry.getPlugins()).toHaveLength(1);
});
it("finds plugin by language", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("ts-plugin", ["typescript", "javascript"]);
registry.register(plugin);
expect(registry.getPluginForLanguage("typescript")).toBe(plugin);
expect(registry.getPluginForLanguage("javascript")).toBe(plugin);
});
it("returns null for unsupported language", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("ts-plugin", ["typescript"]));
expect(registry.getPluginForLanguage("python")).toBeNull();
});
it("finds plugin by file extension", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("ts-plugin", ["typescript"]);
registry.register(plugin);
expect(registry.getPluginForFile("src/index.ts")).toBe(plugin);
expect(registry.getPluginForFile("src/app.tsx")).toBe(plugin);
});
it("maps common extensions to languages", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("multi", ["python", "go", "rust"]);
registry.register(plugin);
expect(registry.getPluginForFile("main.py")).toBe(plugin);
expect(registry.getPluginForFile("main.go")).toBe(plugin);
expect(registry.getPluginForFile("main.rs")).toBe(plugin);
});
it("lists all registered plugins", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("a", ["typescript"]));
registry.register(createMockPlugin("b", ["python"]));
expect(registry.getPlugins()).toHaveLength(2);
});
it("lists supported languages", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("a", ["typescript", "javascript"]));
registry.register(createMockPlugin("b", ["python"]));
const langs = registry.getSupportedLanguages();
expect(langs).toContain("typescript");
expect(langs).toContain("python");
});
it("unregisters a plugin by name", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("removable", ["typescript"]));
expect(registry.getPlugins()).toHaveLength(1);
registry.unregister("removable");
expect(registry.getPlugins()).toHaveLength(0);
});
it("later registration takes priority for same language", () => {
const registry = new PluginRegistry();
const first = createMockPlugin("first", ["typescript"]);
const second = createMockPlugin("second", ["typescript"]);
registry.register(first);
registry.register(second);
expect(registry.getPluginForLanguage("typescript")?.name).toBe("second");
});
it("analyzeFile delegates to correct plugin", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("ts-plugin", ["typescript"]);
plugin.analyzeFile = () => ({
...emptyAnalysis,
functions: [{ name: "hello", lineRange: [1, 5], params: [] }],
});
registry.register(plugin);
const result = registry.analyzeFile("src/test.ts", "const x = 1;");
expect(result).not.toBeNull();
expect(result!.functions).toHaveLength(1);
});
it("analyzeFile returns null for unsupported files", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("ts-plugin", ["typescript"]));
const result = registry.analyzeFile("main.py", "print('hello')");
expect(result).toBeNull();
});
it("unregister rebuilds language map correctly", () => {
const registry = new PluginRegistry();
const plugin1 = createMockPlugin("plugin1", ["typescript", "javascript"]);
const plugin2 = createMockPlugin("plugin2", ["python"]);
registry.register(plugin1);
registry.register(plugin2);
expect(registry.getPluginForLanguage("typescript")).toBe(plugin1);
expect(registry.getPluginForLanguage("python")).toBe(plugin2);
registry.unregister("plugin1");
expect(registry.getPluginForLanguage("typescript")).toBeNull();
expect(registry.getPluginForLanguage("python")).toBe(plugin2);
});
it("unregister does nothing for non-existent plugin", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("existing", ["typescript"]);
registry.register(plugin);
registry.unregister("non-existent");
expect(registry.getPlugins()).toHaveLength(1);
expect(registry.getPluginForLanguage("typescript")).toBe(plugin);
});
it("getLanguageForFile returns correct language id", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("ts-plugin", ["typescript"]));
expect(registry.getLanguageForFile("src/index.ts")).toBe("typescript");
expect(registry.getLanguageForFile("src/component.tsx")).toBe("typescript");
});
it("getLanguageForFile returns null for unsupported extensions", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("ts-plugin", ["typescript"]));
expect(registry.getLanguageForFile("unknown.xyz")).toBeNull();
});
it("resolveImports delegates to correct plugin", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("ts-plugin", ["typescript"]);
const mockImports: ImportResolution[] = [
{
source: "./utils",
resolvedPath: "./utils.ts",
specifiers: [],
},
];
plugin.resolveImports = () => mockImports;
registry.register(plugin);
const result = registry.resolveImports("src/index.ts", "import './utils'");
expect(result).toEqual(mockImports);
});
it("resolveImports returns null for unsupported files", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("ts-plugin", ["typescript"]));
const result = registry.resolveImports("main.py", "import os");
expect(result).toBeNull();
});
it("handles plugins with optional resolveImports (non-code plugins)", () => {
const markdownPlugin: AnalyzerPlugin = {
name: "markdown",
languages: ["markdown"],
analyzeFile: () => ({ functions: [], classes: [], imports: [], exports: [] }),
// No resolveImports — optional for non-code plugins
};
const registry = new PluginRegistry();
registry.register(markdownPlugin);
const result = registry.resolveImports("README.md", "# Hello");
expect(result).toBeNull();
});
});
describe("registerAllParsers smoke test", () => {
it("all registered parsers return valid StructuralAnalysis for minimal content", () => {
const registry = new PluginRegistry();
registerAllParsers(registry);
// Map of file extension -> minimal content for each parser
const testCases: [string, string][] = [
["README.md", "# Hello"],
["config.yaml", "key: value"],
["config.json", '{"key": "value"}'],
["config.toml", 'key = "value"'],
[".env", "KEY=value"],
["Dockerfile", "FROM node:22"],
["schema.sql", "CREATE TABLE t (id INT);"],
["schema.graphql", "type Query { hello: String }"],
["types.proto", 'syntax = "proto3";'],
["main.tf", 'resource "null" "r" {}'],
["Makefile", "build:\n\techo build"],
["script.sh", "#!/bin/bash\necho hello"],
];
for (const [filePath, content] of testCases) {
const result = registry.analyzeFile(filePath, content);
expect(result, `analyzeFile should return a result for ${filePath}`).not.toBeNull();
// Verify basic structural analysis shape
expect(result).toHaveProperty("functions");
expect(result).toHaveProperty("classes");
expect(result).toHaveProperty("imports");
expect(result).toHaveProperty("exports");
}
});
});

View File

@@ -0,0 +1,729 @@
import { describe, it, expect } from "vitest";
import {
validateGraph,
sanitizeGraph,
autoFixGraph,
NODE_TYPE_ALIASES,
EDGE_TYPE_ALIASES,
} from "../schema.js";
import type { KnowledgeGraph } from "../types.js";
const validGraph: KnowledgeGraph = {
version: "1.0.0",
project: {
name: "test-project",
languages: ["typescript"],
frameworks: ["vitest"],
description: "A test project",
analyzedAt: "2026-03-14T00:00:00.000Z",
gitCommitHash: "abc123",
},
nodes: [
{
id: "node-1",
type: "file",
name: "index.ts",
filePath: "src/index.ts",
lineRange: [1, 50],
summary: "Entry point",
tags: ["entry"],
complexity: "simple",
},
],
edges: [
{
source: "node-1",
target: "node-1",
type: "imports",
direction: "forward",
weight: 0.8,
},
],
layers: [
{
id: "layer-1",
name: "Core",
description: "Core layer",
nodeIds: ["node-1"],
},
],
tour: [
{
order: 1,
title: "Start here",
description: "Begin with the entry point",
nodeIds: ["node-1"],
},
],
};
describe("schema validation", () => {
it("validates a correct knowledge graph", () => {
const result = validateGraph(validGraph);
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(result.data!.version).toBe("1.0.0");
expect(result.issues).toEqual([]);
});
it("rejects graph with missing required fields", () => {
const incomplete = { version: "1.0.0" };
const result = validateGraph(incomplete);
expect(result.success).toBe(false);
expect(result.fatal).toBeDefined();
});
it("rejects node with invalid type — drops node, fatal if none remain", () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = "invalid_type";
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.fatal).toContain("No valid nodes");
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "dropped", category: "invalid-node" })
);
});
it("drops edge with invalid EdgeType but loads graph", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = "not_a_real_edge_type";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges.length).toBe(0);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "dropped", category: "invalid-edge" })
);
});
it("auto-corrects weight >1 by clamping", () => {
const graph = structuredClone(validGraph);
graph.edges[0].weight = 1.5;
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "out-of-range" })
);
});
it("auto-corrects weight <0 by clamping", () => {
const graph = structuredClone(validGraph);
graph.edges[0].weight = -0.1;
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "out-of-range" })
);
});
it('normalizes "func" node type to "function"', () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = "func";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].type).toBe("function");
});
it('normalizes "fn" node type to "function"', () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = "fn";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].type).toBe("function");
});
it('normalizes "method" node type to "function"', () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = "method";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].type).toBe("function");
});
it('normalizes "interface" node type to "class"', () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = "interface";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].type).toBe("class");
});
it('normalizes "struct" node type to "class"', () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = "struct";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].type).toBe("class");
});
it("normalizes multiple aliased node types in one graph", () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = "func";
graph.nodes.push({
id: "node-2",
type: "file" as any,
name: "utils.ts",
filePath: "src/utils.ts",
lineRange: [1, 30],
summary: "Utility helpers",
tags: ["utils"],
complexity: "simple",
});
(graph.nodes[1] as any).type = "pkg";
graph.nodes.push({
id: "node-3",
type: "file" as any,
name: "MyClass.ts",
filePath: "src/MyClass.ts",
lineRange: [1, 80],
summary: "A class",
tags: ["class"],
complexity: "moderate",
});
(graph.nodes[2] as any).type = "struct";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].type).toBe("function");
expect(result.data!.nodes[1].type).toBe("module");
expect(result.data!.nodes[2].type).toBe("class");
});
it('normalizes "extends" edge type to "inherits"', () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = "extends";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges[0].type).toBe("inherits");
});
it('normalizes "invokes" edge type to "calls"', () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = "invokes";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges[0].type).toBe("calls");
});
it('normalizes "relates_to" edge type to "related"', () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = "relates_to";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges[0].type).toBe("related");
});
it('normalizes "uses" edge type to "depends_on"', () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = "uses";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges[0].type).toBe("depends_on");
});
it('drops "tests" edge type — direction-inverting alias is unsafe', () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = "tests";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges.length).toBe(0);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "dropped" })
);
});
it("drops truly invalid edge types after normalization", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = "totally_bogus";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges.length).toBe(0);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "dropped" })
);
});
it("NODE_TYPE_ALIASES values are never alias keys (no chains)", () => {
for (const [alias, target] of Object.entries(NODE_TYPE_ALIASES)) {
expect(
NODE_TYPE_ALIASES,
`chain detected: ${alias}${target}${NODE_TYPE_ALIASES[target]}`,
).not.toHaveProperty(target);
}
});
it("EDGE_TYPE_ALIASES values are never alias keys (no chains)", () => {
for (const [alias, target] of Object.entries(EDGE_TYPE_ALIASES)) {
expect(
EDGE_TYPE_ALIASES,
`chain detected: ${alias}${target}${EDGE_TYPE_ALIASES[target]}`,
).not.toHaveProperty(target);
}
});
});
describe("sanitizeGraph", () => {
it("converts null optional node fields to undefined", () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).filePath = null;
(graph.nodes[0] as any).lineRange = null;
(graph.nodes[0] as any).languageNotes = null;
const result = sanitizeGraph(graph as any);
const node = (result as any).nodes[0];
expect(node.filePath).toBeUndefined();
expect(node.lineRange).toBeUndefined();
expect(node.languageNotes).toBeUndefined();
});
it("converts null optional edge fields to undefined", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).description = null;
const result = sanitizeGraph(graph as any);
const edge = (result as any).edges[0];
expect(edge.description).toBeUndefined();
});
it("lowercases enum-like strings on nodes", () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = "FILE";
(graph.nodes[0] as any).complexity = "Simple";
const result = sanitizeGraph(graph as any);
const node = (result as any).nodes[0];
expect(node.type).toBe("file");
expect(node.complexity).toBe("simple");
});
it("lowercases enum-like strings on edges", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = "IMPORTS";
(graph.edges[0] as any).direction = "Forward";
const result = sanitizeGraph(graph as any);
const edge = (result as any).edges[0];
expect(edge.type).toBe("imports");
expect(edge.direction).toBe("forward");
});
it("converts null tour/layers to empty arrays", () => {
const graph = structuredClone(validGraph);
(graph as any).tour = null;
(graph as any).layers = null;
const result = sanitizeGraph(graph as any);
expect((result as any).tour).toEqual([]);
expect((result as any).layers).toEqual([]);
});
it("converts null optional tour step fields to undefined", () => {
const graph = structuredClone(validGraph);
(graph.tour[0] as any).languageLesson = null;
const result = sanitizeGraph(graph as any);
expect((result as any).tour[0].languageLesson).toBeUndefined();
});
it("passes through non-object node/edge items unchanged", () => {
const graph = { nodes: [null, "garbage", 42], edges: [null], tour: [], layers: [] };
const result = sanitizeGraph(graph as any);
expect((result as any).nodes).toEqual([null, "garbage", 42]);
expect((result as any).edges).toEqual([null]);
});
});
describe("autoFixGraph", () => {
it("defaults missing complexity to moderate with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.nodes[0] as any).complexity;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).nodes[0].complexity).toBe("moderate");
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].complexity" })
);
});
it("maps complexity aliases with issue", () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).complexity = "low";
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).nodes[0].complexity).toBe("simple");
expect(issues.length).toBe(1);
expect(issues[0].level).toBe("auto-corrected");
});
it("maps all complexity aliases correctly", () => {
const mapping: Record<string, string> = {
low: "simple", easy: "simple",
medium: "moderate", intermediate: "moderate",
high: "complex", hard: "complex", difficult: "complex",
};
for (const [alias, expected] of Object.entries(mapping)) {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).complexity = alias;
const { data } = autoFixGraph(graph as any);
expect((data as any).nodes[0].complexity).toBe(expected);
}
});
it("defaults missing tags to empty array with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.nodes[0] as any).tags;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).nodes[0].tags).toEqual([]);
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].tags" })
);
});
it("defaults missing summary to node name with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.nodes[0] as any).summary;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).nodes[0].summary).toBe("index.ts");
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].summary" })
);
});
it("defaults missing node type to file with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.nodes[0] as any).type;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).nodes[0].type).toBe("file");
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].type" })
);
});
it("defaults missing direction to forward with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.edges[0] as any).direction;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).edges[0].direction).toBe("forward");
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "edges[0].direction" })
);
});
it("maps direction aliases with issue", () => {
const mapping: Record<string, string> = {
to: "forward", outbound: "forward",
from: "backward", inbound: "backward",
both: "bidirectional", mutual: "bidirectional",
};
for (const [alias, expected] of Object.entries(mapping)) {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).direction = alias;
const { data } = autoFixGraph(graph as any);
expect((data as any).edges[0].direction).toBe(expected);
}
});
it("defaults missing weight to 0.5 with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.edges[0] as any).weight;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).edges[0].weight).toBe(0.5);
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "edges[0].weight" })
);
});
it("coerces string weight to number with issue", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).weight = "0.8";
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).edges[0].weight).toBe(0.8);
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "type-coercion", path: "edges[0].weight" })
);
});
it("clamps out-of-range weight with issue", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).weight = 1.5;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).edges[0].weight).toBe(1);
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "out-of-range", path: "edges[0].weight" })
);
});
it("defaults missing edge type to depends_on with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.edges[0] as any).type;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).edges[0].type).toBe("depends_on");
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "edges[0].type" })
);
});
it("returns no issues for a valid graph", () => {
const { issues } = autoFixGraph(validGraph as any);
expect(issues).toEqual([]);
});
it("passes through non-object node/edge items unchanged", () => {
const graph = { nodes: [null, "garbage"], edges: [null], tour: [], layers: [] };
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).nodes).toEqual([null, "garbage"]);
expect((data as any).edges).toEqual([null]);
expect(issues).toEqual([]);
});
});
describe("permissive validation", () => {
it("drops nodes missing id with dropped issue", () => {
const graph = structuredClone(validGraph);
delete (graph.nodes[0] as any).id;
// Add a second valid node so graph isn't fatal
graph.nodes.push({
id: "node-2", type: "file", name: "other.ts",
summary: "Other file", tags: ["util"], complexity: "simple",
});
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes.length).toBe(1);
expect(result.data!.nodes[0].id).toBe("node-2");
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "dropped", category: "invalid-node" })
);
});
it("drops edges referencing non-existent nodes with dropped issue", () => {
const graph = structuredClone(validGraph);
graph.edges[0].target = "non-existent-node";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges.length).toBe(0);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "dropped", category: "invalid-reference" })
);
});
it("returns fatal when 0 valid nodes remain", () => {
const graph = structuredClone(validGraph);
delete (graph.nodes[0] as any).id;
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.fatal).toContain("No valid nodes");
});
it("returns fatal when project metadata is missing", () => {
const graph = structuredClone(validGraph);
delete (graph as any).project;
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.fatal).toContain("project metadata");
});
it("returns fatal when input is not an object", () => {
const result = validateGraph("not an object");
expect(result.success).toBe(false);
expect(result.fatal).toContain("Invalid input");
});
it("loads graph with mixed good and bad nodes", () => {
const graph = structuredClone(validGraph);
// Add a good node
graph.nodes.push({
id: "node-2", type: "function", name: "doThing",
summary: "Does a thing", tags: ["util"], complexity: "moderate",
});
// Add a bad node (missing id AND name -- unrecoverable)
(graph.nodes as any[]).push({ type: "file", summary: "broken" });
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes.length).toBe(2);
expect(result.issues.some((i) => i.level === "dropped")).toBe(true);
});
it("filters dangling nodeIds from layers", () => {
const graph = structuredClone(validGraph);
graph.layers[0].nodeIds.push("non-existent-node");
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.layers[0].nodeIds).toEqual(["node-1"]);
});
it("filters dangling nodeIds from tour steps", () => {
const graph = structuredClone(validGraph);
graph.tour[0].nodeIds.push("non-existent-node");
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.tour[0].nodeIds).toEqual(["node-1"]);
});
it("returns empty issues array for a perfect graph", () => {
const result = validateGraph(validGraph);
expect(result.success).toBe(true);
expect(result.issues).toEqual([]);
expect(result.errors).toBeUndefined();
});
it("auto-corrects and loads graph that would have failed strict validation", () => {
// Graph with many Tier 2 issues: missing complexity, weight as string, null filePath
const messy = {
version: "1.0.0",
project: validGraph.project,
nodes: [{
id: "n1", type: "FILE", name: "app.ts",
filePath: null, summary: "App entry",
tags: null, complexity: "HIGH",
}],
edges: [{
source: "n1", target: "n1", type: "CALLS",
direction: "TO", weight: "0.9",
}],
layers: [{ id: "l1", name: "Core", description: "Core", nodeIds: ["n1"] }],
tour: [],
};
const result = validateGraph(messy);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].complexity).toBe("complex");
expect(result.data!.nodes[0].tags).toEqual([]);
expect(result.data!.edges[0].weight).toBe(0.9);
expect(result.data!.edges[0].direction).toBe("forward");
expect(result.issues.length).toBeGreaterThan(0);
expect(result.issues.every((i) => i.level === "auto-corrected")).toBe(true);
});
it("handles non-parseable string weight by defaulting to 0.5", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).weight = "not_a_number";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges[0].weight).toBe(0.5);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "type-coercion" })
);
});
it("returns fatal when edges is present but not an array", () => {
const graph = structuredClone(validGraph) as any;
graph.edges = { source: "node-1", target: "node-1" };
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.fatal).toContain('"edges" must be an array');
expect(result.errors).toContain('"edges" must be an array when present');
expect(result.issues).toContainEqual(
expect.objectContaining({
level: "fatal",
category: "invalid-collection",
path: "edges",
})
);
});
it("preserves deprecated errors for dropped-item callers", () => {
const graph = structuredClone(validGraph);
graph.edges[0].target = "non-existent-node";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.errors).toContain('edges[0]: target "non-existent-node" does not exist in nodes — removed');
});
});
describe("Extended node/edge types", () => {
it("validates nodes with new types: config, document, service, table, endpoint, pipeline, schema, resource", () => {
const newTypes = ["config", "document", "service", "table", "endpoint", "pipeline", "schema", "resource"];
for (const type of newTypes) {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = type;
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].type).toBe(type);
}
});
it("validates edges with new types: deploys, serves, migrates, documents, provisions, routes, defines_schema, triggers", () => {
const newTypes = ["deploys", "serves", "migrates", "documents", "provisions", "routes", "defines_schema", "triggers"];
for (const type of newTypes) {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = type;
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges[0].type).toBe(type);
}
});
it("auto-fixes new node type aliases: container->service, doc->document, business_flow->flow, etc.", () => {
const aliases: Record<string, string> = {
container: "service",
doc: "document",
business_flow: "flow",
route: "endpoint",
setting: "config",
infra: "resource",
migration: "table",
};
for (const [alias, canonical] of Object.entries(aliases)) {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = alias;
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].type).toBe(canonical);
}
});
it("auto-fixes new edge type aliases: describes->documents, creates->provisions, exposes->serves", () => {
const aliases: Record<string, string> = {
describes: "documents",
creates: "provisions",
exposes: "serves",
};
for (const [alias, canonical] of Object.entries(aliases)) {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = alias;
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges[0].type).toBe(canonical);
}
});
it("accepts node with bare string ID (schema is lenient on format)", () => {
const graph = structuredClone(validGraph);
graph.nodes[0].id = "src/foo.ts";
const result = validateGraph(graph);
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,158 @@
import { describe, it, expect } from "vitest";
import { SearchEngine } from "../search.js";
import type { GraphNode } from "../types.js";
const makeNode = (overrides: Partial<GraphNode> & { id: string; name: string }): GraphNode => ({
type: "file",
summary: "",
tags: [],
complexity: "simple",
...overrides,
});
const sampleNodes: GraphNode[] = [
makeNode({
id: "auth-ctrl",
name: "AuthenticationController",
type: "class",
summary: "Handles user login, logout, and session management",
tags: ["auth", "controller", "security"],
languageNotes: "Uses Express middleware pattern",
}),
makeNode({
id: "db-pool",
name: "DatabasePool",
type: "class",
summary: "Manages PostgreSQL connection pooling",
tags: ["database", "connection"],
}),
makeNode({
id: "user-model",
name: "UserModel",
type: "class",
summary: "ORM model for the users table",
tags: ["model", "database", "user"],
}),
makeNode({
id: "config",
name: "config.ts",
type: "file",
summary: "Application configuration and environment variables",
tags: ["config", "env"],
}),
makeNode({
id: "helpers",
name: "helpers.ts",
type: "function",
summary: "Utility helper functions for string manipulation",
tags: ["utils", "helpers"],
}),
makeNode({
id: "auth-middleware",
name: "authMiddleware",
type: "function",
summary: "Express middleware that validates JWT tokens for authentication",
tags: ["auth", "middleware", "security"],
}),
];
describe("SearchEngine", () => {
it("returns empty results for empty query", () => {
const engine = new SearchEngine(sampleNodes);
expect(engine.search("")).toEqual([]);
expect(engine.search(" ")).toEqual([]);
});
it("finds exact name match", () => {
const engine = new SearchEngine(sampleNodes);
const results = engine.search("AuthenticationController");
expect(results.length).toBeGreaterThan(0);
expect(results[0].nodeId).toBe("auth-ctrl");
});
it("finds fuzzy name match", () => {
const engine = new SearchEngine(sampleNodes);
const results = engine.search("auth contrl");
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.nodeId === "auth-ctrl")).toBe(true);
});
it("searches across summary field", () => {
const engine = new SearchEngine(sampleNodes);
const results = engine.search("PostgreSQL connection");
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.nodeId === "db-pool")).toBe(true);
});
it("searches across tags", () => {
const engine = new SearchEngine(sampleNodes);
const results = engine.search("security");
expect(results.length).toBeGreaterThan(0);
const nodeIds = results.map((r) => r.nodeId);
expect(nodeIds).toContain("auth-ctrl");
expect(nodeIds).toContain("auth-middleware");
});
it("ranks name matches higher than summary matches", () => {
const engine = new SearchEngine(sampleNodes);
const results = engine.search("UserModel");
expect(results.length).toBeGreaterThan(0);
// UserModel is an exact name match; it should rank first
expect(results[0].nodeId).toBe("user-model");
});
it("returns scored results with score between 0 and 1", () => {
const engine = new SearchEngine(sampleNodes);
const results = engine.search("database");
expect(results.length).toBeGreaterThan(0);
for (const result of results) {
expect(result.score).toBeGreaterThanOrEqual(0);
expect(result.score).toBeLessThanOrEqual(1);
}
});
it("can updateNodes and re-index", () => {
const engine = new SearchEngine(sampleNodes);
// Initially no "PaymentService" results
const before = engine.search("PaymentService");
const hadPayment = before.some((r) => r.nodeId === "payment");
// Add a new node
engine.updateNodes([
...sampleNodes,
makeNode({
id: "payment",
name: "PaymentService",
type: "class",
summary: "Handles payment processing",
tags: ["payment", "billing"],
}),
]);
const after = engine.search("PaymentService");
expect(hadPayment).toBe(false);
expect(after.length).toBeGreaterThan(0);
expect(after[0].nodeId).toBe("payment");
});
it("filters by node type", () => {
const engine = new SearchEngine(sampleNodes);
const results = engine.search("auth", { types: ["function"] });
expect(results.length).toBeGreaterThan(0);
// Should only return function-type nodes
for (const result of results) {
const node = sampleNodes.find((n) => n.id === result.nodeId);
expect(node?.type).toBe("function");
}
// Specifically, authMiddleware (function) should appear but AuthenticationController (class) should not
expect(results.some((r) => r.nodeId === "auth-middleware")).toBe(true);
expect(results.some((r) => r.nodeId === "auth-ctrl")).toBe(false);
});
it("respects the limit option", () => {
const engine = new SearchEngine(sampleNodes);
const results = engine.search("auth", { limit: 1 });
expect(results.length).toBe(1);
});
});

View File

@@ -0,0 +1,253 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { KnowledgeGraph, GraphNode, GraphEdge } from "../types.js";
vi.mock("child_process", () => ({
execFileSync: vi.fn(),
}));
// Import after mocking
import { execFileSync } from "child_process";
import { getChangedFiles, isStale, mergeGraphUpdate } from "../staleness.js";
const mockedExecFileSync = vi.mocked(execFileSync);
const makeNode = (
overrides: Partial<GraphNode> & { id: string; name: string },
): GraphNode => ({
type: "file",
summary: "",
tags: [],
complexity: "simple",
...overrides,
});
const makeEdge = (
overrides: Partial<GraphEdge> & { source: string; target: string },
): GraphEdge => ({
type: "imports",
direction: "forward",
weight: 1,
...overrides,
});
function makeGraph(overrides?: Partial<KnowledgeGraph>): KnowledgeGraph {
return {
version: "1.0.0",
project: {
name: "test-project",
languages: ["typescript"],
frameworks: [],
description: "A test project",
analyzedAt: "2026-01-01T00:00:00.000Z",
gitCommitHash: "abc123",
},
nodes: [],
edges: [],
layers: [],
tour: [],
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("getChangedFiles", () => {
it("returns changed file list from git diff", () => {
mockedExecFileSync.mockReturnValue("src/index.ts\nsrc/utils.ts\n");
const result = getChangedFiles("/project", "abc123");
expect(result).toEqual(["src/index.ts", "src/utils.ts"]);
expect(mockedExecFileSync).toHaveBeenCalledWith(
"git",
["diff", "abc123..HEAD", "--name-only"],
{ cwd: "/project", encoding: "utf-8" },
);
});
it("returns empty array when no changes", () => {
mockedExecFileSync.mockReturnValue("");
const result = getChangedFiles("/project", "abc123");
expect(result).toEqual([]);
});
it("returns empty array on git error", () => {
mockedExecFileSync.mockImplementation(() => {
throw new Error("fatal: bad revision");
});
const result = getChangedFiles("/project", "abc123");
expect(result).toEqual([]);
});
});
describe("isStale", () => {
it("returns stale when files have changed", () => {
mockedExecFileSync.mockReturnValue("src/index.ts\n");
const result = isStale("/project", "abc123");
expect(result).toEqual({
stale: true,
changedFiles: ["src/index.ts"],
});
});
it("returns not stale when no files changed", () => {
mockedExecFileSync.mockReturnValue("");
const result = isStale("/project", "abc123");
expect(result).toEqual({
stale: false,
changedFiles: [],
});
});
});
describe("mergeGraphUpdate", () => {
it("replaces nodes for changed files", () => {
const existingGraph = makeGraph({
nodes: [
makeNode({
id: "file-a",
name: "a.ts",
filePath: "src/a.ts",
summary: "Old summary",
}),
makeNode({
id: "file-b",
name: "b.ts",
filePath: "src/b.ts",
summary: "Unchanged",
}),
makeNode({
id: "func-a1",
name: "funcA1",
type: "function",
filePath: "src/a.ts",
summary: "Old function",
}),
],
});
const newNodes = [
makeNode({
id: "file-a-v2",
name: "a.ts",
filePath: "src/a.ts",
summary: "New summary",
}),
makeNode({
id: "func-a2",
name: "funcA2",
type: "function",
filePath: "src/a.ts",
summary: "New function",
}),
];
const result = mergeGraphUpdate(
existingGraph,
["src/a.ts"],
newNodes,
[],
"def456",
);
// Old nodes from src/a.ts should be gone
expect(result.nodes.find((n) => n.id === "file-a")).toBeUndefined();
expect(result.nodes.find((n) => n.id === "func-a1")).toBeUndefined();
// New nodes should be present
expect(result.nodes.find((n) => n.id === "file-a-v2")).toBeDefined();
expect(result.nodes.find((n) => n.id === "func-a2")).toBeDefined();
// Unchanged file should remain
expect(result.nodes.find((n) => n.id === "file-b")).toBeDefined();
});
it("removes edges originating from changed files", () => {
const existingGraph = makeGraph({
nodes: [
makeNode({ id: "file-a", name: "a.ts", filePath: "src/a.ts" }),
makeNode({ id: "file-b", name: "b.ts", filePath: "src/b.ts" }),
makeNode({ id: "file-c", name: "c.ts", filePath: "src/c.ts" }),
],
edges: [
// Edge from changed file -> should be removed
makeEdge({ source: "file-a", target: "file-b" }),
// Edge between unchanged files -> should remain
makeEdge({ source: "file-b", target: "file-c" }),
// Edge to changed file from unchanged -> should remain
makeEdge({ source: "file-c", target: "file-a" }),
],
});
const newNodes = [
makeNode({
id: "file-a-v2",
name: "a.ts",
filePath: "src/a.ts",
summary: "Updated",
}),
];
const newEdges = [
makeEdge({ source: "file-a-v2", target: "file-c" }),
];
const result = mergeGraphUpdate(
existingGraph,
["src/a.ts"],
newNodes,
newEdges,
"def456",
);
// Old edge from file-a should be removed
expect(
result.edges.find(
(e) => e.source === "file-a" && e.target === "file-b",
),
).toBeUndefined();
// Edge between unchanged files should remain
expect(
result.edges.find(
(e) => e.source === "file-b" && e.target === "file-c",
),
).toBeDefined();
// Edge to changed file from unchanged should be removed (dangling target)
expect(
result.edges.find(
(e) => e.source === "file-c" && e.target === "file-a",
),
).toBeUndefined();
// New edge should be added
expect(
result.edges.find(
(e) => e.source === "file-a-v2" && e.target === "file-c",
),
).toBeDefined();
});
it("updates analyzedAt timestamp and gitCommitHash", () => {
const existingGraph = makeGraph();
const before = new Date().toISOString();
const result = mergeGraphUpdate(existingGraph, [], [], [], "def456");
const after = new Date().toISOString();
expect(result.project.gitCommitHash).toBe("def456");
expect(result.project.analyzedAt >= before).toBe(true);
expect(result.project.analyzedAt <= after).toBe(true);
});
});

View File

@@ -0,0 +1,269 @@
import { describe, it, expect } from "vitest";
import {
buildTourGenerationPrompt,
parseTourGenerationResponse,
generateHeuristicTour,
} from "../analyzer/tour-generator.js";
import type { KnowledgeGraph } from "../types.js";
const sampleGraph: KnowledgeGraph = {
version: "1.0.0",
project: {
name: "test-project",
languages: ["typescript"],
frameworks: ["express"],
description: "A test project",
analyzedAt: "2026-03-14T00:00:00Z",
gitCommitHash: "abc123",
},
nodes: [
{ id: "file:src/index.ts", type: "file", name: "index.ts", filePath: "src/index.ts", summary: "Application entry point", tags: ["entry", "server"], complexity: "simple" },
{ id: "file:src/routes.ts", type: "file", name: "routes.ts", filePath: "src/routes.ts", summary: "Route definitions", tags: ["routes", "api"], complexity: "moderate" },
{ id: "file:src/service.ts", type: "file", name: "service.ts", filePath: "src/service.ts", summary: "Business logic", tags: ["service"], complexity: "complex" },
{ id: "file:src/db.ts", type: "file", name: "db.ts", filePath: "src/db.ts", summary: "Database connection", tags: ["database"], complexity: "simple" },
{ id: "concept:auth-flow", type: "concept", name: "Auth Flow", summary: "Authentication concept", tags: ["concept", "auth"], complexity: "moderate" },
],
edges: [
{ source: "file:src/index.ts", target: "file:src/routes.ts", type: "imports", direction: "forward", weight: 0.9 },
{ source: "file:src/routes.ts", target: "file:src/service.ts", type: "calls", direction: "forward", weight: 0.8 },
{ source: "file:src/service.ts", target: "file:src/db.ts", type: "reads_from", direction: "forward", weight: 0.7 },
],
layers: [
{ id: "layer:api", name: "API Layer", description: "HTTP routes", nodeIds: ["file:src/index.ts", "file:src/routes.ts"] },
{ id: "layer:service", name: "Service Layer", description: "Business logic", nodeIds: ["file:src/service.ts"] },
{ id: "layer:data", name: "Data Layer", description: "Database", nodeIds: ["file:src/db.ts"] },
],
tour: [],
};
describe("tour-generator", () => {
describe("buildTourGenerationPrompt", () => {
it("includes project name and description", () => {
const prompt = buildTourGenerationPrompt(sampleGraph);
expect(prompt).toContain("test-project");
expect(prompt).toContain("A test project");
});
it("includes all node summaries", () => {
const prompt = buildTourGenerationPrompt(sampleGraph);
expect(prompt).toContain("Application entry point");
expect(prompt).toContain("Route definitions");
expect(prompt).toContain("Business logic");
expect(prompt).toContain("Database connection");
expect(prompt).toContain("Authentication concept");
});
it("includes layer information", () => {
const prompt = buildTourGenerationPrompt(sampleGraph);
expect(prompt).toContain("API Layer");
expect(prompt).toContain("Service Layer");
expect(prompt).toContain("Data Layer");
});
it("requests JSON output format", () => {
const prompt = buildTourGenerationPrompt(sampleGraph);
expect(prompt).toContain("JSON");
expect(prompt).toContain("steps");
});
});
describe("parseTourGenerationResponse", () => {
it("parses valid JSON response with tour steps", () => {
const response = JSON.stringify({
steps: [
{
order: 1,
title: "Entry Point",
description: "Start here",
nodeIds: ["file:src/index.ts"],
},
{
order: 2,
title: "Routes",
description: "API routes",
nodeIds: ["file:src/routes.ts"],
},
],
});
const steps = parseTourGenerationResponse(response);
expect(steps).toHaveLength(2);
expect(steps[0].order).toBe(1);
expect(steps[0].title).toBe("Entry Point");
expect(steps[0].nodeIds).toEqual(["file:src/index.ts"]);
expect(steps[1].order).toBe(2);
});
it("extracts JSON from markdown code blocks", () => {
const response = `Here is the tour:
\`\`\`json
{
"steps": [
{
"order": 1,
"title": "Start",
"description": "The beginning",
"nodeIds": ["file:src/index.ts"]
}
]
}
\`\`\``;
const steps = parseTourGenerationResponse(response);
expect(steps).toHaveLength(1);
expect(steps[0].title).toBe("Start");
});
it("returns empty array for unparseable response", () => {
expect(parseTourGenerationResponse("not json at all")).toEqual([]);
expect(parseTourGenerationResponse("")).toEqual([]);
expect(parseTourGenerationResponse("random text here")).toEqual([]);
});
it("filters out steps with missing required fields", () => {
const response = JSON.stringify({
steps: [
{
order: 1,
title: "Valid Step",
description: "Has everything",
nodeIds: ["file:src/index.ts"],
},
{
order: 2,
// missing title
description: "Missing title",
nodeIds: ["file:src/routes.ts"],
},
{
order: 3,
title: "Missing description",
// missing description
nodeIds: ["file:src/routes.ts"],
},
{
order: 4,
title: "Missing nodeIds",
description: "No nodes",
// missing nodeIds
},
{
// missing order
title: "Missing order",
description: "No order",
nodeIds: ["file:src/db.ts"],
},
],
});
const steps = parseTourGenerationResponse(response);
expect(steps).toHaveLength(1);
expect(steps[0].title).toBe("Valid Step");
});
});
describe("generateHeuristicTour", () => {
it("starts with entry-point nodes", () => {
const tour = generateHeuristicTour(sampleGraph);
// Entry point node (0 incoming edges) is file:src/index.ts
// It should appear in the first step's nodeIds
const firstStepNodeIds = tour[0].nodeIds;
expect(firstStepNodeIds).toContain("file:src/index.ts");
});
it("follows topological order", () => {
const tour = generateHeuristicTour(sampleGraph);
// Collect all code node IDs in order across steps (excluding concept steps)
const codeSteps = tour.filter(
(s) => !s.title.toLowerCase().includes("concept"),
);
const orderedNodeIds = codeSteps.flatMap((s) => s.nodeIds);
// index.ts must appear before routes.ts
const indexPos = orderedNodeIds.indexOf("file:src/index.ts");
const routesPos = orderedNodeIds.indexOf("file:src/routes.ts");
const servicePos = orderedNodeIds.indexOf("file:src/service.ts");
const dbPos = orderedNodeIds.indexOf("file:src/db.ts");
expect(indexPos).toBeLessThan(routesPos);
expect(routesPos).toBeLessThan(servicePos);
expect(servicePos).toBeLessThan(dbPos);
});
it("includes concept nodes in separate steps", () => {
const tour = generateHeuristicTour(sampleGraph);
// There should be a step containing the concept node
const conceptStep = tour.find((s) =>
s.nodeIds.includes("concept:auth-flow"),
);
expect(conceptStep).toBeDefined();
// Concept step should not contain file nodes
const fileNodeIds = sampleGraph.nodes
.filter((n) => n.type === "file")
.map((n) => n.id);
for (const fileId of fileNodeIds) {
expect(conceptStep!.nodeIds).not.toContain(fileId);
}
});
it("assigns order numbers sequentially", () => {
const tour = generateHeuristicTour(sampleGraph);
for (let i = 0; i < tour.length; i++) {
expect(tour[i].order).toBe(i + 1);
}
});
it("groups nodes by layer when layers exist", () => {
const tour = generateHeuristicTour(sampleGraph);
// With layers, steps should reference layer names
const stepTitles = tour.map((s) => s.title);
// Should have steps that reference the layer names
const hasApiLayer = stepTitles.some((t) => t.includes("API Layer"));
const hasServiceLayer = stepTitles.some((t) => t.includes("Service Layer"));
const hasDataLayer = stepTitles.some((t) => t.includes("Data Layer"));
expect(hasApiLayer).toBe(true);
expect(hasServiceLayer).toBe(true);
expect(hasDataLayer).toBe(true);
});
it("produces valid TourStep objects", () => {
const tour = generateHeuristicTour(sampleGraph);
for (const step of tour) {
expect(typeof step.order).toBe("number");
expect(typeof step.title).toBe("string");
expect(step.title.length).toBeGreaterThan(0);
expect(typeof step.description).toBe("string");
expect(step.description.length).toBeGreaterThan(0);
expect(Array.isArray(step.nodeIds)).toBe(true);
expect(step.nodeIds.length).toBeGreaterThan(0);
}
});
it("handles graph with no edges gracefully", () => {
const noEdgesGraph: KnowledgeGraph = {
...sampleGraph,
edges: [],
layers: [],
};
const tour = generateHeuristicTour(noEdgesGraph);
expect(tour.length).toBeGreaterThan(0);
// All code nodes should still appear somewhere
const allNodeIds = tour.flatMap((s) => s.nodeIds);
for (const node of noEdgesGraph.nodes) {
expect(allNodeIds).toContain(node.id);
}
});
it("handles graph with no layers", () => {
const noLayersGraph: KnowledgeGraph = {
...sampleGraph,
layers: [],
};
const tour = generateHeuristicTour(noLayersGraph);
expect(tour.length).toBeGreaterThan(0);
// Should batch code nodes (3 per step) instead of grouping by layer
const codeSteps = tour.filter(
(s) => !s.title.toLowerCase().includes("concept"),
);
// With 4 code nodes and batches of 3, expect 2 code steps
expect(codeSteps.length).toBe(2);
});
});
});

View File

@@ -0,0 +1,404 @@
import { describe, it, expect, vi } from "vitest";
import { GraphBuilder } from "./graph-builder.js";
import type { StructuralAnalysis } from "../types.js";
describe("GraphBuilder", () => {
it("should create file nodes from file list", () => {
const builder = new GraphBuilder("test-project", "abc123");
builder.addFile("src/index.ts", {
summary: "Entry point",
tags: ["entry"],
complexity: "simple",
});
builder.addFile("src/utils.ts", {
summary: "Utility functions",
tags: ["utility"],
complexity: "moderate",
});
const graph = builder.build();
expect(graph.nodes).toHaveLength(2);
expect(graph.nodes[0]).toMatchObject({
id: "file:src/index.ts",
type: "file",
name: "index.ts",
filePath: "src/index.ts",
summary: "Entry point",
tags: ["entry"],
complexity: "simple",
});
expect(graph.nodes[1]).toMatchObject({
id: "file:src/utils.ts",
type: "file",
name: "utils.ts",
filePath: "src/utils.ts",
summary: "Utility functions",
});
});
it("should create function and class nodes from structural analysis", () => {
const builder = new GraphBuilder("test-project", "abc123");
const analysis: StructuralAnalysis = {
functions: [
{ name: "processData", lineRange: [10, 25], params: ["input"], returnType: "string" },
{ name: "validate", lineRange: [30, 40], params: ["data"] },
],
classes: [
{ name: "DataStore", lineRange: [50, 100], methods: ["get", "set"], properties: ["data"] },
],
imports: [],
exports: [],
};
builder.addFileWithAnalysis("src/service.ts", analysis, {
summary: "Service module",
tags: ["service"],
complexity: "complex",
fileSummary: "Handles data processing",
summaries: {
processData: "Processes raw input data",
validate: "Validates data format",
DataStore: "Manages stored data",
},
});
const graph = builder.build();
// 1 file + 2 functions + 1 class = 4 nodes
expect(graph.nodes).toHaveLength(4);
const fileNode = graph.nodes.find((n) => n.id === "file:src/service.ts");
expect(fileNode).toBeDefined();
expect(fileNode!.type).toBe("file");
expect(fileNode!.summary).toBe("Handles data processing");
const funcNode = graph.nodes.find((n) => n.id === "function:src/service.ts:processData");
expect(funcNode).toBeDefined();
expect(funcNode!.type).toBe("function");
expect(funcNode!.name).toBe("processData");
expect(funcNode!.lineRange).toEqual([10, 25]);
expect(funcNode!.summary).toBe("Processes raw input data");
const validateNode = graph.nodes.find((n) => n.id === "function:src/service.ts:validate");
expect(validateNode).toBeDefined();
expect(validateNode!.summary).toBe("Validates data format");
const classNode = graph.nodes.find((n) => n.id === "class:src/service.ts:DataStore");
expect(classNode).toBeDefined();
expect(classNode!.type).toBe("class");
expect(classNode!.name).toBe("DataStore");
expect(classNode!.summary).toBe("Manages stored data");
});
it("should create contains edges between files and their functions/classes", () => {
const builder = new GraphBuilder("test-project", "abc123");
const analysis: StructuralAnalysis = {
functions: [
{ name: "helper", lineRange: [5, 15], params: [] },
],
classes: [
{ name: "Widget", lineRange: [20, 50], methods: [], properties: [] },
],
imports: [],
exports: [],
};
builder.addFileWithAnalysis("src/widget.ts", analysis, {
summary: "Widget module",
tags: [],
complexity: "moderate",
fileSummary: "Widget component",
summaries: { helper: "Helper function", Widget: "Widget class" },
});
const graph = builder.build();
const containsEdges = graph.edges.filter((e) => e.type === "contains");
expect(containsEdges).toHaveLength(2);
expect(containsEdges[0]).toMatchObject({
source: "file:src/widget.ts",
target: "function:src/widget.ts:helper",
type: "contains",
direction: "forward",
weight: 1,
});
expect(containsEdges[1]).toMatchObject({
source: "file:src/widget.ts",
target: "class:src/widget.ts:Widget",
type: "contains",
direction: "forward",
weight: 1,
});
});
it("should create import edges between files", () => {
const builder = new GraphBuilder("test-project", "abc123");
builder.addFile("src/index.ts", {
summary: "Entry",
tags: [],
complexity: "simple",
});
builder.addFile("src/utils.ts", {
summary: "Utils",
tags: [],
complexity: "simple",
});
builder.addImportEdge("src/index.ts", "src/utils.ts");
const graph = builder.build();
const importEdges = graph.edges.filter((e) => e.type === "imports");
expect(importEdges).toHaveLength(1);
expect(importEdges[0]).toMatchObject({
source: "file:src/index.ts",
target: "file:src/utils.ts",
type: "imports",
direction: "forward",
});
});
it("should create call edges between functions", () => {
const builder = new GraphBuilder("test-project", "abc123");
builder.addCallEdge("src/index.ts", "main", "src/utils.ts", "helper");
const graph = builder.build();
const callEdges = graph.edges.filter((e) => e.type === "calls");
expect(callEdges).toHaveLength(1);
expect(callEdges[0]).toMatchObject({
source: "function:src/index.ts:main",
target: "function:src/utils.ts:helper",
type: "calls",
direction: "forward",
});
});
it("should set project metadata correctly", () => {
const builder = new GraphBuilder("my-awesome-project", "deadbeef");
builder.addFile("src/app.ts", {
summary: "App",
tags: [],
complexity: "simple",
});
builder.addFile("src/script.py", {
summary: "Script",
tags: [],
complexity: "simple",
});
const graph = builder.build();
expect(graph.version).toBe("1.0.0");
expect(graph.project.name).toBe("my-awesome-project");
expect(graph.project.gitCommitHash).toBe("deadbeef");
expect(graph.project.languages).toEqual(["python", "typescript"]);
expect(graph.project.analyzedAt).toBeTruthy();
expect(graph.layers).toEqual([]);
expect(graph.tour).toEqual([]);
});
it("should detect languages from file extensions", () => {
const builder = new GraphBuilder("polyglot", "hash123");
builder.addFile("main.go", { summary: "", tags: [], complexity: "simple" });
builder.addFile("lib.rs", { summary: "", tags: [], complexity: "simple" });
builder.addFile("app.js", { summary: "", tags: [], complexity: "simple" });
const graph = builder.build();
expect(graph.project.languages).toEqual(["go", "javascript", "rust"]);
});
describe("Non-code file support", () => {
it("adds non-code file nodes with correct types and nodeType-prefixed ID", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFile("README.md", {
nodeType: "document",
summary: "Project documentation",
tags: ["documentation"],
complexity: "simple",
});
const graph = builder.build();
expect(graph.nodes).toHaveLength(1);
expect(graph.nodes[0].type).toBe("document");
expect(graph.nodes[0].id).toBe("document:README.md");
});
it("adds non-code child nodes (definitions)", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("schema.sql", {
nodeType: "file",
summary: "Database schema",
tags: ["database"],
complexity: "moderate",
definitions: [
{ name: "users", kind: "table", lineRange: [1, 20] as [number, number], fields: ["id", "name", "email"] },
],
});
const graph = builder.build();
// File node + table child node
expect(graph.nodes).toHaveLength(2);
expect(graph.nodes[1].type).toBe("table");
expect(graph.nodes[1].name).toBe("users");
// Contains edge
expect(graph.edges.some(e => e.type === "contains" && e.target.includes("users"))).toBe(true);
});
it("adds service child nodes", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("docker-compose.yml", {
nodeType: "config",
summary: "Docker compose config",
tags: ["infra"],
complexity: "moderate",
services: [
{ name: "web", image: "node:22", ports: [3000] },
{ name: "db", image: "postgres:15", ports: [5432] },
],
});
const graph = builder.build();
// File node + 2 service child nodes
expect(graph.nodes).toHaveLength(3);
expect(graph.nodes[1].type).toBe("service");
expect(graph.nodes[1].name).toBe("web");
expect(graph.nodes[2].type).toBe("service");
expect(graph.nodes[2].name).toBe("db");
});
it("adds endpoint child nodes", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("schema.graphql", {
nodeType: "schema",
summary: "GraphQL schema",
tags: ["api"],
complexity: "moderate",
endpoints: [
{ method: "Query", path: "users", lineRange: [5, 5] as [number, number] },
],
});
const graph = builder.build();
expect(graph.nodes).toHaveLength(2);
expect(graph.nodes[1].type).toBe("endpoint");
});
it("adds resource child nodes", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("main.tf", {
nodeType: "resource",
summary: "Terraform config",
tags: ["infra"],
complexity: "moderate",
resources: [
{ name: "aws_s3_bucket.main", kind: "aws_s3_bucket", lineRange: [1, 10] as [number, number] },
],
});
const graph = builder.build();
expect(graph.nodes).toHaveLength(2);
expect(graph.nodes[1].type).toBe("resource");
expect(graph.nodes[1].name).toBe("aws_s3_bucket.main");
});
it("adds step child nodes", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("Makefile", {
nodeType: "pipeline",
summary: "Build targets",
tags: ["build"],
complexity: "simple",
steps: [
{ name: "build", lineRange: [1, 3] as [number, number] },
{ name: "test", lineRange: [5, 7] as [number, number] },
],
});
const graph = builder.build();
expect(graph.nodes).toHaveLength(3);
expect(graph.nodes[1].type).toBe("pipeline");
expect(graph.nodes[1].name).toBe("build");
});
it("detects non-code languages from EXTENSION_LANGUAGE map", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addFile("config.yaml", { summary: "Config", tags: [], complexity: "simple" });
const graph = builder.build();
expect(graph.project.languages).toContain("yaml");
});
it("detects new non-code extensions", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addFile("schema.graphql", { summary: "Schema", tags: [], complexity: "simple" });
builder.addFile("main.tf", { summary: "Terraform", tags: [], complexity: "simple" });
builder.addFile("types.proto", { summary: "Protobuf", tags: [], complexity: "simple" });
const graph = builder.build();
expect(graph.project.languages).toContain("graphql");
expect(graph.project.languages).toContain("terraform");
expect(graph.project.languages).toContain("protobuf");
});
it("mapKindToNodeType falls back to concept for unknown kinds and warns", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("schema.sql", {
nodeType: "file",
summary: "Schema",
tags: [],
complexity: "simple",
definitions: [
{ name: "doStuff", kind: "procedure", lineRange: [1, 10] as [number, number], fields: [] },
],
});
const graph = builder.build();
const childNode = graph.nodes.find(n => n.name === "doStuff");
expect(childNode).toBeDefined();
expect(childNode!.type).toBe("concept");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Unknown definition kind "procedure"'),
);
warnSpy.mockRestore();
});
it("skips duplicate node IDs in addNonCodeFileWithAnalysis and warns", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("schema.sql", {
nodeType: "file",
summary: "Schema",
tags: [],
complexity: "simple",
definitions: [
{ name: "users", kind: "table", lineRange: [1, 10] as [number, number], fields: ["id"] },
{ name: "users", kind: "table", lineRange: [12, 20] as [number, number], fields: ["id", "name"] },
],
});
const graph = builder.build();
// Only the file node + one table node (duplicate skipped)
const tableNodes = graph.nodes.filter(n => n.name === "users");
expect(tableNodes).toHaveLength(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Duplicate node ID "table:schema.sql:users"'),
);
warnSpy.mockRestore();
});
it("uses nodeType in fileId for contains edges", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("docker-compose.yml", {
nodeType: "config",
summary: "Docker compose config",
tags: [],
complexity: "simple",
services: [
{ name: "web", ports: [3000] },
],
});
const graph = builder.build();
const containsEdge = graph.edges.find(e => e.type === "contains");
expect(containsEdge).toBeDefined();
expect(containsEdge!.source).toBe("config:docker-compose.yml");
expect(containsEdge!.target).toBe("service:docker-compose.yml:web");
});
});
});

View File

@@ -0,0 +1,337 @@
import type {
KnowledgeGraph,
GraphNode,
GraphEdge,
StructuralAnalysis,
DefinitionInfo,
ServiceInfo,
EndpointInfo,
StepInfo,
ResourceInfo,
SectionInfo,
} from "../types.js";
import { LanguageRegistry } from "../languages/language-registry.js";
interface FileMeta {
summary: string;
tags: string[];
complexity: "simple" | "moderate" | "complex";
}
interface FileAnalysisMeta extends FileMeta {
summaries: Record<string, string>; // function/class name -> summary
fileSummary: string;
}
interface NonCodeFileMeta extends FileMeta {
nodeType: GraphNode["type"];
}
interface NonCodeFileAnalysisMeta extends NonCodeFileMeta {
definitions?: DefinitionInfo[];
services?: ServiceInfo[];
endpoints?: EndpointInfo[];
steps?: StepInfo[];
resources?: ResourceInfo[];
sections?: SectionInfo[];
}
const KIND_TO_NODE_TYPE: Record<string, GraphNode["type"]> = {
table: "table",
view: "table",
index: "table",
message: "schema",
type: "schema",
enum: "schema",
resource: "resource",
module: "resource",
service: "service",
deployment: "service",
job: "pipeline",
stage: "pipeline",
target: "pipeline",
route: "endpoint",
query: "endpoint",
mutation: "endpoint",
variable: "config",
output: "config",
};
export class GraphBuilder {
private readonly nodes: GraphNode[] = [];
private readonly edges: GraphEdge[] = [];
private readonly languages = new Set<string>();
private readonly nodeIds = new Set<string>();
private readonly edgeKeys = new Set<string>();
private readonly projectName: string;
private readonly gitHash: string;
private readonly languageRegistry: LanguageRegistry;
constructor(projectName: string, gitHash: string, languageRegistry?: LanguageRegistry) {
this.projectName = projectName;
this.gitHash = gitHash;
this.languageRegistry = languageRegistry ?? LanguageRegistry.createDefault();
}
private detectLanguage(filePath: string): string {
return this.languageRegistry.getForFile(filePath)?.id ?? "unknown";
}
private static basename(filePath: string): string {
return filePath.split("/").pop() ?? filePath;
}
addFile(filePath: string, meta: FileMeta): void {
const lang = this.detectLanguage(filePath);
if (lang !== "unknown") {
this.languages.add(lang);
}
const name = GraphBuilder.basename(filePath);
const id = `file:${filePath}`;
this.nodeIds.add(id);
this.nodes.push({
id,
type: "file",
name,
filePath,
summary: meta.summary,
tags: meta.tags,
complexity: meta.complexity,
});
}
addFileWithAnalysis(
filePath: string,
analysis: StructuralAnalysis,
meta: FileAnalysisMeta,
): void {
const lang = this.detectLanguage(filePath);
if (lang !== "unknown") {
this.languages.add(lang);
}
const fileName = GraphBuilder.basename(filePath);
const fileId = `file:${filePath}`;
// Create the file node
this.nodeIds.add(fileId);
this.nodes.push({
id: fileId,
type: "file",
name: fileName,
filePath,
summary: meta.fileSummary,
tags: meta.tags,
complexity: meta.complexity,
});
// Create function nodes with "contains" edges
for (const fn of analysis.functions) {
const funcId = `function:${filePath}:${fn.name}`;
this.nodeIds.add(funcId);
this.nodes.push({
id: funcId,
type: "function",
name: fn.name,
filePath,
lineRange: fn.lineRange,
summary: meta.summaries[fn.name] ?? "",
tags: [],
complexity: meta.complexity,
});
this.edges.push({
source: fileId,
target: funcId,
type: "contains",
direction: "forward",
weight: 1,
});
}
// Create class nodes with "contains" edges
for (const cls of analysis.classes) {
const classId = `class:${filePath}:${cls.name}`;
this.nodeIds.add(classId);
this.nodes.push({
id: classId,
type: "class",
name: cls.name,
filePath,
lineRange: cls.lineRange,
summary: meta.summaries[cls.name] ?? "",
tags: [],
complexity: meta.complexity,
});
this.edges.push({
source: fileId,
target: classId,
type: "contains",
direction: "forward",
weight: 1,
});
}
}
addImportEdge(fromFile: string, toFile: string): void {
const key = `imports|file:${fromFile}|file:${toFile}`;
if (this.edgeKeys.has(key)) return;
this.edgeKeys.add(key);
this.edges.push({
source: `file:${fromFile}`,
target: `file:${toFile}`,
type: "imports",
direction: "forward",
weight: 0.7,
});
}
addCallEdge(
callerFile: string,
callerFunc: string,
calleeFile: string,
calleeFunc: string,
): void {
const key = `calls|function:${callerFile}:${callerFunc}|function:${calleeFile}:${calleeFunc}`;
if (this.edgeKeys.has(key)) return;
this.edgeKeys.add(key);
this.edges.push({
source: `function:${callerFile}:${callerFunc}`,
target: `function:${calleeFile}:${calleeFunc}`,
type: "calls",
direction: "forward",
weight: 0.8,
});
}
addNonCodeFile(filePath: string, meta: NonCodeFileMeta): string {
const lang = this.detectLanguage(filePath);
if (lang !== "unknown") this.languages.add(lang);
const name = GraphBuilder.basename(filePath);
const id = `${meta.nodeType ?? "file"}:${filePath}`;
this.nodeIds.add(id);
this.nodes.push({
id,
type: meta.nodeType,
name,
filePath,
summary: meta.summary,
tags: meta.tags,
complexity: meta.complexity,
});
return id;
}
addNonCodeFileWithAnalysis(filePath: string, meta: NonCodeFileAnalysisMeta): void {
const fileId = this.addNonCodeFile(filePath, meta);
// Create child nodes for definitions (tables, schemas, etc.)
for (const def of meta.definitions ?? []) {
this.addChildNode({
id: `${def.kind}:${filePath}:${def.name}`,
type: this.mapKindToNodeType(def.kind),
name: def.name,
filePath,
lineRange: def.lineRange,
summary: `${def.kind}: ${def.name} (${def.fields.length} fields)`,
tags: [],
complexity: meta.complexity,
}, fileId);
}
// Create child nodes for services
for (const svc of meta.services ?? []) {
this.addChildNode({
id: `service:${filePath}:${svc.name}`,
type: "service",
name: svc.name,
filePath,
summary: `Service ${svc.name}${svc.image ? ` (image: ${svc.image})` : ""}`,
tags: [],
complexity: meta.complexity,
}, fileId);
}
// Create child nodes for endpoints
for (const ep of meta.endpoints ?? []) {
const name = `${ep.method ?? ""} ${ep.path}`.trim();
this.addChildNode({
id: `endpoint:${filePath}:${ep.path}`,
type: "endpoint",
name,
filePath,
lineRange: ep.lineRange,
summary: `Endpoint: ${name}`,
tags: [],
complexity: meta.complexity,
}, fileId);
}
// Create child nodes for steps (pipeline/makefile targets)
for (const step of meta.steps ?? []) {
this.addChildNode({
id: `step:${filePath}:${step.name}`,
type: "pipeline",
name: step.name,
filePath,
lineRange: step.lineRange,
summary: `Step: ${step.name}`,
tags: [],
complexity: meta.complexity,
}, fileId);
}
// Create child nodes for resources (Terraform, etc.)
for (const res of meta.resources ?? []) {
this.addChildNode({
id: `resource:${filePath}:${res.name}`,
type: "resource",
name: res.name,
filePath,
lineRange: res.lineRange,
summary: `Resource: ${res.name} (${res.kind})`,
tags: [],
complexity: meta.complexity,
}, fileId);
}
}
private addChildNode(node: GraphNode, parentId: string): void {
if (this.nodeIds.has(node.id)) {
console.warn(`[GraphBuilder] Duplicate node ID "${node.id}" — skipping`);
return;
}
this.nodeIds.add(node.id);
this.nodes.push(node);
this.edges.push({ source: parentId, target: node.id, type: "contains", direction: "forward", weight: 1 });
}
private mapKindToNodeType(kind: string): GraphNode["type"] {
const mapped = KIND_TO_NODE_TYPE[kind];
if (!mapped) {
console.warn(`[GraphBuilder] Unknown definition kind "${kind}" — falling back to "concept" node type`);
}
return mapped ?? "concept";
}
build(): KnowledgeGraph {
return {
version: "1.0.0",
project: {
name: this.projectName,
languages: [...this.languages].sort((a, b) => a.localeCompare(b)),
frameworks: [],
description: "",
analyzedAt: new Date().toISOString(),
gitCommitHash: this.gitHash,
},
nodes: [...this.nodes],
edges: [...this.edges],
layers: [],
tour: [],
};
}
}

View File

@@ -0,0 +1,210 @@
import type { GraphNode, GraphEdge } from "../types.js";
import type { LanguageConfig } from "../languages/types.js";
export interface LanguageLessonResult {
languageNotes: string;
concepts: Array<{ name: string; explanation: string }>;
}
/**
* Base concept patterns that apply across all languages.
* These are merged with language-specific concepts from LanguageConfig.
*/
const BASE_CONCEPT_PATTERNS: Record<string, string[]> = {
"async/await": ["async", "await", "promise", "asynchronous"],
"middleware pattern": ["middleware", "interceptor", "pipe"],
"generics": ["generic", "type parameter", "template"],
"decorators": ["decorator", "@", "annotation"],
"dependency injection": ["inject", "provider", "container", "di"],
"observer pattern": [
"subscribe",
"publish",
"event",
"observable",
"listener",
],
"singleton": ["singleton", "instance", "shared client"],
"type guards": ["type guard", "is", "narrowing", "discriminated union"],
"higher-order functions": [
"callback",
"factory",
"higher-order",
"closure",
],
"error handling": [
"try/catch",
"error boundary",
"exception",
"Result type",
],
"streams": ["stream", "pipe", "transform", "readable", "writable"],
"concurrency": ["goroutine", "channel", "thread", "worker", "mutex"],
};
/**
* Build the full concept patterns map by merging base patterns with
* language-specific concepts from a LanguageConfig (if provided).
*/
function buildConceptPatterns(
langConfig?: LanguageConfig | null,
): Record<string, string[]> {
const patterns = { ...BASE_CONCEPT_PATTERNS };
if (langConfig?.concepts) {
for (const concept of langConfig.concepts) {
if (!patterns[concept]) {
// Use the concept name itself as a keyword for detection
patterns[concept] = [concept.toLowerCase()];
}
}
}
return patterns;
}
/**
* Detects language concepts present in a graph node based on its tags, summary, and languageNotes.
* When a LanguageConfig is provided, language-specific concepts are also detected.
*/
export function detectLanguageConcepts(
node: GraphNode,
language: string,
langConfig?: LanguageConfig | null,
): string[] {
const text = [
...node.tags,
node.summary.toLowerCase(),
node.languageNotes?.toLowerCase() ?? "",
].join(" ");
const patterns = buildConceptPatterns(langConfig);
const detected: string[] = [];
for (const [concept, keywords] of Object.entries(patterns)) {
const found = keywords.some((keyword) =>
text.toLowerCase().includes(keyword.toLowerCase()),
);
if (found) {
detected.push(concept);
}
}
return detected;
}
/**
* Get the display name for a language.
* Uses LanguageConfig if provided, otherwise falls back to capitalization.
*/
export function getLanguageDisplayName(
language: string,
langConfig?: LanguageConfig | null,
): string {
if (langConfig?.displayName) {
return langConfig.displayName;
}
return language.charAt(0).toUpperCase() + language.slice(1);
}
/**
* Builds a prompt that asks an LLM to produce a language-specific lesson for a given node.
*/
export function buildLanguageLessonPrompt(
node: GraphNode,
edges: GraphEdge[],
language: string,
langConfig?: LanguageConfig | null,
): string {
const capitalizedLanguage = getLanguageDisplayName(language, langConfig);
const concepts = detectLanguageConcepts(node, language, langConfig);
const relationships = edges
.map((edge) => {
const arrow = edge.direction === "forward" ? "->" : "<-";
const other =
edge.source === node.id ? edge.target : edge.source;
return ` ${arrow} ${edge.type} ${other}`;
})
.join("\n");
const conceptSection =
concepts.length > 0
? `\nDetected concepts to explain:\n${concepts.map((c) => ` - ${c}`).join("\n")}`
: `\nNo specific concepts were pre-detected. Please identify any ${capitalizedLanguage} patterns or idioms present.`;
return `You are a programming teacher specializing in ${capitalizedLanguage}. Analyze the following code component and create a language-specific lesson.
Component: ${node.name}
Type: ${node.type}
File: ${node.filePath ?? "N/A"}
Summary: ${node.summary}
Tags: ${node.tags.join(", ")}
Relationships:
${relationships}
${conceptSection}
Return a JSON object with the following fields:
- "languageNotes": A concise explanation of the ${capitalizedLanguage}-specific patterns and idioms used in this component.
- "concepts": An array of objects, each with:
- "name": The concept name (e.g., "async/await", "generics").
- "explanation": A beginner-friendly explanation of this concept as it applies to this component.
Respond ONLY with the JSON object, no additional text.`;
}
/**
* Extracts a JSON block from an LLM response, handling markdown fences.
*/
function extractJson(response: string): string {
const fenceMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
if (fenceMatch) {
return fenceMatch[1].trim();
}
const objectMatch = response.match(/\{[\s\S]*\}/);
if (objectMatch) {
return objectMatch[0].trim();
}
return response.trim();
}
/**
* Parses an LLM response for language lesson content.
* Returns a safe default on parse failure.
*/
export function parseLanguageLessonResponse(
response: string,
): LanguageLessonResult {
try {
const jsonStr = extractJson(response);
const parsed = JSON.parse(jsonStr);
const languageNotes =
typeof parsed.languageNotes === "string" ? parsed.languageNotes : "";
const concepts = Array.isArray(parsed.concepts)
? parsed.concepts
.filter(
(
c: unknown,
): c is { name: string; explanation: string } =>
typeof c === "object" &&
c !== null &&
typeof (c as Record<string, unknown>).name === "string" &&
typeof (c as Record<string, unknown>).explanation ===
"string",
)
.map((c: { name: string; explanation: string }) => ({
name: c.name,
explanation: c.explanation,
}))
: [];
return { languageNotes, concepts };
} catch {
return { languageNotes: "", concepts: [] };
}
}

View File

@@ -0,0 +1,284 @@
import type { KnowledgeGraph, Layer } from "../types.js";
/**
* LLM layer response structure — what the LLM returns for each layer.
*/
export interface LLMLayerResponse {
name: string;
description: string;
filePatterns: string[];
}
/**
* Directory-pattern to layer-name mapping for heuristic detection.
* Order matters: first match wins.
*/
const LAYER_PATTERNS: Array<{ patterns: string[]; layerName: string; description: string }> = [
{
patterns: ["routes", "controller", "handler", "endpoint", "api"],
layerName: "API Layer",
description: "HTTP endpoints, route handlers, and API controllers",
},
{
patterns: ["service", "usecase", "use-case", "business"],
layerName: "Service Layer",
description: "Business logic and application services",
},
{
patterns: ["model", "entity", "schema", "database", "db", "migration", "repository", "repo"],
layerName: "Data Layer",
description: "Data models, database access, and persistence",
},
{
patterns: ["component", "view", "page", "screen", "layout", "widget", "ui"],
layerName: "UI Layer",
description: "User interface components and views",
},
{
patterns: ["middleware", "interceptor", "guard", "filter", "pipe"],
layerName: "Middleware Layer",
description: "Request/response middleware and interceptors",
},
{
patterns: ["client", "integration", "external", "sdk", "vendor", "adapter"],
layerName: "External Services",
description: "External service integrations, SDKs, and third-party adapters",
},
{
patterns: ["worker", "job", "queue", "cron", "consumer", "processor", "scheduler", "background"],
layerName: "Background Tasks",
description: "Background workers, job processors, and scheduled tasks",
},
{
patterns: ["util", "helper", "lib", "common", "shared"],
layerName: "Utility Layer",
description: "Shared utilities, helpers, and common libraries",
},
{
patterns: ["test", "spec", "__test__", "__spec__", "__tests__", "__specs__"],
layerName: "Test Layer",
description: "Test files and test utilities",
},
{
patterns: ["config", "setting", "env"],
layerName: "Configuration Layer",
description: "Application configuration and environment settings",
},
];
/**
* Convert a layer name to a kebab-case layer ID.
*/
function toLayerId(name: string): string {
return `layer:${name.toLowerCase().replace(/\s+/g, "-")}`;
}
/**
* Determine which layer a file path belongs to based on directory patterns.
* Returns the layer name or null if no pattern matches.
*/
function matchFileToLayer(filePath: string): string | null {
// Normalize path separators and split into segments
const normalizedPath = filePath.replace(/\\/g, "/").toLowerCase();
const segments = normalizedPath.split("/");
for (const { patterns, layerName } of LAYER_PATTERNS) {
for (const segment of segments) {
// Check if any directory segment matches a pattern (plural forms too)
for (const pattern of patterns) {
if (segment === pattern || segment === pattern + "s") {
return layerName;
}
}
}
}
return null;
}
/**
* Heuristic layer detection — assigns file nodes to layers based on
* directory path patterns. Unmatched files go to a "Core" layer.
*
* Only FILE-type nodes are assigned to layers.
*/
export function detectLayers(graph: KnowledgeGraph): Layer[] {
const layerMap = new Map<string, string[]>(); // layerName -> nodeIds
for (const node of graph.nodes) {
if (node.type !== "file") continue;
if (!node.filePath) continue;
const layerName = matchFileToLayer(node.filePath) ?? "Core";
const existing = layerMap.get(layerName) ?? [];
existing.push(node.id);
layerMap.set(layerName, existing);
}
// Also catch file nodes without filePath
for (const node of graph.nodes) {
if (node.type !== "file") continue;
if (node.filePath) continue;
const existing = layerMap.get("Core") ?? [];
existing.push(node.id);
layerMap.set("Core", existing);
}
const layers: Layer[] = [];
for (const [name, nodeIds] of layerMap) {
const description =
name === "Core"
? "Core application files"
: LAYER_PATTERNS.find((p) => p.layerName === name)?.description ?? "";
layers.push({
id: toLayerId(name),
name,
description,
nodeIds,
});
}
return layers;
}
/**
* Builds an LLM prompt that asks the model to identify logical layers
* from a list of file paths in the knowledge graph.
*/
export function buildLayerDetectionPrompt(graph: KnowledgeGraph): string {
const filePaths = graph.nodes
.filter((n) => n.type === "file" && n.filePath)
.map((n) => n.filePath!);
const fileListStr = filePaths.map((f) => ` - ${f}`).join("\n");
return `You are a software architecture analyst. Given the following list of file paths from a codebase, identify the logical architectural layers.
File paths:
${fileListStr}
Return a JSON array of 3-7 layers. Each layer object must have:
- "name": A short layer name (e.g., "API", "Data", "UI")
- "description": What this layer is responsible for (1 sentence)
- "filePatterns": An array of path prefixes that belong to this layer (e.g., ["src/routes/", "src/controllers/"])
Every file should belong to exactly one layer. Use the most specific pattern possible.
Respond ONLY with the JSON array, no additional text.`;
}
/**
* Parses an LLM response for layer detection.
* Handles markdown code fences and raw JSON.
* Returns the parsed array or null on failure.
*/
export function parseLayerDetectionResponse(
response: string,
): LLMLayerResponse[] | null {
if (!response || response.trim().length === 0) {
return null;
}
try {
// Try to extract from markdown code fences
const fenceMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
const jsonStr = fenceMatch ? fenceMatch[1].trim() : response.trim();
// Try to find a JSON array
const arrayMatch = jsonStr.match(/\[[\s\S]*\]/);
if (!arrayMatch) {
return null;
}
const parsed = JSON.parse(arrayMatch[0]);
if (!Array.isArray(parsed) || parsed.length === 0) {
return null;
}
// Validate and normalize each layer entry
const layers: LLMLayerResponse[] = [];
for (const item of parsed) {
if (typeof item !== "object" || item === null) continue;
if (typeof item.name !== "string") continue;
layers.push({
name: item.name,
description: typeof item.description === "string" ? item.description : "",
filePatterns: Array.isArray(item.filePatterns)
? item.filePatterns.filter((p: unknown) => typeof p === "string")
: [],
});
}
return layers.length > 0 ? layers : null;
} catch {
return null;
}
}
/**
* Applies LLM-provided layer definitions to a knowledge graph.
* Matches file nodes against LLM filePatterns (path prefix matching).
* Unassigned file nodes go to an "Other" layer.
*/
export function applyLLMLayers(
graph: KnowledgeGraph,
llmLayers: LLMLayerResponse[],
): Layer[] {
const layerMap = new Map<string, string[]>(); // layerName -> nodeIds
// Initialize all LLM layers
for (const llmLayer of llmLayers) {
layerMap.set(llmLayer.name, []);
}
for (const node of graph.nodes) {
if (node.type !== "file") continue;
if (!node.filePath) {
const other = layerMap.get("Other") ?? [];
other.push(node.id);
layerMap.set("Other", other);
continue;
}
const normalizedPath = node.filePath.replace(/\\/g, "/");
let assigned = false;
for (const llmLayer of llmLayers) {
for (const pattern of llmLayer.filePatterns) {
if (normalizedPath.startsWith(pattern) || normalizedPath.includes("/" + pattern)) {
const existing = layerMap.get(llmLayer.name)!;
existing.push(node.id);
assigned = true;
break;
}
}
if (assigned) break;
}
if (!assigned) {
const other = layerMap.get("Other") ?? [];
other.push(node.id);
layerMap.set("Other", other);
}
}
const layers: Layer[] = [];
for (const [name, nodeIds] of layerMap) {
if (nodeIds.length === 0) continue; // Skip empty layers
const llmLayer = llmLayers.find((l) => l.name === name);
layers.push({
id: toLayerId(name),
name,
description: llmLayer?.description ?? "Uncategorized files",
nodeIds,
});
}
return layers;
}

View File

@@ -0,0 +1,248 @@
import { describe, it, expect } from "vitest";
import {
buildFileAnalysisPrompt,
buildProjectSummaryPrompt,
parseFileAnalysisResponse,
parseProjectSummaryResponse,
} from "./llm-analyzer.js";
describe("LLM Analyzer", () => {
describe("buildFileAnalysisPrompt", () => {
it("should include file path and content in the prompt", () => {
const prompt = buildFileAnalysisPrompt(
"src/utils.ts",
"export function add(a: number, b: number) { return a + b; }",
"A math utility library",
);
expect(prompt).toContain("src/utils.ts");
expect(prompt).toContain("export function add");
expect(prompt).toContain("A math utility library");
expect(prompt).toContain("fileSummary");
expect(prompt).toContain("JSON");
});
it("should include project context", () => {
const prompt = buildFileAnalysisPrompt(
"app.py",
"print('hello')",
"A Python web server",
);
expect(prompt).toContain("A Python web server");
});
});
describe("parseFileAnalysisResponse", () => {
it("should parse valid JSON response", () => {
const response = JSON.stringify({
fileSummary: "A utility module for string processing",
tags: ["utility", "string"],
complexity: "simple",
functionSummaries: { capitalize: "Capitalizes the first letter" },
classSummaries: {},
languageNotes: "Uses ES2022 features",
});
const result = parseFileAnalysisResponse(response);
expect(result).not.toBeNull();
expect(result!.fileSummary).toBe("A utility module for string processing");
expect(result!.tags).toEqual(["utility", "string"]);
expect(result!.complexity).toBe("simple");
expect(result!.functionSummaries).toEqual({
capitalize: "Capitalizes the first letter",
});
expect(result!.classSummaries).toEqual({});
expect(result!.languageNotes).toBe("Uses ES2022 features");
});
it("should handle markdown-wrapped JSON (```json ... ```)", () => {
const response = `Here is the analysis:
\`\`\`json
{
"fileSummary": "Database connection handler",
"tags": ["database", "connection"],
"complexity": "complex",
"functionSummaries": { "connect": "Establishes DB connection" },
"classSummaries": { "Pool": "Connection pool manager" }
}
\`\`\`
That's the analysis.`;
const result = parseFileAnalysisResponse(response);
expect(result).not.toBeNull();
expect(result!.fileSummary).toBe("Database connection handler");
expect(result!.tags).toEqual(["database", "connection"]);
expect(result!.complexity).toBe("complex");
expect(result!.functionSummaries.connect).toBe("Establishes DB connection");
expect(result!.classSummaries.Pool).toBe("Connection pool manager");
});
it("should handle markdown fences without language tag", () => {
const response = `\`\`\`
{
"fileSummary": "Config loader",
"tags": ["config"],
"complexity": "simple",
"functionSummaries": {},
"classSummaries": {}
}
\`\`\``;
const result = parseFileAnalysisResponse(response);
expect(result).not.toBeNull();
expect(result!.fileSummary).toBe("Config loader");
});
it("should return null for invalid JSON", () => {
const result = parseFileAnalysisResponse("This is not JSON at all");
expect(result).toBeNull();
});
it("should return null for completely empty response", () => {
const result = parseFileAnalysisResponse("");
expect(result).toBeNull();
});
it("should default complexity to 'moderate' for unknown values", () => {
const response = JSON.stringify({
fileSummary: "Some file",
tags: [],
complexity: "very-hard",
functionSummaries: {},
classSummaries: {},
});
const result = parseFileAnalysisResponse(response);
expect(result).not.toBeNull();
expect(result!.complexity).toBe("moderate");
});
it("should default complexity to 'moderate' when missing", () => {
const response = JSON.stringify({
fileSummary: "Some file",
tags: [],
functionSummaries: {},
classSummaries: {},
});
const result = parseFileAnalysisResponse(response);
expect(result).not.toBeNull();
expect(result!.complexity).toBe("moderate");
});
it("should handle missing optional fields gracefully", () => {
const response = JSON.stringify({
fileSummary: "Minimal response",
});
const result = parseFileAnalysisResponse(response);
expect(result).not.toBeNull();
expect(result!.fileSummary).toBe("Minimal response");
expect(result!.tags).toEqual([]);
expect(result!.complexity).toBe("moderate");
expect(result!.functionSummaries).toEqual({});
expect(result!.classSummaries).toEqual({});
expect(result!.languageNotes).toBeUndefined();
});
});
describe("buildProjectSummaryPrompt", () => {
it("should include file list in the prompt", () => {
const fileList = ["src/index.ts", "src/utils.ts", "package.json"];
const prompt = buildProjectSummaryPrompt(fileList, []);
expect(prompt).toContain("src/index.ts");
expect(prompt).toContain("src/utils.ts");
expect(prompt).toContain("package.json");
expect(prompt).toContain("description");
expect(prompt).toContain("frameworks");
expect(prompt).toContain("layers");
});
it("should include sample file contents when provided", () => {
const prompt = buildProjectSummaryPrompt(
["src/app.ts"],
[{ path: "src/app.ts", content: "const app = express();" }],
);
expect(prompt).toContain("src/app.ts");
expect(prompt).toContain("const app = express()");
});
});
describe("parseProjectSummaryResponse", () => {
it("should parse valid project summary response", () => {
const response = JSON.stringify({
description: "A REST API for managing tasks",
frameworks: ["Express", "TypeScript", "Vitest"],
layers: [
{
name: "API",
description: "HTTP route handlers",
filePatterns: ["src/routes/**"],
},
{
name: "Data",
description: "Database access layer",
filePatterns: ["src/db/**", "src/models/**"],
},
],
});
const result = parseProjectSummaryResponse(response);
expect(result).not.toBeNull();
expect(result!.description).toBe("A REST API for managing tasks");
expect(result!.frameworks).toEqual(["Express", "TypeScript", "Vitest"]);
expect(result!.layers).toHaveLength(2);
expect(result!.layers[0]).toEqual({
name: "API",
description: "HTTP route handlers",
filePatterns: ["src/routes/**"],
});
});
it("should handle markdown-wrapped response", () => {
const response = `\`\`\`json
{
"description": "A CLI tool",
"frameworks": ["Commander"],
"layers": []
}
\`\`\``;
const result = parseProjectSummaryResponse(response);
expect(result).not.toBeNull();
expect(result!.description).toBe("A CLI tool");
expect(result!.frameworks).toEqual(["Commander"]);
});
it("should return null for invalid JSON", () => {
const result = parseProjectSummaryResponse("Not valid JSON");
expect(result).toBeNull();
});
it("should handle missing fields gracefully", () => {
const response = JSON.stringify({
description: "Some project",
});
const result = parseProjectSummaryResponse(response);
expect(result).not.toBeNull();
expect(result!.description).toBe("Some project");
expect(result!.frameworks).toEqual([]);
expect(result!.layers).toEqual([]);
});
});
});

View File

@@ -0,0 +1,186 @@
export interface LLMFileAnalysis {
fileSummary: string;
tags: string[];
complexity: "simple" | "moderate" | "complex";
functionSummaries: Record<string, string>;
classSummaries: Record<string, string>;
languageNotes?: string;
}
export interface LLMProjectSummary {
description: string;
frameworks: string[];
layers: Array<{ name: string; description: string; filePatterns: string[] }>;
}
/**
* Generates a prompt for analyzing a single source file with an LLM.
*/
export function buildFileAnalysisPrompt(
filePath: string,
content: string,
projectContext: string,
): string {
return `You are a code analysis assistant. Analyze the following source file and return a JSON object.
Project context: ${projectContext}
File: ${filePath}
\`\`\`
${content}
\`\`\`
Return a JSON object with the following fields:
- "fileSummary": A concise summary of what this file does (1-2 sentences).
- "tags": An array of relevant tags (e.g., ["utility", "async", "api"]).
- "complexity": One of "simple", "moderate", or "complex".
- "functionSummaries": An object mapping function names to 1-sentence summaries.
- "classSummaries": An object mapping class names to 1-sentence summaries.
- "languageNotes": Optional notes about language-specific patterns or idioms used.
Respond ONLY with the JSON object, no additional text.`;
}
/**
* Generates a prompt for creating a project-level summary with an LLM.
*/
export function buildProjectSummaryPrompt(
fileList: string[],
sampleFiles: Array<{ path: string; content: string }>,
): string {
const fileListStr = fileList.map((f) => ` - ${f}`).join("\n");
let samplesStr = "";
if (sampleFiles.length > 0) {
samplesStr = "\n\nSample files:\n";
for (const sample of sampleFiles) {
samplesStr += `\n--- ${sample.path} ---\n\`\`\`\n${sample.content}\n\`\`\`\n`;
}
}
return `You are a code analysis assistant. Analyze the following project structure and return a JSON object describing the project.
File list:
${fileListStr}${samplesStr}
Return a JSON object with the following fields:
- "description": A concise description of what this project does (2-3 sentences).
- "frameworks": An array of frameworks and major libraries detected (e.g., ["React", "Express", "Vitest"]).
- "layers": An array of logical layers, each with:
- "name": The layer name (e.g., "API", "Data", "UI").
- "description": What this layer is responsible for.
- "filePatterns": Glob patterns or path prefixes that belong to this layer.
Respond ONLY with the JSON object, no additional text.`;
}
/**
* Extracts a JSON block from an LLM response, handling markdown fences.
*/
function extractJson(response: string): string {
// Try to extract from markdown code fences
const fenceMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
if (fenceMatch) {
return fenceMatch[1].trim();
}
// Try to find a raw JSON object
const objectMatch = response.match(/\{[\s\S]*\}/);
if (objectMatch) {
return objectMatch[0].trim();
}
return response.trim();
}
const VALID_COMPLEXITIES = new Set(["simple", "moderate", "complex"]);
/**
* Parses an LLM response for file analysis. Returns null if parsing fails.
*/
export function parseFileAnalysisResponse(
response: string,
): LLMFileAnalysis | null {
try {
const jsonStr = extractJson(response);
const parsed = JSON.parse(jsonStr);
// Validate and normalize complexity
let complexity: "simple" | "moderate" | "complex" = "moderate";
if (
typeof parsed.complexity === "string" &&
VALID_COMPLEXITIES.has(parsed.complexity)
) {
complexity = parsed.complexity as "simple" | "moderate" | "complex";
}
return {
fileSummary:
typeof parsed.fileSummary === "string" ? parsed.fileSummary : "",
tags: Array.isArray(parsed.tags)
? parsed.tags.filter((t: unknown) => typeof t === "string")
: [],
complexity,
functionSummaries:
typeof parsed.functionSummaries === "object" &&
parsed.functionSummaries !== null
? parsed.functionSummaries
: {},
classSummaries:
typeof parsed.classSummaries === "object" &&
parsed.classSummaries !== null
? parsed.classSummaries
: {},
languageNotes:
typeof parsed.languageNotes === "string"
? parsed.languageNotes
: undefined,
};
} catch {
return null;
}
}
/**
* Parses an LLM response for project summary. Returns null if parsing fails.
*/
export function parseProjectSummaryResponse(
response: string,
): LLMProjectSummary | null {
try {
const jsonStr = extractJson(response);
const parsed = JSON.parse(jsonStr);
return {
description:
typeof parsed.description === "string" ? parsed.description : "",
frameworks: Array.isArray(parsed.frameworks)
? parsed.frameworks.filter((f: unknown) => typeof f === "string")
: [],
layers: Array.isArray(parsed.layers)
? parsed.layers
.filter(
(l: unknown): l is { name: string; description: string; filePatterns: string[] } =>
typeof l === "object" &&
l !== null &&
typeof (l as Record<string, unknown>).name === "string",
)
.map(
(l: { name: string; description: string; filePatterns: string[] }) => ({
name: l.name,
description:
typeof l.description === "string" ? l.description : "",
filePatterns: Array.isArray(l.filePatterns)
? l.filePatterns.filter(
(p: unknown) => typeof p === "string",
)
: [],
}),
)
: [],
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,329 @@
const VALID_PREFIXES = new Set([
"file", "func", "class", "module", "concept",
"config", "document", "service", "table", "endpoint",
"pipeline", "schema", "resource",
"domain", "flow", "step",
]);
const TYPE_TO_PREFIX: Record<string, string> = {
file: "file",
function: "func",
class: "class",
module: "module",
concept: "concept",
config: "config",
document: "document",
service: "service",
table: "table",
endpoint: "endpoint",
pipeline: "pipeline",
schema: "schema",
resource: "resource",
domain: "domain",
flow: "flow",
step: "step",
};
/**
* Strips all non-valid prefixes from an ID, returning the bare path
* and the first valid prefix found (if any).
*/
function stripToValidPrefix(id: string): { prefix: string | null; path: string } {
let remaining = id;
// Peel off colon-separated segments until we find a valid prefix or run out
while (true) {
const colonIdx = remaining.indexOf(":");
if (colonIdx <= 0) break;
const segment = remaining.slice(0, colonIdx);
if (VALID_PREFIXES.has(segment)) {
// Check for double valid prefix (e.g., "file:file:src/foo.ts")
const rest = remaining.slice(colonIdx + 1);
const innerColonIdx = rest.indexOf(":");
if (innerColonIdx > 0 && VALID_PREFIXES.has(rest.slice(0, innerColonIdx))) {
// Double-prefixed — skip the outer, recurse on inner
remaining = rest;
continue;
}
return { prefix: segment, path: rest };
}
// Not a valid prefix — strip it and continue
remaining = remaining.slice(colonIdx + 1);
}
return { prefix: null, path: remaining };
}
/**
* Normalizes a node ID to the canonical `type:path` format.
* Handles: double-prefixed IDs, project-name-prefixed IDs, bare paths.
* Idempotent — correct IDs pass through unchanged.
*/
export function normalizeNodeId(
id: string,
node: { type: string; filePath?: string; name?: string; parentFlowSlug?: string },
): string {
const trimmed = id.trim();
if (!trimmed) return trimmed;
const expectedPrefix = TYPE_TO_PREFIX[node.type];
const { prefix, path } = stripToValidPrefix(trimmed);
if (prefix) {
// For step nodes with filePath, reconstruct as step:flowSlug:filePath:stepSlug.
// Keeps the flow discriminator to avoid collisions when two flows
// have a same-named step in the same file.
if (node.type === "step" && node.filePath) {
const segments = path.split(":");
const stepSlug = segments.length > 0 ? segments[segments.length - 1] : path;
const flowSlug = segments.length > 1 ? segments[segments.length - 2] : "";
return flowSlug
? `${prefix}:${flowSlug}:${node.filePath}:${stepSlug}`
: `${prefix}:${node.filePath}:${stepSlug}`;
}
return `${prefix}:${path}`;
}
// No valid prefix found — bare path
if (expectedPrefix) {
// For func/class, reconstruct from filePath + name if available
if (
(node.type === "function" || node.type === "class") &&
node.filePath &&
node.name
) {
return `${expectedPrefix}:${node.filePath}:${node.name}`;
}
// For step nodes with filePath, reconstruct as step:[flowSlug:]filePath:slug
if (node.type === "step" && node.filePath) {
const slug = path.toLowerCase().replace(/\s+/g, "-");
// Include flow discriminator if available (from edge-based lookup)
return node.parentFlowSlug
? `${expectedPrefix}:${node.parentFlowSlug}:${node.filePath}:${slug}`
: `${expectedPrefix}:${node.filePath}:${slug}`;
}
return `${expectedPrefix}:${path}`;
}
return trimmed;
}
const VALID_COMPLEXITIES = new Set(["simple", "moderate", "complex"]);
const COMPLEXITY_STRING_MAP: Record<string, string> = {
low: "simple",
easy: "simple",
trivial: "simple",
basic: "simple",
medium: "moderate",
intermediate: "moderate",
mid: "moderate",
average: "moderate",
high: "complex",
hard: "complex",
difficult: "complex",
advanced: "complex",
};
/**
* Normalizes a complexity value to one of "simple" | "moderate" | "complex".
* Handles both string aliases and numeric scales — defaults to "moderate".
*/
export function normalizeComplexity(
value: unknown,
): "simple" | "moderate" | "complex" {
if (typeof value === "string") {
const lower = value.toLowerCase().trim();
if (VALID_COMPLEXITIES.has(lower)) return lower as "simple" | "moderate" | "complex";
const aliased = COMPLEXITY_STRING_MAP[lower];
if (aliased) return aliased as "simple" | "moderate" | "complex";
return "moderate";
}
if (typeof value === "number" && Number.isFinite(value) && value >= 1) {
if (value <= 3) return "simple";
if (value <= 6) return "moderate";
return "complex";
}
return "moderate";
}
export interface DroppedEdge {
source: string;
target: string;
type: string;
reason: "missing-source" | "missing-target" | "missing-both";
}
export interface NormalizationStats {
idsFixed: number;
complexityFixed: number;
edgesRewritten: number;
danglingEdgesDropped: number;
droppedEdges: DroppedEdge[];
}
export interface NormalizeBatchResult {
nodes: Record<string, unknown>[];
edges: Record<string, unknown>[];
idMap: Map<string, string>;
stats: NormalizationStats;
}
const PREFIX_TO_TYPE: Record<string, string> = {
file: "file", func: "function", class: "class", module: "module",
concept: "concept", config: "config", document: "document",
service: "service", table: "table", endpoint: "endpoint",
pipeline: "pipeline", schema: "schema", resource: "resource",
domain: "domain", flow: "flow", step: "step",
};
/** Infer node type from an ID's prefix (e.g. "step:foo" → "step"). Falls back to "file". */
function inferTypeFromId(id: string): string {
const colonIdx = id.indexOf(":");
if (colonIdx > 0) {
const prefix = id.slice(0, colonIdx);
if (prefix in PREFIX_TO_TYPE) return PREFIX_TO_TYPE[prefix];
}
return "file";
}
/**
* Normalizes a merged batch output: fixes node IDs and numeric complexity,
* rewrites edge references, deduplicates nodes and edges, and drops dangling edges.
*
* This runs BEFORE upstream's sanitizeGraph/autoFixGraph/normalizeGraph pipeline,
* handling concerns that pipeline does not cover: malformed IDs, numeric complexity,
* edge reference rewriting after ID correction, and edge deduplication.
*/
export function normalizeBatchOutput(data: {
nodes: Record<string, unknown>[];
edges: Record<string, unknown>[];
}): NormalizeBatchResult {
const stats: NormalizationStats = {
idsFixed: 0,
complexityFixed: 0,
edgesRewritten: 0,
danglingEdgesDropped: 0,
droppedEdges: [],
};
const idMap = new Map<string, string>();
// Build step→flow slug map from flow_step edges so bare-path step IDs
// can include the flow discriminator to avoid collisions.
const stepToFlowSlug = new Map<string, string>();
const flowNodeNames = new Map<string, string>();
for (const raw of data.nodes) {
if (String(raw.type ?? "") === "flow" && raw.id && raw.name) {
flowNodeNames.set(String(raw.id), String(raw.name).toLowerCase().replace(/\s+/g, "-"));
}
}
for (const raw of data.edges) {
if (String(raw.type ?? "") === "flow_step" && raw.source && raw.target) {
const flowSlug = flowNodeNames.get(String(raw.source));
if (flowSlug) {
stepToFlowSlug.set(String(raw.target), flowSlug);
}
}
}
// Pass 1: Normalize node IDs and numeric complexity
const nodes = data.nodes.map((raw) => {
const oldId = String(raw.id ?? "");
const nodeType = String(raw.type ?? "file");
const newId = normalizeNodeId(oldId, {
type: nodeType,
filePath: typeof raw.filePath === "string" ? raw.filePath : undefined,
name: typeof raw.name === "string" ? raw.name : undefined,
parentFlowSlug: nodeType === "step" ? stepToFlowSlug.get(oldId) : undefined,
});
if (newId !== oldId) {
stats.idsFixed++;
}
idMap.set(oldId, newId);
const result: Record<string, unknown> = { ...raw, id: newId };
// Normalize both numeric and non-canonical string complexity values.
// Upstream's COMPLEXITY_ALIASES handles some strings, but not all variants
// (e.g. "trivial", "advanced"). Normalizing here catches them early.
if (raw.complexity !== undefined) {
const normalized = normalizeComplexity(raw.complexity);
if (normalized !== raw.complexity) {
result.complexity = normalized;
stats.complexityFixed++;
}
}
return result;
});
// Deduplicate nodes (keep last occurrence)
const seenIds = new Map<string, number>();
for (let i = 0; i < nodes.length; i++) {
seenIds.set(String(nodes[i].id), i);
}
const deduped = nodes.filter((_, i) => seenIds.get(String(nodes[i].id)) === i);
const validNodeIds = new Set(deduped.map((n) => String(n.id)));
// Pass 2: Rewrite edge references and deduplicate
const edges: Record<string, unknown>[] = [];
const seenEdges = new Set<string>();
for (const raw of data.edges) {
const oldSource = String(raw.source ?? "");
const oldTarget = String(raw.target ?? "");
let newSource = idMap.get(oldSource) ?? oldSource;
let newTarget = idMap.get(oldTarget) ?? oldTarget;
// Fallback: if endpoint not found in idMap, normalize it directly
// (handles cross-variant malformed IDs between nodes and edges).
// Try the edge's implied type first (from prefix), then fall back to "file".
if (!validNodeIds.has(newSource)) {
const inferredType = inferTypeFromId(newSource);
const normalized = normalizeNodeId(newSource, { type: inferredType });
if (validNodeIds.has(normalized)) newSource = normalized;
}
if (!validNodeIds.has(newTarget)) {
const inferredType = inferTypeFromId(newTarget);
const normalized = normalizeNodeId(newTarget, { type: inferredType });
if (validNodeIds.has(normalized)) newTarget = normalized;
}
if (newSource !== oldSource || newTarget !== oldTarget) {
stats.edgesRewritten++;
}
if (!validNodeIds.has(newSource) || !validNodeIds.has(newTarget)) {
const missingSource = !validNodeIds.has(newSource);
const missingTarget = !validNodeIds.has(newTarget);
stats.danglingEdgesDropped++;
stats.droppedEdges.push({
source: newSource,
target: newTarget,
type: String(raw.type ?? ""),
reason: missingSource && missingTarget ? "missing-both" : missingSource ? "missing-source" : "missing-target",
});
continue;
}
// Deduplicate by composite key (source + target + type)
const edgeType = String(raw.type ?? "");
const edgeKey = `${newSource}|${newTarget}|${edgeType}`;
if (seenEdges.has(edgeKey)) continue;
seenEdges.add(edgeKey);
edges.push({ ...raw, source: newSource, target: newTarget });
}
return {
nodes: deduped,
edges,
idMap,
stats,
};
}

View File

@@ -0,0 +1,293 @@
import type { KnowledgeGraph, TourStep } from "../types.js";
/**
* Builds an LLM prompt asking for a guided tour of the project.
* Includes project metadata, node summaries, edges, and layer info.
*/
export function buildTourGenerationPrompt(graph: KnowledgeGraph): string {
const { project, nodes, edges, layers } = graph;
const nodeList = nodes
.map(
(n) =>
` - [${n.type}] ${n.name}${n.filePath ? ` (${n.filePath})` : ""}: ${n.summary}`,
)
.join("\n");
const edgeList = edges
.slice(0, 50)
.map((e) => ` - ${e.source} --${e.type}--> ${e.target}`)
.join("\n");
const layerList =
layers.length > 0
? layers
.map(
(l) =>
` - ${l.name}: ${l.description} (nodes: ${l.nodeIds.join(", ")})`,
)
.join("\n")
: " (no layers detected)";
return `You are a software architecture educator. Generate a guided tour of the following project that helps a newcomer understand the codebase step by step.
Project: ${project.name}
Description: ${project.description}
Languages: ${project.languages.join(", ")}
Frameworks: ${project.frameworks.join(", ")}
Nodes:
${nodeList}
Edges (dependencies/relationships):
${edgeList}
Layers:
${layerList}
Create a logical tour that:
1. Starts with entry points or high-level overview files
2. Follows the natural dependency flow
3. Groups related files together
4. Ends with supporting utilities or concepts
Return a JSON object with a "steps" array. Each step must have:
- "order": sequential number starting from 1
- "title": a short descriptive title for this tour stop
- "description": 2-3 sentences explaining what the reader will learn at this step
- "nodeIds": array of node IDs to highlight for this step
- "languageLesson" (optional): a brief note about language-specific patterns seen in these files
Respond ONLY with the JSON object, no additional text.`;
}
/**
* Parses an LLM response for tour generation.
* Handles raw JSON and JSON wrapped in markdown code fences.
* Filters out steps missing required fields.
* Returns empty array if parsing fails.
*/
export function parseTourGenerationResponse(response: string): TourStep[] {
if (!response || response.trim().length === 0) {
return [];
}
try {
// Try to extract from markdown code fences
const fenceMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
const jsonStr = fenceMatch ? fenceMatch[1].trim() : response.trim();
// Try to find a JSON object with steps
const objectMatch = jsonStr.match(/\{[\s\S]*\}/);
if (!objectMatch) {
return [];
}
const parsed = JSON.parse(objectMatch[0]);
if (!parsed || !Array.isArray(parsed.steps)) {
return [];
}
// Filter and validate each step
const steps: TourStep[] = [];
for (const item of parsed.steps) {
if (typeof item !== "object" || item === null) continue;
if (typeof item.order !== "number") continue;
if (typeof item.title !== "string" || item.title.length === 0) continue;
if (typeof item.description !== "string" || item.description.length === 0)
continue;
if (!Array.isArray(item.nodeIds) || item.nodeIds.length === 0) continue;
const step: TourStep = {
order: item.order,
title: item.title,
description: item.description,
nodeIds: item.nodeIds.filter((id: unknown) => typeof id === "string"),
};
if (typeof item.languageLesson === "string") {
step.languageLesson = item.languageLesson;
}
steps.push(step);
}
return steps;
} catch {
return [];
}
}
/**
* Generates a tour heuristically (without an LLM) using graph topology.
*
* Strategy:
* 1. Separate concept nodes from code nodes
* 2. Build adjacency info from edges
* 3. Find entry points (nodes with 0 incoming edges)
* 4. Topological sort (Kahn's algorithm)
* 5. If layers exist: group by layer in topological order
* 6. If no layers: batch by 3 nodes per step
* 7. Add concept nodes as final "Key Concepts" step
* 8. Assign sequential order numbers
*/
export function generateHeuristicTour(graph: KnowledgeGraph): TourStep[] {
const { nodes, edges, layers } = graph;
// Separate concept nodes from code nodes
const conceptNodes = nodes.filter((n) => n.type === "concept");
const codeNodes = nodes.filter((n) => n.type !== "concept");
const codeNodeIds = new Set(codeNodes.map((n) => n.id));
// Build adjacency info (only for code nodes)
const inDegree = new Map<string, number>();
const adjacency = new Map<string, string[]>();
for (const node of codeNodes) {
inDegree.set(node.id, 0);
adjacency.set(node.id, []);
}
for (const edge of edges) {
if (!codeNodeIds.has(edge.source) || !codeNodeIds.has(edge.target))
continue;
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
adjacency.get(edge.source)!.push(edge.target);
}
// Kahn's algorithm for topological sort
const queue: string[] = [];
for (const [nodeId, degree] of inDegree) {
if (degree === 0) {
queue.push(nodeId);
}
}
const topoOrder: string[] = [];
while (queue.length > 0) {
const current = queue.shift()!;
topoOrder.push(current);
for (const neighbor of adjacency.get(current) ?? []) {
const newDegree = (inDegree.get(neighbor) ?? 1) - 1;
inDegree.set(neighbor, newDegree);
if (newDegree === 0) {
queue.push(neighbor);
}
}
}
// Add any nodes not reached by topological sort (isolated nodes or cycles)
for (const node of codeNodes) {
if (!topoOrder.includes(node.id)) {
topoOrder.push(node.id);
}
}
// Build tour steps
const steps: TourStep[] = [];
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
if (layers.length > 0) {
// Group by layer in topological order
const nodeToLayer = new Map<string, string>();
for (const layer of layers) {
for (const nodeId of layer.nodeIds) {
nodeToLayer.set(nodeId, layer.id);
}
}
// Determine layer order from topological sort
const layerOrder: string[] = [];
const layerNodes = new Map<string, string[]>();
for (const nodeId of topoOrder) {
const layerId = nodeToLayer.get(nodeId);
if (layerId) {
if (!layerNodes.has(layerId)) {
layerNodes.set(layerId, []);
layerOrder.push(layerId);
}
layerNodes.get(layerId)!.push(nodeId);
}
}
// Create steps for each layer
const layerMap = new Map(layers.map((l) => [l.id, l]));
for (const layerId of layerOrder) {
const layer = layerMap.get(layerId);
const nodeIds = layerNodes.get(layerId) ?? [];
if (layer && nodeIds.length > 0) {
const nodeSummaries = nodeIds
.map((id) => nodeMap.get(id)?.name)
.filter(Boolean)
.join(", ");
steps.push({
order: 0, // assigned later
title: layer.name,
description: `${layer.description}. Key files: ${nodeSummaries}.`,
nodeIds,
});
}
}
// Add unlayered code nodes as "Supporting Components"
const layeredNodeIds = new Set(
layers.flatMap((l) => l.nodeIds),
);
const unlayeredNodes = topoOrder.filter(
(id) => !layeredNodeIds.has(id),
);
if (unlayeredNodes.length > 0) {
const nodeSummaries = unlayeredNodes
.map((id) => nodeMap.get(id)?.name)
.filter(Boolean)
.join(", ");
steps.push({
order: 0,
title: "Supporting Components",
description: `Additional supporting files: ${nodeSummaries}.`,
nodeIds: unlayeredNodes,
});
}
} else {
// No layers: batch by 3 nodes per step
for (let i = 0; i < topoOrder.length; i += 3) {
const batch = topoOrder.slice(i, i + 3);
const nodeSummaries = batch
.map((id) => {
const node = nodeMap.get(id);
return node ? `${node.name} (${node.summary})` : id;
})
.join("; ");
const stepNumber = Math.floor(i / 3) + 1;
steps.push({
order: 0, // assigned later
title: `Step ${stepNumber}: Code Walkthrough`,
description: `Exploring: ${nodeSummaries}.`,
nodeIds: batch,
});
}
}
// Add concept nodes as final step if any exist
if (conceptNodes.length > 0) {
const conceptSummaries = conceptNodes
.map((n) => `${n.name} (${n.summary})`)
.join("; ");
steps.push({
order: 0,
title: "Key Concepts",
description: `Important architectural concepts: ${conceptSummaries}.`,
nodeIds: conceptNodes.map((n) => n.id),
});
}
// Assign sequential order numbers
for (let i = 0; i < steps.length; i++) {
steps[i].order = i + 1;
}
return steps;
}

View File

@@ -0,0 +1,143 @@
import { dirname } from "node:path";
import type { ChangeAnalysis } from "./fingerprint.js";
export interface UpdateDecision {
action: "SKIP" | "PARTIAL_UPDATE" | "ARCHITECTURE_UPDATE" | "FULL_UPDATE";
filesToReanalyze: string[];
rerunArchitecture: boolean;
rerunTour: boolean;
reason: string;
}
/**
* Classify the type of graph update needed based on structural change analysis.
*
* Decision matrix:
* - SKIP: all files NONE or COSMETIC only
* - PARTIAL_UPDATE: some STRUCTURAL, same directories
* - ARCHITECTURE_UPDATE: new/deleted directories or >10 structural files
* - FULL_UPDATE: >30 structural files or >50% of total files changed structurally
*/
export function classifyUpdate(
analysis: ChangeAnalysis,
totalFilesInGraph: number,
allKnownFiles: string[] = [],
): UpdateDecision {
const { newFiles, deletedFiles, structurallyChangedFiles, cosmeticOnlyFiles } = analysis;
const structuralCount = structurallyChangedFiles.length + newFiles.length + deletedFiles.length;
// No structural changes at all — skip
if (structuralCount === 0) {
const cosmeticCount = cosmeticOnlyFiles.length;
const reason = cosmeticCount > 0
? `${cosmeticCount} file(s) have cosmetic-only changes (no structural impact)`
: "No changes detected";
return {
action: "SKIP",
filesToReanalyze: [],
rerunArchitecture: false,
rerunTour: false,
reason,
};
}
// Too many structural changes — suggest full rebuild
const triggeredByCount = structuralCount > 30;
const triggeredByPercentage = totalFilesInGraph > 0 && structuralCount / totalFilesInGraph > 0.5;
if (triggeredByCount || triggeredByPercentage) {
const thresholdReason =
triggeredByCount && triggeredByPercentage
? ">30 files and >50% of project"
: triggeredByCount
? ">30 files"
: ">50% of project";
return {
action: "FULL_UPDATE",
filesToReanalyze: [...structurallyChangedFiles, ...newFiles],
rerunArchitecture: true,
rerunTour: true,
reason: `${structuralCount} files have structural changes (${thresholdReason}) — full rebuild recommended`,
};
}
// Check if directory structure changed (new/deleted top-level directories)
const hasDirectoryChanges = detectDirectoryChanges(newFiles, deletedFiles, allKnownFiles);
if (hasDirectoryChanges || structuralCount > 10) {
return {
action: "ARCHITECTURE_UPDATE",
filesToReanalyze: [...structurallyChangedFiles, ...newFiles],
rerunArchitecture: true,
rerunTour: true,
reason: hasDirectoryChanges
? `Directory structure changed (${newFiles.length} new, ${deletedFiles.length} deleted files)`
: `${structuralCount} files have structural changes — architecture re-analysis needed`,
};
}
// Localized structural changes — partial update
return {
action: "PARTIAL_UPDATE",
filesToReanalyze: [...structurallyChangedFiles, ...newFiles],
rerunArchitecture: false,
rerunTour: false,
reason: `${structuralCount} file(s) have structural changes: ${summarizeChanges(analysis)}`,
};
}
/**
* Detect if the changes affect the directory structure (new or removed directories).
* Uses all known files in the project as the baseline for existing directories,
* then checks if any new/deleted files introduce or remove a top-level source directory.
*/
function detectDirectoryChanges(
newFiles: string[],
deletedFiles: string[],
allKnownFiles: string[],
): boolean {
const existingDirs = new Set(
allKnownFiles.map((f) => topDirectory(f)).filter(Boolean),
);
for (const f of newFiles) {
const dir = topDirectory(f);
if (dir && !existingDirs.has(dir)) return true;
}
for (const f of deletedFiles) {
const dir = topDirectory(f);
if (dir && !existingDirs.has(dir)) return true;
}
return false;
}
/**
* Get the top-level directory of a file path (first path segment).
*/
function topDirectory(filePath: string): string | null {
const dir = dirname(filePath);
if (dir === "." || dir === "") return null;
const segments = dir.split("/");
return segments[0] || null;
}
/**
* Produce a concise human-readable summary of structural changes.
*/
function summarizeChanges(analysis: ChangeAnalysis): string {
const parts: string[] = [];
if (analysis.newFiles.length > 0) {
parts.push(`${analysis.newFiles.length} new`);
}
if (analysis.deletedFiles.length > 0) {
parts.push(`${analysis.deletedFiles.length} deleted`);
}
if (analysis.structurallyChangedFiles.length > 0) {
parts.push(`${analysis.structurallyChangedFiles.length} modified`);
}
return parts.join(", ");
}

View File

@@ -0,0 +1,83 @@
import type { GraphNode } from "./types.js";
import type { SearchResult } from "./search.js";
export interface SemanticSearchOptions {
limit?: number;
threshold?: number;
types?: string[];
}
/**
* Compute cosine similarity between two vectors.
* Returns 0 if either vector has zero magnitude.
*/
export function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0;
let magA = 0;
let magB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
magA += a[i] * a[i];
magB += b[i] * b[i];
}
magA = Math.sqrt(magA);
magB = Math.sqrt(magB);
if (magA === 0 || magB === 0) return 0;
return dot / (magA * magB);
}
/**
* Semantic search engine using vector embeddings.
* Stores pre-computed embeddings for graph nodes and performs
* cosine similarity search against query embeddings.
*/
export class SemanticSearchEngine {
private nodes: GraphNode[];
private embeddings: Map<string, number[]>;
constructor(nodes: GraphNode[], embeddings: Record<string, number[]>) {
this.nodes = nodes;
this.embeddings = new Map(Object.entries(embeddings));
}
hasEmbeddings(): boolean {
return this.embeddings.size > 0;
}
addEmbedding(nodeId: string, embedding: number[]): void {
this.embeddings.set(nodeId, embedding);
}
search(
queryEmbedding: number[],
options?: SemanticSearchOptions,
): SearchResult[] {
const limit = options?.limit ?? 10;
const threshold = options?.threshold ?? 0;
const typeFilter = options?.types;
const scored: Array<{ nodeId: string; score: number }> = [];
for (const node of this.nodes) {
if (typeFilter && !typeFilter.includes(node.type)) continue;
const embedding = this.embeddings.get(node.id);
if (!embedding) continue;
const similarity = cosineSimilarity(queryEmbedding, embedding);
if (similarity >= threshold) {
scored.push({ nodeId: node.id, score: 1 - similarity });
}
}
scored.sort((a, b) => a.score - b.score);
return scored.slice(0, limit);
}
updateNodes(nodes: GraphNode[]): void {
this.nodes = nodes;
}
}

View File

@@ -0,0 +1,385 @@
import { createHash } from "node:crypto";
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import type { StructuralAnalysis } from "./types.js";
import type { PluginRegistry } from "./plugins/registry.js";
// ---- Fingerprint types ----
export interface FunctionFingerprint {
name: string;
params: string[];
returnType?: string;
exported: boolean;
lineCount: number;
}
export interface ClassFingerprint {
name: string;
methods: string[];
properties: string[];
exported: boolean;
lineCount: number;
}
export interface ImportFingerprint {
source: string;
specifiers: string[];
}
export interface FileFingerprint {
filePath: string;
contentHash: string;
functions: FunctionFingerprint[];
classes: ClassFingerprint[];
imports: ImportFingerprint[];
exports: string[];
totalLines: number;
hasStructuralAnalysis: boolean;
}
export interface FingerprintStore {
version: "1.0.0";
gitCommitHash: string;
generatedAt: string;
files: Record<string, FileFingerprint>;
}
export type ChangeLevel = "NONE" | "COSMETIC" | "STRUCTURAL";
export interface FileChangeResult {
filePath: string;
changeLevel: ChangeLevel;
details: string[];
}
export interface ChangeAnalysis {
fileChanges: FileChangeResult[];
newFiles: string[];
deletedFiles: string[];
structurallyChangedFiles: string[];
cosmeticOnlyFiles: string[];
unchangedFiles: string[];
}
// ---- Core functions ----
/**
* Compute SHA-256 content hash for a file's content.
*/
export function contentHash(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
/**
* Extract a structural fingerprint from a file using its tree-sitter analysis.
* The fingerprint captures only the elements that affect the knowledge graph
* (function/class/import/export signatures), not implementation details.
*/
export function extractFileFingerprint(
filePath: string,
content: string,
analysis: StructuralAnalysis,
): FileFingerprint {
const hash = contentHash(content);
const exportedNames = new Set(analysis.exports.map((e) => e.name));
const functions: FunctionFingerprint[] = analysis.functions.map((fn) => ({
name: fn.name,
params: [...fn.params],
returnType: fn.returnType,
exported: exportedNames.has(fn.name),
lineCount: fn.lineRange[1] - fn.lineRange[0] + 1,
}));
const classes: ClassFingerprint[] = analysis.classes.map((cls) => ({
name: cls.name,
methods: [...cls.methods],
properties: [...cls.properties],
exported: exportedNames.has(cls.name),
lineCount: cls.lineRange[1] - cls.lineRange[0] + 1,
}));
const imports: ImportFingerprint[] = analysis.imports.map((imp) => ({
source: imp.source,
specifiers: [...imp.specifiers],
}));
const exports = analysis.exports.map((e) => e.name);
const totalLines = content.split("\n").length;
return {
filePath,
contentHash: hash,
functions,
classes,
imports,
exports,
totalLines,
hasStructuralAnalysis: true,
};
}
/**
* Compare two file fingerprints and determine the change level.
*
* - NONE: content hash identical (file unchanged)
* - COSMETIC: content differs but structural signatures match (internal logic only)
* - STRUCTURAL: signature-level changes detected
*/
export function compareFingerprints(
oldFp: FileFingerprint,
newFp: FileFingerprint,
): FileChangeResult {
const details: string[] = [];
// Fast path: identical content
if (oldFp.contentHash === newFp.contentHash) {
return { filePath: newFp.filePath, changeLevel: "NONE", details: [] };
}
// Conservative path: if either fingerprint lacks structural analysis,
// we cannot verify structure didn't change — classify as STRUCTURAL.
if (!oldFp.hasStructuralAnalysis || !newFp.hasStructuralAnalysis) {
return {
filePath: newFp.filePath,
changeLevel: "STRUCTURAL",
details: ["no structural analysis available — conservative classification"],
};
}
// Compare function signatures
const oldFuncNames = new Set(oldFp.functions.map((f) => f.name));
const newFuncNames = new Set(newFp.functions.map((f) => f.name));
for (const name of newFuncNames) {
if (!oldFuncNames.has(name)) {
details.push(`new function: ${name}`);
}
}
for (const name of oldFuncNames) {
if (!newFuncNames.has(name)) {
details.push(`removed function: ${name}`);
}
}
// Compare shared functions for signature changes
for (const newFn of newFp.functions) {
const oldFn = oldFp.functions.find((f) => f.name === newFn.name);
if (!oldFn) continue;
if (JSON.stringify(oldFn.params) !== JSON.stringify(newFn.params)) {
details.push(`params changed: ${newFn.name}`);
}
if (oldFn.returnType !== newFn.returnType) {
details.push(`return type changed: ${newFn.name}`);
}
if (oldFn.exported !== newFn.exported) {
details.push(`export status changed: ${newFn.name}`);
}
// Flag large line count changes (>50% growth or shrink)
if (oldFn.lineCount > 0) {
const ratio = newFn.lineCount / oldFn.lineCount;
if (ratio > 1.5 || ratio < 0.5) {
details.push(`significant size change: ${newFn.name} (${oldFn.lineCount}${newFn.lineCount} lines)`);
}
}
}
// Compare class signatures
const oldClassNames = new Set(oldFp.classes.map((c) => c.name));
const newClassNames = new Set(newFp.classes.map((c) => c.name));
for (const name of newClassNames) {
if (!oldClassNames.has(name)) {
details.push(`new class: ${name}`);
}
}
for (const name of oldClassNames) {
if (!newClassNames.has(name)) {
details.push(`removed class: ${name}`);
}
}
for (const newCls of newFp.classes) {
const oldCls = oldFp.classes.find((c) => c.name === newCls.name);
if (!oldCls) continue;
if (JSON.stringify([...oldCls.methods].sort()) !== JSON.stringify([...newCls.methods].sort())) {
details.push(`methods changed: ${newCls.name}`);
}
if (JSON.stringify([...oldCls.properties].sort()) !== JSON.stringify([...newCls.properties].sort())) {
details.push(`properties changed: ${newCls.name}`);
}
if (oldCls.exported !== newCls.exported) {
details.push(`export status changed: ${newCls.name}`);
}
}
// Compare imports
const oldImports = oldFp.imports.map((i) => `${i.source}:${[...i.specifiers].sort().join(",")}`).sort();
const newImports = newFp.imports.map((i) => `${i.source}:${[...i.specifiers].sort().join(",")}`).sort();
if (JSON.stringify(oldImports) !== JSON.stringify(newImports)) {
details.push("imports changed");
}
// Compare exports
const oldExports = [...oldFp.exports].sort();
const newExports = [...newFp.exports].sort();
if (JSON.stringify(oldExports) !== JSON.stringify(newExports)) {
details.push("exports changed");
}
if (details.length > 0) {
return { filePath: newFp.filePath, changeLevel: "STRUCTURAL", details };
}
// Content changed but structure is identical
return {
filePath: newFp.filePath,
changeLevel: "COSMETIC",
details: ["internal logic changed (no structural impact)"],
};
}
/**
* Build a fingerprint store for a set of files.
* Files without tree-sitter support get content-hash-only fingerprints
* (conservative: any change is treated as STRUCTURAL).
*/
export function buildFingerprintStore(
projectDir: string,
filePaths: string[],
registry: PluginRegistry,
gitCommitHash: string,
): FingerprintStore {
const files: Record<string, FileFingerprint> = {};
for (const filePath of filePaths) {
const absolutePath = join(projectDir, filePath);
if (!existsSync(absolutePath)) continue;
const content = readFileSync(absolutePath, "utf-8");
const analysis = registry.analyzeFile(filePath, content);
if (analysis) {
files[filePath] = extractFileFingerprint(filePath, content, analysis);
} else {
// No tree-sitter support: content hash only (conservative)
files[filePath] = {
filePath,
contentHash: contentHash(content),
functions: [],
classes: [],
imports: [],
exports: [],
totalLines: content.split("\n").length,
hasStructuralAnalysis: false,
};
}
}
return {
version: "1.0.0",
gitCommitHash,
generatedAt: new Date().toISOString(),
files,
};
}
/**
* Analyze changes between the current state of files and stored fingerprints.
* Returns a detailed breakdown of what changed and at what level.
*/
export function analyzeChanges(
projectDir: string,
changedFiles: string[],
existingStore: FingerprintStore,
registry: PluginRegistry,
): ChangeAnalysis {
const fileChanges: FileChangeResult[] = [];
const newFiles: string[] = [];
const deletedFiles: string[] = [];
const structurallyChangedFiles: string[] = [];
const cosmeticOnlyFiles: string[] = [];
const unchangedFiles: string[] = [];
for (const filePath of changedFiles) {
const absolutePath = join(projectDir, filePath);
const existedBefore = filePath in existingStore.files;
const existsNow = existsSync(absolutePath);
// File was deleted
if (!existsNow) {
if (existedBefore) {
deletedFiles.push(filePath);
fileChanges.push({
filePath,
changeLevel: "STRUCTURAL",
details: ["file deleted"],
});
}
continue;
}
// File is new
if (!existedBefore) {
newFiles.push(filePath);
fileChanges.push({
filePath,
changeLevel: "STRUCTURAL",
details: ["new file"],
});
continue;
}
// File exists in both — compare fingerprints
const content = readFileSync(absolutePath, "utf-8");
const analysis = registry.analyzeFile(filePath, content);
const oldFp = existingStore.files[filePath];
let newFp: FileFingerprint;
if (analysis) {
newFp = extractFileFingerprint(filePath, content, analysis);
} else {
// No tree-sitter support: content hash only
newFp = {
filePath,
contentHash: contentHash(content),
functions: [],
classes: [],
imports: [],
exports: [],
totalLines: content.split("\n").length,
hasStructuralAnalysis: false,
};
}
const result = compareFingerprints(oldFp, newFp);
fileChanges.push(result);
switch (result.changeLevel) {
case "NONE":
unchangedFiles.push(filePath);
break;
case "COSMETIC":
cosmeticOnlyFiles.push(filePath);
break;
case "STRUCTURAL":
structurallyChangedFiles.push(filePath);
break;
}
}
return {
fileChanges,
newFiles,
deletedFiles,
structurallyChangedFiles,
cosmeticOnlyFiles,
unchangedFiles,
};
}

View File

@@ -0,0 +1,111 @@
import ignore, { type Ignore } from "ignore";
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
/**
* Hardcoded default ignore patterns matching the project-scanner agent's
* exclusion rules, plus bin/obj for .NET projects.
*/
export const DEFAULT_IGNORE_PATTERNS: string[] = [
// Dependency directories
"node_modules/",
".git/",
"vendor/",
"venv/",
".venv/",
"__pycache__/",
// Build output
"dist/",
"build/",
"out/",
"coverage/",
".next/",
".cache/",
".turbo/",
"target/",
"obj/",
// Lock files
"*.lock",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
// Binary/asset files
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.svg",
"*.ico",
"*.woff",
"*.woff2",
"*.ttf",
"*.eot",
"*.mp3",
"*.mp4",
"*.pdf",
"*.zip",
"*.tar",
"*.gz",
// Generated files
"*.min.js",
"*.min.css",
"*.map",
"*.generated.*",
// IDE/editor
".idea/",
".vscode/",
// Misc
"LICENSE",
".gitignore",
".editorconfig",
".prettierrc",
".eslintrc*",
"*.log",
];
export interface IgnoreFilter {
/** Returns true if the given relative path should be excluded from analysis. */
isIgnored(relativePath: string): boolean;
}
/**
* Creates an IgnoreFilter that merges hardcoded defaults with user-defined
* patterns from .understandignore files.
*
* Pattern load order (later entries can override earlier ones via ! negation):
* 1. Hardcoded defaults
* 2. .understand-anything/.understandignore (if exists)
* 3. .understandignore at project root (if exists)
*/
export function createIgnoreFilter(projectRoot: string): IgnoreFilter {
const ig: Ignore = ignore();
// Layer 1: hardcoded defaults
ig.add(DEFAULT_IGNORE_PATTERNS);
// Layer 2: .understand-anything/.understandignore
const projectIgnorePath = join(projectRoot, ".understand-anything", ".understandignore");
if (existsSync(projectIgnorePath)) {
const content = readFileSync(projectIgnorePath, "utf-8");
ig.add(content);
}
// Layer 3: .understandignore at project root
const rootIgnorePath = join(projectRoot, ".understandignore");
if (existsSync(rootIgnorePath)) {
const content = readFileSync(rootIgnorePath, "utf-8");
ig.add(content);
}
return {
isIgnored(relativePath: string): boolean {
return ig.ignores(relativePath);
},
};
}

View File

@@ -0,0 +1,102 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DEFAULT_IGNORE_PATTERNS } from "./ignore-filter.js";
const HEADER = `# .understandignore — patterns for files/dirs to exclude from analysis
# Syntax: same as .gitignore (globs, # comments, ! negation, trailing / for dirs)
# Lines below are suggestions — uncomment to activate.
# Use ! prefix to force-include something excluded by defaults.
#
# Built-in defaults (always excluded unless negated):
# node_modules/, .git/, dist/, build/, obj/, *.lock, *.min.js, etc.
#
`;
const DETECTABLE_DIRS = [
{ dir: "__tests__", pattern: "__tests__/" },
{ dir: "test", pattern: "test/" },
{ dir: "tests", pattern: "tests/" },
{ dir: "fixtures", pattern: "fixtures/" },
{ dir: "testdata", pattern: "testdata/" },
{ dir: "docs", pattern: "docs/" },
{ dir: "examples", pattern: "examples/" },
{ dir: "scripts", pattern: "scripts/" },
{ dir: "migrations", pattern: "migrations/" },
{ dir: ".storybook", pattern: ".storybook/" },
];
const GENERIC_SUGGESTIONS = [
"*.test.*",
"*.spec.*",
"*.snap",
];
/**
* Parses a .gitignore file and returns active patterns (no comments, no blanks).
*/
function parseGitignorePatterns(gitignorePath: string): string[] {
if (!existsSync(gitignorePath)) return [];
const content = readFileSync(gitignorePath, "utf-8");
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"));
}
/**
* Returns true if a gitignore pattern is already covered by the hardcoded defaults.
* Normalizes trailing slashes for comparison.
*/
function isCoveredByDefaults(pattern: string): boolean {
const normalize = (p: string) => p.replace(/\/+$/, "");
const normalized = normalize(pattern);
return DEFAULT_IGNORE_PATTERNS.some((d) => normalize(d) === normalized);
}
/**
* Generates a starter .understandignore file content by scanning the project
* for common directories and reading .gitignore patterns.
* All suggestions are commented out — this is a one-time generation.
*/
export function generateStarterIgnoreFile(projectRoot: string): string {
const sections: string[] = [HEADER];
// Section 1: patterns from .gitignore not already in defaults
const gitignorePath = join(projectRoot, ".gitignore");
const gitignorePatterns = parseGitignorePatterns(gitignorePath).filter(
(p) => !isCoveredByDefaults(p),
);
if (gitignorePatterns.length > 0) {
sections.push("# --- From .gitignore (uncomment to exclude) ---\n");
for (const pattern of gitignorePatterns) {
sections.push(`# ${pattern}`);
}
sections.push("");
}
// Section 2: detected directories
const detected: string[] = [];
for (const { dir, pattern } of DETECTABLE_DIRS) {
if (existsSync(join(projectRoot, dir))) {
detected.push(pattern);
}
}
if (detected.length > 0) {
sections.push("# --- Detected directories (uncomment to exclude) ---\n");
for (const pattern of detected) {
sections.push(`# ${pattern}`);
}
sections.push("");
}
// Section 3: generic test patterns
sections.push("# --- Test file patterns (uncomment to exclude) ---\n");
for (const pattern of GENERIC_SUGGESTIONS) {
sections.push(`# ${pattern}`);
}
sections.push("");
return sections.join("\n");
}

View File

@@ -0,0 +1,124 @@
export * from "./types.js";
export * from "./persistence/index.js";
export {
KnowledgeGraphSchema,
validateGraph,
sanitizeGraph,
autoFixGraph,
COMPLEXITY_ALIASES,
DIRECTION_ALIASES,
type ValidationResult,
type GraphIssue,
} from "./schema.js";
export { TreeSitterPlugin } from "./plugins/tree-sitter-plugin.js";
export type { LanguageExtractor } from "./plugins/extractors/types.js";
export { builtinExtractors } from "./plugins/extractors/index.js";
export { GraphBuilder } from "./analyzer/graph-builder.js";
export {
buildFileAnalysisPrompt,
buildProjectSummaryPrompt,
parseFileAnalysisResponse,
parseProjectSummaryResponse,
} from "./analyzer/llm-analyzer.js";
export type { LLMFileAnalysis, LLMProjectSummary } from "./analyzer/llm-analyzer.js";
export {
normalizeNodeId,
normalizeComplexity,
normalizeBatchOutput,
type DroppedEdge,
type NormalizationStats,
type NormalizeBatchResult,
} from "./analyzer/normalize-graph.js";
export { SearchEngine, type SearchResult, type SearchOptions } from "./search.js";
export {
getChangedFiles,
isStale,
mergeGraphUpdate,
type StalenessResult,
} from "./staleness.js";
export {
detectLayers,
buildLayerDetectionPrompt,
parseLayerDetectionResponse,
applyLLMLayers,
} from "./analyzer/layer-detector.js";
export type { LLMLayerResponse } from "./analyzer/layer-detector.js";
export {
buildTourGenerationPrompt,
parseTourGenerationResponse,
generateHeuristicTour,
} from "./analyzer/tour-generator.js";
export {
buildLanguageLessonPrompt,
parseLanguageLessonResponse,
detectLanguageConcepts,
type LanguageLessonResult,
} from "./analyzer/language-lesson.js";
export { PluginRegistry } from "./plugins/registry.js";
export {
LanguageRegistry,
FrameworkRegistry,
builtinLanguageConfigs,
builtinFrameworkConfigs,
LanguageConfigSchema,
FrameworkConfigSchema,
} from "./languages/index.js";
export type {
LanguageConfig,
FrameworkConfig,
TreeSitterConfig,
FilePatternConfig,
} from "./languages/index.js";
export {
parsePluginConfig,
serializePluginConfig,
DEFAULT_PLUGIN_CONFIG,
type PluginConfig,
type PluginEntry,
} from "./plugins/discovery.js";
export {
SemanticSearchEngine,
cosineSimilarity,
type SemanticSearchOptions,
} from "./embedding-search.js";
export {
extractFileFingerprint,
compareFingerprints,
analyzeChanges,
buildFingerprintStore,
contentHash,
type FunctionFingerprint,
type ClassFingerprint,
type ImportFingerprint,
type FileFingerprint,
type FingerprintStore,
type ChangeLevel,
type FileChangeResult,
type ChangeAnalysis,
} from "./fingerprint.js";
export {
classifyUpdate,
type UpdateDecision,
} from "./change-classifier.js";
// Non-code parsers
export {
MarkdownParser,
YAMLConfigParser,
JSONConfigParser,
TOMLParser,
EnvParser,
DockerfileParser,
SQLParser,
GraphQLParser,
ProtobufParser,
TerraformParser,
MakefileParser,
ShellParser,
registerAllParsers,
} from "./plugins/parsers/index.js";
export {
createIgnoreFilter,
DEFAULT_IGNORE_PATTERNS,
type IgnoreFilter,
} from "./ignore-filter.js";
export { generateStarterIgnoreFile } from "./ignore-generator.js";

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const batchConfig = {
id: "batch",
displayName: "Batch Script",
extensions: [".bat", ".cmd"],
concepts: ["commands", "variables", "labels", "goto", "call", "echo", "set", "for loops", "if conditions"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,27 @@
import type { LanguageConfig } from "../types.js";
export const cConfig = {
id: "c",
displayName: "C",
extensions: [".c", ".h"],
treeSitter: {
wasmPackage: "tree-sitter-cpp",
wasmFile: "tree-sitter-cpp.wasm",
},
concepts: [
"pointers",
"manual memory management",
"structs",
"unions",
"function pointers",
"preprocessor macros",
"header files",
"static vs dynamic linking",
],
filePatterns: {
entryPoints: ["main.c", "src/main.c"],
barrels: [],
tests: ["*_test.c", "test_*.c"],
config: ["Makefile", "CMakeLists.txt", "meson.build"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,29 @@
import type { LanguageConfig } from "../types.js";
export const cppConfig = {
id: "cpp",
displayName: "C++",
extensions: [".cpp", ".cc", ".cxx", ".hpp", ".hxx"],
treeSitter: {
wasmPackage: "tree-sitter-cpp",
wasmFile: "tree-sitter-cpp.wasm",
},
concepts: [
"templates",
"RAII",
"smart pointers",
"move semantics",
"operator overloading",
"virtual functions",
"namespaces",
"constexpr",
"lambda expressions",
"STL containers",
],
filePatterns: {
entryPoints: ["main.cpp", "src/main.cpp"],
barrels: [],
tests: ["*_test.cpp", "*_test.cc", "test_*.cpp"],
config: ["CMakeLists.txt", "Makefile", "meson.build"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,29 @@
import type { LanguageConfig } from "../types.js";
export const csharpConfig = {
id: "csharp",
displayName: "C#",
extensions: [".cs"],
treeSitter: {
wasmPackage: "tree-sitter-c-sharp",
wasmFile: "tree-sitter-c_sharp.wasm",
},
concepts: [
"LINQ",
"async/await",
"generics",
"properties",
"delegates and events",
"attributes",
"nullable reference types",
"pattern matching",
"records",
"dependency injection",
],
filePatterns: {
entryPoints: ["Program.cs", "**/Program.cs"],
barrels: [],
tests: ["*Tests.cs", "*Test.cs"],
config: ["*.csproj", "*.sln"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const cssConfig = {
id: "css",
displayName: "CSS",
extensions: [".css", ".scss", ".less"],
concepts: ["selectors", "properties", "media queries", "flexbox", "grid", "variables", "animations", "specificity"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const csvConfig = {
id: "csv",
displayName: "CSV",
extensions: [".csv", ".tsv"],
concepts: ["headers", "rows", "delimiters", "quoting", "escaping"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,15 @@
import type { LanguageConfig } from "../types.js";
export const dockerComposeConfig = {
id: "docker-compose",
displayName: "Docker Compose",
extensions: [],
filenames: ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"],
concepts: ["services", "networks", "volumes", "ports", "environment", "depends_on", "build context", "healthchecks"],
filePatterns: {
entryPoints: ["docker-compose.yml", "compose.yml"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,15 @@
import type { LanguageConfig } from "../types.js";
export const dockerfileConfig = {
id: "dockerfile",
displayName: "Dockerfile",
extensions: [],
filenames: ["Dockerfile", "Dockerfile.dev", "Dockerfile.prod", "Dockerfile.test"],
concepts: ["multi-stage builds", "layers", "base images", "COPY/ADD", "EXPOSE", "ENTRYPOINT", "CMD", "ARG", "ENV"],
filePatterns: {
entryPoints: ["Dockerfile"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,15 @@
import type { LanguageConfig } from "../types.js";
export const envConfig = {
id: "env",
displayName: "Environment Variables",
extensions: [".env"],
filenames: [".env", ".env.local", ".env.development", ".env.production", ".env.test", ".env.example"],
concepts: ["key-value pairs", "variable interpolation", "secrets", "environment-specific config"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [".env", ".env.*"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const githubActionsConfig = {
id: "github-actions",
displayName: "GitHub Actions",
extensions: [],
concepts: ["workflows", "jobs", "steps", "actions", "triggers", "secrets", "matrix strategy", "artifacts"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [".github/workflows/*.yml"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,28 @@
import type { LanguageConfig } from "../types.js";
export const goConfig = {
id: "go",
displayName: "Go",
extensions: [".go"],
treeSitter: {
wasmPackage: "tree-sitter-go",
wasmFile: "tree-sitter-go.wasm",
},
concepts: [
"goroutines",
"channels",
"interfaces",
"struct embedding",
"error handling patterns",
"defer/panic/recover",
"slices",
"pointers",
"concurrency patterns",
],
filePatterns: {
entryPoints: ["main.go", "cmd/*/main.go"],
barrels: [],
tests: ["*_test.go"],
config: ["go.mod", "go.sum"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const graphqlConfig = {
id: "graphql",
displayName: "GraphQL",
extensions: [".graphql", ".gql"],
concepts: ["types", "queries", "mutations", "subscriptions", "resolvers", "directives", "fragments", "schema"],
filePatterns: {
entryPoints: ["schema.graphql"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const htmlConfig = {
id: "html",
displayName: "HTML",
extensions: [".html", ".htm"],
concepts: ["elements", "attributes", "semantic tags", "forms", "meta tags", "scripts", "stylesheets", "accessibility"],
filePatterns: {
entryPoints: ["index.html"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,132 @@
import type { LanguageConfig } from "../types.js";
import { typescriptConfig } from "./typescript.js";
import { javascriptConfig } from "./javascript.js";
import { pythonConfig } from "./python.js";
import { goConfig } from "./go.js";
import { rustConfig } from "./rust.js";
import { javaConfig } from "./java.js";
import { rubyConfig } from "./ruby.js";
import { phpConfig } from "./php.js";
import { swiftConfig } from "./swift.js";
import { kotlinConfig } from "./kotlin.js";
import { cConfig } from "./c.js";
import { cppConfig } from "./cpp.js";
import { csharpConfig } from "./csharp.js";
import { luaConfig } from "./lua.js";
// Non-code language configs
import { markdownConfig } from "./markdown.js";
import { yamlConfig } from "./yaml.js";
import { jsonConfigConfig } from "./json-config.js";
import { tomlConfig } from "./toml.js";
import { envConfig } from "./env.js";
import { xmlConfig } from "./xml.js";
import { dockerfileConfig } from "./dockerfile.js";
import { sqlConfig } from "./sql.js";
import { graphqlConfig } from "./graphql.js";
import { protobufConfig } from "./protobuf.js";
import { terraformConfig } from "./terraform.js";
import { githubActionsConfig } from "./github-actions.js";
import { makefileConfig } from "./makefile.js";
import { shellConfig } from "./shell.js";
import { htmlConfig } from "./html.js";
import { cssConfig } from "./css.js";
import { openapiConfig } from "./openapi.js";
import { kubernetesConfig } from "./kubernetes.js";
import { dockerComposeConfig } from "./docker-compose.js";
import { jsonSchemaConfig } from "./json-schema.js";
import { csvConfig } from "./csv.js";
import { restructuredtextConfig } from "./restructuredtext.js";
import { powershellConfig } from "./powershell.js";
import { batchConfig } from "./batch.js";
import { jenkinsfileConfig } from "./jenkinsfile.js";
import { plaintextConfig } from "./plaintext.js";
export const builtinLanguageConfigs: LanguageConfig[] = [
// Code languages
typescriptConfig,
javascriptConfig,
pythonConfig,
goConfig,
rustConfig,
javaConfig,
rubyConfig,
phpConfig,
swiftConfig,
kotlinConfig,
luaConfig,
cConfig,
cppConfig,
csharpConfig,
// Non-code languages
markdownConfig,
yamlConfig,
jsonConfigConfig,
tomlConfig,
envConfig,
xmlConfig,
dockerfileConfig,
sqlConfig,
graphqlConfig,
protobufConfig,
terraformConfig,
githubActionsConfig,
makefileConfig,
shellConfig,
htmlConfig,
cssConfig,
openapiConfig,
kubernetesConfig,
dockerComposeConfig,
jsonSchemaConfig,
csvConfig,
restructuredtextConfig,
powershellConfig,
batchConfig,
jenkinsfileConfig,
plaintextConfig,
];
export {
// Code languages
typescriptConfig,
javascriptConfig,
pythonConfig,
goConfig,
rustConfig,
javaConfig,
rubyConfig,
phpConfig,
swiftConfig,
kotlinConfig,
luaConfig,
cConfig,
cppConfig,
csharpConfig,
// Non-code languages
markdownConfig,
yamlConfig,
jsonConfigConfig,
tomlConfig,
envConfig,
xmlConfig,
dockerfileConfig,
sqlConfig,
graphqlConfig,
protobufConfig,
terraformConfig,
githubActionsConfig,
makefileConfig,
shellConfig,
htmlConfig,
cssConfig,
openapiConfig,
kubernetesConfig,
dockerComposeConfig,
jsonSchemaConfig,
csvConfig,
restructuredtextConfig,
powershellConfig,
batchConfig,
jenkinsfileConfig,
plaintextConfig,
};

View File

@@ -0,0 +1,33 @@
import type { LanguageConfig } from "../types.js";
export const javaConfig = {
id: "java",
displayName: "Java",
extensions: [".java"],
treeSitter: {
wasmPackage: "tree-sitter-java",
wasmFile: "tree-sitter-java.wasm",
},
concepts: [
"generics",
"annotations",
"interfaces",
"abstract classes",
"streams API",
"lambdas",
"sealed classes",
"records",
"dependency injection",
"checked exceptions",
],
filePatterns: {
entryPoints: [
"**/Application.java",
"**/Main.java",
"src/main/java/**/App.java",
],
barrels: [],
tests: ["*Test.java", "*Tests.java", "*IT.java"],
config: ["pom.xml", "build.gradle", "build.gradle.kts"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,29 @@
import type { LanguageConfig } from "../types.js";
export const javascriptConfig = {
id: "javascript",
displayName: "JavaScript",
extensions: [".js", ".jsx", ".mjs", ".cjs"],
treeSitter: {
wasmPackage: "tree-sitter-javascript",
wasmFile: "tree-sitter-javascript.wasm",
},
concepts: [
"closures",
"prototypes",
"promises",
"async/await",
"event loop",
"destructuring",
"spread operator",
"proxies",
"generators",
"modules (ESM/CJS)",
],
filePatterns: {
entryPoints: ["index.js", "src/index.js", "main.js"],
barrels: ["index.js"],
tests: ["*.test.js", "*.spec.js"],
config: ["package.json", "jsconfig.json"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,15 @@
import type { LanguageConfig } from "../types.js";
export const jenkinsfileConfig = {
id: "jenkinsfile",
displayName: "Jenkinsfile",
extensions: [],
filenames: ["Jenkinsfile"],
concepts: ["pipeline", "stages", "steps", "agents", "environment", "post actions", "parallel execution", "shared libraries"],
filePatterns: {
entryPoints: ["Jenkinsfile"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const jsonConfigConfig = {
id: "json",
displayName: "JSON",
extensions: [".json", ".jsonc"],
concepts: ["objects", "arrays", "nesting", "schema references", "comments (JSONC)"],
filePatterns: {
entryPoints: ["package.json"],
barrels: [],
tests: [],
config: ["tsconfig.json", "package.json", ".eslintrc.json"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,18 @@
import type { LanguageConfig } from "../types.js";
// TODO: JSON Schema files have no unique extension — *.schema.json files will match
// `jsonConfigConfig` by the `.json` extension. Detection requires content-based
// heuristics (e.g., checking for `"$schema"` or `"type"` keys at the root level).
// A future content-based detection pass could re-classify them as JSON Schema.
export const jsonSchemaConfig = {
id: "json-schema",
displayName: "JSON Schema",
extensions: [],
concepts: ["types", "properties", "required fields", "$ref", "$defs", "allOf/anyOf/oneOf", "patterns", "validation"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,25 @@
import type { LanguageConfig } from "../types.js";
export const kotlinConfig = {
id: "kotlin",
displayName: "Kotlin",
extensions: [".kt", ".kts"],
concepts: [
"coroutines",
"data classes",
"sealed classes",
"extension functions",
"null safety",
"delegation",
"DSL builders",
"inline functions",
"companion objects",
"flow",
],
filePatterns: {
entryPoints: ["**/Application.kt", "**/Main.kt"],
barrels: [],
tests: ["*Test.kt", "*Tests.kt"],
config: ["build.gradle.kts", "build.gradle"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,19 @@
import type { LanguageConfig } from "../types.js";
// TODO: Kubernetes manifests are YAML files with no unique extension or filename.
// Detection requires content-based or path-pattern heuristics (e.g., checking for
// `apiVersion`/`kind` fields in YAML, or matching paths like `k8s/`, `kubernetes/`,
// `deploy/`). Currently these files will match `yamlConfig` by extension (.yaml/.yml).
// A future content-based detection pass could re-classify them as Kubernetes.
export const kubernetesConfig = {
id: "kubernetes",
displayName: "Kubernetes",
extensions: [],
concepts: ["deployments", "services", "pods", "configmaps", "secrets", "ingress", "volumes", "namespaces"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: ["k8s/*.yaml", "kubernetes/*.yaml"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,23 @@
import type { LanguageConfig } from "../types.js";
export const luaConfig = {
id: "lua",
displayName: "Lua",
extensions: [".lua"],
concepts: [
"tables",
"metatables",
"coroutines",
"closures",
"prototype-based OOP",
"varargs",
"weak references",
"environments",
],
filePatterns: {
entryPoints: ["main.lua", "init.lua"],
barrels: [],
tests: ["*_test.lua", "test_*.lua", "*_spec.lua"],
config: [".luacheckrc", "rockspec"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,15 @@
import type { LanguageConfig } from "../types.js";
export const makefileConfig = {
id: "makefile",
displayName: "Makefile",
extensions: [".mk"],
filenames: ["Makefile", "GNUmakefile", "makefile"],
concepts: ["targets", "dependencies", "recipes", "variables", "pattern rules", "phony targets", "includes"],
filePatterns: {
entryPoints: ["Makefile"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const markdownConfig = {
id: "markdown",
displayName: "Markdown",
extensions: [".md", ".mdx"],
concepts: ["headings", "links", "code blocks", "front matter", "lists", "tables", "images"],
filePatterns: {
entryPoints: ["README.md"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,15 @@
import type { LanguageConfig } from "../types.js";
export const openapiConfig = {
id: "openapi",
displayName: "OpenAPI",
extensions: [],
filenames: ["openapi.yaml", "openapi.json", "swagger.yaml", "swagger.json"],
concepts: ["paths", "operations", "schemas", "parameters", "responses", "security schemes", "tags", "servers"],
filePatterns: {
entryPoints: ["openapi.yaml", "swagger.yaml"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,29 @@
import type { LanguageConfig } from "../types.js";
export const phpConfig = {
id: "php",
displayName: "PHP",
extensions: [".php"],
treeSitter: {
wasmPackage: "tree-sitter-php",
wasmFile: "tree-sitter-php.wasm",
},
concepts: [
"namespaces",
"traits",
"type declarations",
"attributes",
"enums",
"fibers",
"closures",
"magic methods",
"dependency injection",
"middleware",
],
filePatterns: {
entryPoints: ["index.php", "public/index.php", "artisan"],
barrels: [],
tests: ["*Test.php", "tests/**/*.php"],
config: ["composer.json", "php.ini"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const plaintextConfig = {
id: "plaintext",
displayName: "Plain Text",
extensions: [".txt", ".text"],
concepts: ["paragraphs", "lists", "sections"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const powershellConfig = {
id: "powershell",
displayName: "PowerShell",
extensions: [".ps1", ".psm1", ".psd1"],
concepts: ["cmdlets", "pipelines", "modules", "functions", "parameters", "variables", "error handling"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const protobufConfig = {
id: "protobuf",
displayName: "Protocol Buffers",
extensions: [".proto"],
concepts: ["messages", "services", "enums", "oneof", "repeated fields", "maps", "packages", "imports"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,44 @@
import type { LanguageConfig } from "../types.js";
export const pythonConfig = {
id: "python",
displayName: "Python",
extensions: [".py", ".pyi"],
treeSitter: {
wasmPackage: "tree-sitter-python",
wasmFile: "tree-sitter-python.wasm",
},
concepts: [
"decorators",
"list comprehensions",
"generators",
"context managers",
"type hints",
"dunder methods",
"metaclasses",
"dataclasses",
"async/await",
"descriptors",
"protocols",
],
filePatterns: {
entryPoints: [
"main.py",
"manage.py",
"app.py",
"wsgi.py",
"asgi.py",
"run.py",
"__main__.py",
],
barrels: ["__init__.py"],
tests: ["test_*.py", "*_test.py", "conftest.py"],
config: [
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"Pipfile",
],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const restructuredtextConfig = {
id: "restructuredtext",
displayName: "reStructuredText",
extensions: [".rst"],
concepts: ["headings", "directives", "roles", "cross-references", "toctree", "code blocks", "admonitions"],
filePatterns: {
entryPoints: ["index.rst"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,28 @@
import type { LanguageConfig } from "../types.js";
export const rubyConfig = {
id: "ruby",
displayName: "Ruby",
extensions: [".rb", ".rake"],
treeSitter: {
wasmPackage: "tree-sitter-ruby",
wasmFile: "tree-sitter-ruby.wasm",
},
concepts: [
"blocks and procs",
"mixins",
"metaprogramming",
"duck typing",
"DSLs",
"monkey patching",
"symbols",
"method_missing",
"open classes",
],
filePatterns: {
entryPoints: ["config.ru", "app.rb"],
barrels: [],
tests: ["*_test.rb", "*_spec.rb", "spec_helper.rb"],
config: ["Gemfile", "Rakefile"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,31 @@
import type { LanguageConfig } from "../types.js";
export const rustConfig = {
id: "rust",
displayName: "Rust",
extensions: [".rs"],
treeSitter: {
wasmPackage: "tree-sitter-rust",
wasmFile: "tree-sitter-rust.wasm",
},
concepts: [
"ownership",
"borrowing",
"lifetimes",
"traits",
"pattern matching",
"enums with data",
"error handling (Result/Option)",
"macros",
"async/await",
"unsafe blocks",
"generics",
"closures",
],
filePatterns: {
entryPoints: ["src/main.rs", "src/lib.rs"],
barrels: ["mod.rs", "lib.rs"],
tests: ["tests/*.rs"],
config: ["Cargo.toml"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const shellConfig = {
id: "shell",
displayName: "Shell Script",
extensions: [".sh", ".bash", ".zsh"],
concepts: ["variables", "functions", "conditionals", "loops", "pipes", "redirection", "subshells", "exit codes"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [".bashrc", ".zshrc", ".profile"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const sqlConfig = {
id: "sql",
displayName: "SQL",
extensions: [".sql"],
concepts: ["tables", "columns", "indexes", "foreign keys", "views", "stored procedures", "triggers", "migrations"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,25 @@
import type { LanguageConfig } from "../types.js";
export const swiftConfig = {
id: "swift",
displayName: "Swift",
extensions: [".swift"],
concepts: [
"optionals",
"protocols",
"extensions",
"generics",
"closures",
"property wrappers",
"result builders",
"actors",
"structured concurrency",
"value types vs reference types",
],
filePatterns: {
entryPoints: ["Sources/*/main.swift", "App.swift", "AppDelegate.swift"],
barrels: [],
tests: ["*Tests.swift", "Tests/**/*.swift"],
config: ["Package.swift"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const terraformConfig = {
id: "terraform",
displayName: "Terraform",
extensions: [".tf", ".tfvars"],
concepts: ["resources", "data sources", "variables", "outputs", "modules", "providers", "state", "workspaces"],
filePatterns: {
entryPoints: ["main.tf"],
barrels: [],
tests: [],
config: ["terraform.tfvars", "variables.tf"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const tomlConfig = {
id: "toml",
displayName: "TOML",
extensions: [".toml"],
concepts: ["tables", "inline tables", "arrays of tables", "key-value pairs", "dotted keys"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: ["Cargo.toml", "pyproject.toml", "netlify.toml"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,30 @@
import type { LanguageConfig } from "../types.js";
export const typescriptConfig = {
id: "typescript",
displayName: "TypeScript",
extensions: [".ts", ".tsx"],
treeSitter: {
wasmPackage: "tree-sitter-typescript",
wasmFile: "tree-sitter-typescript.wasm",
},
concepts: [
"generics",
"type guards",
"discriminated unions",
"utility types",
"decorators",
"enums",
"interfaces",
"type inference",
"mapped types",
"conditional types",
"template literal types",
],
filePatterns: {
entryPoints: ["src/index.ts", "src/main.ts", "src/App.tsx", "index.ts"],
barrels: ["index.ts"],
tests: ["*.test.ts", "*.spec.ts", "*.test.tsx"],
config: ["tsconfig.json"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const xmlConfig = {
id: "xml",
displayName: "XML",
extensions: [".xml", ".xsl", ".xsd", ".svg", ".plist"],
concepts: ["elements", "attributes", "namespaces", "DTD", "XPath", "XSLT", "schemas"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: ["pom.xml", "web.xml", "AndroidManifest.xml"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const yamlConfig = {
id: "yaml",
displayName: "YAML",
extensions: [".yaml", ".yml"],
concepts: ["mappings", "sequences", "anchors", "aliases", "multi-document", "tags"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: ["*.yaml", "*.yml"],
},
} satisfies LanguageConfig;

View File

@@ -0,0 +1,86 @@
import { FrameworkConfigSchema } from "./types.js";
import type { FrameworkConfig } from "./types.js";
import { builtinFrameworkConfigs } from "./frameworks/index.js";
/**
* Registry for framework configurations. Provides detection of frameworks
* from manifest file contents and lookup by id or language.
*/
export class FrameworkRegistry {
private byId = new Map<string, FrameworkConfig>();
private byLanguage = new Map<string, FrameworkConfig[]>();
register(config: FrameworkConfig): void {
const parsed = FrameworkConfigSchema.parse(config);
// Prevent duplicate registration
if (this.byId.has(parsed.id)) return;
this.byId.set(parsed.id, parsed);
for (const lang of parsed.languages) {
const existing = this.byLanguage.get(lang) ?? [];
existing.push(parsed);
this.byLanguage.set(lang, existing);
}
}
getById(id: string): FrameworkConfig | null {
return this.byId.get(id) ?? null;
}
getForLanguage(langId: string): FrameworkConfig[] {
return [...(this.byLanguage.get(langId) ?? [])];
}
getAllFrameworks(): FrameworkConfig[] {
return [...this.byId.values()];
}
/**
* Detect frameworks from manifest file contents.
* @param manifests - Map of filename to file content (e.g., { "requirements.txt": "django==4.2\n..." })
* @returns Array of detected FrameworkConfig objects
*/
detectFrameworks(manifests: Record<string, string>): FrameworkConfig[] {
const detected = new Set<string>();
const results: FrameworkConfig[] = [];
for (const config of this.byId.values()) {
if (detected.has(config.id)) continue;
for (const manifestFile of config.manifestFiles) {
// Match manifest entries by filename (basename match)
const content = Object.entries(manifests).find(
([key]) => key === manifestFile || key.endsWith(`/${manifestFile}`),
)?.[1];
if (!content) continue;
const contentLower = content.toLowerCase();
const found = config.detectionKeywords.some((keyword) =>
contentLower.includes(keyword.toLowerCase()),
);
if (found) {
detected.add(config.id);
results.push(config);
break;
}
}
}
return results;
}
/**
* Create a registry pre-populated with all built-in framework configs.
*/
static createDefault(): FrameworkRegistry {
const registry = new FrameworkRegistry();
for (const config of builtinFrameworkConfigs) {
registry.register(config);
}
return registry;
}
}

View File

@@ -0,0 +1,36 @@
import type { FrameworkConfig } from "../types.js";
export const djangoConfig = {
id: "django",
displayName: "Django",
languages: ["python"],
detectionKeywords: [
"django",
"djangorestframework",
"django-rest-framework",
"django-cors-headers",
"django-filter",
],
manifestFiles: [
"requirements.txt",
"pyproject.toml",
"setup.py",
"setup.cfg",
"Pipfile",
],
promptSnippetPath: "./frameworks/django.md",
entryPoints: ["manage.py", "wsgi.py", "asgi.py"],
layerHints: {
views: "api",
models: "data",
serializers: "api",
urls: "api",
templates: "ui",
migrations: "data",
management: "config",
signals: "service",
admin: "config",
forms: "ui",
templatetags: "utility",
},
} satisfies FrameworkConfig;

View File

@@ -0,0 +1,26 @@
import type { FrameworkConfig } from "../types.js";
export const expressConfig = {
id: "express",
displayName: "Express",
languages: ["javascript", "typescript"],
detectionKeywords: ["\"express\":", "express-validator", "express-session"],
manifestFiles: ["package.json"],
promptSnippetPath: "./frameworks/express.md",
entryPoints: [
"src/index.js",
"src/app.js",
"server.js",
"app.js",
"src/index.ts",
"src/app.ts",
],
layerHints: {
routes: "api",
controllers: "service",
models: "data",
middleware: "middleware",
services: "service",
db: "data",
},
} satisfies FrameworkConfig;

View File

@@ -0,0 +1,25 @@
import type { FrameworkConfig } from "../types.js";
export const fastapiConfig = {
id: "fastapi",
displayName: "FastAPI",
languages: ["python"],
detectionKeywords: ["fastapi", "uvicorn", "starlette"],
manifestFiles: [
"requirements.txt",
"pyproject.toml",
"setup.py",
"setup.cfg",
"Pipfile",
],
promptSnippetPath: "./frameworks/fastapi.md",
entryPoints: ["main.py", "app.py"],
layerHints: {
routers: "api",
schemas: "types",
models: "data",
dependencies: "service",
crud: "service",
api: "api",
},
} satisfies FrameworkConfig;

View File

@@ -0,0 +1,31 @@
import type { FrameworkConfig } from "../types.js";
export const flaskConfig = {
id: "flask",
displayName: "Flask",
languages: ["python"],
detectionKeywords: [
"flask",
"flask-restful",
"flask-sqlalchemy",
"flask-marshmallow",
"flask-wtf",
],
manifestFiles: [
"requirements.txt",
"pyproject.toml",
"setup.py",
"setup.cfg",
"Pipfile",
],
promptSnippetPath: "./frameworks/flask.md",
entryPoints: ["app.py", "run.py", "wsgi.py"],
layerHints: {
blueprints: "api",
views: "api",
models: "data",
forms: "ui",
templates: "ui",
extensions: "config",
},
} satisfies FrameworkConfig;

View File

@@ -0,0 +1,19 @@
import type { FrameworkConfig } from "../types.js";
export const ginConfig = {
id: "gin",
displayName: "Gin",
languages: ["go"],
detectionKeywords: ["github.com/gin-gonic/gin"],
manifestFiles: ["go.mod"],
promptSnippetPath: "./frameworks/gin.md",
entryPoints: ["main.go", "cmd/server/main.go"],
layerHints: {
handlers: "api",
routes: "api",
models: "data",
middleware: "middleware",
services: "service",
repository: "data",
},
} satisfies FrameworkConfig;

View File

@@ -0,0 +1,38 @@
import type { FrameworkConfig } from "../types.js";
import { djangoConfig } from "./django.js";
import { fastapiConfig } from "./fastapi.js";
import { flaskConfig } from "./flask.js";
import { reactConfig } from "./react.js";
import { nextjsConfig } from "./nextjs.js";
import { expressConfig } from "./express.js";
import { vueConfig } from "./vue.js";
import { springConfig } from "./spring.js";
import { railsConfig } from "./rails.js";
import { ginConfig } from "./gin.js";
export const builtinFrameworkConfigs: FrameworkConfig[] = [
djangoConfig,
fastapiConfig,
flaskConfig,
reactConfig,
nextjsConfig,
expressConfig,
vueConfig,
springConfig,
railsConfig,
ginConfig,
];
export {
djangoConfig,
fastapiConfig,
flaskConfig,
reactConfig,
nextjsConfig,
expressConfig,
vueConfig,
springConfig,
railsConfig,
ginConfig,
};

View File

@@ -0,0 +1,23 @@
import type { FrameworkConfig } from "../types.js";
export const nextjsConfig = {
id: "nextjs",
displayName: "Next.js",
languages: ["typescript", "javascript"],
detectionKeywords: ["\"next\":", "@next/font", "@next/image"],
manifestFiles: ["package.json"],
promptSnippetPath: "./frameworks/nextjs.md",
entryPoints: [
"src/app/layout.tsx",
"pages/_app.tsx",
"src/pages/_app.tsx",
],
layerHints: {
app: "ui",
pages: "ui",
api: "api",
components: "ui",
lib: "service",
middleware: "middleware",
},
} satisfies FrameworkConfig;

View File

@@ -0,0 +1,28 @@
import type { FrameworkConfig } from "../types.js";
export const railsConfig = {
id: "rails",
displayName: "Ruby on Rails",
languages: ["ruby"],
detectionKeywords: [
"rails",
"railties",
"actionpack",
"activerecord",
"actionview",
],
manifestFiles: ["Gemfile"],
promptSnippetPath: "./frameworks/rails.md",
entryPoints: ["config.ru", "bin/rails"],
layerHints: {
controllers: "api",
models: "data",
views: "ui",
helpers: "utility",
mailers: "service",
jobs: "service",
channels: "service",
middleware: "middleware",
lib: "service",
},
} satisfies FrameworkConfig;

View File

@@ -0,0 +1,19 @@
import type { FrameworkConfig } from "../types.js";
export const reactConfig = {
id: "react",
displayName: "React",
languages: ["typescript", "javascript"],
detectionKeywords: ["react", "react-dom", "@types/react"],
manifestFiles: ["package.json"],
promptSnippetPath: "./frameworks/react.md",
entryPoints: ["src/App.tsx", "src/App.jsx", "src/index.tsx", "src/main.tsx"],
layerHints: {
components: "ui",
hooks: "service",
pages: "ui",
contexts: "service",
utils: "utility",
lib: "service",
},
} satisfies FrameworkConfig;

View File

@@ -0,0 +1,27 @@
import type { FrameworkConfig } from "../types.js";
export const springConfig = {
id: "spring",
displayName: "Spring Boot",
languages: ["java", "kotlin"],
detectionKeywords: [
"spring-boot",
"spring-boot-starter",
"spring-web",
"spring-data",
"org.springframework",
],
manifestFiles: ["pom.xml", "build.gradle", "build.gradle.kts"],
promptSnippetPath: "./frameworks/spring.md",
entryPoints: ["**/Application.java", "**/App.java"],
layerHints: {
controller: "api",
service: "service",
repository: "data",
model: "data",
entity: "data",
config: "config",
dto: "types",
security: "middleware",
},
} satisfies FrameworkConfig;

View File

@@ -0,0 +1,19 @@
import type { FrameworkConfig } from "../types.js";
export const vueConfig = {
id: "vue",
displayName: "Vue",
languages: ["typescript", "javascript"],
detectionKeywords: ["vue", "@vue/cli-service", "nuxt", "vite-plugin-vue"],
manifestFiles: ["package.json"],
promptSnippetPath: "./frameworks/vue.md",
entryPoints: ["src/main.ts", "src/App.vue", "src/main.js"],
layerHints: {
components: "ui",
views: "ui",
store: "service",
composables: "service",
router: "config",
plugins: "config",
},
} satisfies FrameworkConfig;

View File

@@ -0,0 +1,22 @@
// Types
export type {
LanguageConfig,
TreeSitterConfig,
FilePatternConfig,
FrameworkConfig,
} from "./types.js";
export {
LanguageConfigSchema,
TreeSitterConfigSchema,
FilePatternConfigSchema,
FrameworkConfigSchema,
} from "./types.js";
// Registries
export { LanguageRegistry } from "./language-registry.js";
export { FrameworkRegistry } from "./framework-registry.js";
// Built-in configs
export { builtinLanguageConfigs } from "./configs/index.js";
export { builtinFrameworkConfigs } from "./frameworks/index.js";

View File

@@ -0,0 +1,64 @@
import { LanguageConfigSchema } from "./types.js";
import type { LanguageConfig } from "./types.js";
import { builtinLanguageConfigs } from "./configs/index.js";
/**
* Registry for language configurations. Maps language ids and file extensions
* to their corresponding LanguageConfig objects.
*/
export class LanguageRegistry {
private byId = new Map<string, LanguageConfig>();
private byExtension = new Map<string, LanguageConfig>();
private byFilename = new Map<string, LanguageConfig>();
register(config: LanguageConfig): void {
const parsed = LanguageConfigSchema.parse(config);
this.byId.set(parsed.id, parsed);
for (const ext of parsed.extensions) {
// Normalize: strip leading dot if present for lookup consistency
const key = ext.startsWith(".") ? ext : `.${ext}`;
this.byExtension.set(key, parsed);
}
if (parsed.filenames) {
for (const filename of parsed.filenames) {
this.byFilename.set(filename.toLowerCase(), parsed);
}
}
}
getById(id: string): LanguageConfig | null {
return this.byId.get(id) ?? null;
}
getByExtension(ext: string): LanguageConfig | null {
const key = (ext.startsWith(".") ? ext : `.${ext}`).toLowerCase();
return this.byExtension.get(key) ?? null;
}
getForFile(filePath: string): LanguageConfig | null {
// Try filename-based lookup first (more specific: docker-compose.yml, Makefile, etc.)
const basename = filePath.split("/").pop() ?? filePath;
const filenameMatch = this.byFilename.get(basename.toLowerCase());
if (filenameMatch) return filenameMatch;
// Fall back to extension-based lookup
const lastDot = filePath.lastIndexOf(".");
if (lastDot === -1) return null;
const ext = filePath.slice(lastDot).toLowerCase();
return this.getByExtension(ext);
}
getAllLanguages(): LanguageConfig[] {
return [...this.byId.values()];
}
/**
* Create a registry pre-populated with all built-in language configs.
*/
static createDefault(): LanguageRegistry {
const registry = new LanguageRegistry();
for (const config of builtinLanguageConfigs) {
registry.register(config);
}
return registry;
}
}

View File

@@ -0,0 +1,62 @@
import { z } from "zod";
// Tree-sitter grammar configuration for a language.
// Only wasmPackage and wasmFile are needed for grammar loading.
// The extraction logic in tree-sitter-plugin.ts is currently TS/JS-specific;
// when grammars for other languages are added, language-specific extractors
// should be registered via the customAnalyzer escape hatch.
export const TreeSitterConfigSchema = z.object({
wasmPackage: z.string(),
wasmFile: z.string(),
});
export type TreeSitterConfig = z.infer<typeof TreeSitterConfigSchema>;
// File pattern conventions for a language
export const FilePatternConfigSchema = z.object({
entryPoints: z.array(z.string()),
barrels: z.array(z.string()),
tests: z.array(z.string()),
config: z.array(z.string()),
});
export type FilePatternConfig = z.infer<typeof FilePatternConfigSchema>;
// Complete language configuration (base schema — used by LanguageRegistry.register())
export const LanguageConfigSchema = z.object({
id: z.string().min(1),
displayName: z.string().min(1),
extensions: z.array(z.string()),
filenames: z.array(z.string()).optional(),
treeSitter: TreeSitterConfigSchema.optional(),
concepts: z.array(z.string()),
filePatterns: FilePatternConfigSchema,
});
export type LanguageConfig = z.infer<typeof LanguageConfigSchema>;
/**
* Strict schema with refinement: ensures at least one extension or filename
* is provided so the config can actually be detected by the registry.
* Use this for validating new/user-supplied configs (some builtin configs like
* kubernetes/github-actions intentionally lack both and rely on future
* content-based detection).
*/
export const StrictLanguageConfigSchema = LanguageConfigSchema.refine(
(c) => c.extensions.length > 0 || (c.filenames !== undefined && c.filenames.length > 0),
{ message: "LanguageConfig must have at least one extension or filename for detection" }
);
// Framework configuration
export const FrameworkConfigSchema = z.object({
id: z.string().min(1),
displayName: z.string().min(1),
languages: z.array(z.string().min(1)).min(1),
detectionKeywords: z.array(z.string()).min(1),
manifestFiles: z.array(z.string()).min(1),
promptSnippetPath: z.string().min(1),
entryPoints: z.array(z.string()).optional(),
layerHints: z.record(z.string(), z.string()).optional(),
});
export type FrameworkConfig = z.infer<typeof FrameworkConfigSchema>;

View File

@@ -0,0 +1,182 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join, isAbsolute, relative, basename } from "node:path";
import type { KnowledgeGraph, AnalysisMeta, ProjectConfig } from "../types.js";
import type { FingerprintStore } from "../fingerprint.js";
import { validateGraph } from "../schema.js";
const UA_DIR = ".understand-anything";
const GRAPH_FILE = "knowledge-graph.json";
const META_FILE = "meta.json";
const FINGERPRINT_FILE = "fingerprints.json";
const CONFIG_FILE = "config.json";
function ensureDir(projectRoot: string): string {
const dir = join(projectRoot, UA_DIR);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
return dir;
}
/**
* Sanitise every node's filePath before writing to disk.
*
* The analysis agent produces absolute paths like:
* /Users/alice/company/src/auth.ts
*
* We convert them to paths relative to projectRoot:
* src/auth.ts
*
* Three cases are handled:
* 1. Path is inside projectRoot → make it relative
* 2. Path is absolute but outside → keep only the filename (last segment)
* 3. Path is already relative → leave it untouched
*
* This means the developer's home directory, username, and company
* directory layout are never written to knowledge-graph.json.
*/
function sanitiseFilePaths(
graph: KnowledgeGraph,
projectRoot: string,
): KnowledgeGraph {
const normalRoot = projectRoot.endsWith("/")
? projectRoot
: projectRoot + "/";
const sanitisedNodes = graph.nodes.map((node) => {
if (typeof node.filePath !== "string") return node;
const fp = node.filePath;
if (!isAbsolute(fp)) {
// Already relative — nothing to do.
return node;
}
if (fp.startsWith(normalRoot) || fp.startsWith(projectRoot)) {
// Inside the project root — make it relative.
return { ...node, filePath: relative(projectRoot, fp) };
}
// Absolute but outside the project root — use only the filename
// so we leak as little as possible.
return { ...node, filePath: basename(fp) };
});
return { ...graph, nodes: sanitisedNodes };
}
export function saveGraph(projectRoot: string, graph: KnowledgeGraph): void {
const dir = ensureDir(projectRoot);
// FIX — sanitise absolute file paths before persisting.
// Without this, absolute paths like /Users/alice/company/src/auth.ts
// are written verbatim into knowledge-graph.json and later served
// by the dashboard server, leaking the developer's directory layout.
const sanitised = sanitiseFilePaths(graph, projectRoot);
writeFileSync(
join(dir, GRAPH_FILE),
JSON.stringify(sanitised, null, 2),
"utf-8",
);
}
export function loadGraph(
projectRoot: string,
options?: { validate?: boolean },
): KnowledgeGraph | null {
const filePath = join(projectRoot, UA_DIR, GRAPH_FILE);
if (!existsSync(filePath)) return null;
const data = JSON.parse(readFileSync(filePath, "utf-8"));
if (options?.validate !== false) {
const result = validateGraph(data);
if (!result.success) {
throw new Error(
`Invalid knowledge graph: ${result.fatal ?? "unknown error"}`,
);
}
return result.data as KnowledgeGraph;
}
return data as KnowledgeGraph;
}
export function saveMeta(projectRoot: string, meta: AnalysisMeta): void {
const dir = ensureDir(projectRoot);
writeFileSync(join(dir, META_FILE), JSON.stringify(meta, null, 2), "utf-8");
}
export function loadMeta(projectRoot: string): AnalysisMeta | null {
const filePath = join(projectRoot, UA_DIR, META_FILE);
if (!existsSync(filePath)) return null;
return JSON.parse(readFileSync(filePath, "utf-8")) as AnalysisMeta;
}
export function saveFingerprints(projectRoot: string, store: FingerprintStore): void {
const dir = ensureDir(projectRoot);
writeFileSync(join(dir, FINGERPRINT_FILE), JSON.stringify(store, null, 2), "utf-8");
}
export function loadFingerprints(projectRoot: string): FingerprintStore | null {
const filePath = join(projectRoot, UA_DIR, FINGERPRINT_FILE);
if (!existsSync(filePath)) return null;
try {
return JSON.parse(readFileSync(filePath, "utf-8")) as FingerprintStore;
} catch {
return null;
}
}
const DEFAULT_CONFIG: ProjectConfig = { autoUpdate: false, outputLanguage: "en" };
export function saveConfig(projectRoot: string, config: ProjectConfig): void {
const dir = ensureDir(projectRoot);
writeFileSync(join(dir, CONFIG_FILE), JSON.stringify(config, null, 2), "utf-8");
}
export function loadConfig(projectRoot: string): ProjectConfig {
const filePath = join(projectRoot, UA_DIR, CONFIG_FILE);
if (!existsSync(filePath)) return { ...DEFAULT_CONFIG };
try {
return JSON.parse(readFileSync(filePath, "utf-8")) as ProjectConfig;
} catch {
return { ...DEFAULT_CONFIG };
}
}
const DOMAIN_GRAPH_FILE = "domain-graph.json";
export function saveDomainGraph(projectRoot: string, graph: KnowledgeGraph): void {
const dir = ensureDir(projectRoot);
const sanitised = sanitiseFilePaths(graph, projectRoot);
writeFileSync(
join(dir, DOMAIN_GRAPH_FILE),
JSON.stringify(sanitised, null, 2),
"utf-8",
);
}
export function loadDomainGraph(
projectRoot: string,
options?: { validate?: boolean },
): KnowledgeGraph | null {
const filePath = join(projectRoot, UA_DIR, DOMAIN_GRAPH_FILE);
if (!existsSync(filePath)) return null;
const data = JSON.parse(readFileSync(filePath, "utf-8"));
if (options?.validate !== false) {
const result = validateGraph(data);
if (!result.success) {
throw new Error(
`Invalid domain graph: ${result.fatal ?? "unknown error"}`,
);
}
return result.data as KnowledgeGraph;
}
return data as KnowledgeGraph;
}

View File

@@ -0,0 +1,204 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { writeFileSync } from "node:fs";
import { saveGraph, loadGraph, saveMeta, loadMeta, saveFingerprints, loadFingerprints, saveConfig, loadConfig } from "./index.js";
import type { KnowledgeGraph, AnalysisMeta } from "../types.js";
import type { FingerprintStore } from "../fingerprint.js";
describe("persistence", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "ua-test-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
const sampleGraph: KnowledgeGraph = {
version: "1.0.0",
project: {
name: "test-project",
languages: ["typescript"],
frameworks: ["vitest"],
description: "A test project",
analyzedAt: "2026-03-14T00:00:00.000Z",
gitCommitHash: "abc123",
},
nodes: [
{
id: "node-1",
type: "file",
name: "index.ts",
filePath: "src/index.ts",
lineRange: [1, 50],
summary: "Entry point",
tags: ["entry"],
complexity: "simple",
},
],
edges: [
{
source: "node-1",
target: "node-1",
type: "imports",
direction: "forward",
weight: 0.8,
},
],
layers: [
{
id: "layer-1",
name: "Core",
description: "Core layer",
nodeIds: ["node-1"],
},
],
tour: [
{
order: 1,
title: "Start here",
description: "Begin with the entry point",
nodeIds: ["node-1"],
},
],
};
const sampleMeta: AnalysisMeta = {
lastAnalyzedAt: "2026-03-14T00:00:00.000Z",
gitCommitHash: "abc123",
version: "1.0.0",
analyzedFiles: 42,
};
describe("saveGraph / loadGraph", () => {
it("should write knowledge-graph.json to .understand-anything/", () => {
saveGraph(tempDir, sampleGraph);
const filePath = join(tempDir, ".understand-anything", "knowledge-graph.json");
expect(existsSync(filePath)).toBe(true);
});
it("should read back the saved graph correctly", () => {
saveGraph(tempDir, sampleGraph);
const loaded = loadGraph(tempDir);
expect(loaded).not.toBeNull();
expect(loaded).toEqual(sampleGraph);
});
it("should return null when no graph exists", () => {
const loaded = loadGraph(tempDir);
expect(loaded).toBeNull();
});
it("should throw error when loading a fatally invalid graph", () => {
const invalidGraph = { ...sampleGraph, project: null };
saveGraph(tempDir, invalidGraph as unknown as KnowledgeGraph);
expect(() => {
loadGraph(tempDir);
}).toThrow(/Invalid knowledge graph/);
});
it("should skip validation when validate option is false", () => {
const invalidGraph = { ...sampleGraph, version: 123 };
saveGraph(tempDir, invalidGraph as unknown as KnowledgeGraph);
const loaded = loadGraph(tempDir, { validate: false });
expect(loaded).not.toBeNull();
expect(loaded?.version).toBe(123);
});
});
describe("saveMeta / loadMeta", () => {
it("should write meta.json to .understand-anything/", () => {
saveMeta(tempDir, sampleMeta);
const filePath = join(tempDir, ".understand-anything", "meta.json");
expect(existsSync(filePath)).toBe(true);
});
it("should read back the saved meta correctly", () => {
saveMeta(tempDir, sampleMeta);
const loaded = loadMeta(tempDir);
expect(loaded).not.toBeNull();
expect(loaded).toEqual(sampleMeta);
});
it("should return null when no meta exists", () => {
const loaded = loadMeta(tempDir);
expect(loaded).toBeNull();
});
});
describe("saveFingerprints / loadFingerprints", () => {
const sampleFingerprints: FingerprintStore = {
version: "1.0.0",
gitCommitHash: "abc123",
generatedAt: "2026-03-14T00:00:00.000Z",
files: {
"src/index.ts": {
filePath: "src/index.ts",
contentHash: "deadbeef",
functions: [],
classes: [],
imports: [],
exports: [],
totalLines: 10,
hasStructuralAnalysis: false,
},
},
};
it("should round-trip fingerprints correctly", () => {
saveFingerprints(tempDir, sampleFingerprints);
const loaded = loadFingerprints(tempDir);
expect(loaded).toEqual(sampleFingerprints);
});
it("should return null when no fingerprints file exists", () => {
const loaded = loadFingerprints(tempDir);
expect(loaded).toBeNull();
});
it("should return null when fingerprints.json is corrupted", () => {
const dir = join(tempDir, ".understand-anything");
// Ensure the directory exists by saving first, then overwrite with garbage
saveFingerprints(tempDir, sampleFingerprints);
writeFileSync(join(dir, "fingerprints.json"), "{{not valid json!!", "utf-8");
const loaded = loadFingerprints(tempDir);
expect(loaded).toBeNull();
});
});
describe("saveConfig / loadConfig", () => {
it("should round-trip config correctly", () => {
saveConfig(tempDir, { autoUpdate: true });
const loaded = loadConfig(tempDir);
expect(loaded).toEqual({ autoUpdate: true });
});
it("should return default config when no file exists", () => {
const loaded = loadConfig(tempDir);
expect(loaded).toEqual({ autoUpdate: false, outputLanguage: "en" });
});
it("should return default config when config.json is corrupted", () => {
saveConfig(tempDir, { autoUpdate: true });
const dir = join(tempDir, ".understand-anything");
writeFileSync(join(dir, "config.json"), "not json!!", "utf-8");
const loaded = loadConfig(tempDir);
expect(loaded).toEqual({ autoUpdate: false, outputLanguage: "en" });
});
});
});

View File

@@ -0,0 +1,68 @@
import { builtinLanguageConfigs } from "../languages/configs/index.js";
export interface PluginEntry {
name: string;
enabled: boolean;
languages: string[];
options?: Record<string, unknown>;
}
export interface PluginConfig {
plugins: PluginEntry[];
}
export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
plugins: [
{
name: "tree-sitter",
enabled: true,
languages: builtinLanguageConfigs
.filter((c) => c.treeSitter)
.map((c) => c.id),
},
],
};
/**
* Parse a plugin config JSON string.
* Returns DEFAULT_PLUGIN_CONFIG if parsing fails.
*/
export function parsePluginConfig(jsonString: string): PluginConfig {
if (!jsonString.trim()) return { ...DEFAULT_PLUGIN_CONFIG };
try {
const parsed = JSON.parse(jsonString);
if (!parsed || !Array.isArray(parsed.plugins)) {
return { ...DEFAULT_PLUGIN_CONFIG };
}
const plugins = parsed.plugins
.filter((entry: unknown): entry is Record<string, unknown> => {
if (typeof entry !== "object" || entry === null) return false;
const e = entry as Record<string, unknown>;
return (
typeof e.name === "string" &&
e.name.length > 0 &&
Array.isArray(e.languages) &&
e.languages.length > 0
);
})
.map((e: Record<string, unknown>): PluginEntry => ({
name: e.name as string,
enabled: typeof e.enabled === "boolean" ? e.enabled : true,
languages: e.languages as string[],
...(e.options ? { options: e.options as Record<string, unknown> } : {}),
}));
return { plugins };
} catch {
return { ...DEFAULT_PLUGIN_CONFIG };
}
}
/**
* Serialize a plugin config to JSON for saving.
*/
export function serializePluginConfig(config: PluginConfig): string {
return JSON.stringify(config, null, 2);
}

View File

@@ -0,0 +1,696 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { CppExtractor } from "../cpp-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + C++ grammar once
let Parser: any;
let Language: any;
let cppLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-cpp/tree-sitter-cpp.wasm",
);
cppLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(cppLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("CppExtractor", () => {
const extractor = new CppExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["cpp", "c"]);
});
// ---- Functions ----
describe("extractStructure - functions", () => {
it("extracts top-level functions with params and return types", () => {
const { tree, parser, root } = parse(`
int add(int a, int b) {
return a + b;
}
void greet(const char* name) {
printf("Hello %s", name);
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("add");
expect(result.functions[0].params).toEqual(["a", "b"]);
expect(result.functions[0].returnType).toBe("int");
expect(result.functions[1].name).toBe("greet");
expect(result.functions[1].params).toEqual(["name"]);
expect(result.functions[1].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts functions with no params", () => {
const { tree, parser, root } = parse(`
int get_value() {
return 42;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("get_value");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBe("int");
tree.delete();
parser.delete();
});
it("reports correct line ranges for multi-line functions", () => {
const { tree, parser, root } = parse(`
int multiline(
int a,
int b
) {
int result = a + b;
return result;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(2);
expect(result.functions[0].lineRange[1]).toBe(8);
tree.delete();
parser.delete();
});
it("handles pointer and reference parameters", () => {
const { tree, parser, root } = parse(`
void process(int* ptr, const char& ref, int arr[]) {
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].params).toEqual(["ptr", "ref", "arr"]);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts class with properties and method declarations", () => {
const { tree, parser, root } = parse(`
class Server {
public:
std::string host;
int port;
void start();
int getPort() { return port; }
};
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].properties).toEqual(["host", "port"]);
expect(result.classes[0].methods).toContain("start");
expect(result.classes[0].methods).toContain("getPort");
tree.delete();
parser.delete();
});
it("respects access specifiers for exports", () => {
const { tree, parser, root } = parse(`
class Foo {
private:
int secret;
void hidden();
public:
int visible;
void exposed();
};
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
// Public members should be exported
expect(exportNames).toContain("exposed");
// Private members should NOT be exported (except the class name itself)
expect(exportNames).not.toContain("hidden");
expect(exportNames).not.toContain("secret");
// The class itself is always exported
expect(exportNames).toContain("Foo");
tree.delete();
parser.delete();
});
it("defaults class members to private access", () => {
const { tree, parser, root } = parse(`
class Priv {
int x;
void secret();
};
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Priv");
// Members without access specifier in a class default to private
expect(exportNames).not.toContain("secret");
tree.delete();
parser.delete();
});
it("handles inline method definitions (function_definition inside class)", () => {
const { tree, parser, root } = parse(`
class Calculator {
public:
int add(int a, int b) { return a + b; }
};
`);
const result = extractor.extractStructure(root);
// Inline method should appear in both classes.methods and functions
expect(result.classes[0].methods).toContain("add");
const addFn = result.functions.find((f) => f.name === "add");
expect(addFn).toBeDefined();
expect(addFn!.params).toEqual(["a", "b"]);
expect(addFn!.returnType).toBe("int");
tree.delete();
parser.delete();
});
});
// ---- Structs ----
describe("extractStructure - structs", () => {
it("extracts struct with fields", () => {
const { tree, parser, root } = parse(`
struct Point {
int x;
int y;
};
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Point");
expect(result.classes[0].properties).toEqual(["x", "y"]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("defaults struct members to public access and exports them", () => {
const { tree, parser, root } = parse(`
struct Config {
int port;
void init();
};
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
// Struct members default to public
expect(exportNames).toContain("Config");
expect(exportNames).toContain("init");
tree.delete();
parser.delete();
});
});
// ---- Includes (imports) ----
describe("extractStructure - includes", () => {
it("extracts system includes (angle brackets)", () => {
const { tree, parser, root } = parse(`
#include <iostream>
#include <vector>
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("iostream");
expect(result.imports[0].specifiers).toEqual(["iostream"]);
expect(result.imports[1].source).toBe("vector");
tree.delete();
parser.delete();
});
it("extracts local includes (quoted)", () => {
const { tree, parser, root } = parse(`
#include "config.h"
#include "utils/helper.h"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("config.h");
expect(result.imports[0].specifiers).toEqual(["config.h"]);
expect(result.imports[1].source).toBe("utils/helper.h");
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`
#include <iostream>
#include "config.h"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].lineNumber).toBe(2);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
});
// ---- Namespaces ----
describe("extractStructure - namespaces", () => {
it("extracts functions inside namespaces", () => {
const { tree, parser, root } = parse(`
namespace utils {
int add(int a, int b) {
return a + b;
}
void log(const char* msg) {}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
const names = result.functions.map((f) => f.name);
expect(names).toContain("add");
expect(names).toContain("log");
tree.delete();
parser.delete();
});
it("extracts classes inside namespaces", () => {
const { tree, parser, root } = parse(`
namespace models {
class User {
public:
std::string name;
int id;
};
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("User");
expect(result.classes[0].properties).toEqual(["name", "id"]);
tree.delete();
parser.delete();
});
});
// ---- Out-of-class method definitions ----
describe("extractStructure - out-of-class methods", () => {
it("associates out-of-class method with its class", () => {
const { tree, parser, root } = parse(`
class Server {
public:
void start();
};
void Server::start() {
// implementation
}
`);
const result = extractor.extractStructure(root);
// The class should have start as a method (from both declaration and definition)
expect(result.classes[0].methods).toContain("start");
// The out-of-class definition should appear in functions
const startFn = result.functions.find((f) => f.name === "start");
expect(startFn).toBeDefined();
expect(startFn!.returnType).toBe("void");
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("exports non-static functions and not static ones", () => {
const { tree, parser, root } = parse(`
int public_fn(int x) { return x; }
static void private_fn() {}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("public_fn");
expect(exportNames).not.toContain("private_fn");
tree.delete();
parser.delete();
});
it("reports correct export line numbers", () => {
const { tree, parser, root } = parse(`
struct Point {
int x;
int y;
};
int compute(int n) { return n * 2; }
`);
const result = extractor.extractStructure(root);
const pointExport = result.exports.find((e) => e.name === "Point");
expect(pointExport?.lineNumber).toBe(2);
const computeExport = result.exports.find((e) => e.name === "compute");
expect(computeExport?.lineNumber).toBe(7);
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple function calls", () => {
const { tree, parser, root } = parse(`
void helper(int x) {}
int main() {
helper(42);
}
`);
const result = extractor.extractCallGraph(root);
const mainCalls = result.filter((e) => e.caller === "main");
expect(mainCalls.some((e) => e.callee === "helper")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts multiple calls from one function", () => {
const { tree, parser, root } = parse(`
void foo() {}
void bar() {}
int main() {
foo();
bar();
}
`);
const result = extractor.extractCallGraph(root);
const mainCalls = result.filter((e) => e.caller === "main");
expect(mainCalls).toHaveLength(2);
expect(mainCalls.some((e) => e.callee === "foo")).toBe(true);
expect(mainCalls.some((e) => e.callee === "bar")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts calls inside namespace functions", () => {
const { tree, parser, root } = parse(`
int baz(int x) { return x; }
namespace ns {
void inner() {
baz(42);
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result.some((e) => e.caller === "inner" && e.callee === "baz")).toBe(true);
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`
int main() {
foo();
bar();
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(3);
expect(result[1].lineNumber).toBe(4);
tree.delete();
parser.delete();
});
it("ignores calls outside of functions (no caller)", () => {
const { tree, parser, root } = parse(`
int x = compute();
`);
const result = extractor.extractCallGraph(root);
// Top-level initializers have no enclosing function
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
it("tracks member function calls (field_expression)", () => {
const { tree, parser, root } = parse(`
void process() {
obj.method();
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("process");
expect(result[0].callee).toBe("method");
tree.delete();
parser.delete();
});
});
// ---- Comprehensive C++ test ----
describe("comprehensive C++ file", () => {
it("handles the full C++ test scenario from the spec", () => {
const { tree, parser, root } = parse(`#include <iostream>
#include "config.h"
class Server {
public:
std::string host;
int port;
void start();
int getPort() { return port; }
};
void Server::start() {
std::cout << "starting" << std::endl;
}
namespace utils {
int add(int a, int b) {
return a + b;
}
}
`);
const result = extractor.extractStructure(root);
// Imports: 2 includes
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("iostream");
expect(result.imports[1].source).toBe("config.h");
// Classes: Server
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].properties).toEqual(["host", "port"]);
expect(result.classes[0].methods).toContain("start");
expect(result.classes[0].methods).toContain("getPort");
// Functions: getPort (inline), start (out-of-class), add (namespace)
expect(result.functions).toHaveLength(3);
const fnNames = result.functions.map((f) => f.name).sort();
expect(fnNames).toEqual(["add", "getPort", "start"]);
// add() params
const addFn = result.functions.find((f) => f.name === "add");
expect(addFn?.params).toEqual(["a", "b"]);
expect(addFn?.returnType).toBe("int");
// getPort() inline
const getPortFn = result.functions.find((f) => f.name === "getPort");
expect(getPortFn?.params).toEqual([]);
expect(getPortFn?.returnType).toBe("int");
// Exports: Server, start, getPort, add (all non-static/public)
const exportNames = result.exports.map((e) => e.name).sort();
expect(exportNames).toContain("Server");
expect(exportNames).toContain("start");
expect(exportNames).toContain("getPort");
expect(exportNames).toContain("add");
tree.delete();
parser.delete();
});
});
// ---- Comprehensive pure C test ----
describe("comprehensive pure C file", () => {
it("handles pure C code with structs and functions", () => {
const { tree, parser, root } = parse(`#include <stdio.h>
#include "helper.h"
struct Point {
int x;
int y;
};
void print_point(struct Point* p) {
printf("(%d, %d)", p->x, p->y);
}
int main() {
struct Point p = {1, 2};
print_point(&p);
return 0;
}
`);
const result = extractor.extractStructure(root);
// Imports: 2 includes
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("stdio.h");
expect(result.imports[0].specifiers).toEqual(["stdio.h"]);
expect(result.imports[1].source).toBe("helper.h");
// Classes: Point (struct mapped to class)
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Point");
expect(result.classes[0].properties).toEqual(["x", "y"]);
expect(result.classes[0].methods).toEqual([]);
// Functions: print_point and main
expect(result.functions).toHaveLength(2);
const fnNames = result.functions.map((f) => f.name).sort();
expect(fnNames).toEqual(["main", "print_point"]);
// print_point params
const printFn = result.functions.find((f) => f.name === "print_point");
expect(printFn?.params).toEqual(["p"]);
expect(printFn?.returnType).toBe("void");
// main params
const mainFn = result.functions.find((f) => f.name === "main");
expect(mainFn?.params).toEqual([]);
expect(mainFn?.returnType).toBe("int");
// Exports: non-static functions + struct name
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Point");
expect(exportNames).toContain("print_point");
expect(exportNames).toContain("main");
// Call graph
const calls = extractor.extractCallGraph(root);
// print_point calls printf
const printCalls = calls.filter((e) => e.caller === "print_point");
expect(printCalls.some((e) => e.callee === "printf")).toBe(true);
// main calls print_point
const mainCalls = calls.filter((e) => e.caller === "main");
expect(mainCalls.some((e) => e.callee === "print_point")).toBe(true);
tree.delete();
parser.delete();
});
it("handles pure C code without any classes or structs", () => {
const { tree, parser, root } = parse(`
#include <stdlib.h>
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
int main() {
int result = factorial(5);
return 0;
}
`);
const result = extractor.extractStructure(root);
// No classes in pure C without structs
expect(result.classes).toHaveLength(0);
// Functions
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("factorial");
expect(result.functions[0].params).toEqual(["n"]);
expect(result.functions[1].name).toBe("main");
// Call graph: factorial is recursive, main calls factorial
const calls = extractor.extractCallGraph(root);
expect(calls.some((e) => e.caller === "factorial" && e.callee === "factorial")).toBe(true);
expect(calls.some((e) => e.caller === "main" && e.callee === "factorial")).toBe(true);
tree.delete();
parser.delete();
});
});
});

View File

@@ -0,0 +1,665 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { CSharpExtractor } from "../csharp-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + C# grammar once
let Parser: any;
let Language: any;
let csharpLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-c-sharp/tree-sitter-c_sharp.wasm",
);
csharpLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(csharpLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("CSharpExtractor", () => {
const extractor = new CSharpExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["csharp"]);
});
// ---- Methods/Constructors (mapped to functions) ----
describe("extractStructure - functions (methods & constructors)", () => {
it("extracts methods with params and return types", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public string GetName(int id) {
return "";
}
private void Process(string data, int count) {
}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("GetName");
expect(result.functions[0].params).toEqual(["id"]);
expect(result.functions[0].returnType).toBe("string");
expect(result.functions[1].name).toBe("Process");
expect(result.functions[1].params).toEqual(["data", "count"]);
expect(result.functions[1].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts constructors", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public Foo(string name, int value) {
this.name = name;
}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("Foo");
expect(result.functions[0].params).toEqual(["name", "value"]);
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("extracts methods with no params", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public void Run() {}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("Run");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts methods with generic return types", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public List<string> GetItems() {
return null;
}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("GetItems");
expect(result.functions[0].returnType).toBe("List<string>");
tree.delete();
parser.delete();
});
it("reports correct line ranges for multi-line methods", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public int Calculate(
int a,
int b
) {
int result = a + b;
return result;
}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(3);
expect(result.functions[0].lineRange[1]).toBe(9);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts class with methods, properties, and fields", () => {
const { tree, parser, root } = parse(`namespace App {
public class Server {
private string _host;
private int _port;
public string Address { get; set; }
public void Start() {}
public void Stop() {}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].properties).toEqual(["_host", "_port", "Address"]);
expect(result.classes[0].methods).toEqual(["Start", "Stop"]);
expect(result.classes[0].lineRange[0]).toBe(2);
tree.delete();
parser.delete();
});
it("extracts empty class", () => {
const { tree, parser, root } = parse(`namespace App {
public class Empty {
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Empty");
expect(result.classes[0].properties).toEqual([]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("includes constructors in methods list", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public Foo() {}
public void Run() {}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes[0].methods).toEqual(["Foo", "Run"]);
tree.delete();
parser.delete();
});
});
// ---- Interfaces ----
describe("extractStructure - interfaces", () => {
it("extracts interface with method signatures", () => {
const { tree, parser, root } = parse(`namespace App {
interface IRepository {
List<User> FindAll();
User FindById(int id);
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("IRepository");
expect(result.classes[0].methods).toEqual(["FindAll", "FindById"]);
expect(result.classes[0].properties).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts empty interface", () => {
const { tree, parser, root } = parse(`namespace App {
interface IMarker {
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("IMarker");
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
});
// ---- Imports (using directives) ----
describe("extractStructure - imports", () => {
it("extracts simple using directives", () => {
const { tree, parser, root } = parse(`using System;
namespace App {
public class Foo {}
}
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("System");
expect(result.imports[0].specifiers).toEqual(["System"]);
expect(result.imports[0].lineNumber).toBe(1);
tree.delete();
parser.delete();
});
it("extracts qualified using directives", () => {
const { tree, parser, root } = parse(`using System;
using System.Collections.Generic;
namespace App {
public class Foo {}
}
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("System");
expect(result.imports[0].specifiers).toEqual(["System"]);
expect(result.imports[0].lineNumber).toBe(1);
expect(result.imports[1].source).toBe("System.Collections.Generic");
expect(result.imports[1].specifiers).toEqual(["Generic"]);
expect(result.imports[1].lineNumber).toBe(2);
tree.delete();
parser.delete();
});
it("reports correct import line numbers with gaps", () => {
const { tree, parser, root } = parse(`using System;
using System.Linq;
namespace App {
public class Foo {}
}
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(1);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("exports public class, methods, constructor, and properties", () => {
const { tree, parser, root } = parse(`namespace App {
public class UserService {
private string _name;
public int MaxRetries { get; set; }
public UserService(string name) {
_name = name;
}
public void Start() {}
private void Helper() {}
}
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService"); // class
// Constructor is also named UserService
const userServiceExports = result.exports.filter(
(e) => e.name === "UserService",
);
expect(userServiceExports.length).toBe(2); // class + constructor
expect(exportNames).toContain("MaxRetries"); // public property
expect(exportNames).toContain("Start");
expect(exportNames).not.toContain("Helper");
expect(exportNames).not.toContain("_name"); // private field
tree.delete();
parser.delete();
});
it("does not export non-public classes", () => {
const { tree, parser, root } = parse(`namespace App {
class Internal {
void Run() {}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(0);
tree.delete();
parser.delete();
});
it("exports public fields", () => {
const { tree, parser, root } = parse(`namespace App {
public class Config {
public string ApiKey;
private int _retries;
}
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Config");
expect(exportNames).toContain("ApiKey");
expect(exportNames).not.toContain("_retries");
tree.delete();
parser.delete();
});
it("exports public interface", () => {
const { tree, parser, root } = parse(`namespace App {
public interface IRepository {
void Save();
}
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("IRepository");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple method calls", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public void Process(int data) {
Transform(data);
Format(data);
}
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].caller).toBe("Process");
expect(result[0].callee).toBe("Transform");
expect(result[1].caller).toBe("Process");
expect(result[1].callee).toBe("Format");
tree.delete();
parser.delete();
});
it("extracts qualified method calls (e.g. Console.WriteLine)", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
private void Log(string message) {
Console.WriteLine(message);
}
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("Log");
expect(result[0].callee).toBe("Console.WriteLine");
tree.delete();
parser.delete();
});
it("extracts object creation expressions", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public void Create() {
var b = new Bar();
}
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("Create");
expect(result[0].callee).toBe("new Bar");
tree.delete();
parser.delete();
});
it("tracks correct caller for constructors", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public Foo() {
Init();
}
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("Foo");
expect(result[0].callee).toBe("Init");
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public void Run() {
Foo();
Bar();
}
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(4);
expect(result[1].lineNumber).toBe(5);
tree.delete();
parser.delete();
});
it("ignores calls outside methods (no caller)", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
private string _value = String.Empty;
}
}
`);
const result = extractor.extractCallGraph(root);
// No enclosing method, so these are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
});
// ---- Namespace handling ----
describe("namespace handling", () => {
it("extracts declarations from block-scoped namespace", () => {
const { tree, parser, root } = parse(`namespace App.Services {
public class Svc {
public void Run() {}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Svc");
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("Run");
tree.delete();
parser.delete();
});
it("extracts declarations alongside file-scoped namespace", () => {
const { tree, parser, root } = parse(`namespace App.Services;
public class Svc {
public void Run() {}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Svc");
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("Run");
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive C# file", () => {
it("handles a realistic C# module", () => {
const { tree, parser, root } = parse(`using System;
using System.Collections.Generic;
namespace App.Services
{
public class UserService
{
private string _name;
public int MaxRetries { get; set; }
public UserService(string name)
{
_name = name;
}
public List<User> GetUsers(int limit)
{
return FetchFromDb(limit);
}
private void Log(string message)
{
Console.WriteLine(message);
}
}
public interface IRepository
{
List<User> FindAll();
User FindById(int id);
}
}
`);
const result = extractor.extractStructure(root);
// Functions: UserService (constructor), GetUsers, Log
expect(result.functions).toHaveLength(3);
expect(result.functions.map((f) => f.name).sort()).toEqual(
["GetUsers", "Log", "UserService"].sort(),
);
// Constructor has params but no return type
const ctor = result.functions.find((f) => f.name === "UserService");
expect(ctor?.params).toEqual(["name"]);
expect(ctor?.returnType).toBeUndefined();
// GetUsers has params and generic return type
const getUsers = result.functions.find((f) => f.name === "GetUsers");
expect(getUsers?.params).toEqual(["limit"]);
expect(getUsers?.returnType).toBe("List<User>");
// Log has params and void return type
const log = result.functions.find((f) => f.name === "Log");
expect(log?.params).toEqual(["message"]);
expect(log?.returnType).toBe("void");
// Classes: UserService, IRepository
expect(result.classes).toHaveLength(2);
const userService = result.classes.find(
(c) => c.name === "UserService",
);
expect(userService).toBeDefined();
expect(userService!.methods.sort()).toEqual(
["GetUsers", "Log", "UserService"].sort(),
);
expect(userService!.properties.sort()).toEqual(
["MaxRetries", "_name"].sort(),
);
const repository = result.classes.find(
(c) => c.name === "IRepository",
);
expect(repository).toBeDefined();
expect(repository!.methods).toEqual(["FindAll", "FindById"]);
expect(repository!.properties).toEqual([]);
// Imports: 2 (System, System.Collections.Generic)
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("System");
expect(result.imports[0].specifiers).toEqual(["System"]);
expect(result.imports[1].source).toBe("System.Collections.Generic");
expect(result.imports[1].specifiers).toEqual(["Generic"]);
// Exports: UserService (class + constructor), MaxRetries, GetUsers, IRepository
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService");
expect(exportNames).toContain("GetUsers");
expect(exportNames).toContain("MaxRetries");
expect(exportNames).toContain("IRepository");
expect(exportNames).not.toContain("Log"); // private
expect(exportNames).not.toContain("_name"); // private field
// Call graph
const calls = extractor.extractCallGraph(root);
const getUsersCalls = calls.filter((e) => e.caller === "GetUsers");
expect(getUsersCalls.some((e) => e.callee === "FetchFromDb")).toBe(
true,
);
const logCalls = calls.filter((e) => e.caller === "Log");
expect(
logCalls.some((e) => e.callee === "Console.WriteLine"),
).toBe(true);
tree.delete();
parser.delete();
});
});
});

View File

@@ -0,0 +1,599 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { GoExtractor } from "../go-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + Go grammar once
let Parser: any;
let Language: any;
let goLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-go/tree-sitter-go.wasm",
);
goLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(goLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("GoExtractor", () => {
const extractor = new GoExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["go"]);
});
// ---- Functions ----
describe("extractStructure - functions", () => {
it("extracts functions with params and return types", () => {
const { tree, parser, root } = parse(`package main
func NewServer(host string, port int) *Server {
return nil
}
func helper(x int) string {
return ""
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("NewServer");
expect(result.functions[0].params).toEqual(["host", "port"]);
expect(result.functions[0].returnType).toBe("*Server");
expect(result.functions[0].lineRange[0]).toBe(3);
expect(result.functions[1].name).toBe("helper");
expect(result.functions[1].params).toEqual(["x"]);
expect(result.functions[1].returnType).toBe("string");
tree.delete();
parser.delete();
});
it("extracts functions with no params and no return type", () => {
const { tree, parser, root } = parse(`package main
func noop() {
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("noop");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("extracts functions with multiple return types", () => {
const { tree, parser, root } = parse(`package main
func divide(a, b float64) (float64, error) {
return 0, nil
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("divide");
expect(result.functions[0].params).toEqual(["a", "b"]);
expect(result.functions[0].returnType).toBe("(float64, error)");
tree.delete();
parser.delete();
});
it("reports correct line ranges for multi-line functions", () => {
const { tree, parser, root } = parse(`package main
func multiline(
a int,
b int,
) int {
result := a + b
return result
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(3);
expect(result.functions[0].lineRange[1]).toBe(9);
tree.delete();
parser.delete();
});
});
// ---- Methods ----
describe("extractStructure - methods", () => {
it("extracts methods with receivers", () => {
const { tree, parser, root } = parse(`package main
type Server struct {
Host string
}
func (s *Server) Start() error {
return nil
}
func (s Server) Name() string {
return s.Host
}
`);
const result = extractor.extractStructure(root);
// Methods appear in functions list
const methodNames = result.functions.map((f) => f.name);
expect(methodNames).toContain("Start");
expect(methodNames).toContain("Name");
// Methods are also linked to the struct
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].methods).toContain("Start");
expect(result.classes[0].methods).toContain("Name");
// Method return types are extracted
const startFn = result.functions.find((f) => f.name === "Start");
expect(startFn?.returnType).toBe("error");
expect(startFn?.params).toEqual([]);
tree.delete();
parser.delete();
});
});
// ---- Structs ----
describe("extractStructure - structs", () => {
it("extracts struct with fields", () => {
const { tree, parser, root } = parse(`package main
type Server struct {
Host string
Port int
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].properties).toEqual(["Host", "Port"]);
expect(result.classes[0].methods).toEqual([]);
expect(result.classes[0].lineRange[0]).toBe(3);
tree.delete();
parser.delete();
});
it("extracts empty struct", () => {
const { tree, parser, root } = parse(`package main
type Empty struct{}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Empty");
expect(result.classes[0].properties).toEqual([]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts struct with multiple name fields sharing a type", () => {
const { tree, parser, root } = parse(`package main
type Point struct {
X, Y int
Z float64
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].properties).toContain("X");
expect(result.classes[0].properties).toContain("Y");
expect(result.classes[0].properties).toContain("Z");
tree.delete();
parser.delete();
});
});
// ---- Interfaces ----
describe("extractStructure - interfaces", () => {
it("extracts interface with method signatures", () => {
const { tree, parser, root } = parse(`package main
type Reader interface {
Read(buf []byte) (int, error)
Close() error
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Reader");
expect(result.classes[0].methods).toEqual(["Read", "Close"]);
expect(result.classes[0].properties).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts empty interface", () => {
const { tree, parser, root } = parse(`package main
type Any interface{}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Any");
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
});
// ---- Imports ----
describe("extractStructure - imports", () => {
it("extracts grouped imports", () => {
const { tree, parser, root } = parse(`package main
import (
"fmt"
"os"
)
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("fmt");
expect(result.imports[0].specifiers).toEqual(["fmt"]);
expect(result.imports[1].source).toBe("os");
expect(result.imports[1].specifiers).toEqual(["os"]);
tree.delete();
parser.delete();
});
it("extracts single import", () => {
const { tree, parser, root } = parse(`package main
import "fmt"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("fmt");
expect(result.imports[0].specifiers).toEqual(["fmt"]);
tree.delete();
parser.delete();
});
it("extracts imports with path components", () => {
const { tree, parser, root } = parse(`package main
import "net/http"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("net/http");
expect(result.imports[0].specifiers).toEqual(["http"]);
tree.delete();
parser.delete();
});
it("extracts aliased imports", () => {
const { tree, parser, root } = parse(`package main
import (
f "fmt"
myhttp "net/http"
)
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("fmt");
expect(result.imports[0].specifiers).toEqual(["f"]);
expect(result.imports[1].source).toBe("net/http");
expect(result.imports[1].specifiers).toEqual(["myhttp"]);
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`package main
import (
"fmt"
"os"
)
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(4);
expect(result.imports[1].lineNumber).toBe(5);
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("exports uppercase function and type names", () => {
const { tree, parser, root } = parse(`package main
type Server struct {
Host string
Port int
}
func (s *Server) Start() error {
return nil
}
func NewServer(host string, port int) *Server {
return nil
}
func helper(x int) string {
return ""
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Server");
expect(exportNames).toContain("Start");
expect(exportNames).toContain("NewServer");
expect(exportNames).not.toContain("helper");
expect(result.exports).toHaveLength(3);
tree.delete();
parser.delete();
});
it("does not export lowercase names", () => {
const { tree, parser, root } = parse(`package main
type internal struct {
value int
}
func private() {}
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(0);
tree.delete();
parser.delete();
});
it("exports uppercase interface names", () => {
const { tree, parser, root } = parse(`package main
type Writer interface {
Write(data []byte) error
}
type reader interface {
read() error
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Writer");
expect(exportNames).not.toContain("reader");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple function calls", () => {
const { tree, parser, root } = parse(`package main
func process(data int) {
transform(data)
formatOutput(data)
}
func main() {
process(42)
}
`);
const result = extractor.extractCallGraph(root);
const processCalls = result.filter((e) => e.caller === "process");
expect(processCalls.some((e) => e.callee === "transform")).toBe(true);
expect(processCalls.some((e) => e.callee === "formatOutput")).toBe(true);
const mainCalls = result.filter((e) => e.caller === "main");
expect(mainCalls.some((e) => e.callee === "process")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts selector expression calls (e.g. fmt.Println)", () => {
const { tree, parser, root } = parse(`package main
import "fmt"
func Start() {
fmt.Println("starting")
}
func helper(x int) string {
return fmt.Sprintf("%d", x)
}
`);
const result = extractor.extractCallGraph(root);
const startCalls = result.filter((e) => e.caller === "Start");
expect(startCalls.some((e) => e.callee === "fmt.Println")).toBe(true);
const helperCalls = result.filter((e) => e.caller === "helper");
expect(helperCalls.some((e) => e.callee === "fmt.Sprintf")).toBe(true);
tree.delete();
parser.delete();
});
it("tracks correct caller for methods", () => {
const { tree, parser, root } = parse(`package main
import "fmt"
func (s *Server) Start() error {
fmt.Println("starting")
return nil
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("Start");
expect(result[0].callee).toBe("fmt.Println");
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`package main
func main() {
foo()
bar()
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(4);
expect(result[1].lineNumber).toBe(5);
tree.delete();
parser.delete();
});
it("ignores top-level calls (no caller)", () => {
const { tree, parser, root } = parse(`package main
var _ = fmt.Println("hello")
`);
const result = extractor.extractCallGraph(root);
// Top-level calls have no enclosing function, so they are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive Go file", () => {
it("handles a realistic Go module", () => {
const { tree, parser, root } = parse(`package main
import (
"fmt"
"os"
)
type Server struct {
Host string
Port int
}
func (s *Server) Start() error {
fmt.Println("starting")
return nil
}
func NewServer(host string, port int) *Server {
return &Server{Host: host, Port: port}
}
func helper(x int) string {
return fmt.Sprintf("%d", x)
}
`);
const result = extractor.extractStructure(root);
// Functions: Start, NewServer, helper
expect(result.functions).toHaveLength(3);
expect(result.functions.map((f) => f.name).sort()).toEqual(
["Start", "NewServer", "helper"].sort(),
);
// Struct: Server with properties Host, Port and method Start
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].properties).toEqual(["Host", "Port"]);
expect(result.classes[0].methods).toContain("Start");
// Imports: fmt, os
expect(result.imports).toHaveLength(2);
expect(result.imports.map((i) => i.source).sort()).toEqual(["fmt", "os"]);
// Exports: Server, Start, NewServer (all uppercase)
const exportNames = result.exports.map((e) => e.name).sort();
expect(exportNames).toEqual(["NewServer", "Server", "Start"]);
// Call graph
const calls = extractor.extractCallGraph(root);
const startCalls = calls.filter((e) => e.caller === "Start");
expect(startCalls.some((e) => e.callee === "fmt.Println")).toBe(true);
const helperCalls = calls.filter((e) => e.caller === "helper");
expect(helperCalls.some((e) => e.callee === "fmt.Sprintf")).toBe(true);
tree.delete();
parser.delete();
});
});
});

View File

@@ -0,0 +1,568 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { JavaExtractor } from "../java-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + Java grammar once
let Parser: any;
let Language: any;
let javaLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-java/tree-sitter-java.wasm",
);
javaLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(javaLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("JavaExtractor", () => {
const extractor = new JavaExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["java"]);
});
// ---- Methods/Constructors (mapped to functions) ----
describe("extractStructure - functions (methods & constructors)", () => {
it("extracts methods with params and return types", () => {
const { tree, parser, root } = parse(`public class Foo {
public String getName(int id) {
return "";
}
private void process(String data, int count) {
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("getName");
expect(result.functions[0].params).toEqual(["id"]);
expect(result.functions[0].returnType).toBe("String");
expect(result.functions[1].name).toBe("process");
expect(result.functions[1].params).toEqual(["data", "count"]);
expect(result.functions[1].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts constructors", () => {
const { tree, parser, root } = parse(`public class Foo {
public Foo(String name, int value) {
this.name = name;
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("Foo");
expect(result.functions[0].params).toEqual(["name", "value"]);
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("extracts methods with no params", () => {
const { tree, parser, root } = parse(`public class Foo {
public void run() {}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("run");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts methods with generic return types", () => {
const { tree, parser, root } = parse(`public class Foo {
public List<String> getItems() {
return null;
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("getItems");
expect(result.functions[0].returnType).toBe("List<String>");
tree.delete();
parser.delete();
});
it("reports correct line ranges for multi-line methods", () => {
const { tree, parser, root } = parse(`public class Foo {
public int calculate(
int a,
int b
) {
int result = a + b;
return result;
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(2);
expect(result.functions[0].lineRange[1]).toBe(8);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts class with methods and fields", () => {
const { tree, parser, root } = parse(`public class Server {
private String host;
private int port;
public void start() {}
public void stop() {}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].properties).toEqual(["host", "port"]);
expect(result.classes[0].methods).toEqual(["start", "stop"]);
expect(result.classes[0].lineRange[0]).toBe(1);
tree.delete();
parser.delete();
});
it("extracts empty class", () => {
const { tree, parser, root } = parse(`public class Empty {
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Empty");
expect(result.classes[0].properties).toEqual([]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("includes constructors in methods list", () => {
const { tree, parser, root } = parse(`public class Foo {
public Foo() {}
public void run() {}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes[0].methods).toEqual(["Foo", "run"]);
tree.delete();
parser.delete();
});
});
// ---- Interfaces ----
describe("extractStructure - interfaces", () => {
it("extracts interface with method signatures", () => {
const { tree, parser, root } = parse(`interface Repository {
List<User> findAll();
User findById(int id);
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Repository");
expect(result.classes[0].methods).toEqual(["findAll", "findById"]);
expect(result.classes[0].properties).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts empty interface", () => {
const { tree, parser, root } = parse(`interface Marker {
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Marker");
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
});
// ---- Imports ----
describe("extractStructure - imports", () => {
it("extracts regular imports", () => {
const { tree, parser, root } = parse(`import java.util.List;
import java.util.Map;
public class Foo {}
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("java.util.List");
expect(result.imports[0].specifiers).toEqual(["List"]);
expect(result.imports[0].lineNumber).toBe(1);
expect(result.imports[1].source).toBe("java.util.Map");
expect(result.imports[1].specifiers).toEqual(["Map"]);
expect(result.imports[1].lineNumber).toBe(2);
tree.delete();
parser.delete();
});
it("extracts wildcard imports", () => {
const { tree, parser, root } = parse(`import java.util.*;
public class Foo {}
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("java.util");
expect(result.imports[0].specifiers).toEqual(["*"]);
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`import java.util.List;
import java.util.Map;
public class Foo {}
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(1);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("exports public class, methods, and constructor", () => {
const { tree, parser, root } = parse(`public class UserService {
private String name;
public UserService(String name) {
this.name = name;
}
public void start() {}
private void helper() {}
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService"); // class
// The constructor is also named UserService, check it's listed
const userServiceExports = result.exports.filter(
(e) => e.name === "UserService",
);
expect(userServiceExports.length).toBe(2); // class + constructor
expect(exportNames).toContain("start");
expect(exportNames).not.toContain("helper");
expect(exportNames).not.toContain("name"); // private field
tree.delete();
parser.delete();
});
it("does not export non-public classes", () => {
const { tree, parser, root } = parse(`class Internal {
void run() {}
}
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(0);
tree.delete();
parser.delete();
});
it("exports public fields", () => {
const { tree, parser, root } = parse(`public class Config {
public String apiKey;
private int retries;
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Config");
expect(exportNames).toContain("apiKey");
expect(exportNames).not.toContain("retries");
tree.delete();
parser.delete();
});
it("exports public interface", () => {
const { tree, parser, root } = parse(`public interface Repository {
void save();
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Repository");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple method calls", () => {
const { tree, parser, root } = parse(`public class Foo {
public void process(int data) {
transform(data);
format(data);
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].caller).toBe("process");
expect(result[0].callee).toBe("transform");
expect(result[1].caller).toBe("process");
expect(result[1].callee).toBe("format");
tree.delete();
parser.delete();
});
it("extracts qualified method calls (e.g. System.out.println)", () => {
const { tree, parser, root } = parse(`public class Foo {
private void log(String message) {
System.out.println(message);
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("log");
expect(result[0].callee).toBe("System.out.println");
tree.delete();
parser.delete();
});
it("extracts object creation expressions", () => {
const { tree, parser, root } = parse(`public class Foo {
public void create() {
Bar b = new Bar();
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("create");
expect(result[0].callee).toBe("new Bar");
tree.delete();
parser.delete();
});
it("tracks correct caller for constructors", () => {
const { tree, parser, root } = parse(`public class Foo {
public Foo() {
init();
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("Foo");
expect(result[0].callee).toBe("init");
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`public class Foo {
public void run() {
foo();
bar();
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(3);
expect(result[1].lineNumber).toBe(4);
tree.delete();
parser.delete();
});
it("ignores calls outside methods (no caller)", () => {
// Java doesn't really allow top-level calls, but field initializers
// can have method calls. We skip those without a method context.
const { tree, parser, root } = parse(`public class Foo {
private String value = String.valueOf(42);
}
`);
const result = extractor.extractCallGraph(root);
// No enclosing method, so these are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive Java file", () => {
it("handles a realistic Java module", () => {
const { tree, parser, root } = parse(`import java.util.List;
import java.util.Map;
public class UserService {
private String name;
private int maxRetries;
public UserService(String name) {
this.name = name;
}
public List<User> getUsers(int limit) {
return fetchFromDb(limit);
}
private void log(String message) {
System.out.println(message);
}
}
interface Repository {
List<User> findAll();
User findById(int id);
}
`);
const result = extractor.extractStructure(root);
// Functions: UserService (constructor), getUsers, log
expect(result.functions).toHaveLength(3);
expect(result.functions.map((f) => f.name).sort()).toEqual(
["UserService", "getUsers", "log"].sort(),
);
// Constructor has params but no return type
const ctor = result.functions.find((f) => f.name === "UserService");
expect(ctor?.params).toEqual(["name"]);
expect(ctor?.returnType).toBeUndefined();
// getUsers has params and generic return type
const getUsers = result.functions.find((f) => f.name === "getUsers");
expect(getUsers?.params).toEqual(["limit"]);
expect(getUsers?.returnType).toBe("List<User>");
// log has params and void return type
const log = result.functions.find((f) => f.name === "log");
expect(log?.params).toEqual(["message"]);
expect(log?.returnType).toBe("void");
// Classes: UserService, Repository
expect(result.classes).toHaveLength(2);
const userService = result.classes.find(
(c) => c.name === "UserService",
);
expect(userService).toBeDefined();
expect(userService!.methods.sort()).toEqual(
["UserService", "getUsers", "log"].sort(),
);
expect(userService!.properties.sort()).toEqual(
["name", "maxRetries"].sort(),
);
const repository = result.classes.find(
(c) => c.name === "Repository",
);
expect(repository).toBeDefined();
expect(repository!.methods).toEqual(["findAll", "findById"]);
expect(repository!.properties).toEqual([]);
// Imports: 2 (java.util.List, java.util.Map)
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("java.util.List");
expect(result.imports[0].specifiers).toEqual(["List"]);
expect(result.imports[1].source).toBe("java.util.Map");
expect(result.imports[1].specifiers).toEqual(["Map"]);
// Exports: UserService (class), UserService (constructor), getUsers (public method)
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService");
expect(exportNames).toContain("getUsers");
expect(exportNames).not.toContain("log"); // private
expect(exportNames).not.toContain("name"); // private field
expect(exportNames).not.toContain("maxRetries"); // private field
// Call graph
const calls = extractor.extractCallGraph(root);
const getUsersCalls = calls.filter((e) => e.caller === "getUsers");
expect(getUsersCalls.some((e) => e.callee === "fetchFromDb")).toBe(
true,
);
const logCalls = calls.filter((e) => e.caller === "log");
expect(
logCalls.some((e) => e.callee === "System.out.println"),
).toBe(true);
tree.delete();
parser.delete();
});
});
});

View File

@@ -0,0 +1,676 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { PhpExtractor } from "../php-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + PHP grammar once
let Parser: any;
let Language: any;
let phpLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-php/tree-sitter-php.wasm",
);
phpLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(phpLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("PhpExtractor", () => {
const extractor = new PhpExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["php"]);
});
// ---- Functions ----
describe("extractStructure - functions", () => {
it("extracts top-level functions with params and return types", () => {
const { tree, parser, root } = parse(`<?php
function helper(string $x): string {
return strtoupper($x);
}
function greet(string $name, int $times): void {
echo $name;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("helper");
expect(result.functions[0].params).toEqual(["$x"]);
expect(result.functions[0].returnType).toBe("string");
expect(result.functions[1].name).toBe("greet");
expect(result.functions[1].params).toEqual(["$name", "$times"]);
expect(result.functions[1].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts functions without return type", () => {
const { tree, parser, root } = parse(`<?php
function noReturn($x) {
echo $x;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("noReturn");
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("extracts functions with no parameters", () => {
const { tree, parser, root } = parse(`<?php
function noop(): void {
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("noop");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("reports correct line ranges", () => {
const { tree, parser, root } = parse(`<?php
function multiline(
string $a,
string $b
): string {
$result = $a . $b;
return $result;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(2);
expect(result.functions[0].lineRange[1]).toBe(8);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts classes with methods and properties", () => {
const { tree, parser, root } = parse(`<?php
class UserService {
private string $name;
protected int $maxRetries;
public function __construct(string $name) {
$this->name = $name;
}
public function getUser(int $id): User {
return $this->fetchFromDb($id);
}
private function log(string $message): void {
error_log($message);
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("UserService");
expect(result.classes[0].methods).toContain("__construct");
expect(result.classes[0].methods).toContain("getUser");
expect(result.classes[0].methods).toContain("log");
expect(result.classes[0].methods).toHaveLength(3);
expect(result.classes[0].properties).toContain("name");
expect(result.classes[0].properties).toContain("maxRetries");
expect(result.classes[0].properties).toHaveLength(2);
tree.delete();
parser.delete();
});
it("also adds class methods to the functions array", () => {
const { tree, parser, root } = parse(`<?php
class Svc {
public function run(string $x): string {
return $x;
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions.some((f) => f.name === "run")).toBe(true);
expect(result.functions[0].params).toEqual(["$x"]);
expect(result.functions[0].returnType).toBe("string");
tree.delete();
parser.delete();
});
it("extracts classes with static methods", () => {
const { tree, parser, root } = parse(`<?php
class Factory {
public static function create(): self {
return new self();
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].methods).toContain("create");
tree.delete();
parser.delete();
});
it("extracts nullable and optional type properties", () => {
const { tree, parser, root } = parse(`<?php
class Config {
public ?string $nullable;
public static int $counter = 0;
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].properties).toContain("nullable");
expect(result.classes[0].properties).toContain("counter");
tree.delete();
parser.delete();
});
it("reports correct class line ranges", () => {
const { tree, parser, root } = parse(`<?php
class MyClass {
public function a(): void {}
public function b(): void {}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].lineRange[0]).toBe(2);
expect(result.classes[0].lineRange[1]).toBe(5);
tree.delete();
parser.delete();
});
});
// ---- Interfaces ----
describe("extractStructure - interfaces", () => {
it("extracts interfaces with method signatures", () => {
const { tree, parser, root } = parse(`<?php
interface Loggable {
public function log(string $msg): void;
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Loggable");
expect(result.classes[0].methods).toContain("log");
expect(result.classes[0].properties).toEqual([]);
tree.delete();
parser.delete();
});
it("treats interfaces as exports", () => {
const { tree, parser, root } = parse(`<?php
interface Repository {
public function find(int $id): mixed;
public function save(object $entity): void;
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Repository");
tree.delete();
parser.delete();
});
});
// ---- Imports (use statements) ----
describe("extractStructure - imports", () => {
it("extracts simple use statements", () => {
const { tree, parser, root } = parse(`<?php
use App\\Models\\User;
use App\\Contracts\\Repository;
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("App\\Models\\User");
expect(result.imports[0].specifiers).toEqual(["User"]);
expect(result.imports[1].source).toBe("App\\Contracts\\Repository");
expect(result.imports[1].specifiers).toEqual(["Repository"]);
tree.delete();
parser.delete();
});
it("extracts grouped use statements", () => {
const { tree, parser, root } = parse(`<?php
use App\\Models\\{User, Post};
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].specifiers).toEqual(["User", "Post"]);
tree.delete();
parser.delete();
});
it("extracts aliased use statements", () => {
const { tree, parser, root } = parse(`<?php
use App\\Contracts\\Repository as Repo;
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("App\\Contracts\\Repository");
expect(result.imports[0].specifiers).toEqual(["Repository"]);
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`<?php
use App\\Models\\User;
use App\\Models\\Post;
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(2);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("treats top-level functions as exports", () => {
const { tree, parser, root } = parse(`<?php
function publicFunc(): void {}
function anotherFunc(string $x): string { return $x; }
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("publicFunc");
expect(exportNames).toContain("anotherFunc");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("treats classes as exports", () => {
const { tree, parser, root } = parse(`<?php
class MyService {}
class MyModel {}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("MyService");
expect(exportNames).toContain("MyModel");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("does not treat use statements as exports", () => {
const { tree, parser, root } = parse(`<?php
use App\\Models\\User;
function myFunc(): void {}
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(1);
expect(result.exports[0].name).toBe("myFunc");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts standalone function calls", () => {
const { tree, parser, root } = parse(`<?php
function process(string $data): string {
$result = transform($data);
return format_output($result);
}
`);
const result = extractor.extractCallGraph(root);
const callees = result.map((e) => e.callee);
expect(result.every((e) => e.caller === "process")).toBe(true);
expect(callees).toContain("transform");
expect(callees).toContain("format_output");
tree.delete();
parser.delete();
});
it("extracts instance method calls ($this->method())", () => {
const { tree, parser, root } = parse(`<?php
class Svc {
public function getUser(int $id): User {
return $this->fetchFromDb($id);
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result.some((e) => e.caller === "getUser" && e.callee === "$this->fetchFromDb")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts static method calls (Class::method())", () => {
const { tree, parser, root } = parse(`<?php
class Foo {
public function doWork(): void {
$result = Bar::staticMethod();
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result.some((e) => e.caller === "doWork" && e.callee === "Bar::staticMethod")).toBe(true);
tree.delete();
parser.delete();
});
it("tracks correct caller context across nested calls", () => {
const { tree, parser, root } = parse(`<?php
class Service {
public function start(): void {
$this->setup();
run_server();
}
private function setup(): void {
init_config();
}
}
`);
const result = extractor.extractCallGraph(root);
const startCalls = result.filter((e) => e.caller === "start");
expect(startCalls.some((e) => e.callee === "$this->setup")).toBe(true);
expect(startCalls.some((e) => e.callee === "run_server")).toBe(true);
const setupCalls = result.filter((e) => e.caller === "setup");
expect(setupCalls.some((e) => e.callee === "init_config")).toBe(true);
tree.delete();
parser.delete();
});
it("ignores top-level calls (no caller)", () => {
const { tree, parser, root } = parse(`<?php
echo "hello";
main();
`);
const result = extractor.extractCallGraph(root);
// Top-level calls have no enclosing function, so they are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`<?php
function main(): void {
foo();
bar();
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(3);
expect(result[1].lineNumber).toBe(4);
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive PHP file", () => {
it("handles the full test fixture", () => {
const { tree, parser, root } = parse(`<?php
namespace App\\Services;
use App\\Models\\User;
use App\\Contracts\\Repository;
class UserService {
private string $name;
protected int $maxRetries;
public function __construct(string $name) {
$this->name = $name;
}
public function getUser(int $id): User {
return $this->fetchFromDb($id);
}
private function log(string $message): void {
error_log($message);
}
}
function helper(string $x): string {
return strtoupper($x);
}
`);
const result = extractor.extractStructure(root);
// Functions: __construct, getUser, log (from class), helper (top-level)
const funcNames = result.functions.map((f) => f.name);
expect(funcNames).toContain("__construct");
expect(funcNames).toContain("getUser");
expect(funcNames).toContain("log");
expect(funcNames).toContain("helper");
expect(result.functions).toHaveLength(4);
// Classes: UserService
expect(result.classes).toHaveLength(1);
const userService = result.classes[0];
expect(userService.name).toBe("UserService");
expect(userService.methods).toContain("__construct");
expect(userService.methods).toContain("getUser");
expect(userService.methods).toContain("log");
expect(userService.properties).toContain("name");
expect(userService.properties).toContain("maxRetries");
// Imports: 2 use statements
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("App\\Models\\User");
expect(result.imports[0].specifiers).toEqual(["User"]);
expect(result.imports[1].source).toBe("App\\Contracts\\Repository");
expect(result.imports[1].specifiers).toEqual(["Repository"]);
// Exports: UserService (class) + helper (function)
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService");
expect(exportNames).toContain("helper");
expect(result.exports).toHaveLength(2);
// Return types
const getUser = result.functions.find((f) => f.name === "getUser");
expect(getUser).toBeDefined();
expect(getUser!.returnType).toBe("User");
const log = result.functions.find((f) => f.name === "log");
expect(log).toBeDefined();
expect(log!.returnType).toBe("void");
const helper = result.functions.find((f) => f.name === "helper");
expect(helper).toBeDefined();
expect(helper!.returnType).toBe("string");
// Call graph
const calls = extractor.extractCallGraph(root);
// getUser -> $this->fetchFromDb
const getUserCalls = calls.filter((e) => e.caller === "getUser");
expect(getUserCalls.some((e) => e.callee === "$this->fetchFromDb")).toBe(true);
// log -> error_log
const logCalls = calls.filter((e) => e.caller === "log");
expect(logCalls.some((e) => e.callee === "error_log")).toBe(true);
// helper -> strtoupper
const helperCalls = calls.filter((e) => e.caller === "helper");
expect(helperCalls.some((e) => e.callee === "strtoupper")).toBe(true);
tree.delete();
parser.delete();
});
});
// ---- Block-scoped namespaces ----
describe("extractStructure - block-scoped namespaces", () => {
it("extracts classes and functions inside block-scoped namespaces", () => {
const { tree, parser, root } = parse(`<?php
namespace App\\Controllers {
class UserController {
public function index(): void {}
}
function helperInNs(): string {
return "ok";
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("UserController");
expect(result.classes[0].methods).toContain("index");
expect(result.functions.some((f) => f.name === "helperInNs")).toBe(true);
expect(result.functions.some((f) => f.name === "index")).toBe(true);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserController");
expect(exportNames).toContain("helperInNs");
tree.delete();
parser.delete();
});
it("extracts interfaces inside block-scoped namespaces", () => {
const { tree, parser, root } = parse(`<?php
namespace App\\Contracts {
interface Repository {
public function find(int $id): mixed;
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Repository");
expect(result.classes[0].methods).toContain("find");
tree.delete();
parser.delete();
});
it("extracts use statements inside block-scoped namespaces", () => {
const { tree, parser, root } = parse(`<?php
namespace App\\Services {
use App\\Models\\User;
class UserService {
public function get(): void {}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("App\\Models\\User");
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("UserService");
tree.delete();
parser.delete();
});
});
// ---- Nullable return types ----
describe("nullable return types", () => {
it("extracts nullable return type", () => {
const { tree, parser, root } = parse(`<?php
function findUser(int $id): ?User {
return null;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].returnType).toBe("?User");
tree.delete();
parser.delete();
});
});
});

View File

@@ -0,0 +1,659 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { PythonExtractor } from "../python-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + Python grammar once
let Parser: any;
let Language: any;
let pythonLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-python/tree-sitter-python.wasm",
);
pythonLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(pythonLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("PythonExtractor", () => {
const extractor = new PythonExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["python"]);
});
// ---- Functions ----
describe("extractStructure - functions", () => {
it("extracts simple functions with type annotations", () => {
const { tree, parser, root } = parse(`
def hello(name: str) -> str:
return f"Hello {name}"
def add(a: int, b: int) -> int:
return a + b
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("hello");
expect(result.functions[0].params).toEqual(["name"]);
expect(result.functions[0].returnType).toBe("str");
expect(result.functions[0].lineRange[0]).toBeGreaterThan(0);
expect(result.functions[1].name).toBe("add");
expect(result.functions[1].params).toEqual(["a", "b"]);
expect(result.functions[1].returnType).toBe("int");
tree.delete();
parser.delete();
});
it("extracts functions without type annotations", () => {
const { tree, parser, root } = parse(`
def greet(name):
print(name)
def noop():
pass
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("greet");
expect(result.functions[0].params).toEqual(["name"]);
expect(result.functions[0].returnType).toBeUndefined();
expect(result.functions[1].name).toBe("noop");
expect(result.functions[1].params).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts functions with default parameters", () => {
const { tree, parser, root } = parse(`
def connect(host: str, port: int = 8080, timeout: float = 30.0):
pass
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("connect");
expect(result.functions[0].params).toEqual(["host", "port", "timeout"]);
tree.delete();
parser.delete();
});
it("extracts functions with *args and **kwargs", () => {
const { tree, parser, root } = parse(`
def flexible(*args, **kwargs):
pass
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].params).toEqual(["*args", "**kwargs"]);
tree.delete();
parser.delete();
});
it("extracts decorated functions", () => {
const { tree, parser, root } = parse(`
@decorator
def decorated_func():
pass
@app.route("/api")
def api_handler():
pass
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("decorated_func");
expect(result.functions[1].name).toBe("api_handler");
tree.delete();
parser.delete();
});
it("reports correct line ranges", () => {
const { tree, parser, root } = parse(`
def multiline(
a: int,
b: int,
) -> int:
result = a + b
return result
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(2);
expect(result.functions[0].lineRange[1]).toBe(7);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts classes with methods and properties", () => {
const { tree, parser, root } = parse(`
class DataProcessor:
name: str
def __init__(self, name: str):
self.name = name
def process(self, data: list) -> dict:
return transform(data)
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("DataProcessor");
expect(result.classes[0].methods).toContain("__init__");
expect(result.classes[0].methods).toContain("process");
expect(result.classes[0].properties).toContain("name");
tree.delete();
parser.delete();
});
it("extracts dataclass-style annotated properties", () => {
const { tree, parser, root } = parse(`
class Config:
name: str
value: int
debug: bool
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].properties).toEqual(["name", "value", "debug"]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts decorated classes", () => {
const { tree, parser, root } = parse(`
@dataclass
class Config:
name: str
value: int = 0
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Config");
expect(result.classes[0].properties).toContain("name");
expect(result.classes[0].properties).toContain("value");
tree.delete();
parser.delete();
});
it("extracts decorated methods within a class", () => {
const { tree, parser, root } = parse(`
class MyClass:
@staticmethod
def static_method():
pass
@classmethod
def class_method(cls):
pass
@property
def prop(self):
return self._prop
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].methods).toContain("static_method");
expect(result.classes[0].methods).toContain("class_method");
expect(result.classes[0].methods).toContain("prop");
tree.delete();
parser.delete();
});
it("filters self and cls from method params", () => {
const { tree, parser, root } = parse(`
class Foo:
def instance_method(self, x: int):
pass
@classmethod
def class_method(cls, y: str):
pass
`);
const result = extractor.extractStructure(root);
// Methods are on the class, but top-level functions should not include them
expect(result.functions).toHaveLength(0);
expect(result.classes[0].methods).toEqual(["instance_method", "class_method"]);
tree.delete();
parser.delete();
});
it("reports correct class line ranges", () => {
const { tree, parser, root } = parse(`
class MyClass:
def method_a(self):
pass
def method_b(self):
pass
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].lineRange[0]).toBe(2);
expect(result.classes[0].lineRange[1]).toBe(7);
tree.delete();
parser.delete();
});
});
// ---- Imports ----
describe("extractStructure - imports", () => {
it("extracts simple import statements", () => {
const { tree, parser, root } = parse(`
import os
import sys
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("os");
expect(result.imports[0].specifiers).toEqual(["os"]);
expect(result.imports[1].source).toBe("sys");
expect(result.imports[1].specifiers).toEqual(["sys"]);
tree.delete();
parser.delete();
});
it("extracts from-import statements", () => {
const { tree, parser, root } = parse(`
from pathlib import Path
from typing import Optional, List
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("pathlib");
expect(result.imports[0].specifiers).toEqual(["Path"]);
expect(result.imports[1].source).toBe("typing");
expect(result.imports[1].specifiers).toEqual(["Optional", "List"]);
tree.delete();
parser.delete();
});
it("extracts aliased imports", () => {
const { tree, parser, root } = parse(`
from foo import bar as baz
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("foo");
expect(result.imports[0].specifiers).toEqual(["baz"]);
tree.delete();
parser.delete();
});
it("extracts dotted module imports", () => {
const { tree, parser, root } = parse(`
import os.path
from os.path import join, exists
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("os.path");
expect(result.imports[0].specifiers).toEqual(["os.path"]);
expect(result.imports[1].source).toBe("os.path");
expect(result.imports[1].specifiers).toEqual(["join", "exists"]);
tree.delete();
parser.delete();
});
it("extracts wildcard imports", () => {
const { tree, parser, root } = parse(`
from os.path import *
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("os.path");
expect(result.imports[0].specifiers).toEqual(["*"]);
tree.delete();
parser.delete();
});
it("handles all import types together", () => {
const { tree, parser, root } = parse(`
import os
from pathlib import Path
from typing import Optional, List
`);
const result = extractor.extractStructure(root);
expect(result.imports.length).toBeGreaterThanOrEqual(3);
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`
import os
from pathlib import Path
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(2);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("treats top-level functions as exports", () => {
const { tree, parser, root } = parse(`
def public_func():
pass
def another_func(x: int) -> str:
return str(x)
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("public_func");
expect(exportNames).toContain("another_func");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("treats top-level classes as exports", () => {
const { tree, parser, root } = parse(`
class MyService:
pass
class MyModel:
pass
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("MyService");
expect(exportNames).toContain("MyModel");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("treats decorated top-level definitions as exports", () => {
const { tree, parser, root } = parse(`
@dataclass
class Config:
name: str
@app.route("/")
def index():
pass
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Config");
expect(exportNames).toContain("index");
tree.delete();
parser.delete();
});
it("does not treat imports as exports", () => {
const { tree, parser, root } = parse(`
import os
from pathlib import Path
def my_func():
pass
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(1);
expect(result.exports[0].name).toBe("my_func");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple function calls", () => {
const { tree, parser, root } = parse(`
def process(data):
result = transform(data)
return format_output(result)
def main():
process([1, 2, 3])
`);
const result = extractor.extractCallGraph(root);
expect(result.length).toBeGreaterThanOrEqual(2);
const processCallers = result.filter((e) => e.caller === "process");
expect(processCallers.some((e) => e.callee === "transform")).toBe(true);
expect(processCallers.some((e) => e.callee === "format_output")).toBe(true);
const mainCallers = result.filter((e) => e.caller === "main");
expect(mainCallers.some((e) => e.callee === "process")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts attribute-based calls (method calls)", () => {
const { tree, parser, root } = parse(`
def process():
self.method()
os.path.join("a", "b")
result.save()
`);
const result = extractor.extractCallGraph(root);
const callees = result.map((e) => e.callee);
expect(callees).toContain("self.method");
expect(callees).toContain("os.path.join");
expect(callees).toContain("result.save");
tree.delete();
parser.delete();
});
it("tracks correct caller context for nested calls", () => {
const { tree, parser, root } = parse(`
def outer():
helper()
def inner():
deep_call()
another()
`);
const result = extractor.extractCallGraph(root);
const outerCalls = result.filter((e) => e.caller === "outer");
expect(outerCalls.some((e) => e.callee === "helper")).toBe(true);
expect(outerCalls.some((e) => e.callee === "another")).toBe(true);
const innerCalls = result.filter((e) => e.caller === "inner");
expect(innerCalls.some((e) => e.callee === "deep_call")).toBe(true);
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`
def main():
foo()
bar()
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(3);
expect(result[1].lineNumber).toBe(4);
tree.delete();
parser.delete();
});
it("ignores top-level calls (no caller)", () => {
const { tree, parser, root } = parse(`
print("hello")
main()
`);
const result = extractor.extractCallGraph(root);
// Top-level calls have no enclosing function, so they are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
it("handles calls inside class methods", () => {
const { tree, parser, root } = parse(`
class Service:
def start(self):
self.setup()
run_server()
`);
const result = extractor.extractCallGraph(root);
const startCalls = result.filter((e) => e.caller === "start");
expect(startCalls.some((e) => e.callee === "self.setup")).toBe(true);
expect(startCalls.some((e) => e.callee === "run_server")).toBe(true);
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive Python file", () => {
it("handles a realistic Python module", () => {
const { tree, parser, root } = parse(`
import os
from pathlib import Path
from typing import Optional, List
class FileProcessor:
name: str
verbose: bool
def __init__(self, name: str, verbose: bool = False):
self.name = name
self.verbose = verbose
def process(self, paths: List[str]) -> dict:
results = {}
for p in paths:
results[p] = self._read_file(p)
return results
def _read_file(self, path: str) -> Optional[str]:
full = Path(path)
if full.exists():
return full.read_text()
return None
def create_processor(name: str) -> FileProcessor:
return FileProcessor(name)
@staticmethod
def utility_func(*args, **kwargs) -> None:
print(args, kwargs)
`);
const result = extractor.extractStructure(root);
// Imports
expect(result.imports.length).toBeGreaterThanOrEqual(3);
// Class
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("FileProcessor");
expect(result.classes[0].methods).toContain("__init__");
expect(result.classes[0].methods).toContain("process");
expect(result.classes[0].methods).toContain("_read_file");
expect(result.classes[0].properties).toContain("name");
expect(result.classes[0].properties).toContain("verbose");
// Top-level functions
expect(result.functions.some((f) => f.name === "create_processor")).toBe(
true,
);
expect(result.functions.some((f) => f.name === "utility_func")).toBe(
true,
);
// Exports (top-level defs)
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("FileProcessor");
expect(exportNames).toContain("create_processor");
expect(exportNames).toContain("utility_func");
// Call graph
const calls = extractor.extractCallGraph(root);
expect(calls.length).toBeGreaterThan(0);
tree.delete();
parser.delete();
});
});
});

Some files were not shown because too many files have changed in this diff Show More