Add under-anything knowledge dashboard
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user