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);
});
});