Add under-anything knowledge dashboard
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ";
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user