feat(core): add graph builder and LLM analysis prompt system

Add GraphBuilder class that assembles KnowledgeGraph from file analysis
results, with support for file/function/class nodes and import/call/contains
edges. Add LLM prompt templates for file and project analysis, along with
robust JSON response parsers that handle markdown fences and invalid input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-14 17:47:40 +08:00
co-authored by Claude Opus 4.6
parent 801c5e7af3
commit e1fd03ec38
5 changed files with 864 additions and 0 deletions
@@ -0,0 +1,215 @@
import { describe, it, expect } 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 === "func: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 === "func: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: "func: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: "func:src/index.ts:main",
target: "func: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"]);
});
});
+207
View File
@@ -0,0 +1,207 @@
import type {
KnowledgeGraph,
GraphNode,
GraphEdge,
StructuralAnalysis,
} from "../types.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;
}
const EXTENSION_LANGUAGE: Record<string, string> = {
".ts": "typescript",
".tsx": "typescript",
".js": "javascript",
".jsx": "javascript",
".mjs": "javascript",
".cjs": "javascript",
".py": "python",
".rb": "ruby",
".go": "go",
".rs": "rust",
".java": "java",
".kt": "kotlin",
".swift": "swift",
".c": "c",
".cpp": "cpp",
".h": "c",
".hpp": "cpp",
".cs": "csharp",
".php": "php",
".lua": "lua",
".sh": "shell",
".bash": "shell",
".zsh": "shell",
".json": "json",
".yaml": "yaml",
".yml": "yaml",
".toml": "toml",
".xml": "xml",
".html": "html",
".css": "css",
".scss": "scss",
".less": "less",
".md": "markdown",
".sql": "sql",
};
function detectLanguage(filePath: string): string {
const lastDot = filePath.lastIndexOf(".");
if (lastDot === -1) return "unknown";
const ext = filePath.slice(lastDot).toLowerCase();
return EXTENSION_LANGUAGE[ext] ?? "unknown";
}
export class GraphBuilder {
private nodes: GraphNode[] = [];
private edges: GraphEdge[] = [];
private languages = new Set<string>();
private projectName: string;
private gitHash: string;
constructor(projectName: string, gitHash: string) {
this.projectName = projectName;
this.gitHash = gitHash;
}
addFile(filePath: string, meta: FileMeta): void {
const lang = detectLanguage(filePath);
if (lang !== "unknown") {
this.languages.add(lang);
}
const name = filePath.split("/").pop() ?? filePath;
this.nodes.push({
id: `file:${filePath}`,
type: "file",
name,
filePath,
summary: meta.summary,
tags: meta.tags,
complexity: meta.complexity,
});
}
addFileWithAnalysis(
filePath: string,
analysis: StructuralAnalysis,
meta: FileAnalysisMeta,
): void {
const lang = detectLanguage(filePath);
if (lang !== "unknown") {
this.languages.add(lang);
}
const fileName = filePath.split("/").pop() ?? filePath;
const fileId = `file:${filePath}`;
// Create the file node
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 = `func:${filePath}:${fn.name}`;
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.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 {
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 {
this.edges.push({
source: `func:${callerFile}:${callerFunc}`,
target: `func:${calleeFile}:${calleeFunc}`,
type: "calls",
direction: "forward",
weight: 0.8,
});
}
build(): KnowledgeGraph {
return {
version: "1.0.0",
project: {
name: this.projectName,
languages: [...this.languages].sort(),
frameworks: [],
description: "",
analyzedAt: new Date().toISOString(),
gitCommitHash: this.gitHash,
},
nodes: this.nodes,
edges: this.edges,
layers: [],
tour: [],
};
}
}
@@ -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([]);
});
});
});
+186
View File
@@ -0,0 +1,186 @@
export interface LLMFileAnalysis {
fileSummary: string;
tags: string[];
complexity: "simple" | "moderate" | "complex";
functionSummaries: Record<string, string>;
classSummaries: Record<string, string>;
languageNotes?: string;
}
export interface LLMProjectSummary {
description: string;
frameworks: string[];
layers: Array<{ name: string; description: string; filePatterns: string[] }>;
}
/**
* Generates a prompt for analyzing a single source file with an LLM.
*/
export function buildFileAnalysisPrompt(
filePath: string,
content: string,
projectContext: string,
): string {
return `You are a code analysis assistant. Analyze the following source file and return a JSON object.
Project context: ${projectContext}
File: ${filePath}
\`\`\`
${content}
\`\`\`
Return a JSON object with the following fields:
- "fileSummary": A concise summary of what this file does (1-2 sentences).
- "tags": An array of relevant tags (e.g., ["utility", "async", "api"]).
- "complexity": One of "simple", "moderate", or "complex".
- "functionSummaries": An object mapping function names to 1-sentence summaries.
- "classSummaries": An object mapping class names to 1-sentence summaries.
- "languageNotes": Optional notes about language-specific patterns or idioms used.
Respond ONLY with the JSON object, no additional text.`;
}
/**
* Generates a prompt for creating a project-level summary with an LLM.
*/
export function buildProjectSummaryPrompt(
fileList: string[],
sampleFiles: Array<{ path: string; content: string }>,
): string {
const fileListStr = fileList.map((f) => ` - ${f}`).join("\n");
let samplesStr = "";
if (sampleFiles.length > 0) {
samplesStr = "\n\nSample files:\n";
for (const sample of sampleFiles) {
samplesStr += `\n--- ${sample.path} ---\n\`\`\`\n${sample.content}\n\`\`\`\n`;
}
}
return `You are a code analysis assistant. Analyze the following project structure and return a JSON object describing the project.
File list:
${fileListStr}${samplesStr}
Return a JSON object with the following fields:
- "description": A concise description of what this project does (2-3 sentences).
- "frameworks": An array of frameworks and major libraries detected (e.g., ["React", "Express", "Vitest"]).
- "layers": An array of logical layers, each with:
- "name": The layer name (e.g., "API", "Data", "UI").
- "description": What this layer is responsible for.
- "filePatterns": Glob patterns or path prefixes that belong to this layer.
Respond ONLY with the JSON object, no additional text.`;
}
/**
* Extracts a JSON block from an LLM response, handling markdown fences.
*/
function extractJson(response: string): string {
// Try to extract from markdown code fences
const fenceMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
if (fenceMatch) {
return fenceMatch[1].trim();
}
// Try to find a raw JSON object
const objectMatch = response.match(/\{[\s\S]*\}/);
if (objectMatch) {
return objectMatch[0].trim();
}
return response.trim();
}
const VALID_COMPLEXITIES = new Set(["simple", "moderate", "complex"]);
/**
* Parses an LLM response for file analysis. Returns null if parsing fails.
*/
export function parseFileAnalysisResponse(
response: string,
): LLMFileAnalysis | null {
try {
const jsonStr = extractJson(response);
const parsed = JSON.parse(jsonStr);
// Validate and normalize complexity
let complexity: "simple" | "moderate" | "complex" = "moderate";
if (
typeof parsed.complexity === "string" &&
VALID_COMPLEXITIES.has(parsed.complexity)
) {
complexity = parsed.complexity as "simple" | "moderate" | "complex";
}
return {
fileSummary:
typeof parsed.fileSummary === "string" ? parsed.fileSummary : "",
tags: Array.isArray(parsed.tags)
? parsed.tags.filter((t: unknown) => typeof t === "string")
: [],
complexity,
functionSummaries:
typeof parsed.functionSummaries === "object" &&
parsed.functionSummaries !== null
? parsed.functionSummaries
: {},
classSummaries:
typeof parsed.classSummaries === "object" &&
parsed.classSummaries !== null
? parsed.classSummaries
: {},
languageNotes:
typeof parsed.languageNotes === "string"
? parsed.languageNotes
: undefined,
};
} catch {
return null;
}
}
/**
* Parses an LLM response for project summary. Returns null if parsing fails.
*/
export function parseProjectSummaryResponse(
response: string,
): LLMProjectSummary | null {
try {
const jsonStr = extractJson(response);
const parsed = JSON.parse(jsonStr);
return {
description:
typeof parsed.description === "string" ? parsed.description : "",
frameworks: Array.isArray(parsed.frameworks)
? parsed.frameworks.filter((f: unknown) => typeof f === "string")
: [],
layers: Array.isArray(parsed.layers)
? parsed.layers
.filter(
(l: unknown): l is { name: string; description: string; filePatterns: string[] } =>
typeof l === "object" &&
l !== null &&
typeof (l as Record<string, unknown>).name === "string",
)
.map(
(l: { name: string; description: string; filePatterns: string[] }) => ({
name: l.name,
description:
typeof l.description === "string" ? l.description : "",
filePatterns: Array.isArray(l.filePatterns)
? l.filePatterns.filter(
(p: unknown) => typeof p === "string",
)
: [],
}),
)
: [],
};
} catch {
return null;
}
}
+8
View File
@@ -1,3 +1,11 @@
export * from "./types.js";
export * from "./persistence/index.js";
export { TreeSitterPlugin } from "./plugins/tree-sitter-plugin.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";