From 305fb4055b91fb246f450e3cdeaf4b193fe4dbe4 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sat, 14 Mar 2026 18:51:19 +0800 Subject: [PATCH] feat(core): add heuristic and LLM-based layer auto-detection Add layer-detector module with four exported functions: - detectLayers: heuristic detection using directory path patterns - buildLayerDetectionPrompt: generates LLM prompt for layer identification - parseLayerDetectionResponse: parses LLM JSON response with fence handling - applyLLMLayers: applies LLM-provided layer definitions to the graph Includes 10 tests covering all four functions. Co-Authored-By: Claude Opus 4.6 --- .../core/src/__tests__/layer-detector.test.ts | 188 ++++++++++++ packages/core/src/analyzer/layer-detector.ts | 268 ++++++++++++++++++ packages/core/src/index.ts | 7 + 3 files changed, 463 insertions(+) create mode 100644 packages/core/src/__tests__/layer-detector.test.ts create mode 100644 packages/core/src/analyzer/layer-detector.ts diff --git a/packages/core/src/__tests__/layer-detector.test.ts b/packages/core/src/__tests__/layer-detector.test.ts new file mode 100644 index 0000000..37b6719 --- /dev/null +++ b/packages/core/src/__tests__/layer-detector.test.ts @@ -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 & { 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"); + }); +}); diff --git a/packages/core/src/analyzer/layer-detector.ts b/packages/core/src/analyzer/layer-detector.ts new file mode 100644 index 0000000..2cd5a02 --- /dev/null +++ b/packages/core/src/analyzer/layer-detector.ts @@ -0,0 +1,268 @@ +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: ["util", "helper", "lib", "common", "shared"], + layerName: "Utility Layer", + description: "Shared utilities, helpers, and common libraries", + }, + { + patterns: ["test", "spec", "__test__", "__spec__"], + 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(); // 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(); // 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) 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; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d7e2a05..6cceea8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,3 +17,10 @@ export { mergeGraphUpdate, type StalenessResult, } from "./staleness.js"; +export { + detectLayers, + buildLayerDetectionPrompt, + parseLayerDetectionResponse, + applyLLMLayers, +} from "./analyzer/layer-detector.js"; +export type { LLMLayerResponse } from "./analyzer/layer-detector.js";