mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
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:
co-authored by
Claude Opus 4.6
parent
801c5e7af3
commit
e1fd03ec38
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user