Add under-anything knowledge dashboard

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

View File

@@ -0,0 +1,234 @@
import { describe, it, expect } from "vitest";
import { buildChatContext, formatContextForPrompt } from "../context-builder.js";
import type { KnowledgeGraph, GraphNode, GraphEdge, Layer } from "@understand-anything/core";
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",
filePath: "src/controllers/auth.ts",
summary: "Handles user login, logout, and session management",
tags: ["auth", "controller", "security"],
complexity: "complex",
languageNotes: "Uses Express middleware pattern",
}),
makeNode({
id: "db-pool",
name: "DatabasePool",
type: "class",
filePath: "src/db/pool.ts",
summary: "Manages PostgreSQL connection pooling",
tags: ["database", "connection"],
complexity: "moderate",
}),
makeNode({
id: "user-model",
name: "UserModel",
type: "class",
filePath: "src/models/user.ts",
summary: "ORM model for the users table",
tags: ["model", "database", "user"],
complexity: "moderate",
}),
makeNode({
id: "auth-middleware",
name: "authMiddleware",
type: "function",
filePath: "src/middleware/auth.ts",
summary: "Express middleware that validates JWT tokens for authentication",
tags: ["auth", "middleware", "security"],
complexity: "simple",
}),
makeNode({
id: "config",
name: "config.ts",
type: "file",
filePath: "src/config.ts",
summary: "Application configuration and environment variables",
tags: ["config", "env"],
complexity: "simple",
}),
];
const sampleEdges: GraphEdge[] = [
{
source: "auth-ctrl",
target: "user-model",
type: "depends_on",
direction: "forward",
description: "AuthenticationController uses UserModel for user lookup",
weight: 0.9,
},
{
source: "auth-ctrl",
target: "auth-middleware",
type: "calls",
direction: "forward",
description: "Controller registers auth middleware",
weight: 0.7,
},
{
source: "user-model",
target: "db-pool",
type: "depends_on",
direction: "forward",
description: "UserModel uses DatabasePool for queries",
weight: 0.8,
},
];
const sampleLayers: Layer[] = [
{
id: "layer-api",
name: "API Layer",
description: "HTTP controllers and middleware",
nodeIds: ["auth-ctrl", "auth-middleware"],
},
{
id: "layer-data",
name: "Data Layer",
description: "Database models and connections",
nodeIds: ["user-model", "db-pool"],
},
];
const sampleGraph: KnowledgeGraph = {
version: "1.0.0",
project: {
name: "test-project",
languages: ["TypeScript"],
frameworks: ["Express"],
description: "A test project for unit tests",
analyzedAt: "2026-03-14T00:00:00Z",
gitCommitHash: "abc123",
},
nodes: sampleNodes,
edges: sampleEdges,
layers: sampleLayers,
tour: [],
};
describe("buildChatContext", () => {
it("finds relevant nodes for a query", () => {
const ctx = buildChatContext(sampleGraph, "authentication");
expect(ctx.relevantNodes.length).toBeGreaterThan(0);
const nodeNames = ctx.relevantNodes.map((n) => n.name);
expect(nodeNames).toContain("AuthenticationController");
});
it("includes connected nodes via 1-hop expansion", () => {
// Searching for "authentication" should find auth-ctrl directly.
// auth-ctrl connects to user-model and auth-middleware via edges,
// so those should also appear in relevantNodes.
const ctx = buildChatContext(sampleGraph, "authentication");
const nodeIds = ctx.relevantNodes.map((n) => n.id);
// auth-ctrl is a direct match
expect(nodeIds).toContain("auth-ctrl");
// user-model and auth-middleware are 1-hop connected
expect(nodeIds).toContain("user-model");
expect(nodeIds).toContain("auth-middleware");
});
it("includes project metadata", () => {
const ctx = buildChatContext(sampleGraph, "database");
expect(ctx.projectName).toBe("test-project");
expect(ctx.projectDescription).toBe("A test project for unit tests");
expect(ctx.languages).toEqual(["TypeScript"]);
expect(ctx.frameworks).toEqual(["Express"]);
});
it("includes relevant layers containing matched nodes", () => {
const ctx = buildChatContext(sampleGraph, "authentication");
const layerNames = ctx.relevantLayers.map((l) => l.name);
// auth-ctrl is in API Layer
expect(layerNames).toContain("API Layer");
});
it("includes relevant edges between relevant nodes", () => {
const ctx = buildChatContext(sampleGraph, "authentication");
expect(ctx.relevantEdges.length).toBeGreaterThan(0);
// Should include the edge from auth-ctrl to user-model
const hasAuthToUser = ctx.relevantEdges.some(
(e) => e.source === "auth-ctrl" && e.target === "user-model",
);
expect(hasAuthToUser).toBe(true);
});
it("stores the original query", () => {
const ctx = buildChatContext(sampleGraph, "database pool");
expect(ctx.query).toBe("database pool");
});
it("respects maxNodes parameter", () => {
const ctx = buildChatContext(sampleGraph, "auth", 1);
// With maxNodes=1, only 1 search result (before expansion)
// Expansion may add connected nodes, but initial search is limited
expect(ctx.relevantNodes.length).toBeGreaterThanOrEqual(1);
// Should still be bounded reasonably
expect(ctx.relevantNodes.length).toBeLessThanOrEqual(sampleNodes.length);
});
it("returns empty relevantNodes for a query with no matches", () => {
const ctx = buildChatContext(sampleGraph, "xyznonexistent");
expect(ctx.relevantNodes.length).toBe(0);
expect(ctx.relevantEdges.length).toBe(0);
expect(ctx.relevantLayers.length).toBe(0);
});
});
describe("formatContextForPrompt", () => {
it("produces a string containing node names and summaries", () => {
const ctx = buildChatContext(sampleGraph, "authentication");
const formatted = formatContextForPrompt(ctx);
expect(formatted).toContain("AuthenticationController");
expect(formatted).toContain("Handles user login, logout, and session management");
});
it("includes project header information", () => {
const ctx = buildChatContext(sampleGraph, "authentication");
const formatted = formatContextForPrompt(ctx);
expect(formatted).toContain("test-project");
expect(formatted).toContain("TypeScript");
expect(formatted).toContain("Express");
});
it("includes edge/relationship descriptions", () => {
const ctx = buildChatContext(sampleGraph, "authentication");
const formatted = formatContextForPrompt(ctx);
// Should reference the relationship between auth-ctrl and user-model
expect(formatted).toContain("AuthenticationController");
expect(formatted).toContain("UserModel");
// Edge type or description should appear
expect(formatted).toContain("depends_on");
});
it("includes layer information when layers are present", () => {
const ctx = buildChatContext(sampleGraph, "authentication");
const formatted = formatContextForPrompt(ctx);
expect(formatted).toContain("API Layer");
});
it("includes file paths for nodes that have them", () => {
const ctx = buildChatContext(sampleGraph, "authentication");
const formatted = formatContextForPrompt(ctx);
expect(formatted).toContain("src/controllers/auth.ts");
});
it("includes complexity and type information", () => {
const ctx = buildChatContext(sampleGraph, "authentication");
const formatted = formatContextForPrompt(ctx);
expect(formatted).toContain("complex");
expect(formatted).toContain("class");
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect } from "vitest";
import { buildDiffContext, formatDiffAnalysis } from "../diff-analyzer.js";
import type { KnowledgeGraph } from "@understand-anything/core";
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: "Entry point", tags: ["entry"], complexity: "simple" },
{ id: "file:src/routes.ts", type: "file", name: "routes.ts", filePath: "src/routes.ts", summary: "Routes", tags: ["routes"], complexity: "moderate" },
{ id: "file:src/service.ts", type: "file", name: "service.ts", filePath: "src/service.ts", summary: "Service", tags: ["service"], complexity: "complex" },
{ id: "function:src/service.ts:process", type: "function", name: "process", filePath: "src/service.ts", lineRange: [10, 30], summary: "Process function", tags: ["core"], complexity: "complex" },
{ id: "file:src/db.ts", type: "file", name: "db.ts", filePath: "src/db.ts", summary: "Database", tags: ["db"], complexity: "simple" },
],
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: "function:src/service.ts:process", type: "contains", direction: "forward", weight: 1.0 },
{ 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", "function:src/service.ts:process"] },
{ id: "layer:data", name: "Data Layer", description: "Database", nodeIds: ["file:src/db.ts"] },
],
tour: [],
};
describe("diff-analyzer", () => {
describe("buildDiffContext", () => {
it("identifies directly changed nodes", () => {
const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]);
expect(ctx.changedNodes.map((n) => n.id)).toContain("file:src/service.ts");
});
it("identifies child nodes of changed files", () => {
const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]);
expect(ctx.changedNodes.map((n) => n.id)).toContain("function:src/service.ts:process");
});
it("identifies affected nodes via edges (1-hop)", () => {
const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]);
expect(ctx.affectedNodes.map((n) => n.id)).toContain("file:src/routes.ts");
expect(ctx.affectedNodes.map((n) => n.id)).toContain("file:src/db.ts");
});
it("identifies affected layers", () => {
const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]);
expect(ctx.affectedLayers.map((l) => l.name)).toContain("Service Layer");
});
it("identifies impacted edges", () => {
const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]);
expect(ctx.impactedEdges.length).toBeGreaterThan(0);
});
it("handles files not in the graph gracefully", () => {
const ctx = buildDiffContext(sampleGraph, ["src/unknown.ts"]);
expect(ctx.changedNodes).toHaveLength(0);
expect(ctx.unmappedFiles).toContain("src/unknown.ts");
});
it("handles empty diff", () => {
const ctx = buildDiffContext(sampleGraph, []);
expect(ctx.changedNodes).toHaveLength(0);
expect(ctx.affectedNodes).toHaveLength(0);
});
it("de-duplicates affected nodes (not in changed set)", () => {
const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]);
const changedIds = new Set(ctx.changedNodes.map((n) => n.id));
for (const affected of ctx.affectedNodes) {
expect(changedIds.has(affected.id)).toBe(false);
}
});
});
describe("formatDiffAnalysis", () => {
it("produces structured markdown", () => {
const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]);
const analysis = formatDiffAnalysis(ctx);
expect(analysis).toContain("## Changed Components");
expect(analysis).toContain("## Affected Components");
expect(analysis).toContain("## Affected Layers");
});
it("includes risk assessment section", () => {
const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]);
const analysis = formatDiffAnalysis(ctx);
expect(analysis).toContain("## Risk Assessment");
});
it("lists unmapped files when present", () => {
const ctx = buildDiffContext(sampleGraph, ["src/unknown.ts"]);
const analysis = formatDiffAnalysis(ctx);
expect(analysis).toContain("src/unknown.ts");
});
});
});

View File

@@ -0,0 +1,82 @@
import { describe, it, expect } from "vitest";
import { buildExplainContext, formatExplainPrompt } from "../explain-builder.js";
import type { KnowledgeGraph } from "@understand-anything/core";
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/auth.ts", type: "file", name: "auth.ts", filePath: "src/auth.ts", summary: "Auth module", tags: ["auth"], complexity: "complex" },
{ id: "function:src/auth.ts:login", type: "function", name: "login", filePath: "src/auth.ts", lineRange: [10, 30], summary: "Login handler", tags: ["auth", "login"], complexity: "moderate" },
{ id: "function:src/auth.ts:verify", type: "function", name: "verify", filePath: "src/auth.ts", lineRange: [32, 50], summary: "Token verification", tags: ["auth", "jwt"], complexity: "moderate" },
{ id: "file:src/db.ts", type: "file", name: "db.ts", filePath: "src/db.ts", summary: "Database", tags: ["db"], complexity: "simple" },
],
edges: [
{ source: "file:src/auth.ts", target: "function:src/auth.ts:login", type: "contains", direction: "forward", weight: 1.0 },
{ source: "file:src/auth.ts", target: "function:src/auth.ts:verify", type: "contains", direction: "forward", weight: 1.0 },
{ source: "function:src/auth.ts:login", target: "file:src/db.ts", type: "reads_from", direction: "forward", weight: 0.8 },
],
layers: [
{ id: "layer:auth", name: "Auth Layer", description: "Authentication", nodeIds: ["file:src/auth.ts", "function:src/auth.ts:login", "function:src/auth.ts:verify"] },
],
tour: [],
};
describe("explain-builder", () => {
describe("buildExplainContext", () => {
it("finds the file node by path", () => {
const ctx = buildExplainContext(sampleGraph, "src/auth.ts");
expect(ctx.targetNode?.id).toBe("file:src/auth.ts");
});
it("includes child nodes (functions/classes in the file)", () => {
const ctx = buildExplainContext(sampleGraph, "src/auth.ts");
expect(ctx.childNodes.map((n) => n.name)).toContain("login");
expect(ctx.childNodes.map((n) => n.name)).toContain("verify");
});
it("includes connected nodes", () => {
const ctx = buildExplainContext(sampleGraph, "src/auth.ts");
const allIds = ctx.connectedNodes.map((n) => n.id);
expect(allIds).toContain("file:src/db.ts");
});
it("includes the layer", () => {
const ctx = buildExplainContext(sampleGraph, "src/auth.ts");
expect(ctx.layer?.name).toBe("Auth Layer");
});
it("returns null targetNode for unknown paths", () => {
const ctx = buildExplainContext(sampleGraph, "src/unknown.ts");
expect(ctx.targetNode).toBeNull();
});
it("finds function nodes by partial path match", () => {
const ctx = buildExplainContext(sampleGraph, "src/auth.ts:login");
expect(ctx.targetNode?.name).toBe("login");
});
});
describe("formatExplainPrompt", () => {
it("produces structured markdown for valid context", () => {
const ctx = buildExplainContext(sampleGraph, "src/auth.ts");
const prompt = formatExplainPrompt(ctx);
expect(prompt).toContain("auth.ts");
expect(prompt).toContain("login");
expect(prompt).toContain("Auth Layer");
});
it("produces helpful message for unknown path", () => {
const ctx = buildExplainContext(sampleGraph, "src/unknown.ts");
const prompt = formatExplainPrompt(ctx);
expect(prompt).toContain("not found");
});
});
});

View File

@@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import { buildResult } from "../../skills/understand/extract-structure.mjs";
const file = (overrides = {}) => ({
path: "src/foo.py",
language: "python",
fileCategory: "code",
...overrides,
});
const analysis = (overrides = {}) => ({
functions: [],
classes: [],
imports: [],
exports: [],
...overrides,
});
describe("extract-structure buildResult", () => {
describe("language pass-through", () => {
it("preserves the input language on the output", () => {
const result = buildResult(file({ language: "python" }), 10, 8, analysis(), null, {});
expect(result.language).toBe("python");
});
it("preserves null when caller did not set a language", () => {
// Documents the failure mode the SKILL.md/file-analyzer.md fix prevents:
// if the dispatch prompt loses `language`, it propagates to the output.
const result = buildResult(file({ language: null }), 10, 8, analysis(), null, {});
expect(result.language).toBeNull();
});
});
describe("importCount fallback", () => {
// Only relative imports count toward the fallback metric — external
// package imports would never produce edges so counting them would be
// misleading. (`.helpers`, `..util`, `./local` all start with `.`)
const analysisWithImports = analysis({
imports: [
{ source: ".helpers", specifiers: [] },
{ source: "..util", specifiers: [] },
{ source: "./local", specifiers: [] },
],
});
it("uses pre-resolved imports when batchImportData has entries", () => {
const batchImportData = { "src/foo.py": ["src/bar.py", "src/baz.py"] };
const result = buildResult(file(), 10, 8, analysisWithImports, null, batchImportData);
expect(result.metrics.importCount).toBe(2);
});
it("falls back to parser imports when batchImportData entry is an empty array", () => {
// Regression test: empty arrays are truthy in JS, so a naive `if (importPaths)`
// would clobber the parser's count with 0. This is the bug Python projects
// using absolute imports (which the project scanner doesn't resolve) hit.
const batchImportData = { "src/foo.py": [] };
const result = buildResult(file(), 10, 8, analysisWithImports, null, batchImportData);
expect(result.metrics.importCount).toBe(3);
});
it("falls back to parser imports when batchImportData has no entry for the file", () => {
const result = buildResult(file(), 10, 8, analysisWithImports, null, {});
expect(result.metrics.importCount).toBe(3);
});
it("falls back to parser imports when batchImportData is undefined", () => {
const result = buildResult(file(), 10, 8, analysisWithImports, null, undefined);
expect(result.metrics.importCount).toBe(3);
});
it("reports 0 imports when neither source has any", () => {
const result = buildResult(file(), 10, 8, analysis(), null, { "src/foo.py": [] });
expect(result.metrics.importCount).toBe(0);
});
it("excludes external package imports from the fallback count", () => {
// Regression: pre-2.6.2 the fallback counted ALL parser imports (incl.
// `os`, `sys`, etc.), so files where the scanner couldn't resolve
// anything would over-report imports vs. files where it could.
const ext = analysis({
imports: [
{ source: "os", specifiers: [] },
{ source: "sys", specifiers: [] },
{ source: "./local", specifiers: [] },
],
});
const result = buildResult(file(), 10, 8, ext, null, {});
expect(result.metrics.importCount).toBe(1);
});
});
describe("totalLines", () => {
// Documents the off-by-one fix: `wc -l` reports N for a POSIX text file
// with N lines + trailing \n; the extractor must match.
it("matches wc -l semantics for trailing-newline files", () => {
// Mimic what main() computes: read file, split on \n.
// Build a synthetic 3-line file ending in \n.
const content = "a\nb\nc\n";
const lines = content.split("\n"); // ["a","b","c",""]
const totalLines = content.endsWith("\n") ? Math.max(0, lines.length - 1) : lines.length;
expect(totalLines).toBe(3);
});
it("counts content without trailing newline correctly", () => {
const content = "a\nb\nc";
const lines = content.split("\n");
const totalLines = content.endsWith("\n") ? Math.max(0, lines.length - 1) : lines.length;
expect(totalLines).toBe(3);
});
});
});

View File

@@ -0,0 +1,187 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const MERGE_SCRIPT = resolve(__dirname, "../../skills/understand/merge-batch-graphs.py");
let projectRoot;
let intermediateDir;
function runMerge() {
const result = spawnSync("python3", [MERGE_SCRIPT, projectRoot], {
encoding: "utf-8",
});
if (result.status !== 0) {
throw new Error(`merge script failed: status=${result.status}\nstderr:\n${result.stderr}`);
}
const assembled = JSON.parse(
readFileSync(join(intermediateDir, "assembled-graph.json"), "utf-8"),
);
return { assembled, stderr: result.stderr };
}
function fileNode(path) {
return {
id: `file:${path}`,
type: "file",
name: path.split("/").pop(),
filePath: path,
summary: "",
tags: [],
complexity: "simple",
};
}
function importsEdge(src, tgt) {
return {
source: `file:${src}`,
target: `file:${tgt}`,
type: "imports",
direction: "forward",
weight: 0.7,
};
}
beforeEach(() => {
projectRoot = mkdtempSync(join(tmpdir(), "ua-merge-test-"));
intermediateDir = join(projectRoot, ".understand-anything", "intermediate");
mkdirSync(intermediateDir, { recursive: true });
});
afterEach(() => {
rmSync(projectRoot, { recursive: true, force: true });
});
describe("merge-batch-graphs.py imports recovery", () => {
it("recovers imports edges that batches dropped despite importMap having them", () => {
// Batch contains all the file nodes but only emits ONE of three imports edges.
writeFileSync(
join(intermediateDir, "batch-0.json"),
JSON.stringify({
nodes: [fileNode("src/a.py"), fileNode("src/b.py"), fileNode("src/c.py"), fileNode("src/d.py")],
edges: [importsEdge("src/a.py", "src/b.py")],
}),
);
// scan-result.json has the full importMap — agent dropped 2/3 of these.
writeFileSync(
join(intermediateDir, "scan-result.json"),
JSON.stringify({
importMap: {
"src/a.py": ["src/b.py", "src/c.py", "src/d.py"],
"src/b.py": [],
},
}),
);
const { assembled, stderr } = runMerge();
const importsEdges = assembled.edges.filter((e) => e.type === "imports");
expect(importsEdges).toHaveLength(3);
const targets = new Set(importsEdges.map((e) => e.target));
expect(targets).toEqual(new Set(["file:src/b.py", "file:src/c.py", "file:src/d.py"]));
// Recovered edges are tagged so downstream consumers can audit.
const recovered = importsEdges.filter((e) => e.recoveredFromImportMap);
expect(recovered).toHaveLength(2);
expect(stderr).toContain("Recovered 2 `imports` edges");
});
it("does not duplicate edges the batch already emitted", () => {
writeFileSync(
join(intermediateDir, "batch-0.json"),
JSON.stringify({
nodes: [fileNode("src/a.py"), fileNode("src/b.py")],
edges: [importsEdge("src/a.py", "src/b.py")],
}),
);
writeFileSync(
join(intermediateDir, "scan-result.json"),
JSON.stringify({
importMap: { "src/a.py": ["src/b.py"], "src/b.py": [] },
}),
);
const { assembled, stderr } = runMerge();
const importsEdges = assembled.edges.filter((e) => e.type === "imports");
expect(importsEdges).toHaveLength(1);
expect(stderr).toContain("Recovered 0 `imports` edges");
});
it("skips importMap entries whose source file is missing from the graph", () => {
// src/missing.py is in importMap but has no file: node — must not produce a dangling edge.
writeFileSync(
join(intermediateDir, "batch-0.json"),
JSON.stringify({
nodes: [fileNode("src/b.py")],
edges: [],
}),
);
writeFileSync(
join(intermediateDir, "scan-result.json"),
JSON.stringify({
importMap: { "src/missing.py": ["src/b.py"] },
}),
);
const { assembled, stderr } = runMerge();
expect(assembled.edges.filter((e) => e.type === "imports")).toHaveLength(0);
expect(stderr).toContain("Skipped 1 importMap source files with no `file:` node");
});
it("skips importMap targets that don't have a file: node", () => {
writeFileSync(
join(intermediateDir, "batch-0.json"),
JSON.stringify({
nodes: [fileNode("src/a.py")],
edges: [],
}),
);
writeFileSync(
join(intermediateDir, "scan-result.json"),
JSON.stringify({
importMap: { "src/a.py": ["src/dropped.py", "src/also-missing.py"] },
}),
);
const { assembled, stderr } = runMerge();
expect(assembled.edges.filter((e) => e.type === "imports")).toHaveLength(0);
expect(stderr).toContain("Skipped 2 importMap target paths with no `file:` node");
});
it("works when scan-result.json is missing (incremental update path)", () => {
writeFileSync(
join(intermediateDir, "batch-0.json"),
JSON.stringify({
nodes: [fileNode("src/a.py"), fileNode("src/b.py")],
edges: [importsEdge("src/a.py", "src/b.py")],
}),
);
// No scan-result.json written.
const { assembled, stderr } = runMerge();
expect(assembled.edges.filter((e) => e.type === "imports")).toHaveLength(1);
expect(stderr).toContain("importMap recovery skipped — scan-result.json not found");
});
it("never produces self-import edges", () => {
writeFileSync(
join(intermediateDir, "batch-0.json"),
JSON.stringify({
nodes: [fileNode("src/a.py")],
edges: [],
}),
);
writeFileSync(
join(intermediateDir, "scan-result.json"),
JSON.stringify({
importMap: { "src/a.py": ["src/a.py"] }, // pathological self-reference
}),
);
const { assembled } = runMerge();
expect(assembled.edges.filter((e) => e.type === "imports")).toHaveLength(0);
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { buildOnboardingGuide } from "../onboard-builder.js";
import type { KnowledgeGraph } from "@understand-anything/core";
const sampleGraph: KnowledgeGraph = {
version: "1.0.0",
project: {
name: "test-project",
languages: ["typescript", "python"],
frameworks: ["express", "prisma"],
description: "A test REST API",
analyzedAt: "2026-03-14T00:00:00Z",
gitCommitHash: "abc123",
},
nodes: [
{ id: "file:src/index.ts", type: "file", name: "index.ts", filePath: "src/index.ts", summary: "Entry point", tags: ["entry"], complexity: "simple" },
{ id: "file:src/service.ts", type: "file", name: "service.ts", filePath: "src/service.ts", summary: "Core service", tags: ["service"], complexity: "complex" },
{ id: "concept:auth", type: "concept", name: "Auth Flow", summary: "JWT-based authentication", tags: ["concept", "auth"], complexity: "complex" },
],
edges: [
{ source: "file:src/index.ts", target: "file:src/service.ts", type: "imports", direction: "forward", weight: 0.8 },
],
layers: [
{ id: "layer:api", name: "API Layer", description: "Routes and handlers", nodeIds: ["file:src/index.ts"] },
{ id: "layer:service", name: "Service Layer", description: "Business logic", nodeIds: ["file:src/service.ts"] },
],
tour: [
{ order: 1, title: "Start Here", description: "Begin with index.ts", nodeIds: ["file:src/index.ts"] },
{ order: 2, title: "Core Logic", description: "Service layer", nodeIds: ["file:src/service.ts"] },
],
};
describe("onboard-builder", () => {
it("includes project overview section", () => {
const guide = buildOnboardingGuide(sampleGraph);
expect(guide).toContain("# test-project");
expect(guide).toContain("A test REST API");
});
it("lists languages and frameworks", () => {
const guide = buildOnboardingGuide(sampleGraph);
expect(guide).toContain("typescript");
expect(guide).toContain("express");
});
it("includes architecture layers section", () => {
const guide = buildOnboardingGuide(sampleGraph);
expect(guide).toContain("## Architecture");
expect(guide).toContain("API Layer");
expect(guide).toContain("Service Layer");
});
it("includes key concepts section", () => {
const guide = buildOnboardingGuide(sampleGraph);
expect(guide).toContain("## Key Concepts");
expect(guide).toContain("Auth Flow");
});
it("includes getting started / tour section", () => {
const guide = buildOnboardingGuide(sampleGraph);
expect(guide).toContain("## Getting Started");
expect(guide).toContain("Start Here");
});
it("includes complexity hotspots", () => {
const guide = buildOnboardingGuide(sampleGraph);
expect(guide).toContain("## Complexity Hotspots");
expect(guide).toContain("service.ts");
});
it("includes file map section", () => {
const guide = buildOnboardingGuide(sampleGraph);
expect(guide).toContain("## File Map");
});
it("handles graph with no layers gracefully", () => {
const noLayers = { ...sampleGraph, layers: [] };
const guide = buildOnboardingGuide(noLayers);
expect(guide).toContain("# test-project");
});
it("handles graph with no tour gracefully", () => {
const noTour = { ...sampleGraph, tour: [] };
const guide = buildOnboardingGuide(noTour);
expect(guide).toContain("# test-project");
});
});

View File

@@ -0,0 +1,89 @@
// Validates the worktree-redirect bash snippet embedded in
// `skills/understand/SKILL.md` Phase 0 step 1 and
// `skills/understand-domain/SKILL.md` Phase 0.
//
// If you edit the snippet in either SKILL.md, mirror the change to RESOLVE_SNIPPET
// below — there is no shared script to source (per-skill convention in this repo).
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const RESOLVE_SNIPPET = `
COMMON_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null)
GIT_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-dir 2>/dev/null)
if [ -n "$COMMON_DIR" ] && [ -n "$GIT_DIR" ]; then
COMMON_ABS=$(cd "$PROJECT_ROOT" && cd "$COMMON_DIR" 2>/dev/null && pwd -P)
GIT_ABS=$(cd "$PROJECT_ROOT" && cd "$GIT_DIR" 2>/dev/null && pwd -P)
if [ -n "$COMMON_ABS" ] && [ "$COMMON_ABS" != "$GIT_ABS" ]; then
MAIN_ROOT=$(dirname "$COMMON_ABS")
if [ -d "$MAIN_ROOT" ] && [ "\${UNDERSTAND_NO_WORKTREE_REDIRECT:-0}" != "1" ]; then
PROJECT_ROOT="$MAIN_ROOT"
fi
fi
fi
echo "$PROJECT_ROOT"
`;
function runResolve(projectRoot, env = {}) {
// No `set -e` — the snippet relies on `git ... 2>/dev/null` returning empty
// strings when not in a git repo; `set -e` would short-circuit instead.
const script = `PROJECT_ROOT=${JSON.stringify(projectRoot)}\n${RESOLVE_SNIPPET}`;
return execFileSync("bash", ["-c", script], {
env: { ...process.env, ...env },
encoding: "utf8",
}).trim();
}
let tmpRoot;
let mainRepo;
let worktree;
let subdir;
beforeAll(() => {
tmpRoot = realpathSync(mkdtempSync(join(tmpdir(), "ua-wt-")));
mainRepo = join(tmpRoot, "main");
worktree = join(tmpRoot, "wt");
subdir = join(worktree, "src", "deep");
execFileSync("git", ["init", "-q", "-b", "main", mainRepo]);
execFileSync("git", ["-C", mainRepo, "config", "user.email", "t@t"]);
execFileSync("git", ["-C", mainRepo, "config", "user.name", "t"]);
writeFileSync(join(mainRepo, "README.md"), "main\n");
execFileSync("git", ["-C", mainRepo, "add", "."]);
execFileSync("git", ["-C", mainRepo, "commit", "-q", "-m", "init"]);
execFileSync("git", ["-C", mainRepo, "worktree", "add", "-q", worktree]);
mkdirSync(subdir, { recursive: true });
});
afterAll(() => {
if (tmpRoot) rmSync(tmpRoot, { recursive: true, force: true });
});
describe("worktree-redirect snippet (issue #133)", () => {
it("leaves PROJECT_ROOT alone in a normal checkout", () => {
expect(runResolve(mainRepo)).toBe(mainRepo);
});
it("redirects PROJECT_ROOT to the main repo when started in a worktree", () => {
expect(runResolve(worktree)).toBe(mainRepo);
});
it("redirects from a subdirectory inside a worktree", () => {
expect(runResolve(subdir)).toBe(mainRepo);
});
it("respects UNDERSTAND_NO_WORKTREE_REDIRECT=1", () => {
expect(runResolve(worktree, { UNDERSTAND_NO_WORKTREE_REDIRECT: "1" })).toBe(worktree);
});
it("leaves PROJECT_ROOT alone when not inside a git repo", () => {
// Use a path under the resolved tmp root so we never accidentally land
// inside a parent git repo (e.g. when /tmp is symlinked into one).
const nonGit = join(tmpRoot, "no-git");
mkdirSync(nonGit, { recursive: true });
expect(runResolve(nonGit)).toBe(nonGit);
});
});

View File

@@ -0,0 +1,147 @@
import { SearchEngine } from "@understand-anything/core";
import type {
KnowledgeGraph,
GraphNode,
GraphEdge,
Layer,
} from "@understand-anything/core";
export interface ChatContext {
projectName: string;
projectDescription: string;
languages: string[];
frameworks: string[];
relevantNodes: GraphNode[];
relevantEdges: GraphEdge[];
relevantLayers: Layer[];
query: string;
}
/**
* Build a ChatContext by searching the knowledge graph for nodes relevant
* to the user's query, expanding 1 hop via edges, and collecting the
* associated layers.
*/
export function buildChatContext(
graph: KnowledgeGraph,
query: string,
maxNodes?: number,
): ChatContext {
const limit = maxNodes ?? 15;
// 1. Use SearchEngine to find relevant nodes
const engine = new SearchEngine(graph.nodes);
const searchResults = engine.search(query, { limit });
// Build a set of matched node IDs
const matchedIds = new Set(searchResults.map((r) => r.nodeId));
// 2. Expand to connected nodes (1 hop via edges)
const expandedIds = new Set(matchedIds);
for (const edge of graph.edges) {
if (matchedIds.has(edge.source)) {
expandedIds.add(edge.target);
}
if (matchedIds.has(edge.target)) {
expandedIds.add(edge.source);
}
}
// Collect the actual node objects
const nodeMap = new Map(graph.nodes.map((n) => [n.id, n]));
const relevantNodes: GraphNode[] = [];
for (const id of expandedIds) {
const node = nodeMap.get(id);
if (node) {
relevantNodes.push(node);
}
}
// 3. Collect edges where both endpoints are in the relevant set
const relevantEdges = graph.edges.filter(
(e) => expandedIds.has(e.source) && expandedIds.has(e.target),
);
// 4. Find layers containing any relevant node
const relevantLayers = graph.layers.filter((layer) =>
layer.nodeIds.some((id) => expandedIds.has(id)),
);
return {
projectName: graph.project.name,
projectDescription: graph.project.description,
languages: graph.project.languages,
frameworks: graph.project.frameworks,
relevantNodes,
relevantEdges,
relevantLayers,
query,
};
}
/**
* Format the ChatContext as a readable markdown string for LLM consumption.
*/
export function formatContextForPrompt(context: ChatContext): string {
const lines: string[] = [];
// Project header
lines.push(`# Project: ${context.projectName}`);
lines.push("");
lines.push(context.projectDescription);
lines.push("");
lines.push(`**Languages:** ${context.languages.join(", ")}`);
lines.push(`**Frameworks:** ${context.frameworks.join(", ")}`);
lines.push("");
// Layers section
if (context.relevantLayers.length > 0) {
lines.push("## Relevant Layers");
lines.push("");
for (const layer of context.relevantLayers) {
lines.push(`### ${layer.name}`);
lines.push(layer.description);
lines.push("");
}
}
// Nodes section
if (context.relevantNodes.length > 0) {
lines.push("## Code Components");
lines.push("");
for (const node of context.relevantNodes) {
lines.push(`### ${node.name} (${node.type})`);
if (node.filePath) {
lines.push(`- **File:** ${node.filePath}`);
}
lines.push(`- **Complexity:** ${node.complexity}`);
lines.push(`- **Summary:** ${node.summary}`);
if (node.tags.length > 0) {
lines.push(`- **Tags:** ${node.tags.join(", ")}`);
}
if (node.languageNotes) {
lines.push(`- **Language Notes:** ${node.languageNotes}`);
}
lines.push("");
}
}
// Edges/relationships section
if (context.relevantEdges.length > 0) {
const nodeMap = new Map(context.relevantNodes.map((n) => [n.id, n]));
lines.push("## Relationships");
lines.push("");
for (const edge of context.relevantEdges) {
const sourceName = nodeMap.get(edge.source)?.name ?? edge.source;
const targetName = nodeMap.get(edge.target)?.name ?? edge.target;
let line = `- ${sourceName} --[${edge.type}]--> ${targetName}`;
if (edge.description) {
line += `: ${edge.description}`;
}
lines.push(line);
}
lines.push("");
}
return lines.join("\n");
}

View File

@@ -0,0 +1,198 @@
import type {
KnowledgeGraph,
GraphNode,
GraphEdge,
Layer,
} from "@understand-anything/core";
export interface DiffContext {
projectName: string;
changedFiles: string[];
changedNodes: GraphNode[];
affectedNodes: GraphNode[];
impactedEdges: GraphEdge[];
affectedLayers: Layer[];
unmappedFiles: string[];
}
/**
* Map a list of changed file paths to knowledge graph nodes and
* identify the ripple effect (affected nodes, layers, edges).
*/
export function buildDiffContext(
graph: KnowledgeGraph,
changedFiles: string[],
): DiffContext {
const { nodes, edges, layers } = graph;
const changedNodeIds = new Set<string>();
const unmappedFiles: string[] = [];
for (const file of changedFiles) {
let mapped = false;
for (const node of nodes) {
if (node.filePath === file) {
changedNodeIds.add(node.id);
mapped = true;
}
}
if (!mapped) {
unmappedFiles.push(file);
}
}
// Also include "contains" children of changed file nodes
for (const edge of edges) {
if (edge.type === "contains" && changedNodeIds.has(edge.source)) {
changedNodeIds.add(edge.target);
}
}
const changedNodes = nodes.filter((n) => changedNodeIds.has(n.id));
// Find affected nodes: 1-hop neighbors of changed nodes (excluding already changed)
const affectedNodeIds = new Set<string>();
const impactedEdges: GraphEdge[] = [];
for (const edge of edges) {
const sourceChanged = changedNodeIds.has(edge.source);
const targetChanged = changedNodeIds.has(edge.target);
if (sourceChanged || targetChanged) {
impactedEdges.push(edge);
if (sourceChanged && !changedNodeIds.has(edge.target)) {
affectedNodeIds.add(edge.target);
}
if (targetChanged && !changedNodeIds.has(edge.source)) {
affectedNodeIds.add(edge.source);
}
}
}
const affectedNodes = nodes.filter((n) => affectedNodeIds.has(n.id));
const allImpactedIds = new Set([...changedNodeIds, ...affectedNodeIds]);
const affectedLayers = layers.filter((layer) =>
layer.nodeIds.some((id) => allImpactedIds.has(id)),
);
return {
projectName: graph.project.name,
changedFiles,
changedNodes,
affectedNodes,
impactedEdges,
affectedLayers,
unmappedFiles,
};
}
/**
* Format the diff analysis as structured markdown for LLM or human consumption.
*/
export function formatDiffAnalysis(ctx: DiffContext): string {
const lines: string[] = [];
lines.push(`# Diff Analysis: ${ctx.projectName}`);
lines.push("");
lines.push("## Changed Components");
lines.push("");
if (ctx.changedNodes.length === 0) {
lines.push("No mapped components found for changed files.");
} else {
for (const node of ctx.changedNodes) {
lines.push(`- **${node.name}** (${node.type}) — ${node.summary}`);
if (node.filePath) lines.push(` - File: \`${node.filePath}\``);
lines.push(` - Complexity: ${node.complexity}`);
}
}
lines.push("");
lines.push("## Affected Components");
lines.push("");
if (ctx.affectedNodes.length === 0) {
lines.push("No downstream impact detected.");
} else {
lines.push(
"These components are connected to changed code and may need attention:",
);
lines.push("");
for (const node of ctx.affectedNodes) {
lines.push(`- **${node.name}** (${node.type}) — ${node.summary}`);
}
}
lines.push("");
lines.push("## Affected Layers");
lines.push("");
if (ctx.affectedLayers.length === 0) {
lines.push("No layers affected.");
} else {
for (const layer of ctx.affectedLayers) {
lines.push(`- **${layer.name}**: ${layer.description}`);
}
}
lines.push("");
if (ctx.impactedEdges.length > 0) {
lines.push("## Impacted Relationships");
lines.push("");
for (const edge of ctx.impactedEdges) {
lines.push(`- ${edge.source} --[${edge.type}]--> ${edge.target}`);
}
lines.push("");
}
if (ctx.unmappedFiles.length > 0) {
lines.push("## Unmapped Files");
lines.push("");
lines.push("These changed files are not yet in the knowledge graph:");
lines.push("");
for (const f of ctx.unmappedFiles) {
lines.push(`- \`${f}\``);
}
lines.push("");
}
lines.push("## Risk Assessment");
lines.push("");
const complexChanges = ctx.changedNodes.filter(
(n) => n.complexity === "complex",
);
const crossLayerCount = new Set(ctx.affectedLayers.map((l) => l.id)).size;
if (complexChanges.length > 0) {
lines.push(
`- **High complexity**: ${complexChanges.length} complex component(s) changed: ${complexChanges.map((n) => n.name).join(", ")}`,
);
}
if (crossLayerCount > 1) {
lines.push(
`- **Cross-layer impact**: Changes span ${crossLayerCount} architectural layers`,
);
}
if (ctx.affectedNodes.length > 5) {
lines.push(
`- **Wide blast radius**: ${ctx.affectedNodes.length} components affected downstream`,
);
}
if (ctx.unmappedFiles.length > 0) {
lines.push(
`- **New/unmapped files**: ${ctx.unmappedFiles.length} files not in the knowledge graph (may need re-analysis)`,
);
}
if (
complexChanges.length === 0 &&
crossLayerCount <= 1 &&
ctx.affectedNodes.length <= 5 &&
ctx.unmappedFiles.length === 0
) {
lines.push(
"- **Low risk**: Changes are localized with limited downstream impact.",
);
}
lines.push("");
return lines.join("\n");
}

View File

@@ -0,0 +1,196 @@
import type {
KnowledgeGraph,
GraphNode,
GraphEdge,
Layer,
} from "@understand-anything/core";
export interface ExplainContext {
projectName: string;
path: string;
targetNode: GraphNode | null;
childNodes: GraphNode[];
connectedNodes: GraphNode[];
relevantEdges: GraphEdge[];
layer: Layer | null;
}
/**
* Build a context for explaining a specific file or function.
* Supports file paths ("src/auth.ts") and path:function ("src/auth.ts:login").
*/
export function buildExplainContext(
graph: KnowledgeGraph,
path: string,
): ExplainContext {
const { nodes, edges, layers } = graph;
let targetNode: GraphNode | null = null;
// Check for path:function format (e.g. "src/auth.ts:login")
const colonIdx = path.lastIndexOf(":");
if (colonIdx > 0 && !path.includes("://")) {
const filePath = path.slice(0, colonIdx);
const funcName = path.slice(colonIdx + 1);
targetNode =
nodes.find(
(n) => n.filePath === filePath && n.name === funcName,
) ?? null;
}
// Fall back to file path match
if (!targetNode) {
targetNode = nodes.find((n) => n.filePath === path) ?? null;
}
if (!targetNode) {
return {
projectName: graph.project.name,
path,
targetNode: null,
childNodes: [],
connectedNodes: [],
relevantEdges: [],
layer: null,
};
}
// Find child nodes (contained by this node via "contains" edges)
const childNodes = nodes.filter((n) =>
edges.some(
(e) =>
e.source === targetNode!.id &&
e.target === n.id &&
e.type === "contains",
),
);
const allRelatedIds = new Set([
targetNode.id,
...childNodes.map((n) => n.id),
]);
// Find connected nodes (1-hop neighbors, excluding children and self)
const connectedIds = new Set<string>();
const relevantEdges: GraphEdge[] = [];
for (const edge of edges) {
if (allRelatedIds.has(edge.source) || allRelatedIds.has(edge.target)) {
relevantEdges.push(edge);
if (allRelatedIds.has(edge.source) && !allRelatedIds.has(edge.target)) {
connectedIds.add(edge.target);
}
if (allRelatedIds.has(edge.target) && !allRelatedIds.has(edge.source)) {
connectedIds.add(edge.source);
}
}
}
const connectedNodes = nodes.filter((n) => connectedIds.has(n.id));
const layer =
layers.find((l) => l.nodeIds.includes(targetNode!.id)) ?? null;
return {
projectName: graph.project.name,
path,
targetNode,
childNodes,
connectedNodes,
relevantEdges,
layer,
};
}
/**
* Format the explain context as a structured prompt for LLM consumption.
*/
export function formatExplainPrompt(ctx: ExplainContext): string {
if (!ctx.targetNode) {
return [
`# Component Not Found`,
``,
`The path "${ctx.path}" was not found in the knowledge graph for ${ctx.projectName}.`,
``,
`Possible reasons:`,
`- The file hasn't been analyzed yet — try running /understand first`,
`- The path may be different in the graph — check the exact file path`,
`- The file may have been deleted or renamed since the last analysis`,
].join("\n");
}
const { targetNode, childNodes, connectedNodes, relevantEdges, layer } = ctx;
const lines: string[] = [];
lines.push(`# Deep Dive: ${targetNode.name}`);
lines.push("");
lines.push(
`**Type:** ${targetNode.type} | **Complexity:** ${targetNode.complexity}`,
);
if (targetNode.filePath)
lines.push(`**File:** \`${targetNode.filePath}\``);
if (targetNode.lineRange)
lines.push(
`**Lines:** ${targetNode.lineRange[0]}-${targetNode.lineRange[1]}`,
);
lines.push("");
lines.push(`**Summary:** ${targetNode.summary}`);
lines.push("");
if (layer) {
lines.push(`## Architectural Layer: ${layer.name}`);
lines.push(layer.description);
lines.push("");
}
if (childNodes.length > 0) {
lines.push("## Internal Components");
for (const child of childNodes) {
lines.push(`- **${child.name}** (${child.type}): ${child.summary}`);
}
lines.push("");
}
if (connectedNodes.length > 0) {
lines.push("## Connected Components");
for (const node of connectedNodes) {
lines.push(`- **${node.name}** (${node.type}): ${node.summary}`);
}
lines.push("");
}
if (relevantEdges.length > 0) {
const nodeMap = new Map(
[...[targetNode], ...childNodes, ...connectedNodes].map((n) => [
n.id,
n,
]),
);
lines.push("## Relationships");
for (const edge of relevantEdges) {
if (edge.type === "contains") continue;
const src = nodeMap.get(edge.source)?.name ?? edge.source;
const tgt = nodeMap.get(edge.target)?.name ?? edge.target;
const desc = edge.description ? `${edge.description}` : "";
lines.push(`- ${src} --[${edge.type}]--> ${tgt}${desc}`);
}
lines.push("");
}
if (targetNode.languageNotes) {
lines.push("## Language Notes");
lines.push(targetNode.languageNotes);
lines.push("");
}
lines.push("## Instructions");
lines.push("Provide a thorough explanation of this component:");
lines.push("1. What it does and why it exists in the project");
lines.push("2. How data flows through it (inputs, processing, outputs)");
lines.push("3. How it interacts with connected components");
lines.push("4. Any patterns, idioms, or design decisions worth noting");
lines.push("5. Potential gotchas or areas of complexity");
lines.push("");
return lines.join("\n");
}

View File

@@ -0,0 +1,17 @@
export {
buildChatContext,
formatContextForPrompt,
type ChatContext,
} from "./context-builder.js";
export { buildChatPrompt } from "./understand-chat.js";
export {
buildDiffContext,
formatDiffAnalysis,
type DiffContext,
} from "./diff-analyzer.js";
export {
buildExplainContext,
formatExplainPrompt,
type ExplainContext,
} from "./explain-builder.js";
export { buildOnboardingGuide } from "./onboard-builder.js";

View File

@@ -0,0 +1,124 @@
import type { KnowledgeGraph } from "@understand-anything/core";
/**
* Generate a structured onboarding guide from the knowledge graph.
* Output is standalone markdown suitable for a README, wiki, or docs.
*/
export function buildOnboardingGuide(graph: KnowledgeGraph): string {
const { project, nodes, edges, layers, tour } = graph;
const lines: string[] = [];
// --- Project Overview ---
lines.push(`# ${project.name}`);
lines.push("");
lines.push(`> ${project.description}`);
lines.push("");
lines.push(`| | |`);
lines.push(`|---|---|`);
lines.push(`| **Languages** | ${project.languages.join(", ")} |`);
lines.push(`| **Frameworks** | ${project.frameworks.join(", ")} |`);
lines.push(`| **Components** | ${nodes.length} nodes, ${edges.length} relationships |`);
lines.push(`| **Last Analyzed** | ${project.analyzedAt} |`);
lines.push("");
// --- Architecture ---
if (layers.length > 0) {
lines.push("## Architecture");
lines.push("");
lines.push("The project is organized into the following layers:");
lines.push("");
for (const layer of layers) {
const memberNames = layer.nodeIds
.map((id) => nodes.find((n) => n.id === id)?.name)
.filter(Boolean);
lines.push(`### ${layer.name}`);
lines.push("");
lines.push(layer.description);
lines.push("");
if (memberNames.length > 0) {
lines.push(`Key components: ${memberNames.join(", ")}`);
lines.push("");
}
}
}
// --- Key Concepts ---
const conceptNodes = nodes.filter((n) => n.type === "concept");
if (conceptNodes.length > 0) {
lines.push("## Key Concepts");
lines.push("");
lines.push("Important architectural and domain concepts to understand:");
lines.push("");
for (const concept of conceptNodes) {
lines.push(`### ${concept.name}`);
lines.push("");
lines.push(concept.summary);
lines.push("");
}
}
// --- Getting Started (Tour) ---
if (tour.length > 0) {
lines.push("## Getting Started");
lines.push("");
lines.push("Follow this guided tour to understand the codebase:");
lines.push("");
for (const step of tour) {
const stepNodes = step.nodeIds
.map((id) => nodes.find((n) => n.id === id))
.filter(Boolean);
lines.push(`### ${step.order}. ${step.title}`);
lines.push("");
lines.push(step.description);
lines.push("");
if (stepNodes.length > 0) {
lines.push("**Files to look at:**");
for (const node of stepNodes) {
if (node!.filePath) {
lines.push(`- \`${node!.filePath}\`${node!.summary}`);
}
}
lines.push("");
}
if (step.languageLesson) {
lines.push(`> **Language Tip:** ${step.languageLesson}`);
lines.push("");
}
}
}
// --- File Map ---
const fileNodes = nodes.filter((n) => n.type === "file" && n.filePath);
if (fileNodes.length > 0) {
lines.push("## File Map");
lines.push("");
lines.push("| File | Purpose | Complexity |");
lines.push("|------|---------|------------|");
for (const node of fileNodes) {
lines.push(`| \`${node.filePath}\` | ${node.summary} | ${node.complexity} |`);
}
lines.push("");
}
// --- Complexity Hotspots ---
const complexNodes = nodes.filter((n) => n.complexity === "complex");
if (complexNodes.length > 0) {
lines.push("## Complexity Hotspots");
lines.push("");
lines.push("These components are the most complex and deserve extra attention:");
lines.push("");
for (const node of complexNodes) {
lines.push(`- **${node.name}** (${node.type}): ${node.summary}`);
}
lines.push("");
}
// --- Footer ---
lines.push("---");
lines.push("");
lines.push(`*Generated by [Understand Anything](https://github.com/Lum1104/Understand-Anything) from knowledge graph v${graph.version}*`);
lines.push("");
return lines.join("\n");
}

View File

@@ -0,0 +1,29 @@
import type { KnowledgeGraph } from "@understand-anything/core";
import { buildChatContext, formatContextForPrompt } from "./context-builder.js";
/**
* Build a complete chat prompt by combining knowledge graph context
* with a system instruction for answering codebase questions.
*/
export function buildChatPrompt(
graph: KnowledgeGraph,
query: string,
): string {
const context = buildChatContext(graph, query);
const formattedContext = formatContextForPrompt(context);
return [
"You are a knowledgeable assistant that answers questions about a software codebase.",
"Use the following knowledge graph context to inform your answer.",
"Reference specific files, functions, classes, and relationships from the graph.",
"If layers are present, explain which architectural layer(s) are relevant.",
"Be concise but thorough — link concepts to actual code locations.",
"",
"---",
"",
formattedContext,
"---",
"",
`**User question:** ${query}`,
].join("\n");
}