diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6372323..c67965a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,8 +9,8 @@ { "name": "understand-anything", "description": "Multi-agent codebase analysis with interactive dashboard, guided tours, and skill commands", - "version": "1.2.0", + "version": "2.0.0", "source": "./understand-anything-plugin" } ] -} \ No newline at end of file +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b496896..08d46de 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "1.2.0", + "version": "2.0.0", "author": { "name": "Lum1104" }, @@ -15,4 +15,4 @@ "onboarding", "dashboard" ] -} \ No newline at end of file +} diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 006ddb2..c1a0f00 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "understand-anything", "displayName": "Understand Anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "1.2.0", + "version": "2.0.0", "author": { "name": "Lum1104" }, diff --git a/.gitignore b/.gitignore index fafe0f1..5d78e4f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ dist .env.* coverage/ *.log +.claude/ +.worktrees/ diff --git a/README.md b/README.md index ea6d0ff..999fb49 100644 --- a/README.md +++ b/README.md @@ -237,9 +237,9 @@ The `/understand` command orchestrates 5 specialized agents: | `file-analyzer` | Extract functions, classes, imports; produce graph nodes and edges | | `architecture-analyzer` | Identify architectural layers | | `tour-builder` | Generate guided learning tours | -| `graph-reviewer` | Validate graph completeness and referential integrity | +| `graph-reviewer` | Validate graph completeness and referential integrity (runs inline by default; use `--review` for full LLM review) | -File analyzers run in parallel (up to 3 concurrent). Supports incremental updates — only re-analyzes files that changed since the last run. +File analyzers run in parallel (up to 5 concurrent, 20-30 files per batch). Supports incremental updates — only re-analyzes files that changed since the last run. ### Project Structure diff --git a/docs/plans/2026-03-25-dashboard-robustness-impl.md b/docs/plans/2026-03-25-dashboard-robustness-impl.md new file mode 100644 index 0000000..0fe6d09 --- /dev/null +++ b/docs/plans/2026-03-25-dashboard-robustness-impl.md @@ -0,0 +1,1277 @@ +# Dashboard Robustness Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make the dashboard resilient to imperfect LLM-generated knowledge graphs by auto-fixing recoverable issues, dropping broken items, and showing user-friendly amber warnings with copy-paste-friendly error reports. + +**Architecture:** Three-layer pipeline in `schema.ts`: sanitize (Tier 1 silent) → auto-fix (Tier 2 tracked) → per-item validate (Tier 3 drop) → fatal gate (Tier 4). New `WarningBanner` component in dashboard displays categorized issues with copy button. + +**Tech Stack:** Zod (validation), React + TailwindCSS (dashboard UI), Vitest (testing) + +--- + +### Task 1: Add GraphIssue type and sanitizeGraph (Tier 1) + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/schema.ts:95-99` +- Test: `understand-anything-plugin/packages/core/src/__tests__/schema.test.ts` + +**Step 1: Write the failing tests for sanitizeGraph** + +Add to the end of `schema.test.ts`, before the closing `});`: + +```typescript +describe("sanitizeGraph", () => { + it("converts null optional node fields to undefined", () => { + const graph = structuredClone(validGraph); + (graph.nodes[0] as any).filePath = null; + (graph.nodes[0] as any).lineRange = null; + (graph.nodes[0] as any).languageNotes = null; + + const result = sanitizeGraph(graph as any); + const node = (result as any).nodes[0]; + expect(node.filePath).toBeUndefined(); + expect(node.lineRange).toBeUndefined(); + expect(node.languageNotes).toBeUndefined(); + }); + + it("converts null optional edge fields to undefined", () => { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).description = null; + + const result = sanitizeGraph(graph as any); + const edge = (result as any).edges[0]; + expect(edge.description).toBeUndefined(); + }); + + it("lowercases enum-like strings on nodes", () => { + const graph = structuredClone(validGraph); + (graph.nodes[0] as any).type = "FILE"; + (graph.nodes[0] as any).complexity = "Simple"; + + const result = sanitizeGraph(graph as any); + const node = (result as any).nodes[0]; + expect(node.type).toBe("file"); + expect(node.complexity).toBe("simple"); + }); + + it("lowercases enum-like strings on edges", () => { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).type = "IMPORTS"; + (graph.edges[0] as any).direction = "Forward"; + + const result = sanitizeGraph(graph as any); + const edge = (result as any).edges[0]; + expect(edge.type).toBe("imports"); + expect(edge.direction).toBe("forward"); + }); + + it("converts null tour/layers to empty arrays", () => { + const graph = structuredClone(validGraph); + (graph as any).tour = null; + (graph as any).layers = null; + + const result = sanitizeGraph(graph as any); + expect((result as any).tour).toEqual([]); + expect((result as any).layers).toEqual([]); + }); + + it("converts null optional tour step fields to undefined", () => { + const graph = structuredClone(validGraph); + (graph.tour[0] as any).languageLesson = null; + + const result = sanitizeGraph(graph as any); + expect((result as any).tour[0].languageLesson).toBeUndefined(); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @understand-anything/core test` +Expected: FAIL — `sanitizeGraph` is not exported + +**Step 3: Add GraphIssue type and update ValidationResult** + +In `schema.ts`, replace the `ValidationResult` interface (lines 95-99) with: + +```typescript +export interface GraphIssue { + level: "auto-corrected" | "dropped" | "fatal"; + category: string; + message: string; + path?: string; +} + +export interface ValidationResult { + success: boolean; + data?: z.infer; + issues: GraphIssue[]; + fatal?: string; + /** @deprecated Use issues/fatal instead */ + errors?: string[]; +} +``` + +**Step 4: Implement sanitizeGraph** + +Add after the alias maps (after line 39), before `GraphNodeSchema`: + +```typescript +export function sanitizeGraph(data: Record): Record { + const result = { ...data }; + + // Null → empty array for top-level collections + if (data.tour === null || data.tour === undefined) result.tour = []; + if (data.layers === null || data.layers === undefined) result.layers = []; + + // Sanitize nodes + if (Array.isArray(data.nodes)) { + result.nodes = (data.nodes as Record[]).map((node) => { + if (typeof node !== "object" || node === null) return node; + const n = { ...node }; + // Null → undefined for optional fields + if (n.filePath === null) delete n.filePath; + if (n.lineRange === null) delete n.lineRange; + if (n.languageNotes === null) delete n.languageNotes; + // Lowercase enum-like strings + if (typeof n.type === "string") n.type = n.type.toLowerCase(); + if (typeof n.complexity === "string") n.complexity = n.complexity.toLowerCase(); + return n; + }); + } + + // Sanitize edges + if (Array.isArray(data.edges)) { + result.edges = (data.edges as Record[]).map((edge) => { + if (typeof edge !== "object" || edge === null) return edge; + const e = { ...edge }; + if (e.description === null) delete e.description; + if (typeof e.type === "string") e.type = e.type.toLowerCase(); + if (typeof e.direction === "string") e.direction = e.direction.toLowerCase(); + return e; + }); + } + + // Sanitize tour steps + if (Array.isArray(result.tour)) { + result.tour = (result.tour as Record[]).map((step) => { + if (typeof step !== "object" || step === null) return step; + const s = { ...step }; + if (s.languageLesson === null) delete s.languageLesson; + return s; + }); + } + + return result; +} +``` + +**Step 5: Update imports in test file** + +Update the import line in `schema.test.ts`: + +```typescript +import { + validateGraph, + normalizeGraph, + sanitizeGraph, + NODE_TYPE_ALIASES, + EDGE_TYPE_ALIASES, +} from "../schema.js"; +``` + +**Step 6: Run tests to verify they pass** + +Run: `pnpm --filter @understand-anything/core test` +Expected: All sanitizeGraph tests PASS. Existing tests still PASS. + +**Step 7: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/schema.ts understand-anything-plugin/packages/core/src/__tests__/schema.test.ts +git commit -m "feat(core): add GraphIssue type and sanitizeGraph (Tier 1 silent fixes)" +``` + +--- + +### Task 2: Add auto-fix maps and autoFixGraph (Tier 2) + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/schema.ts` +- Test: `understand-anything-plugin/packages/core/src/__tests__/schema.test.ts` + +**Step 1: Write the failing tests** + +Add to `schema.test.ts`, before the closing `});`: + +```typescript +describe("autoFixGraph", () => { + it("defaults missing complexity to moderate with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.nodes[0] as any).complexity; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).nodes[0].complexity).toBe("moderate"); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].complexity" }) + ); + }); + + it("maps complexity aliases with issue", () => { + const graph = structuredClone(validGraph); + (graph.nodes[0] as any).complexity = "low"; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).nodes[0].complexity).toBe("simple"); + expect(issues.length).toBe(1); + expect(issues[0].level).toBe("auto-corrected"); + }); + + it("maps all complexity aliases correctly", () => { + const mapping: Record = { + low: "simple", easy: "simple", + medium: "moderate", intermediate: "moderate", + high: "complex", hard: "complex", difficult: "complex", + }; + for (const [alias, expected] of Object.entries(mapping)) { + const graph = structuredClone(validGraph); + (graph.nodes[0] as any).complexity = alias; + const { data } = autoFixGraph(graph as any); + expect((data as any).nodes[0].complexity).toBe(expected); + } + }); + + it("defaults missing tags to empty array with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.nodes[0] as any).tags; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).nodes[0].tags).toEqual([]); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].tags" }) + ); + }); + + it("defaults missing summary to node name with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.nodes[0] as any).summary; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).nodes[0].summary).toBe("index.ts"); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].summary" }) + ); + }); + + it("defaults missing node type to file with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.nodes[0] as any).type; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).nodes[0].type).toBe("file"); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].type" }) + ); + }); + + it("defaults missing direction to forward with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.edges[0] as any).direction; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).edges[0].direction).toBe("forward"); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "edges[0].direction" }) + ); + }); + + it("maps direction aliases with issue", () => { + const mapping: Record = { + to: "forward", outbound: "forward", + from: "backward", inbound: "backward", + both: "bidirectional", mutual: "bidirectional", + }; + for (const [alias, expected] of Object.entries(mapping)) { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).direction = alias; + const { data } = autoFixGraph(graph as any); + expect((data as any).edges[0].direction).toBe(expected); + } + }); + + it("defaults missing weight to 0.5 with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.edges[0] as any).weight; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).edges[0].weight).toBe(0.5); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "edges[0].weight" }) + ); + }); + + it("coerces string weight to number with issue", () => { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).weight = "0.8"; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).edges[0].weight).toBe(0.8); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "type-coercion", path: "edges[0].weight" }) + ); + }); + + it("clamps out-of-range weight with issue", () => { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).weight = 1.5; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).edges[0].weight).toBe(1); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "out-of-range", path: "edges[0].weight" }) + ); + }); + + it("defaults missing edge type to depends_on with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.edges[0] as any).type; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).edges[0].type).toBe("depends_on"); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "edges[0].type" }) + ); + }); + + it("returns no issues for a valid graph", () => { + const { issues } = autoFixGraph(validGraph as any); + expect(issues).toEqual([]); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @understand-anything/core test` +Expected: FAIL — `autoFixGraph` is not exported + +**Step 3: Implement alias maps and autoFixGraph** + +Add to `schema.ts` after the existing `EDGE_TYPE_ALIASES` map (after line 39): + +```typescript +export const COMPLEXITY_ALIASES: Record = { + low: "simple", + easy: "simple", + medium: "moderate", + intermediate: "moderate", + high: "complex", + hard: "complex", + difficult: "complex", +}; + +export const DIRECTION_ALIASES: Record = { + to: "forward", + outbound: "forward", + from: "backward", + inbound: "backward", + both: "bidirectional", + mutual: "bidirectional", +}; +``` + +Add `autoFixGraph` function after `sanitizeGraph`: + +```typescript +export function autoFixGraph(data: Record): { + data: Record; + issues: GraphIssue[]; +} { + const issues: GraphIssue[] = []; + const result = { ...data }; + + if (Array.isArray(data.nodes)) { + result.nodes = (data.nodes as Record[]).map((node, i) => { + if (typeof node !== "object" || node === null) return node; + const n = { ...node }; + const name = (n.name as string) || (n.id as string) || `index ${i}`; + + // Missing or empty type + if (!n.type || typeof n.type !== "string") { + n.type = "file"; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `nodes[${i}] ("${name}"): missing "type" — defaulted to "file"`, + path: `nodes[${i}].type`, + }); + } + + // Missing or empty complexity + if (!n.complexity || n.complexity === "") { + n.complexity = "moderate"; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `nodes[${i}] ("${name}"): missing "complexity" — defaulted to "moderate"`, + path: `nodes[${i}].complexity`, + }); + } else if (typeof n.complexity === "string" && n.complexity in COMPLEXITY_ALIASES) { + const original = n.complexity; + n.complexity = COMPLEXITY_ALIASES[n.complexity]; + issues.push({ + level: "auto-corrected", + category: "alias", + message: `nodes[${i}] ("${name}"): complexity "${original}" — mapped to "${n.complexity}"`, + path: `nodes[${i}].complexity`, + }); + } + + // Missing tags + if (!Array.isArray(n.tags)) { + n.tags = []; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `nodes[${i}] ("${name}"): missing "tags" — defaulted to []`, + path: `nodes[${i}].tags`, + }); + } + + // Missing summary + if (!n.summary || typeof n.summary !== "string") { + n.summary = (n.name as string) || "No summary"; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `nodes[${i}] ("${name}"): missing "summary" — defaulted to name`, + path: `nodes[${i}].summary`, + }); + } + + return n; + }); + } + + if (Array.isArray(data.edges)) { + result.edges = (data.edges as Record[]).map((edge, i) => { + if (typeof edge !== "object" || edge === null) return edge; + const e = { ...edge }; + + // Missing type + if (!e.type || typeof e.type !== "string") { + e.type = "depends_on"; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `edges[${i}]: missing "type" — defaulted to "depends_on"`, + path: `edges[${i}].type`, + }); + } + + // Missing direction + if (!e.direction || typeof e.direction !== "string") { + e.direction = "forward"; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `edges[${i}]: missing "direction" — defaulted to "forward"`, + path: `edges[${i}].direction`, + }); + } else if (e.direction in DIRECTION_ALIASES) { + const original = e.direction; + e.direction = DIRECTION_ALIASES[e.direction as string]; + issues.push({ + level: "auto-corrected", + category: "alias", + message: `edges[${i}]: direction "${original}" — mapped to "${e.direction}"`, + path: `edges[${i}].direction`, + }); + } + + // Missing weight + if (e.weight === undefined || e.weight === null) { + e.weight = 0.5; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `edges[${i}]: missing "weight" — defaulted to 0.5`, + path: `edges[${i}].weight`, + }); + } else if (typeof e.weight === "string") { + const parsed = parseFloat(e.weight as string); + if (!isNaN(parsed)) { + const original = e.weight; + e.weight = parsed; + issues.push({ + level: "auto-corrected", + category: "type-coercion", + message: `edges[${i}]: weight was string "${original}" — coerced to number`, + path: `edges[${i}].weight`, + }); + } + } + + // Clamp weight to [0, 1] + if (typeof e.weight === "number" && (e.weight < 0 || e.weight > 1)) { + const original = e.weight; + e.weight = Math.max(0, Math.min(1, e.weight)); + issues.push({ + level: "auto-corrected", + category: "out-of-range", + message: `edges[${i}]: weight ${original} clamped to ${e.weight}`, + path: `edges[${i}].weight`, + }); + } + + return e; + }); + } + + return { data: result, issues }; +} +``` + +**Step 4: Update imports in test file** + +```typescript +import { + validateGraph, + normalizeGraph, + sanitizeGraph, + autoFixGraph, + NODE_TYPE_ALIASES, + EDGE_TYPE_ALIASES, +} from "../schema.js"; +``` + +**Step 5: Run tests to verify they pass** + +Run: `pnpm --filter @understand-anything/core test` +Expected: All new autoFixGraph tests PASS. Existing tests still PASS. + +**Step 6: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/schema.ts understand-anything-plugin/packages/core/src/__tests__/schema.test.ts +git commit -m "feat(core): add autoFixGraph with complexity/direction aliases and default values (Tier 2)" +``` + +--- + +### Task 3: Rewrite validateGraph to be permissive (Tier 3 + 4) + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/schema.ts:138-151` +- Test: `understand-anything-plugin/packages/core/src/__tests__/schema.test.ts` + +**Step 1: Write the failing tests for permissive validation** + +Add to `schema.test.ts`: + +```typescript +describe("permissive validation", () => { + it("drops nodes missing id with dropped issue", () => { + const graph = structuredClone(validGraph); + delete (graph.nodes[0] as any).id; + // Add a second valid node so graph isn't fatal + graph.nodes.push({ + id: "node-2", type: "file", name: "other.ts", + summary: "Other file", tags: ["util"], complexity: "simple", + }); + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.nodes.length).toBe(1); + expect(result.data!.nodes[0].id).toBe("node-2"); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "dropped", category: "invalid-node" }) + ); + }); + + it("drops edges referencing non-existent nodes with dropped issue", () => { + const graph = structuredClone(validGraph); + graph.edges[0].target = "non-existent-node"; + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.edges.length).toBe(0); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "dropped", category: "invalid-reference" }) + ); + }); + + it("returns fatal when 0 valid nodes remain", () => { + const graph = structuredClone(validGraph); + delete (graph.nodes[0] as any).id; + + const result = validateGraph(graph); + expect(result.success).toBe(false); + expect(result.fatal).toContain("No valid nodes"); + }); + + it("returns fatal when project metadata is missing", () => { + const graph = structuredClone(validGraph); + delete (graph as any).project; + + const result = validateGraph(graph); + expect(result.success).toBe(false); + expect(result.fatal).toContain("project metadata"); + }); + + it("returns fatal when input is not an object", () => { + const result = validateGraph("not an object"); + expect(result.success).toBe(false); + expect(result.fatal).toContain("Invalid input"); + }); + + it("loads graph with mixed good and bad nodes", () => { + const graph = structuredClone(validGraph); + // Add a good node + graph.nodes.push({ + id: "node-2", type: "function", name: "doThing", + summary: "Does a thing", tags: ["util"], complexity: "moderate", + }); + // Add a bad node (missing id AND name — unrecoverable) + (graph.nodes as any[]).push({ type: "file", summary: "broken" }); + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.nodes.length).toBe(2); + expect(result.issues.some((i) => i.level === "dropped")).toBe(true); + }); + + it("filters dangling nodeIds from layers", () => { + const graph = structuredClone(validGraph); + graph.layers[0].nodeIds.push("non-existent-node"); + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.layers[0].nodeIds).toEqual(["node-1"]); + }); + + it("filters dangling nodeIds from tour steps", () => { + const graph = structuredClone(validGraph); + graph.tour[0].nodeIds.push("non-existent-node"); + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.tour[0].nodeIds).toEqual(["node-1"]); + }); + + it("returns empty issues array for a perfect graph", () => { + const result = validateGraph(validGraph); + expect(result.success).toBe(true); + expect(result.issues).toEqual([]); + }); + + it("auto-corrects and loads graph that would have failed strict validation", () => { + // Graph with many Tier 2 issues: missing complexity, weight as string, null filePath + const messy = { + version: "1.0.0", + project: validGraph.project, + nodes: [{ + id: "n1", type: "FILE", name: "app.ts", + filePath: null, summary: "App entry", + tags: null, complexity: "HIGH", + }], + edges: [{ + source: "n1", target: "n1", type: "CALLS", + direction: "TO", weight: "0.9", + }], + layers: [{ id: "l1", name: "Core", description: "Core", nodeIds: ["n1"] }], + tour: [], + }; + + const result = validateGraph(messy); + expect(result.success).toBe(true); + expect(result.data!.nodes[0].complexity).toBe("complex"); + expect(result.data!.nodes[0].tags).toEqual([]); + expect(result.data!.edges[0].weight).toBe(0.9); + expect(result.data!.edges[0].direction).toBe("forward"); + expect(result.issues.length).toBeGreaterThan(0); + expect(result.issues.every((i) => i.level === "auto-corrected")).toBe(true); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @understand-anything/core test` +Expected: FAIL — `validateGraph` doesn't return `issues` or `fatal` + +**Step 3: Rewrite validateGraph** + +Replace the existing `validateGraph` function in `schema.ts` (lines 138-151) with: + +```typescript +export function validateGraph(data: unknown): ValidationResult { + // Tier 4: Fatal — not even an object + if (typeof data !== "object" || data === null) { + return { success: false, issues: [], fatal: "Invalid input: not an object" }; + } + + const raw = data as Record; + + // Tier 1: Sanitize + const sanitized = sanitizeGraph(raw); + + // Existing: Normalize type aliases + const normalized = normalizeGraph(sanitized) as Record; + + // Tier 2: Auto-fix defaults and coercion + const { data: fixed, issues } = autoFixGraph( + normalized as Record, + ); + + // Tier 4: Fatal — missing project metadata + const projectResult = ProjectMetaSchema.safeParse(fixed.project); + if (!projectResult.success) { + return { + success: false, + issues, + fatal: "Missing or invalid project metadata", + }; + } + + // Tier 3: Validate nodes individually, drop broken + const validNodes: z.infer[] = []; + if (Array.isArray(fixed.nodes)) { + for (let i = 0; i < fixed.nodes.length; i++) { + const node = fixed.nodes[i] as Record; + const result = GraphNodeSchema.safeParse(node); + if (result.success) { + validNodes.push(result.data); + } else { + const name = node?.name || node?.id || `index ${i}`; + issues.push({ + level: "dropped", + category: "invalid-node", + message: `nodes[${i}] ("${name}"): ${result.error.issues[0]?.message ?? "validation failed"} — removed`, + path: `nodes[${i}]`, + }); + } + } + } + + // Tier 4: Fatal — no valid nodes + if (validNodes.length === 0) { + return { + success: false, + issues, + fatal: "No valid nodes found in knowledge graph", + }; + } + + // Tier 3: Validate edges + referential integrity + const nodeIds = new Set(validNodes.map((n) => n.id)); + const validEdges: z.infer[] = []; + if (Array.isArray(fixed.edges)) { + for (let i = 0; i < fixed.edges.length; i++) { + const edge = fixed.edges[i] as Record; + const result = GraphEdgeSchema.safeParse(edge); + if (!result.success) { + issues.push({ + level: "dropped", + category: "invalid-edge", + message: `edges[${i}]: ${result.error.issues[0]?.message ?? "validation failed"} — removed`, + path: `edges[${i}]`, + }); + continue; + } + if (!nodeIds.has(result.data.source)) { + issues.push({ + level: "dropped", + category: "invalid-reference", + message: `edges[${i}]: source "${result.data.source}" does not exist in nodes — removed`, + path: `edges[${i}].source`, + }); + continue; + } + if (!nodeIds.has(result.data.target)) { + issues.push({ + level: "dropped", + category: "invalid-reference", + message: `edges[${i}]: target "${result.data.target}" does not exist in nodes — removed`, + path: `edges[${i}].target`, + }); + continue; + } + validEdges.push(result.data); + } + } + + // Validate layers (drop broken, filter dangling nodeIds) + const validLayers: z.infer[] = []; + if (Array.isArray(fixed.layers)) { + for (let i = 0; i < (fixed.layers as unknown[]).length; i++) { + const result = LayerSchema.safeParse((fixed.layers as unknown[])[i]); + if (result.success) { + validLayers.push({ + ...result.data, + nodeIds: result.data.nodeIds.filter((id) => nodeIds.has(id)), + }); + } else { + issues.push({ + level: "dropped", + category: "invalid-layer", + message: `layers[${i}]: ${result.error.issues[0]?.message ?? "validation failed"} — removed`, + path: `layers[${i}]`, + }); + } + } + } + + // Validate tour steps (drop broken, filter dangling nodeIds) + const validTour: z.infer[] = []; + if (Array.isArray(fixed.tour)) { + for (let i = 0; i < (fixed.tour as unknown[]).length; i++) { + const result = TourStepSchema.safeParse((fixed.tour as unknown[])[i]); + if (result.success) { + validTour.push({ + ...result.data, + nodeIds: result.data.nodeIds.filter((id) => nodeIds.has(id)), + }); + } else { + issues.push({ + level: "dropped", + category: "invalid-tour-step", + message: `tour[${i}]: ${result.error.issues[0]?.message ?? "validation failed"} — removed`, + path: `tour[${i}]`, + }); + } + } + } + + const graph = { + version: typeof fixed.version === "string" ? fixed.version : "1.0.0", + project: projectResult.data, + nodes: validNodes, + edges: validEdges, + layers: validLayers, + tour: validTour, + }; + + return { success: true, data: graph, issues }; +} +``` + +**Step 4: Run tests to verify new tests pass** + +Run: `pnpm --filter @understand-anything/core test` +Expected: New permissive tests PASS. Some old tests may now fail (expected — handled in Task 4). + +**Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/schema.ts understand-anything-plugin/packages/core/src/__tests__/schema.test.ts +git commit -m "feat(core): rewrite validateGraph for permissive per-item validation (Tier 3+4)" +``` + +--- + +### Task 4: Update existing tests for new permissive behavior + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/__tests__/schema.test.ts` + +The new permissive validation changes behavior for several existing tests. Here's what changes: + +| Test | Old behavior | New behavior | +|------|-------------|-------------| +| "validates a correct graph" | `success: true, errors: undefined` | `success: true, issues: []` | +| "rejects missing required fields" | `success: false, errors` | `success: false, fatal` (missing project) | +| "rejects node with invalid type" | `success: false, errors` | `success: false, fatal` (0 valid nodes after drop) | +| "rejects edge with invalid EdgeType" | `success: false, errors` | `success: true` (edge dropped, node valid) | +| "rejects weight >1" | `success: false, errors` | `success: true` (weight clamped) | +| "rejects weight <0" | `success: false, errors` | `success: true` (weight clamped) | +| "rejects 'tests' edge type" | `success: false` | `success: true` (edge dropped) | +| "rejects truly invalid edge types" | `success: false` | `success: true` (edge dropped) | + +**Step 1: Update the affected tests** + +Replace the following tests in the `"schema validation"` describe block: + +```typescript +it("validates a correct knowledge graph", () => { + const result = validateGraph(validGraph); + expect(result.success).toBe(true); + expect(result.data).toBeDefined(); + expect(result.data!.version).toBe("1.0.0"); + expect(result.issues).toEqual([]); +}); + +it("rejects graph with missing required fields", () => { + const incomplete = { version: "1.0.0" }; + const result = validateGraph(incomplete); + expect(result.success).toBe(false); + expect(result.fatal).toBeDefined(); +}); + +it("rejects node with invalid type — drops node, fatal if none remain", () => { + const graph = structuredClone(validGraph); + (graph.nodes[0] as any).type = "invalid_type"; + + const result = validateGraph(graph); + expect(result.success).toBe(false); + expect(result.fatal).toContain("No valid nodes"); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "dropped", category: "invalid-node" }) + ); +}); + +it("drops edge with invalid EdgeType but loads graph", () => { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).type = "not_a_real_edge_type"; + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.edges.length).toBe(0); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "dropped", category: "invalid-edge" }) + ); +}); + +it("auto-corrects weight >1 by clamping", () => { + const graph = structuredClone(validGraph); + graph.edges[0].weight = 1.5; + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "out-of-range" }) + ); +}); + +it("auto-corrects weight <0 by clamping", () => { + const graph = structuredClone(validGraph); + graph.edges[0].weight = -0.1; + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "out-of-range" }) + ); +}); +``` + +Also update the "tests" edge type test and "truly invalid edge types" test: + +```typescript +it('drops "tests" edge type — direction-inverting alias is unsafe', () => { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).type = "tests"; + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.edges.length).toBe(0); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "dropped" }) + ); +}); + +it("drops truly invalid edge types after normalization", () => { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).type = "totally_bogus"; + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.edges.length).toBe(0); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "dropped" }) + ); +}); +``` + +**Step 2: Run all tests** + +Run: `pnpm --filter @understand-anything/core test` +Expected: ALL tests PASS + +**Step 3: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/__tests__/schema.test.ts +git commit -m "test(core): update existing tests for permissive validation behavior" +``` + +--- + +### Task 5: Create WarningBanner dashboard component + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/components/WarningBanner.tsx` + +**Step 1: Build core package for dashboard import** + +Run: `pnpm --filter @understand-anything/core build` +Expected: Build succeeds with new exports + +**Step 2: Create WarningBanner component** + +Create `understand-anything-plugin/packages/dashboard/src/components/WarningBanner.tsx`: + +```tsx +import { useState } from "react"; +import type { GraphIssue } from "@understand-anything/core/schema"; + +interface WarningBannerProps { + issues: GraphIssue[]; +} + +export default function WarningBanner({ issues }: WarningBannerProps) { + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + + const autoCorrected = issues.filter((i) => i.level === "auto-corrected"); + const dropped = issues.filter((i) => i.level === "dropped"); + + const summaryParts: string[] = []; + if (autoCorrected.length > 0) { + summaryParts.push( + `${autoCorrected.length} auto-correction${autoCorrected.length > 1 ? "s" : ""}`, + ); + } + if (dropped.length > 0) { + summaryParts.push( + `${dropped.length} dropped item${dropped.length > 1 ? "s" : ""}`, + ); + } + + const copyText = [ + "The following issues were found in your knowledge-graph.json.", + "These are LLM generation errors — not a system bug.", + "You can ask your agent to fix these specific issues in the knowledge-graph.json file:", + "", + ...issues.map( + (i) => + `[${i.level === "auto-corrected" ? "Auto-corrected" : "Dropped"}] ${i.message}`, + ), + ].join("\n"); + + const handleCopy = async () => { + await navigator.clipboard.writeText(copyText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+ + +
+ + {expanded && ( +
+ {autoCorrected.length > 0 && ( + <> +
+ Auto-corrected ({autoCorrected.length}) +
+ {autoCorrected.map((issue, i) => ( +
+ {issue.message} +
+ ))} + + )} + {dropped.length > 0 && ( + <> +
+ Dropped ({dropped.length}) +
+ {dropped.map((issue, i) => ( +
+ {issue.message} +
+ ))} + + )} +

+ These are LLM generation issues, not system bugs. Copy the issues + above and ask your agent to fix them in the knowledge-graph.json, or + re-run{" "} + /understand for a fresh + generation. +

+
+ )} +
+ ); +} +``` + +**Step 3: Verify dashboard builds** + +Run: `pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds (component not yet wired, but should compile) + +**Step 4: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/WarningBanner.tsx +git commit -m "feat(dashboard): add WarningBanner component for graph validation issues" +``` + +--- + +### Task 6: Wire WarningBanner into App.tsx + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/App.tsx` + +**Step 1: Update App.tsx** + +Add import at top of file (after other component imports): + +```typescript +import WarningBanner from "./components/WarningBanner"; +import type { GraphIssue } from "@understand-anything/core/schema"; +``` + +Add state for issues (after `loadError` state, line 26): + +```typescript +const [graphIssues, setGraphIssues] = useState([]); +``` + +Replace the graph loading `useEffect` (lines 119-136) with: + +```typescript +useEffect(() => { + fetch("/knowledge-graph.json") + .then((res) => res.json()) + .then((data: unknown) => { + const result = validateGraph(data); + if (result.success && result.data) { + setGraph(result.data); + setGraphIssues(result.issues); + if (result.issues.length > 0) { + const autoCorrected = result.issues.filter((i) => i.level === "auto-corrected"); + const dropped = result.issues.filter((i) => i.level === "dropped"); + if (autoCorrected.length > 0) console.warn(`[understand-anything] Auto-corrected ${autoCorrected.length} graph issues`); + if (dropped.length > 0) console.error(`[understand-anything] Dropped ${dropped.length} broken graph items`); + } + } else if (result.fatal) { + console.error("Knowledge graph fatal error:", result.fatal); + setLoadError(result.fatal); + } else { + setLoadError("Unknown validation error"); + } + }) + .catch((err) => { + console.error("Failed to load knowledge graph:", err); + setLoadError( + `Failed to load knowledge graph: ${err instanceof Error ? err.message : String(err)}`, + ); + }); +}, [setGraph]); +``` + +Replace the error banner section (lines 213-218) with: + +```tsx +{/* Warning banner for graph issues */} +{graphIssues.length > 0 && !loadError && ( + +)} + +{/* Fatal error banner */} +{loadError && ( +
+ {loadError} +
+)} +``` + +**Step 2: Build and verify** + +Run: `pnpm --filter @understand-anything/core build && pnpm --filter @understand-anything/dashboard build` +Expected: Both builds succeed + +**Step 3: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/App.tsx +git commit -m "feat(dashboard): wire WarningBanner to display graph validation issues" +``` + +--- + +### Task 7: Final verification + +**Step 1: Run all core tests** + +Run: `pnpm --filter @understand-anything/core test` +Expected: ALL tests pass + +**Step 2: Build full pipeline** + +Run: `pnpm --filter @understand-anything/core build && pnpm --filter @understand-anything/dashboard build` +Expected: Both builds succeed with no errors + +**Step 3: Lint** + +Run: `pnpm lint` +Expected: No lint errors in changed files + +**Step 4: Final commit (if any lint fixes needed)** + +```bash +git add -A +git commit -m "chore: lint fixes for dashboard robustness feature" +``` diff --git a/docs/plans/2026-03-25-dashboard-robustness-plan.md b/docs/plans/2026-03-25-dashboard-robustness-plan.md new file mode 100644 index 0000000..16d5c74 --- /dev/null +++ b/docs/plans/2026-03-25-dashboard-robustness-plan.md @@ -0,0 +1,149 @@ +# Design: Dashboard Robustness — Permissive Graph Loading + +## Problem + +When the LLM agent produces a knowledge-graph.json that deviates from the strict Zod schema, the dashboard shows a blank screen with cryptic Zod error paths. Users don't know whether it's a system bug or an agent generation issue, and their only recourse is a full re-run of `/understand`. + +## Goals + +1. **Maximize what the user can see** — load valid nodes/edges even if some are broken +2. **Clearly communicate generation issues** — amber warnings (not red errors) with copy-paste-friendly messages +3. **Empower targeted fixes** — users can copy the issue report and ask their agent to fix specific problems instead of a full re-run + +## Design + +### Three-Layer Robustness Pipeline + +``` +Raw JSON → Sanitize (Tier 1) → Normalize + Auto-fix (Tier 2) → Validate per-item (Tier 3) → Fatal check (Tier 4) → Dashboard +``` + +### Tier 1: Sanitize Silently + +Common LLM quirks that are pure noise — fix without reporting. + +| Issue | Fix | +|-------|-----| +| `null` on optional fields (`filePath`, `lineRange`, `description`, `languageNotes`) | Convert to `undefined` | +| Mixed-case enum strings (`"Forward"`, `"SIMPLE"`) | Lowercase before matching | + +### Tier 2: Auto-fix With Info Notice + +Recoverable issues — apply sensible defaults, track as `auto-corrected` issues. + +| Issue | Default | Notes | +|-------|---------|-------| +| Missing `complexity` | `"moderate"` | Most common LLM omission | +| Missing `tags` | `[]` | Empty is valid | +| Missing `weight` | `0.5` | Middle of 0–1 range | +| `weight` as string | Coerce to number | e.g., `"0.8"` → `0.8` | +| Missing `direction` | `"forward"` | Safe default | +| Missing `summary` | Use node `name` | Better than empty | +| `tour: null` / `layers: null` | `[]` | Null vs empty array | +| Complexity aliases | `low/easy→simple`, `medium/intermediate→moderate`, `high/hard→complex` | | +| Direction aliases | `to/outbound→forward`, `from/inbound→backward`, `both→bidirectional` | | +| Existing node/edge type aliases | Already handled by `normalizeGraph` | No change needed | +| Missing node `type` | `"file"` | Safe fallback | +| Missing edge `type` | `"depends_on"` | Generic fallback | + +### Tier 3: Drop With Warning + +Can't safely guess — remove the item, track as `dropped` issue. + +| Issue | Action | +|-------|--------| +| Edge references non-existent node ID | Drop edge | +| Node missing `id` | Drop node | +| Node missing `name` | Drop node | +| Edge missing `source` or `target` | Drop edge | +| Unrecognizable `type` value (not in canonical or alias list) | Drop item | +| `weight` not coercible to number | Drop edge | + +### Tier 4: Fatal + +Graph is unsalvageable — show red error banner. + +| Condition | Message | +|-----------|---------| +| 0 valid nodes after filtering | "No valid nodes found in knowledge graph" | +| Missing `project` metadata entirely | "Missing project metadata" | +| Input is not an object / not valid JSON | "Invalid input format" | + +### Return Type + +```typescript +interface GraphIssue { + level: 'auto-corrected' | 'dropped' | 'fatal'; + category: string; // e.g., "missing-field", "invalid-reference", "type-coercion" + message: string; // human-readable, copy-paste friendly + path?: string; // e.g., "nodes[3].complexity" +} + +interface ValidationResult { + success: boolean; + data?: KnowledgeGraph; + issues: GraphIssue[]; + fatal?: string; +} +``` + +### Dashboard UI: WarningBanner Component + +**New component** in `packages/dashboard/src/components/WarningBanner.tsx`. + +**Visual design:** +- **Amber/gold theme** — `bg-amber-900/20`, `border-amber-700`, `text-amber-200` +- Matches dashboard's gold accent aesthetic; signals "generation quality issue" not "system crash" +- **Collapsed by default** — summary line: "Knowledge graph loaded with 5 auto-corrections and 2 dropped items" +- **Expandable** — click to reveal categorized issue list +- **Copy button** — one-click copies the full issue report as a pre-formatted message +- **Actionable footer** — tells users to copy issues and ask their agent to fix them + +**Copy-paste output format:** +``` +The following issues were found in your knowledge-graph.json. +These are LLM generation errors — not a system bug. +You can ask your agent to fix these specific issues in the knowledge-graph.json file: + +[Auto-corrected] nodes[3] ("AuthService"): missing "complexity" — defaulted to "moderate" +[Auto-corrected] nodes[7] ("utils.ts"): missing "tags" — defaulted to [] +[Auto-corrected] edges[12]: weight was string "0.8" — coerced to number +[Dropped] edges[5]: target "file:src/nonexistent.ts" does not exist in nodes +[Dropped] nodes[14]: missing required "id" field — cannot recover +``` + +**Fatal errors** stay red (`bg-red-900/30`) with message: "Knowledge graph is unsalvageable: [reason]. Please re-run `/understand` to generate a new one." + +**Existing red error banner** for network/JSON-parse errors stays as-is (those ARE system/infra issues). + +### App.tsx Changes + +- On `result.success === true` with `result.issues.length > 0`: show `WarningBanner` with issues, load graph normally +- On `result.fatal`: show existing red banner with fatal message +- `console.warn` for auto-corrected items, `console.error` for dropped items + +### Test Coverage + +All in `packages/core/src/__tests__/schema.test.ts`: + +- **Tier 1:** `null` optional fields silently become `undefined` +- **Tier 2:** Missing `complexity`/`tags`/`weight`/`direction`/`summary` get defaults; issues tracked +- **Tier 2:** String `weight` coerced; complexity/direction aliases mapped +- **Tier 3:** Dangling edge references dropped; nodes missing `id` dropped; issues recorded +- **Tier 4:** Empty graph after filtering → fatal; missing `project` → fatal +- **Integration:** Graph with mixed good/bad nodes → loads with correct node count + correct issues list + +### Files Changed + +| File | Change | +|------|--------| +| `packages/core/src/schema.ts` | Sanitize, expanded normalize, permissive validate, new types | +| `packages/dashboard/src/components/WarningBanner.tsx` | New component | +| `packages/dashboard/src/App.tsx` | Wire issues to WarningBanner | +| `packages/core/src/__tests__/schema.test.ts` | Tests for all tiers | + +### Files NOT Changed + +- Agent prompts (can be tightened later as a separate effort) +- GraphView / store logic (they already handle valid `KnowledgeGraph` objects) +- Existing node/edge type alias maps (preserved, extended around) diff --git a/docs/plans/2026-03-26-theme-system-design.md b/docs/plans/2026-03-26-theme-system-design.md new file mode 100644 index 0000000..6f94154 --- /dev/null +++ b/docs/plans/2026-03-26-theme-system-design.md @@ -0,0 +1,415 @@ +# Theme System Design + +## Overview + +Add a curated theme preset system with accent color customization to the dashboard. Users select from 5 hand-designed theme presets and optionally swap the accent color within each preset from a set of 8-10 tested swatches. + +### Goals +- Support 5 theme presets: Dark Gold (current), Dark Ocean, Dark Forest, Dark Rose, Light Minimal +- Allow accent color customization within each preset (curated swatches only, no free picker) +- Persist theme preference in both `localStorage` (personal) and `meta.json` (project-level) +- Maintain visual coherence — no user-breakable color combinations +- Zero-reload theme switching via CSS variable injection at runtime + +### Non-Goals +- Free color picker (risk of ugly/unreadable combos) +- Per-component color overrides +- Multiple simultaneous themes + +--- + +## 1. Theme Presets & Color System + +### 1.1 Preset Definitions + +Each preset is a complete mapping of CSS variable names to values. The 5 presets: + +| Token | Dark Gold | Dark Ocean | Dark Forest | Dark Rose | Light Minimal | +|-------|-----------|------------|-------------|-----------|---------------| +| `--color-root` | `#0a0a0a` | `#0a0e14` | `#0a100a` | `#100a0a` | `#f5f3f0` | +| `--color-surface` | `#111111` | `#111820` | `#111811` | `#181111` | `#eae7e3` | +| `--color-elevated` | `#1a1a1a` | `#1a222c` | `#1a241a` | `#221a1a` | `#ffffff` | +| `--color-panel` | `#141414` | `#141c24` | `#141c14` | `#1c1414` | `#f0ede9` | +| `--color-gold`* | `#d4a574` | `#5ba4cf` | `#5ea67a` | `#cf7a8a` | `#4a6fa5` | +| `--color-gold-dim`* | `#c9a96e` | `#4e93ba` | `#4e9468` | `#b96e7e` | `#3d5f8f` | +| `--color-gold-bright`* | `#e8c49a` | `#7abce0` | `#78c492` | `#e094a4` | `#6088bf` | +| `--color-text-primary` | `#f5f0eb` | `#e8edf2` | `#ebf0eb` | `#f2e8ea` | `#1a1a1a` | +| `--color-text-secondary` | `#a39787` | `#87939f` | `#87a38f` | `#9f8790` | `#6b6b6b` | +| `--color-text-muted` | `#6b5f53` | `#536b7a` | `#536b5a` | `#6b535a` | `#a0a0a0` | +| `--color-border-subtle` | `rgba(212,165,116,0.12)` | `rgba(91,164,207,0.12)` | `rgba(94,166,122,0.12)` | `rgba(207,122,138,0.12)` | `rgba(74,111,165,0.10)` | +| `--color-border-medium` | `rgba(212,165,116,0.25)` | `rgba(91,164,207,0.25)` | `rgba(94,166,122,0.25)` | `rgba(207,122,138,0.25)` | `rgba(74,111,165,0.18)` | + +*\* The CSS variable names stay as `--color-gold`, `--color-gold-dim`, `--color-gold-bright` even for non-gold themes. They represent "the accent color" generically. Renaming them to `--color-accent` is a refactor we can do, but not required — the variable name is an implementation detail invisible to users.* + +**Decision: Rename `--color-gold*` to `--color-accent*`** to avoid confusion. This is a find-and-replace across the codebase with no behavioral change. + +### 1.2 Glass Effects + +Glass effects derive from base colors and need per-preset values: + +| Token | Dark themes | Light Minimal | +|-------|-------------|---------------| +| `--glass-bg` | `rgba(20,20,20,0.8)` | `rgba(255,255,255,0.8)` | +| `--glass-bg-heavy` | `rgba(20,20,20,0.95)` | `rgba(255,255,255,0.95)` | +| `--glass-border` | `rgba(accent,0.1)` | `rgba(accent,0.08)` | +| `--glass-border-heavy` | `rgba(accent,0.15)` | `rgba(accent,0.12)` | + +The `.glass` and `.glass-heavy` CSS classes will reference these variables instead of hardcoded values. + +### 1.3 Scrollbar & Glow Colors + +These also derive from the accent color and need to become CSS variables: + +| Token | Purpose | +|-------|---------| +| `--scrollbar-thumb` | `rgba(accent, 0.2)` | +| `--scrollbar-thumb-hover` | `rgba(accent, 0.35)` | +| `--glow-color` | `rgba(accent, 0.4)` for node selection glow | +| `--glow-pulse` | `rgba(accent, 0.6)` for tour highlight pulse | + +### 1.4 Node-Type & Diff Colors + +These are **semantic** and stay fixed across all dark themes: + +| Variable | Value | Purpose | +|----------|-------|---------| +| `--color-node-file` | `#4a7c9b` | File nodes | +| `--color-node-function` | `#5a9e6f` | Function nodes | +| `--color-node-class` | `#8b6fb0` | Class nodes | +| `--color-node-module` | `#c9a06c` | Module nodes | +| `--color-node-concept` | `#b07a8a` | Concept nodes | +| `--color-diff-changed` | `#e05252` | Changed nodes | +| `--color-diff-affected` | `#d4a030` | Affected nodes | + +For **Light Minimal only**, these are slightly desaturated/darkened to maintain readability on light backgrounds: + +| Variable | Light Minimal Value | +|----------|-------------------| +| `--color-node-file` | `#3a6a87` | +| `--color-node-function` | `#488a5b` | +| `--color-node-class` | `#755d99` | +| `--color-node-module` | `#a88a56` | +| `--color-node-concept` | `#966674` | + +### 1.5 Accent Swatches + +Each preset offers 8 accent color options. The first is the "native" default for that preset. Each swatch provides 3 values (accent, accent-dim, accent-bright) plus auto-derived border and glass opacities. + +**Dark theme accent swatches** (shared across all 4 dark presets): + +| Name | Accent | Dim | Bright | +|------|--------|-----|--------| +| Gold | `#d4a574` | `#c9a96e` | `#e8c49a` | +| Ocean | `#5ba4cf` | `#4e93ba` | `#7abce0` | +| Emerald | `#5ea67a` | `#4e9468` | `#78c492` | +| Rose | `#cf7a8a` | `#b96e7e` | `#e094a4` | +| Purple | `#9b7abf` | `#876bb0` | `#b494d4` | +| Amber | `#c9963a` | `#b5862e` | `#ddb05c` | +| Teal | `#4aab9a` | `#3d9686` | `#68c4b4` | +| Silver | `#a0a8b0` | `#8e959c` | `#b8bfc6` | + +**Light Minimal accent swatches:** + +| Name | Accent | Dim | Bright | +|------|--------|-----|--------| +| Indigo | `#4a6fa5` | `#3d5f8f` | `#6088bf` | +| Ocean | `#3a8ab5` | `#2e7aa0` | `#55a0cc` | +| Emerald | `#3a8a5c` | `#2e7a4e` | `#55a878` | +| Rose | `#a5566a` | `#8f4a5c` | `#bf6e82` | +| Purple | `#6b5a9e` | `#5c4d8a` | `#8474b5` | +| Amber | `#9e7a30` | `#8a6a28` | `#b5923e` | +| Teal | `#2e8a7a` | `#267a6c` | `#45a595` | +| Slate | `#5a6570` | `#4e5860` | `#6e7a85` | + +### 1.6 Border & Glass Derivation + +When an accent swatch is selected, borders and glass effects are auto-derived: + +```typescript +function deriveFromAccent(accentHex: string, isDark: boolean) { + return { + borderSubtle: `rgba(${hexToRgb(accentHex)}, ${isDark ? 0.12 : 0.10})`, + borderMedium: `rgba(${hexToRgb(accentHex)}, ${isDark ? 0.25 : 0.18})`, + glassBorder: `rgba(${hexToRgb(accentHex)}, ${isDark ? 0.1 : 0.08})`, + glassBorderHeavy: `rgba(${hexToRgb(accentHex)}, ${isDark ? 0.15 : 0.12})`, + scrollbarThumb: `rgba(${hexToRgb(accentHex)}, 0.2)`, + scrollbarThumbHover: `rgba(${hexToRgb(accentHex)}, 0.35)`, + glowColor: `rgba(${hexToRgb(accentHex)}, 0.4)`, + glowPulse: `rgba(${hexToRgb(accentHex)}, 0.6)`, + }; +} +``` + +--- + +## 2. Architecture & Data Flow + +### 2.1 File Structure + +``` +packages/dashboard/src/ + themes/ + types.ts # ThemePreset, AccentSwatch, ThemeConfig types + presets.ts # 5 preset definitions + accent swatch arrays + theme-engine.ts # applyTheme(), deriveFromAccent(), hexToRgb() + ThemeContext.tsx # React context + provider + useTheme() hook + components/ + ThemePicker.tsx # Popover UI for preset + accent selection +``` + +### 2.2 Type Definitions + +```typescript +// themes/types.ts + +export type PresetId = 'dark-gold' | 'dark-ocean' | 'dark-forest' | 'dark-rose' | 'light-minimal'; + +export interface ThemePreset { + id: PresetId; + name: string; // Display name: "Dark Gold" + isDark: boolean; // true for dark themes, false for light + colors: Record; // CSS variable name -> value (without --) + accentSwatches: AccentSwatch[]; + defaultAccentId: string; // Which swatch is the native default +} + +export interface AccentSwatch { + id: string; // e.g. 'gold', 'ocean' + name: string; // Display name: "Gold" + accent: string; // Primary accent hex + accentDim: string; // Dimmed accent hex + accentBright: string; // Bright accent hex +} + +export interface ThemeConfig { + presetId: PresetId; + accentId: string; // Selected accent swatch ID +} +``` + +### 2.3 Theme Engine + +The theme engine is a pure function layer (no React dependency): + +```typescript +// themes/theme-engine.ts + +export function applyTheme(config: ThemeConfig): void { + const preset = getPreset(config.presetId); + const accent = getAccent(preset, config.accentId); + + // 1. Apply base preset colors + for (const [key, value] of Object.entries(preset.colors)) { + document.documentElement.style.setProperty(`--color-${key}`, value); + } + + // 2. Override accent colors from swatch + document.documentElement.style.setProperty('--color-accent', accent.accent); + document.documentElement.style.setProperty('--color-accent-dim', accent.accentDim); + document.documentElement.style.setProperty('--color-accent-bright', accent.accentBright); + + // 3. Apply derived values (borders, glass, scrollbar, glow) + const derived = deriveFromAccent(accent.accent, preset.isDark); + for (const [key, value] of Object.entries(derived)) { + document.documentElement.style.setProperty(`--${key}`, value); + } + + // 4. Set data-theme attribute for any CSS-only selectors needed + document.documentElement.setAttribute('data-theme', preset.isDark ? 'dark' : 'light'); +} +``` + +### 2.4 React Context + +```typescript +// themes/ThemeContext.tsx + +interface ThemeContextValue { + config: ThemeConfig; + preset: ThemePreset; + setPreset: (presetId: PresetId) => void; + setAccent: (accentId: string) => void; +} +``` + +The provider: +1. On mount: resolves theme from `localStorage` > `meta.json` field in loaded graph > default (`dark-gold`) +2. Calls `applyTheme()` on every config change +3. Persists to `localStorage` on every change +4. Does NOT write to `meta.json` from the dashboard (the dashboard is read-only for meta.json; meta.json is written by the CLI/plugin side) + +### 2.5 Integration with Zustand Store + +The theme system is **separate from the Zustand store** — it uses its own React context. Rationale: +- Theme state is orthogonal to graph/UI state +- Theme needs to apply before the graph even loads (avoid flash of wrong theme) +- Keeps the store focused on graph interaction + +The store does NOT gain any theme-related fields. + +--- + +## 3. UI Components + +### 3.1 Theme Picker Button (Header) + +A small palette icon button in the top header bar, positioned after existing controls (PersonaSelector, DiffToggle, etc.). + +- Click opens a popover/dropdown panel +- Popover has two sections: + - **Presets**: 5 cards/buttons showing preset name + small color preview circles + - **Accent Colors**: row of 8 color circles for the active preset +- Active preset and accent are highlighted with a ring/check +- Selecting a preset instantly applies it; selecting an accent instantly applies it +- Clicking outside or pressing Escape closes the popover + +### 3.2 Preset Preview + +Each preset card shows: +- Name (e.g., "Dark Gold") +- 3-4 small circles showing root, surface, and accent colors as a visual preview +- Check mark or ring on the active one + +### 3.3 Accent Swatch Row + +- 8 small filled circles in a horizontal row +- Tooltip or label on hover showing the accent name +- Active one has a ring/border indicator + +### 3.4 Transitions + +When switching themes: +- CSS variables update instantly (no transition needed for most properties) +- Optionally add a subtle `transition: background-color 0.2s, color 0.2s` on `html` for a smooth feel +- No page reload required + +--- + +## 4. Persistence & Resolution + +### 4.1 Storage Locations + +| Location | Format | Written by | Read by | +|----------|--------|-----------|---------| +| `localStorage` key: `ua-theme` | `JSON.stringify(ThemeConfig)` | Dashboard (on every change) | Dashboard (on mount) | +| `.understand-anything/meta.json` | `{ ..., theme?: ThemeConfig }` | CLI/plugin (during analysis or explicit set) | Dashboard (on mount, as fallback) | + +### 4.2 Resolution Order + +``` +1. localStorage('ua-theme') → user's personal preference (wins) +2. meta.json.theme → project-level default (fallback) +3. { presetId: 'dark-gold', accentId: 'gold' } → hard default +``` + +### 4.3 meta.json Schema Extension + +Extend `AnalysisMeta` in `packages/core/src/types.ts`: + +```typescript +export interface AnalysisMeta { + lastAnalyzedAt: string; + gitCommitHash: string; + version: string; + analyzedFiles: number; + theme?: ThemeConfig; // NEW — optional, project-level theme preference +} +``` + +### 4.4 Dashboard Reads meta.json Theme + +The dashboard currently loads `/knowledge-graph.json` on mount. It also needs to load `/meta.json` (or the theme field can be embedded in `knowledge-graph.json`). + +**Decision:** Load `/meta.json` separately — it's a small file and keeps concerns separated. The dashboard fetches `/meta.json` on mount, extracts the `theme` field if present, and uses it as fallback when `localStorage` has no theme. + +--- + +## 5. Hardcoded Color Consolidation + +### 5.1 Problem + +Many components use hardcoded RGBA values instead of CSS variables: +- `rgba(212,165,116,0.3)` scattered in GraphView, CustomNode, etc. +- `rgba(20,20,20,0.8)` in glass effects +- `rgba(224,82,82,0.25)` in diff overlays + +These won't respond to theme changes. + +### 5.2 Solution + +Before implementing theme switching, consolidate all hardcoded color references: + +1. **Audit**: grep for hardcoded hex/rgba values in component files +2. **Replace with CSS variables**: create new variables where needed (e.g., `--edge-color`, `--edge-color-dim`) +3. **Glass classes**: update `.glass` and `.glass-heavy` in `index.css` to use variables +4. **Scrollbar**: update scrollbar styles to use variables +5. **Glow effects**: update `.node-glow`, `.diff-changed-glow`, `.diff-affected-glow` to use variables + +Key hardcoded patterns to consolidate: + +| Hardcoded Value | Replace With | +|-----------------|-------------| +| `rgba(212,165,116,X)` | `var(--color-accent)` with opacity modifier or dedicated variable | +| `rgba(20,20,20,0.8)` | `var(--glass-bg)` | +| `rgba(20,20,20,0.95)` | `var(--glass-bg-heavy)` | +| `color="rgba(212,165,116,0.15)"` in React Flow | Variable reference | +| Amber colors in WarningBanner | Keep as-is (semantic warning color, theme-independent) | + +### 5.3 CSS Variable Rename + +Rename throughout codebase: +- `--color-gold` -> `--color-accent` +- `--color-gold-dim` -> `--color-accent-dim` +- `--color-gold-bright` -> `--color-accent-bright` +- All Tailwind class usages: `text-gold` -> `text-accent`, `bg-gold` -> `bg-accent`, etc. + +--- + +## 6. Light Theme Considerations + +The Light Minimal theme requires special attention: + +### 6.1 Inverted Contrast + +- Text is dark on light backgrounds (flipped from dark themes) +- Borders need lower opacity to avoid looking harsh +- Glass effects use white-based rgba instead of black-based + +### 6.2 Node Colors + +Slightly darker/desaturated variants for readability on light backgrounds (see Section 1.4). + +### 6.3 data-theme Attribute + +Set `data-theme="light"` on `` for any styles that can't be handled purely through CSS variables (e.g., third-party component overrides, box-shadow directions). + +### 6.4 React Flow + +React Flow's background, minimap, and edge colors all need to respect the theme. The existing `!important` override on `.react-flow__background` already uses `var(--color-root)`, which is good. MiniMap colors in GraphView.tsx are currently hardcoded and need to be updated. + +--- + +## 7. Summary of Changes by Package + +### packages/core +- Extend `AnalysisMeta` type with optional `theme?: ThemeConfig` +- Export `ThemeConfig` and `PresetId` types from `./types` subpath + +### packages/dashboard +- New `themes/` directory with types, presets, engine, and context +- New `ThemePicker` component in header +- Rename `--color-gold*` to `--color-accent*` across all files +- Consolidate hardcoded RGBA values into CSS variables +- Update `index.css`: glass classes, scrollbar, glow effects to use variables +- Update `App.tsx`: wrap with ThemeProvider, add ThemePicker to header, fetch meta.json +- Update components with hardcoded colors: GraphView, CustomNode, LayerLegend, etc. + +--- + +## 8. Out of Scope + +- Theme import/export +- Custom theme creation UI +- Per-node color customization +- Animated theme transitions beyond simple CSS transitions +- Syncing theme across browser tabs (nice-to-have for later) diff --git a/docs/plans/2026-03-26-theme-system-implementation.md b/docs/plans/2026-03-26-theme-system-implementation.md new file mode 100644 index 0000000..148c548 --- /dev/null +++ b/docs/plans/2026-03-26-theme-system-implementation.md @@ -0,0 +1,1166 @@ +# Theme System Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add curated theme presets with accent customization to the dashboard. + +**Architecture:** CSS variable injection at runtime via a pure theme engine, React context for state, localStorage + meta.json for persistence. Five presets (4 dark + 1 light) with 8 accent swatches each. + +**Tech Stack:** React, TypeScript, TailwindCSS v4, Zustand (untouched), CSS custom properties. + +**Design Doc:** `docs/plans/2026-03-26-theme-system-design.md` + +--- + +### Task 1: Rename `gold` to `accent` in CSS variables and Tailwind classes + +This is a mechanical find-and-replace with no behavioral change. Must be done first so all subsequent tasks use the new naming. + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/index.css` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/LearnPanel.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/ProjectOverview.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/LayerLegend.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/PersonaSelector.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/CodeViewer.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/App.tsx` + +**Step 1: Rename CSS variables in index.css** + +In the `@theme` block, rename: +- `--color-gold` -> `--color-accent` +- `--color-gold-dim` -> `--color-accent-dim` +- `--color-gold-bright` -> `--color-accent-bright` + +Also rename the `@keyframes goldPulse` to `accentPulse` and `.animate-gold-pulse` to `.animate-accent-pulse`. + +**Step 2: Rename all Tailwind class references across components** + +Find and replace in all component files: +- `text-gold-bright` -> `text-accent-bright` +- `text-gold-dim` -> `text-accent-dim` +- `text-gold` -> `text-accent` +- `bg-gold` -> `bg-accent` +- `border-gold` -> `border-accent` +- `ring-gold-dim` -> `ring-accent-dim` +- `ring-gold-bright` -> `ring-accent-bright` +- `ring-gold` -> `ring-accent` +- `animate-gold-pulse` -> `animate-accent-pulse` + +Order matters — replace the longer `-bright` and `-dim` variants first to avoid partial matches. + +Also replace any `var(--color-gold` with `var(--color-accent` in inline styles. + +**Step 3: Verify the build compiles** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds with no errors. + +**Step 4: Visually verify (optional)** + +Run: `cd understand-anything-plugin && pnpm dev:dashboard` +Expected: Dashboard looks identical — same gold accent, no visual changes. + +**Step 5: Commit** + +```bash +git add -A +git commit -m "refactor(dashboard): rename gold CSS variables to accent" +``` + +--- + +### Task 2: Consolidate hardcoded RGBA values into CSS variables + +Replace scattered hardcoded color values in components with CSS variables so they respond to theme changes. + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/index.css` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/CodeViewer.tsx` + +**Step 1: Add new CSS variables to index.css @theme block** + +Add these new variables after the existing border variables: + +```css +/* Glass */ +--glass-bg: rgba(20, 20, 20, 0.8); +--glass-bg-heavy: rgba(20, 20, 20, 0.95); +--glass-border: rgba(212, 165, 116, 0.1); +--glass-border-heavy: rgba(212, 165, 116, 0.15); + +/* Scrollbar */ +--scrollbar-thumb: rgba(212, 165, 116, 0.2); +--scrollbar-thumb-hover: rgba(212, 165, 116, 0.35); + +/* Glow */ +--glow-accent: rgba(212, 165, 116, 0.15); +--glow-accent-strong: rgba(212, 165, 116, 0.4); +--glow-accent-pulse: rgba(212, 165, 116, 0.6); + +/* Edges */ +--color-edge: rgba(212, 165, 116, 0.3); +--color-edge-dim: rgba(212, 165, 116, 0.08); +--color-edge-dot: rgba(212, 165, 116, 0.15); + +/* Layer group (accent-based overlays) */ +--color-accent-overlay-bg: rgba(212, 165, 116, 0.05); +--color-accent-overlay-border: rgba(212, 165, 116, 0.25); + +/* kbd */ +--kbd-bg: rgba(212, 165, 116, 0.1); +``` + +**Step 2: Update .glass, .glass-heavy classes in index.css** + +Replace hardcoded values with the new variables: + +```css +.glass { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.glass-heavy { + background: var(--glass-bg-heavy); + border: 1px solid var(--glass-border-heavy); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} +``` + +**Step 3: Update scrollbar styles in index.css** + +```css +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} +``` + +**Step 4: Update glow classes in index.css** + +```css +.node-glow { + box-shadow: 0 0 20px var(--glow-accent); +} +``` + +Update `@keyframes accentPulse` (renamed in Task 1): +```css +@keyframes accentPulse { + 0%, 100% { + box-shadow: 0 0 8px var(--glow-accent-strong); + } + 50% { + box-shadow: 0 0 20px var(--glow-accent-pulse); + } +} +``` + +**Step 5: Update .kbd class in index.css** + +```css +.kbd { + /* ... keep existing sizing/layout ... */ + color: var(--color-accent); + background: var(--kbd-bg); +} +``` + +**Step 6: Update GraphView.tsx hardcoded colors** + +Replace these inline style values: + +| Location | Old Value | New Value | +|----------|-----------|-----------| +| Edge default style stroke | `"rgba(212,165,116,0.3)"` | `"var(--color-edge)"` | +| Edge diff-faded stroke | `"rgba(212,165,116,0.08)"` | `"var(--color-edge-dim)"` | +| Background dots color prop | `"rgba(212,165,116,0.15)"` | `"var(--color-edge-dot)"` | +| MiniMap nodeColor | `"#1a1a1a"` | `"var(--color-elevated)"` | +| MiniMap maskColor | `"rgba(10,10,10,0.7)"` | `"var(--glass-bg)"` | +| Group node backgroundColor | `"rgba(212,165,116,0.05)"` | `"var(--color-accent-overlay-bg)"` | +| Group node border | `"2px dashed rgba(212,165,116,0.25)"` | `"2px dashed var(--color-accent-overlay-border)"` | +| Group node label color | `"#d4a574"` | `"var(--color-accent)"` | +| Edge label fill (normal) | `"#a39787"` | `"var(--color-text-secondary)"` | +| Edge label fill (diff faded) | `"rgba(163,151,135,0.3)"` | `"var(--color-text-muted)"` | +| Spinner border class | `border-gold` already renamed to `border-accent` | Already done in Task 1 | + +**Step 7: Update CodeViewer.tsx hardcoded colors** + +Replace inline styles for the file type badge: +- `color: "var(--color-node-file)"` — already uses CSS var, keep +- `borderColor: "rgba(74,124,155,0.3)"` -> `"color-mix(in srgb, var(--color-node-file) 30%, transparent)"` +- `backgroundColor: "rgba(74,124,155,0.1)"` -> `"color-mix(in srgb, var(--color-node-file) 10%, transparent)"` + +**Step 8: Update CustomNode.tsx hardcoded shadow** + +Replace `shadow-[0_2px_8px_rgba(0,0,0,0.3)]` — this black shadow is fine for dark themes but keep it. Leave as-is since it works on both dark and light. + +**Step 9: Verify build** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds. + +**Step 10: Commit** + +```bash +git add -A +git commit -m "refactor(dashboard): consolidate hardcoded colors into CSS variables" +``` + +--- + +### Task 3: Create theme type definitions + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/themes/types.ts` + +**Step 1: Write the types file** + +```typescript +export type PresetId = + | "dark-gold" + | "dark-ocean" + | "dark-forest" + | "dark-rose" + | "light-minimal"; + +export interface AccentSwatch { + id: string; + name: string; + accent: string; + accentDim: string; + accentBright: string; +} + +export interface ThemePreset { + id: PresetId; + name: string; + isDark: boolean; + colors: Record; + accentSwatches: AccentSwatch[]; + defaultAccentId: string; +} + +export interface ThemeConfig { + presetId: PresetId; + accentId: string; +} + +export const DEFAULT_THEME_CONFIG: ThemeConfig = { + presetId: "dark-gold", + accentId: "gold", +}; +``` + +**Step 2: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): add theme type definitions" +``` + +--- + +### Task 4: Create theme presets + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/themes/presets.ts` + +**Step 1: Write the presets file** + +```typescript +import type { AccentSwatch, ThemePreset } from "./types.ts"; + +const DARK_ACCENT_SWATCHES: AccentSwatch[] = [ + { id: "gold", name: "Gold", accent: "#d4a574", accentDim: "#c9a96e", accentBright: "#e8c49a" }, + { id: "ocean", name: "Ocean", accent: "#5ba4cf", accentDim: "#4e93ba", accentBright: "#7abce0" }, + { id: "emerald", name: "Emerald", accent: "#5ea67a", accentDim: "#4e9468", accentBright: "#78c492" }, + { id: "rose", name: "Rose", accent: "#cf7a8a", accentDim: "#b96e7e", accentBright: "#e094a4" }, + { id: "purple", name: "Purple", accent: "#9b7abf", accentDim: "#876bb0", accentBright: "#b494d4" }, + { id: "amber", name: "Amber", accent: "#c9963a", accentDim: "#b5862e", accentBright: "#ddb05c" }, + { id: "teal", name: "Teal", accent: "#4aab9a", accentDim: "#3d9686", accentBright: "#68c4b4" }, + { id: "silver", name: "Silver", accent: "#a0a8b0", accentDim: "#8e959c", accentBright: "#b8bfc6" }, +]; + +const LIGHT_ACCENT_SWATCHES: AccentSwatch[] = [ + { id: "indigo", name: "Indigo", accent: "#4a6fa5", accentDim: "#3d5f8f", accentBright: "#6088bf" }, + { id: "ocean", name: "Ocean", accent: "#3a8ab5", accentDim: "#2e7aa0", accentBright: "#55a0cc" }, + { id: "emerald", name: "Emerald", accent: "#3a8a5c", accentDim: "#2e7a4e", accentBright: "#55a878" }, + { id: "rose", name: "Rose", accent: "#a5566a", accentDim: "#8f4a5c", accentBright: "#bf6e82" }, + { id: "purple", name: "Purple", accent: "#6b5a9e", accentDim: "#5c4d8a", accentBright: "#8474b5" }, + { id: "amber", name: "Amber", accent: "#9e7a30", accentDim: "#8a6a28", accentBright: "#b5923e" }, + { id: "teal", name: "Teal", accent: "#2e8a7a", accentDim: "#267a6c", accentBright: "#45a595" }, + { id: "slate", name: "Slate", accent: "#5a6570", accentDim: "#4e5860", accentBright: "#6e7a85" }, +]; + +export const PRESETS: ThemePreset[] = [ + { + id: "dark-gold", + name: "Dark Gold", + isDark: true, + defaultAccentId: "gold", + accentSwatches: DARK_ACCENT_SWATCHES, + colors: { + root: "#0a0a0a", + surface: "#111111", + elevated: "#1a1a1a", + panel: "#141414", + "text-primary": "#f5f0eb", + "text-secondary": "#a39787", + "text-muted": "#6b5f53", + "node-file": "#4a7c9b", + "node-function": "#5a9e6f", + "node-class": "#8b6fb0", + "node-module": "#c9a06c", + "node-concept": "#b07a8a", + }, + }, + { + id: "dark-ocean", + name: "Dark Ocean", + isDark: true, + defaultAccentId: "ocean", + accentSwatches: DARK_ACCENT_SWATCHES, + colors: { + root: "#0a0e14", + surface: "#111820", + elevated: "#1a222c", + panel: "#141c24", + "text-primary": "#e8edf2", + "text-secondary": "#87939f", + "text-muted": "#536b7a", + "node-file": "#4a7c9b", + "node-function": "#5a9e6f", + "node-class": "#8b6fb0", + "node-module": "#c9a06c", + "node-concept": "#b07a8a", + }, + }, + { + id: "dark-forest", + name: "Dark Forest", + isDark: true, + defaultAccentId: "emerald", + accentSwatches: DARK_ACCENT_SWATCHES, + colors: { + root: "#0a100a", + surface: "#111811", + elevated: "#1a241a", + panel: "#141c14", + "text-primary": "#ebf0eb", + "text-secondary": "#87a38f", + "text-muted": "#536b5a", + "node-file": "#4a7c9b", + "node-function": "#5a9e6f", + "node-class": "#8b6fb0", + "node-module": "#c9a06c", + "node-concept": "#b07a8a", + }, + }, + { + id: "dark-rose", + name: "Dark Rose", + isDark: true, + defaultAccentId: "rose", + accentSwatches: DARK_ACCENT_SWATCHES, + colors: { + root: "#100a0a", + surface: "#181111", + elevated: "#221a1a", + panel: "#1c1414", + "text-primary": "#f2e8ea", + "text-secondary": "#9f8790", + "text-muted": "#6b535a", + "node-file": "#4a7c9b", + "node-function": "#5a9e6f", + "node-class": "#8b6fb0", + "node-module": "#c9a06c", + "node-concept": "#b07a8a", + }, + }, + { + id: "light-minimal", + name: "Light Minimal", + isDark: false, + defaultAccentId: "indigo", + accentSwatches: LIGHT_ACCENT_SWATCHES, + colors: { + root: "#f5f3f0", + surface: "#eae7e3", + elevated: "#ffffff", + panel: "#f0ede9", + "text-primary": "#1a1a1a", + "text-secondary": "#6b6b6b", + "text-muted": "#a0a0a0", + "node-file": "#3a6a87", + "node-function": "#488a5b", + "node-class": "#755d99", + "node-module": "#a88a56", + "node-concept": "#966674", + }, + }, +]; + +export function getPreset(id: string): ThemePreset { + return PRESETS.find((p) => p.id === id) ?? PRESETS[0]; +} + +export function getAccent(preset: ThemePreset, accentId: string): AccentSwatch { + return ( + preset.accentSwatches.find((s) => s.id === accentId) ?? + preset.accentSwatches.find((s) => s.id === preset.defaultAccentId) ?? + preset.accentSwatches[0] + ); +} +``` + +**Step 2: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): add theme preset definitions" +``` + +--- + +### Task 5: Create theme engine + +Pure functions with no React dependency. Handles CSS variable injection and accent derivation. + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/themes/theme-engine.ts` + +**Step 1: Write the theme engine** + +```typescript +import type { ThemeConfig } from "./types.ts"; +import { getAccent, getPreset } from "./presets.ts"; + +export function hexToRgb(hex: string): string { + const h = hex.replace("#", ""); + const n = parseInt(h, 16); + return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`; +} + +function deriveFromAccent(accentHex: string, isDark: boolean): Record { + const rgb = hexToRgb(accentHex); + return { + "color-border-subtle": `rgba(${rgb}, ${isDark ? 0.12 : 0.1})`, + "color-border-medium": `rgba(${rgb}, ${isDark ? 0.25 : 0.18})`, + "glass-bg": isDark ? "rgba(20, 20, 20, 0.8)" : "rgba(255, 255, 255, 0.8)", + "glass-bg-heavy": isDark ? "rgba(20, 20, 20, 0.95)" : "rgba(255, 255, 255, 0.95)", + "glass-border": `rgba(${rgb}, ${isDark ? 0.1 : 0.08})`, + "glass-border-heavy": `rgba(${rgb}, ${isDark ? 0.15 : 0.12})`, + "scrollbar-thumb": `rgba(${rgb}, 0.2)`, + "scrollbar-thumb-hover": `rgba(${rgb}, 0.35)`, + "glow-accent": `rgba(${rgb}, 0.15)`, + "glow-accent-strong": `rgba(${rgb}, 0.4)`, + "glow-accent-pulse": `rgba(${rgb}, 0.6)`, + "color-edge": `rgba(${rgb}, 0.3)`, + "color-edge-dim": `rgba(${rgb}, 0.08)`, + "color-edge-dot": `rgba(${rgb}, 0.15)`, + "color-accent-overlay-bg": `rgba(${rgb}, 0.05)`, + "color-accent-overlay-border": `rgba(${rgb}, 0.25)`, + "kbd-bg": `rgba(${rgb}, 0.1)`, + }; +} + +export function applyTheme(config: ThemeConfig): void { + const preset = getPreset(config.presetId); + const accent = getAccent(preset, config.accentId); + const style = document.documentElement.style; + + // 1. Apply base preset colors + for (const [key, value] of Object.entries(preset.colors)) { + style.setProperty(`--color-${key}`, value); + } + + // 2. Apply accent colors from swatch + style.setProperty("--color-accent", accent.accent); + style.setProperty("--color-accent-dim", accent.accentDim); + style.setProperty("--color-accent-bright", accent.accentBright); + + // 3. Apply derived values + const derived = deriveFromAccent(accent.accent, preset.isDark); + for (const [key, value] of Object.entries(derived)) { + style.setProperty(`--${key}`, value); + } + + // 4. Set data-theme for CSS-only selectors + document.documentElement.setAttribute("data-theme", preset.isDark ? "dark" : "light"); +} +``` + +**Step 2: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): add theme engine with CSS variable injection" +``` + +--- + +### Task 6: Create ThemeContext + +React context + provider that manages theme state, persistence, and resolution. + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/themes/ThemeContext.tsx` + +**Step 1: Write the context** + +```typescript +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import type { PresetId, ThemeConfig, ThemePreset } from "./types.ts"; +import { DEFAULT_THEME_CONFIG } from "./types.ts"; +import { getPreset } from "./presets.ts"; +import { applyTheme } from "./theme-engine.ts"; + +const STORAGE_KEY = "ua-theme"; + +interface ThemeContextValue { + config: ThemeConfig; + preset: ThemePreset; + setPreset: (presetId: PresetId) => void; + setAccent: (accentId: string) => void; +} + +const ThemeContext = createContext(null); + +function loadFromLocalStorage(): ThemeConfig | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.presetId === "string" && typeof parsed.accentId === "string") { + return parsed as ThemeConfig; + } + return null; + } catch { + return null; + } +} + +function saveToLocalStorage(config: ThemeConfig): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); + } catch { + // Storage full or unavailable — ignore + } +} + +function resolveInitialTheme(metaTheme?: ThemeConfig | null): ThemeConfig { + return loadFromLocalStorage() ?? metaTheme ?? DEFAULT_THEME_CONFIG; +} + +interface ThemeProviderProps { + metaTheme?: ThemeConfig | null; + children: ReactNode; +} + +export function ThemeProvider({ metaTheme, children }: ThemeProviderProps) { + const [config, setConfig] = useState(() => resolveInitialTheme(metaTheme)); + const initialized = useRef(false); + + // Apply theme on mount and config changes + useEffect(() => { + applyTheme(config); + if (initialized.current) { + saveToLocalStorage(config); + } + initialized.current = true; + }, [config]); + + // Update if metaTheme arrives later (async fetch) and no localStorage preference exists + useEffect(() => { + if (metaTheme && !loadFromLocalStorage()) { + setConfig(metaTheme); + } + }, [metaTheme]); + + const setPreset = useCallback((presetId: PresetId) => { + setConfig((prev) => { + const newPreset = getPreset(presetId); + return { presetId, accentId: newPreset.defaultAccentId }; + }); + }, []); + + const setAccent = useCallback((accentId: string) => { + setConfig((prev) => ({ ...prev, accentId })); + }, []); + + const preset = getPreset(config.presetId); + + return ( + + {children} + + ); +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within ThemeProvider"); + return ctx; +} +``` + +**Step 2: Create barrel export** + +Create: `understand-anything-plugin/packages/dashboard/src/themes/index.ts` + +```typescript +export { ThemeProvider, useTheme } from "./ThemeContext.tsx"; +export { PRESETS, getPreset, getAccent } from "./presets.ts"; +export { applyTheme } from "./theme-engine.ts"; +export type { PresetId, ThemeConfig, ThemePreset, AccentSwatch } from "./types.ts"; +export { DEFAULT_THEME_CONFIG } from "./types.ts"; +``` + +**Step 3: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): add ThemeContext with localStorage persistence" +``` + +--- + +### Task 7: Extend AnalysisMeta with theme field + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/types.ts` + +**Step 1: Add ThemeConfig type and extend AnalysisMeta** + +Add near the top of the file (after existing imports/types): + +```typescript +export interface ThemeConfig { + presetId: string; + accentId: string; +} +``` + +Add `theme` field to `AnalysisMeta`: + +```typescript +export interface AnalysisMeta { + lastAnalyzedAt: string; + gitCommitHash: string; + version: string; + analyzedFiles: number; + theme?: ThemeConfig; +} +``` + +**Step 2: Verify core builds** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/core build` +Expected: Build succeeds. + +**Step 3: Verify core tests pass** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/core test` +Expected: All tests pass. + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat(core): add optional theme field to AnalysisMeta" +``` + +--- + +### Task 8: Create ThemePicker component + +The popover UI with preset selection and accent swatch row. + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/components/ThemePicker.tsx` + +**Step 1: Write the component** + +```tsx +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTheme, PRESETS } from "../themes/index.ts"; + +export function ThemePicker() { + const { config, preset, setPreset, setAccent } = useTheme(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + // Close on Escape + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); + + const handlePreset = useCallback( + (id: string) => { + setPreset(id as Parameters[0]); + }, + [setPreset], + ); + + return ( +
+ + + {open && ( +
+ {/* Presets */} +
+
+ Theme +
+
+ {PRESETS.map((p) => ( + + ))} +
+
+ + {/* Accent swatches */} +
+
+ Accent Color +
+
+ {preset.accentSwatches.map((swatch) => ( +
+
+
+ )} +
+ ); +} +``` + +**Step 2: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): add ThemePicker popover component" +``` + +--- + +### Task 9: Integrate ThemeProvider and ThemePicker into App + +Wire everything together in the root component. + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/App.tsx` + +**Step 1: Add imports** + +Add to imports at top of App.tsx: + +```typescript +import { ThemeProvider } from "./themes/index.ts"; +import { ThemePicker } from "./components/ThemePicker.tsx"; +import type { ThemeConfig } from "./themes/index.ts"; +``` + +**Step 2: Add meta.json theme loading** + +Inside the App component, add state and effect for meta.json theme: + +```typescript +const [metaTheme, setMetaTheme] = useState(null); + +useEffect(() => { + fetch("/meta.json") + .then((r) => (r.ok ? r.json() : null)) + .then((meta) => { + if (meta?.theme) setMetaTheme(meta.theme); + }) + .catch(() => {}); +}, []); +``` + +**Step 3: Wrap return JSX with ThemeProvider** + +Wrap the entire return value of App with `...`. + +**Step 4: Add ThemePicker to header** + +In the header bar (the `
` or top flex row), add `` after the existing controls (PersonaSelector, DiffToggle, LayerLegend) and before the help button. + +**Step 5: Verify build** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds. + +**Step 6: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): integrate ThemeProvider and ThemePicker into App" +``` + +--- + +### Task 10: Light theme CSS adjustments + +Handle edge cases where CSS variables alone aren't sufficient for the light theme. + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/index.css` + +**Step 1: Add data-theme selectors for light theme overrides** + +Add at the end of index.css: + +```css +/* Light theme overrides */ +[data-theme="light"] { + color-scheme: light; +} + +[data-theme="light"] .diff-faded { + opacity: 0.35; +} + +[data-theme="light"] ::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="dark"] { + color-scheme: dark; +} +``` + +**Step 2: Add transition for smooth theme switching** + +Add to the `html` base styles: + +```css +html { + transition: background-color 0.2s ease, color 0.2s ease; +} +``` + +**Step 3: Update the WarningBanner consideration** + +WarningBanner uses Tailwind amber/orange colors directly (e.g., `bg-amber-900/20`). These are semantic warning colors and should NOT change with theme. However, for the light theme, the amber colors on a light background need adjustment. + +Add to light theme overrides if needed: + +```css +[data-theme="light"] .warning-banner { + background: rgba(180, 130, 30, 0.1); + border-color: rgba(180, 130, 30, 0.3); + color: #92600a; +} +``` + +Note: Only add this if the WarningBanner looks broken on the light theme during visual testing. It may work fine as-is with Tailwind's amber colors. + +**Step 4: Verify build** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds. + +**Step 5: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): add light theme CSS overrides" +``` + +--- + +### Task 11: Remove @theme defaults from index.css + +Now that the theme engine sets all CSS variables at runtime, the `@theme` block in index.css serves as the initial/fallback values before React mounts. Keep it but update it to use the accent naming. + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/index.css` + +**Step 1: Update @theme block** + +The `@theme` block should already have `--color-accent` (from Task 1 rename). Ensure the new variables added in Task 2 are also present in the `@theme` block as defaults: + +```css +@theme { + /* Base */ + --color-root: #0a0a0a; + --color-surface: #111111; + --color-elevated: #1a1a1a; + --color-panel: #141414; + + /* Accent */ + --color-accent: #d4a574; + --color-accent-dim: #c9a96e; + --color-accent-bright: #e8c49a; + + /* Text */ + --color-text-primary: #f5f0eb; + --color-text-secondary: #a39787; + --color-text-muted: #6b5f53; + + /* Borders */ + --color-border-subtle: rgba(212, 165, 116, 0.12); + --color-border-medium: rgba(212, 165, 116, 0.25); + + /* Node types */ + --color-node-file: #4a7c9b; + --color-node-function: #5a9e6f; + --color-node-class: #8b6fb0; + --color-node-module: #c9a06c; + --color-node-concept: #b07a8a; + + /* Diff */ + --color-diff-changed: #e05252; + --color-diff-affected: #d4a030; + --color-diff-changed-dim: rgba(224, 82, 82, 0.25); + --color-diff-affected-dim: rgba(212, 160, 48, 0.25); + + /* Glass */ + --glass-bg: rgba(20, 20, 20, 0.8); + --glass-bg-heavy: rgba(20, 20, 20, 0.95); + --glass-border: rgba(212, 165, 116, 0.1); + --glass-border-heavy: rgba(212, 165, 116, 0.15); + + /* Scrollbar */ + --scrollbar-thumb: rgba(212, 165, 116, 0.2); + --scrollbar-thumb-hover: rgba(212, 165, 116, 0.35); + + /* Glow */ + --glow-accent: rgba(212, 165, 116, 0.15); + --glow-accent-strong: rgba(212, 165, 116, 0.4); + --glow-accent-pulse: rgba(212, 165, 116, 0.6); + + /* Edges */ + --color-edge: rgba(212, 165, 116, 0.3); + --color-edge-dim: rgba(212, 165, 116, 0.08); + --color-edge-dot: rgba(212, 165, 116, 0.15); + + /* Accent overlays */ + --color-accent-overlay-bg: rgba(212, 165, 116, 0.05); + --color-accent-overlay-border: rgba(212, 165, 116, 0.25); + + /* Kbd */ + --kbd-bg: rgba(212, 165, 116, 0.1); + + /* Typography */ + --font-serif: 'DM Serif Display', Georgia, serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --font-sans: 'Inter', system-ui, sans-serif; +} +``` + +This ensures: +- Tailwind v4 generates all the correct utility classes from the `@theme` block +- Before React mounts, the page shows the Dark Gold default (no flash of unstyled content) +- The theme engine overrides these values at runtime + +**Step 2: Verify build** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds. + +**Step 3: Commit** + +```bash +git add -A +git commit -m "refactor(dashboard): align @theme defaults with theme engine variables" +``` + +--- + +### Task 12: Full build + visual verification + +**Files:** None (verification only) + +**Step 1: Build core** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/core build` +Expected: Build succeeds. + +**Step 2: Build dashboard** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds. + +**Step 3: Run core tests** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/core test` +Expected: All tests pass. + +**Step 4: Run lint** + +Run: `cd understand-anything-plugin && pnpm lint` +Expected: No lint errors. + +**Step 5: Start dev server and visually verify** + +Run: `cd understand-anything-plugin && pnpm dev:dashboard` + +Verify: +1. Dashboard loads with Dark Gold theme (default) — looks identical to current +2. Theme picker button visible in header +3. Click theme picker — popover opens with 5 presets and 8 accent swatches +4. Select Dark Ocean — backgrounds turn navy-blue, accent turns cyan +5. Select Dark Forest — backgrounds turn dark green, accent turns emerald +6. Select Dark Rose — backgrounds turn dark warm, accent turns rose +7. Select Light Minimal — backgrounds turn light, text turns dark, accent turns indigo +8. Select different accent swatches within each preset — accent color, borders, glass, glow all update +9. Refresh page — theme persists from localStorage +10. Click outside popover — it closes +11. Press Escape — popover closes + +**Step 6: Commit (if any fixes needed)** + +```bash +git add -A +git commit -m "fix(dashboard): theme system visual adjustments" +``` + +--- + +## Dependency Graph + +``` +Task 1 (rename gold→accent) ─┐ + ├─> Task 3 (types) ──┐ +Task 2 (consolidate colors) ──┤ │ + │ Task 4 (presets) ─┤ + │ ├─> Task 6 (context) ─┐ + │ Task 5 (engine) ──┘ │ + │ ├─> Task 8 (picker) ─┐ + │ Task 7 (core types) ────────────────────┘ │ + │ │ + └───────────────────────────────────────────> Task 9 (integrate) ─┤ + │ + Task 10 (light CSS) ┤ + │ + Task 11 (defaults) ─┤ + │ + Task 12 (verify) ───┘ +``` + +**Parallelizable groups:** +- Tasks 1 + 2 can be done sequentially (both touch index.css) +- Tasks 3, 4, 5 can be done in parallel (independent new files) +- Task 6 depends on 3, 4, 5 +- Task 7 is independent (core package) +- Task 8 depends on 6 +- Task 9 depends on 1, 2, 7, 8 +- Tasks 10, 11 can be done after 9 +- Task 12 is final verification diff --git a/docs/plans/2026-03-27-token-reduction-design.md b/docs/plans/2026-03-27-token-reduction-design.md new file mode 100644 index 0000000..8bf8975 --- /dev/null +++ b/docs/plans/2026-03-27-token-reduction-design.md @@ -0,0 +1,395 @@ +# Token Reduction Design + +**Date:** 2026-03-27 +**Status:** Draft +**Goal:** Reduce total token cost of `/understand` by ~85-90% on large codebases (200+ files) + +--- + +## Problem + +For large codebases, the `/understand` pipeline spends the vast majority of its tokens on **repeated context injection**. The same data is sent to every subagent independently, even when that data could be computed once and shared. + +### Token cost breakdown (500-file TypeScript+React project, baseline) + +| Source | Phase | Tokens (input) | % of total | +|---|---|---|---| +| `allProjectFiles` list × 67 batches | Phase 2 | ~167,000 | ~50% | +| `file-analyzer-prompt.md` × 67 batches | Phase 2 | ~134,000 | ~40% | +| Language/framework addendums × 67 batches | Phase 2 | ~68,000 | ~20% | +| Tour builder payload (all nodes + edges) | Phase 5 | ~80,000 | ~24% | +| Graph reviewer (assembled graph + inventory) | Phase 6 | ~58,000 | ~17% | +| Architecture analyzer payload | Phase 4 | ~22,000 | ~7% | +| **Total** | | **~529,000** | | + +The root cause: **Phase 2 runs 67 batches (at 5-10 files each), and every single batch receives the full 500-file list for import resolution.** The file list alone costs ~2,500 tokens × 67 repetitions = 167,000 tokens on input, doing work that is entirely redundant between batches. + +--- + +## Goals + +- Reduce total input tokens by 85%+ on a 500-file project +- No degradation in graph quality for standard projects +- Preserve the `--full` / incremental / scope flags +- Maintain backward compatibility with existing `knowledge-graph.json` output schema + +--- + +## Changes + +Five changes compose the full approach (C1–C5). Each is independent and can be shipped separately, but all five are needed for the full reduction. + +--- + +### C1 — Pre-resolve imports in the project scanner + +**Root cause addressed:** `allProjectFiles` (the entire file list) is injected into every file-analyzer batch solely so each batch's extraction script can resolve relative imports. This is redundant: the full file list is available during Phase 1, and import resolution is deterministic. It should happen once, not 67 times. + +**Change:** Extend the Phase 1 scanner script to also parse import statements from every source file and resolve relative imports against the discovered file list. The resolved results are written into `scan-result.json` as a new `importMap` field. File-analyzer batches then receive only their own batch's pre-resolved imports — not the full file list. + +#### Scanner output addition + +`scan-result.json` gains: + +```json +{ + "importMap": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/utils.ts": [], + "src/components/App.tsx": ["src/hooks/useAuth.ts", "src/store/index.ts"] + } +} +``` + +- Keys are project-relative paths (matching `files[*].path`) +- Values are resolved project-relative paths only (external/unresolvable imports are omitted) +- External imports (`node_modules`, unresolvable paths) are excluded from the map entirely + +#### Scanner script additions (Phase 1 Step 8) + +After the existing 7 steps, the scanner script adds a new step: + +``` +Step 8 — Import Resolution + +For each file in the discovered source list: + 1. Read the file content + 2. Extract import statements (language-specific patterns per Step 3's language detection): + - TypeScript/JavaScript: `import ... from '...'`, `require('...')` + - Python: `import ...`, `from ... import ...` + - Go: `import "..."` blocks + - Rust: `use ...` statements + - Java/Kotlin: `import ...` statements + - Ruby: `require`, `require_relative` + 3. For each relative import (starts with `./` or `../`): + a. Compute the resolved path from the current file's directory + b. Normalize to project-relative format + c. Try common extension variants if the import has no extension: + `.ts`, `.tsx`, `.js`, `.jsx`, `/index.ts`, `/index.js`, `/index.tsx` + d. If any variant exists in the discovered file list, record it; otherwise skip + 4. For absolute imports (no `.` prefix): skip (external package) + +Output the full importMap in the JSON result. +``` + +#### File-analyzer input schema change + +**Before:** +```json +{ + "projectRoot": "/path/to/project", + "allProjectFiles": ["src/index.ts", "src/utils.ts", "...500 paths..."], + "batchFiles": [ + {"path": "src/index.ts", "language": "typescript", "sizeLines": 150} + ] +} +``` + +**After:** +```json +{ + "projectRoot": "/path/to/project", + "batchFiles": [ + {"path": "src/index.ts", "language": "typescript", "sizeLines": 150} + ], + "batchImportData": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/components/App.tsx": ["src/hooks/useAuth.ts"] + } +} +``` + +`allProjectFiles` is removed entirely. `batchImportData` contains only the pre-resolved imports for the files in this batch (sliced from `importMap` by the orchestrator). + +#### File-analyzer extraction script change + +The extraction script no longer performs import resolution. It: +- Still extracts: functions, classes, exports, metrics (unchanged) +- For imports: reads `batchImportData[file.path]` from the input JSON — no cross-referencing needed +- The `imports` array in each file result becomes: `batchImportData[file.path]` mapped to import edge objects with `resolvedPath` already populated, `isExternal: false` + +#### SKILL.md Phase 2 change + +Remove the `allProjectFiles` injection from the batch dispatch prompt. Replace with a per-batch `batchImportData` slice: + +``` +For each batch, slice importData from the importMap read in Phase 1: +batchImportData = { [file.path]: importMap[file.path] ?? [] } + for each file in this batch +``` + +#### Token savings estimate + +| | Batches | Tokens/batch | Total | +|---|---|---|---| +| Before | 67 | ~2,500 (file list) | ~167,500 | +| After (C1 alone) | 67 | ~200 (batch importData) | ~13,400 | +| **Savings** | | | **~154,100** | + +--- + +### C2 — Increase batch size from 5-10 to 20-30 files + +**Root cause addressed:** Every batch incurs the full cost of `file-analyzer-prompt.md` (~2,000 tokens) plus the batch dispatch overhead. With 67 batches, this adds up even without `allProjectFiles`. Fewer, larger batches directly reduce this repetition. + +**Change:** In SKILL.md Phase 2, change the batch size guidance: + +- **Before:** "Batch the file list from Phase 1 into groups of **5-10 files each**" +- **After:** "Batch the file list from Phase 1 into groups of **20-30 files each** (aim for ~25 per batch)" + +Also update the concurrency limit from 3 to **5** concurrent batches. Fewer total batches means we can afford more parallelism without overwhelming the system. + +#### Trade-offs + +| | Smaller batches (current) | Larger batches (new) | +|---|---|---| +| Files per batch | 5-10 | 20-30 | +| Total batches (500 files) | ~67 | ~20 | +| Prompt repetition | 67× | 20× | +| Quality risk | Lower (focused) | Slightly higher (more files per subagent) | +| Concurrency | 3 | 5 | + +Quality risk is low: each subagent still operates on distinct, non-overlapping file groups. The extraction script is deterministic regardless of batch size. Semantic analysis (summaries, tags) may be marginally less focused, but the quality difference is negligible in practice for well-structured files. + +#### Token savings estimate (combined with C1) + +| | Batches | Tokens/batch (prompt) | Total | +|---|---|---|---| +| Before (C1 only) | 67 | ~2,000 | ~134,000 | +| After (C1+C2) | 20 | ~2,000 | ~40,000 | +| **Savings from C2** | | | **~94,000** | + +C1+C2 combined eliminate ~248,000 tokens from Phase 2 (down from ~301,500 to ~53,500, a ~82% Phase 2 reduction). + +--- + +### C3 — Remove language/framework addendums from file-analyzer batches + +**Root cause addressed:** `languages/typescript.md` (~600 tokens) and `frameworks/react.md` (~700 tokens) are read and injected into every file-analyzer batch prompt. For a TypeScript+React project with 20 batches (after C2), this costs 20 × 1,300 = 26,000 additional tokens — and the model already has deep knowledge of these languages from training. + +**Change:** Stop injecting addendum files into Phase 2 batch prompts entirely. The addendums remain injected into Phase 4 (architecture analyzer) where there is only **one** subagent call, making the cost acceptable. + +Instead, add a compact "Language and Framework Hints" reference section directly into `file-analyzer-prompt.md`. This section is a distilled, one-time addition (~150 tokens total) that captures the most useful patterns from all addendums in a concise lookup table. + +#### New section in `file-analyzer-prompt.md` (replace addendum injection) + +```markdown +## Language and Framework Quick Reference + +Use these hints to improve tag and edge accuracy. These supplement your training knowledge. + +| Signal | Tag(s) | Note | +|---|---|---| +| File in `hooks/`, exports function starting with `use` | `hook`, `service` | React custom hook | +| File in `contexts/`, exports a Provider | `service`, `state` | React context | +| File in `pages/` or `views/` | `ui`, `routing` | Page-level component | +| File in `store/`, `slices/`, `reducers/` | `state` | State management | +| File in `services/`, `api/` | `service` | Data-fetching / API client | +| `__init__.py` with re-exports | `entry-point`, `barrel` | Python package root | +| `manage.py` at project root | `entry-point` | Django management entry | +| File named `mod.rs` | `barrel` | Rust module barrel | +| File named `main.go` in `cmd/` | `entry-point` | Go binary entry | + +For React: create `depends_on` edges from components to hooks they call. Create `publishes`/`subscribes` edges for Context provider/consumer patterns. +``` + +#### SKILL.md Phase 2 change + +Remove steps 2 and 3 from the "Build the combined prompt template" block: +- **Remove:** Step 2 (Language context injection — read `./languages/.md` per detected language) +- **Remove:** Step 3 (Framework addendum injection — read `./frameworks/.md` per detected framework) +- **Keep:** Step 1 (Read the base template at `./file-analyzer-prompt.md`) + +The addendum injection steps **remain unchanged** in Phase 4 (architecture analyzer), since they run once. + +#### Token savings estimate + +| | Batches | Addendum tokens/batch | Total | +|---|---|---|---| +| Before (after C2) | 20 | ~1,300 (TS+React) | ~26,000 | +| After | 20 | ~150 (inline hints) | ~3,000 | +| **Savings** | | | **~23,000** | + +--- + +### C4 — Slim Phase 4 and Phase 5 payloads + +**Root cause addressed:** Phase 5 (tour builder) receives all nodes (file + function + class) and all edges (imports + contains + calls + exports + ...). For a 500-file project, this can include 1,500+ nodes and 3,000+ edges. Most of this data is not needed for tour design. + +#### Phase 4 (Architecture Analyzer) — minor trim + +Phase 4 already only sends file-type nodes, which is correct. Minor change: explicitly strip `languageNotes` from each node object in the payload (it's not useful for layer assignment and can be verbose). Also strip `name` — it is always derivable as the basename of `filePath`. + +**Before per node:** `{id, name, filePath, summary, tags, complexity, languageNotes?}` +**After per node:** `{id, filePath, summary, tags}` + +Savings: ~15-20% fewer tokens per node, ~3,000–5,000 tokens total for Phase 4. + +#### Phase 5 (Tour Builder) — major trim + +Three changes to what the orchestrator injects into the tour-builder subagent: + +**1. File nodes only (strip function/class nodes)** + +The tour references node IDs for wayfinding. In practice the tour always references `file:` nodes — function and class nodes are visible in the dashboard's NodeInfo sidebar once a file is selected, but the tour itself navigates at the file level. + +- **Before:** all nodes (file + function + class) — for 500 files, maybe 1,500+ nodes +- **After:** file-type nodes only — 500 nodes + +**2. Slim node format** + +The tour builder script only uses node IDs, names, and types for graph computation. Summaries and tags are used in Phase 2 (pedagogical narrative writing). Strip heavy optional fields from the injected payload: + +- **Before per node:** `{id, name, filePath, summary, type, tags, complexity, languageNotes?}` +- **After per node:** `{id, name, filePath, summary, type}` (drop tags, complexity, languageNotes) + +**3. Slim edges (imports + calls only) and slim layers** + +The tour's BFS traversal only traverses `imports` and `calls` edges. `contains`, `exports`, `tested_by`, `depends_on`, and other edge types add no value to the traversal and inflate the payload. + +- **Before edges:** all edge types (~3,000+ edges including all `contains` edges to function/class nodes) +- **After edges:** only `imports` and `calls` edge types (~400–800 edges for typical projects) + +For layers, the tour builder uses layer data only to inform the tour's narrative arc (which layer to introduce first, second, etc.). It does not need the full `nodeIds` arrays — those can be very large. + +- **Before per layer:** `{id, name, description, nodeIds: [...hundreds of IDs]}` +- **After per layer:** `{id, name, description}` (drop nodeIds) + +#### Token savings estimate (Phase 5) + +| Data | Before | After | +|---|---|---| +| Node count | ~1,500 × ~180 chars | ~500 × ~120 chars | +| Node tokens | ~67,500 | ~15,000 | +| Edge count | ~3,000 × ~80 chars | ~600 × ~80 chars | +| Edge tokens | ~60,000 | ~12,000 | +| Layer tokens | ~5,000 | ~500 | +| **Phase 5 total** | **~132,500** | **~27,500** | +| **Savings** | | **~105,000** | + +#### SKILL.md changes + +In **Phase 4** dispatch prompt template, update the file node format: +``` +File nodes: +[list of {id, filePath, summary, tags} for all file-type nodes] +``` + +In **Phase 5** dispatch prompt template, update all three payload specs: +``` +Nodes (file nodes only): +[list of {id, name, filePath, summary, type} for all file-type nodes only — do NOT include function or class nodes] + +Key edges (imports and calls only): +[list of edges where type is "imports" or "calls" only] + +Layers: +[list of {id, name, description} — omit nodeIds] +``` + +--- + +### C5 — Gate the graph-reviewer subagent behind `--review` + +**Root cause addressed:** The graph-reviewer subagent (Phase 6) reads the entire assembled graph (~500 nodes, all edges, layers, tour) and runs a LLM-powered validation. However, its Phase 1 is entirely a deterministic script, and its Phase 2 is a simple threshold decision: if `issues.length === 0`, approve. There is no LLM judgment needed for the happy path. + +**Change:** By default, skip the graph-reviewer subagent. The orchestrator performs inline deterministic validation using a pre-written script. Only when `--review` is explicitly passed in `$ARGUMENTS` does the full LLM reviewer subagent run. + +#### Default path (no `--review`) + +In Phase 6, instead of dispatching the graph-reviewer subagent, the orchestrator: + +1. Writes a compact validation script inline (embedded in SKILL.md, ~50 lines of Node.js): + - Check: every edge source/target references a real node ID + - Check: every file node appears in exactly one layer + - Check: every tour step nodeId exists + - Check: no duplicate node IDs + - Check: required fields present on nodes and edges +2. Runs the script against `assembled-graph.json` +3. If `issues.length === 0`: proceed to Phase 7 (save) +4. If `issues.length > 0`: apply the same automated fixes as before (remove dangling edges, fill defaults), then save + +This is sufficient for standard runs. The LLM reviewer adds value for catching subtle quality issues (generic summaries, orphan nodes, tour step coherence) — but those are nice-to-have, not blocking. + +#### `--review` path + +When `--review` is in `$ARGUMENTS`, the full graph-reviewer subagent runs as it does today. No change to that code path. + +#### Token savings estimate + +| Path | Tokens | +|---|---| +| Current (always runs LLM reviewer) | ~58,000 input + ~500 output | +| Default (inline script, no LLM) | ~0 | +| `--review` (unchanged) | ~58,000 (same as current) | +| **Savings for default runs** | **~58,500** | + +--- + +## Combined savings summary + +| Change | Tokens before | Tokens after | Savings | +|---|---|---|---| +| C1+C2: import map + batch consolidation | ~301,500 | ~53,500 | ~248,000 | +| C3: remove addendums from batches | ~26,000 | ~3,000 | ~23,000 | +| C4: slim Phase 4+5 payloads | ~154,500 | ~33,000 | ~121,500 | +| C5: gate reviewer (default path) | ~58,500 | ~0 | ~58,500 | +| **Total** | **~540,500** | **~89,500** | **~451,000 (~83%)** | + +Estimates are for a 500-file TypeScript+React project. Actual savings scale with project size — a 1,000-file project would see proportionally larger savings from C1+C2 (more batches = more repetition eliminated). + +--- + +## File changes + +| File | Change | +|---|---| +| `skills/understand/project-scanner-prompt.md` | Add Step 8 (import resolution); add `importMap` to output schema | +| `skills/understand/file-analyzer-prompt.md` | Replace `allProjectFiles` with `batchImportData` in input schema; update extraction script to use pre-resolved imports; add compact Language/Framework Quick Reference section; remove addendum injection steps | +| `skills/understand/SKILL.md` | Phase 1: note importMap in scan result; Phase 2: remove addendum injection (steps 2+3), increase batch size 5-10→20-30, increase concurrency 3→5, replace `allProjectFiles` injection with `batchImportData` slice; Phase 4: slim node format in dispatch; Phase 5: file nodes only + slim edges + slim layers in dispatch; Phase 6: conditional reviewer — default inline script, `--review` flag for LLM reviewer | +| `skills/understand/architecture-analyzer-prompt.md` | No change (addendums still injected here) | +| `skills/understand/tour-builder-prompt.md` | Update input schema to reflect file-only nodes, imports+calls-only edges, slim layer format | +| `skills/understand/graph-reviewer-prompt.md` | No change (only used when `--review` flag is passed) | + +--- + +## Risks and mitigations + +| Risk | Likelihood | Mitigation | +|---|---|---| +| Scanner import resolution misses edge cases (complex re-exports, dynamic imports) | Medium | Log unresolved imports; file-analyzer still uses resolved data and creates edges only for confirmed matches. Missed imports = missing edges, which is same behavior as before for unresolvable imports | +| Larger batches (C2) reduce summary quality | Low | Summary quality is driven by the model's analysis of individual files. Batch size mainly affects how many files share one subagent's context window, not per-file quality. 20-30 files remains well within context limits | +| Stripping function/class nodes from tour (C4) breaks existing tour steps | None | Tour steps reference `file:` node IDs. No existing tour data references function/class nodes at the step level | +| Removing reviewer by default (C5) misses graph errors | Low | The inline deterministic script catches all critical structural issues (dangling refs, missing layers, duplicate IDs). The LLM reviewer's additional value is quality warnings (orphan nodes, generic summaries), which are non-blocking | +| Import map generation slows down Phase 1 | Low | The scanner script already reads all files for line counting. Import parsing adds one regex pass per file — negligible overhead | + +--- + +## Phased rollout recommendation + +Given the risk profile, implement in this order: + +1. **C5 first** — gate the reviewer, lowest risk, immediate 58K token savings per run +2. **C4** — slim Phase 5 payload, no scanner changes, no quality risk +3. **C3** — remove addendums from batches, add inline hints +4. **C1+C2 together** — scanner changes and batch consolidation, test thoroughly on small/medium/large projects before releasing diff --git a/docs/plans/2026-03-27-token-reduction-impl.md b/docs/plans/2026-03-27-token-reduction-impl.md new file mode 100644 index 0000000..848276b --- /dev/null +++ b/docs/plans/2026-03-27-token-reduction-impl.md @@ -0,0 +1,971 @@ +# Token Reduction Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Reduce `/understand` token cost by ~85% on large codebases through import pre-resolution, batch consolidation, addendum removal, payload slimming, and gating the LLM reviewer. + +**Architecture:** Five changes (C5 → C4 → C3 → C1+C2) applied in rollout order — lowest risk first. All changes are to prompt/skill markdown files in `understand-anything-plugin/skills/understand/`. No TypeScript source changes required. + +**Tech Stack:** Markdown skill files, Node.js inline scripts embedded in SKILL.md, knowledge-graph JSON pipeline. + +**Design doc:** `docs/plans/2026-03-27-token-reduction-design.md` + +--- + +## Task 1: C5 — Gate graph-reviewer behind `--review` flag + +Replaces the always-on LLM graph-reviewer subagent with a deterministic inline validation script. The LLM reviewer only runs when `--review` is in `$ARGUMENTS`. Saves ~58,500 tokens per default run. + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 6, lines 330–362) + +### Step 1: Open SKILL.md and locate Phase 6 + +Read the file and find "## Phase 6 — REVIEW" (line 297). Identify steps 3–6 (lines 330–362) which currently always dispatch the LLM graph-reviewer subagent. + +### Step 2: Replace Phase 6 steps 3–6 with conditional reviewer logic + +Replace lines 330–362 (from "3. Dispatch a subagent using the prompt template" through "6. **If `approved: true`:** Proceed to Phase 7.") with: + +```markdown +3. **Check `$ARGUMENTS` for `--review` flag.** Then run the appropriate validation path: + +--- + +#### Default path (no `--review`): inline deterministic validation + +Write the following Node.js script to `$PROJECT_ROOT/.understand-anything/tmp/ua-inline-validate.js`: + +```javascript +#!/usr/bin/env node +const fs = require('fs'); +const graphPath = process.argv[2]; +const outputPath = process.argv[3]; +try { + const graph = JSON.parse(fs.readFileSync(graphPath, 'utf8')); + const issues = [], warnings = []; + const nodeIds = new Set(); + const seen = new Map(); + graph.nodes.forEach((n, i) => { + if (!n.id) { issues.push(`Node[${i}] missing id`); return; } + if (!n.type) issues.push(`Node[${i}] '${n.id}' missing type`); + if (!n.name) issues.push(`Node[${i}] '${n.id}' missing name`); + if (!n.summary) issues.push(`Node[${i}] '${n.id}' missing summary`); + if (!n.tags || !n.tags.length) issues.push(`Node[${i}] '${n.id}' missing tags`); + if (seen.has(n.id)) issues.push(`Duplicate node ID '${n.id}' at indices ${seen.get(n.id)} and ${i}`); + else seen.set(n.id, i); + nodeIds.add(n.id); + }); + graph.edges.forEach((e, i) => { + if (!nodeIds.has(e.source)) issues.push(`Edge[${i}] source '${e.source}' not found`); + if (!nodeIds.has(e.target)) issues.push(`Edge[${i}] target '${e.target}' not found`); + }); + const fileNodes = graph.nodes.filter(n => n.type === 'file').map(n => n.id); + const assigned = new Map(); + (graph.layers || []).forEach(layer => { + (layer.nodeIds || []).forEach(id => { + if (!nodeIds.has(id)) issues.push(`Layer '${layer.id}' refs missing node '${id}'`); + if (assigned.has(id)) issues.push(`Node '${id}' appears in multiple layers`); + assigned.set(id, layer.id); + }); + }); + fileNodes.forEach(id => { + if (!assigned.has(id)) issues.push(`File node '${id}' not in any layer`); + }); + (graph.tour || []).forEach((step, i) => { + (step.nodeIds || []).forEach(id => { + if (!nodeIds.has(id)) issues.push(`Tour step[${i}] refs missing node '${id}'`); + }); + }); + const withEdges = new Set([ + ...graph.edges.map(e => e.source), + ...graph.edges.map(e => e.target) + ]); + graph.nodes.forEach(n => { + if (!withEdges.has(n.id)) warnings.push(`Node '${n.id}' has no edges (orphan)`); + }); + const stats = { + totalNodes: graph.nodes.length, + totalEdges: graph.edges.length, + totalLayers: (graph.layers || []).length, + tourSteps: (graph.tour || []).length, + nodeTypes: graph.nodes.reduce((a, n) => { a[n.type] = (a[n.type]||0)+1; return a; }, {}), + edgeTypes: graph.edges.reduce((a, e) => { a[e.type] = (a[e.type]||0)+1; return a; }, {}) + }; + fs.writeFileSync(outputPath, JSON.stringify({ issues, warnings, stats }, null, 2)); + process.exit(0); +} catch (err) { process.stderr.write(err.message + '\n'); process.exit(1); } +``` + +Execute it: +```bash +node $PROJECT_ROOT/.understand-anything/tmp/ua-inline-validate.js \ + "$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json" \ + "$PROJECT_ROOT/.understand-anything/intermediate/review.json" +``` + +If the script exits non-zero, read stderr, fix the script, and retry once. + +--- + +#### `--review` path: full LLM reviewer + +If `--review` IS in `$ARGUMENTS`, dispatch the LLM graph-reviewer subagent as follows: + +Dispatch a subagent using the prompt template at `./graph-reviewer-prompt.md`. Read the template file and pass the full content as the subagent's prompt, appending the following additional context: + +> **Additional context from main session:** +> +> Phase 1 scan results (file inventory): +> ```json +> [list of {path, sizeLines} from scan-result.json] +> ``` +> +> Phase warnings/errors accumulated during analysis: +> - [list any batch failures, skipped files, or warnings from Phases 2-5] +> +> Cross-validate: every file in the scan inventory should have a corresponding `file:` node in the graph. Flag any missing files. Also flag any graph nodes whose `filePath` doesn't appear in the scan inventory. + +Pass these parameters in the dispatch prompt: + +> Validate the knowledge graph at `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`. +> Project root: `$PROJECT_ROOT` +> Read the file and validate it for completeness and correctness. +> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/review.json` + +--- + +4. Read `$PROJECT_ROOT/.understand-anything/intermediate/review.json`. + +5. **If `issues` array is non-empty:** + - Review the `issues` list + - Apply automated fixes where possible: + - Remove edges with dangling references + - Fill missing required fields with sensible defaults (e.g., empty `tags` -> `["untagged"]`, empty `summary` -> `"No summary available"`) + - Remove nodes with invalid types + - Re-run the final graph validation after automated fixes + - If critical issues remain after one fix attempt, save the graph anyway but include the warnings in the final report and mark dashboard auto-launch as skipped + +6. **If `issues` array is empty:** Proceed to Phase 7. +``` + +### Step 3: Verify the edit + +Re-read SKILL.md lines 297–380 and confirm: +- Phase 6 step 3 now checks for `--review` flag +- The inline validation script is present and complete +- The `--review` path still dispatches the LLM subagent identically to before +- Steps 4–6 handle the `review.json` output the same way as before + +### Step 4: Commit + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md +git commit -m "perf(understand): gate LLM graph-reviewer behind --review flag, add inline deterministic validation" +``` + +--- + +## Task 2: C4a — Slim Phase 4 (architecture) node payload + +Removes `name` and `languageNotes` from the file node format injected into the architecture-analyzer subagent. These fields are not needed for architectural layer assignment and add unnecessary tokens. + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 4, around line 188–196) + +### Step 1: Locate the Phase 4 dispatch prompt in SKILL.md + +Find the block starting "Pass these parameters in the dispatch prompt:" under Phase 4 (around line 181). Look for: + +``` +> File nodes: +> ```json +> [list of {id, name, filePath, summary, tags} for all file-type nodes] +> ``` +``` + +### Step 2: Update the file node format + +Change the file nodes line from: +``` +> [list of {id, name, filePath, summary, tags} for all file-type nodes] +``` + +To: +``` +> [list of {id, filePath, summary, tags} for all file-type nodes — omit name, complexity, languageNotes] +``` + +### Step 3: Verify + +Re-read Phase 4 and confirm the node format line is updated. Import edges line below it (`[list of edges with type "imports"]`) is unchanged. + +### Step 4: Commit + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md +git commit -m "perf(understand): slim Phase 4 architecture payload — drop redundant node fields" +``` + +--- + +## Task 3: C4b — Slim Phase 5 (tour builder) payload + +Phase 5 currently injects all nodes (including function/class), all edge types, and full layer objects (with nodeIds arrays). Only file nodes, import+calls edges, and slim layers are needed for tour design. This is the largest single payload change, saving ~105,000 tokens on a 500-file project. + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 5, lines 257–270) +- Modify: `understand-anything-plugin/skills/understand/tour-builder-prompt.md` (input schema) + +### Step 1: Locate the Phase 5 dispatch prompt in SKILL.md + +Find the block starting with (around line 257): +``` +> Nodes (summarized): +> ```json +> [list of {id, name, filePath, summary, type} for key nodes] +> ``` +> +> Layers: +> ```json +> [layers from Phase 4] +> ``` +> +> Key edges: +> ```json +> [imports and calls edges] +> ``` +``` + +### Step 2: Replace all three payload sections + +Replace those lines with: + +```markdown +> Nodes (file nodes only): +> ```json +> [list of {id, name, filePath, summary, type} for file-type nodes ONLY — do NOT include function or class nodes] +> ``` +> +> Layers: +> ```json +> [list of {id, name, description} for each layer — omit nodeIds] +> ``` +> +> Edges (imports and calls only): +> ```json +> [list of edges where type is "imports" or "calls" only — exclude all other edge types] +> ``` +``` + +### Step 3: Update tour-builder-prompt.md input schema + +Open `tour-builder-prompt.md` and find the "Script Requirements" section (around line 18–35). The input schema currently shows: +```json +{ + "nodes": [...], + "edges": [...], + "layers": [ + {"id": "layer:core", "name": "Core", "nodeIds": ["file:src/index.ts"]} + ] +} +``` + +Update the layers example to reflect the slim format: +```json +{ + "nodes": [ + {"id": "file:src/index.ts", "type": "file", "name": "index.ts", "filePath": "src/index.ts", "summary": "..."} + ], + "edges": [ + {"source": "file:src/index.ts", "target": "file:src/utils.ts", "type": "imports"} + ], + "layers": [ + {"id": "layer:core", "name": "Core", "description": "Core application logic"} + ] +} +``` + +Also update the "G. Node Summary Index" description (around line 84) to reflect that input nodes are file-type only: + +Find: +``` +**G. Node Summary Index** + +Create a lookup of each node ID to its `summary`, `type`, `tags` (default to empty array `[]` if not present in input), and `name` for easy reference. +``` + +Add a note after it: +``` +Note: input nodes are file-type only. The nodeSummaryIndex will contain only file nodes. +``` + +### Step 4: Verify + +- Re-read SKILL.md Phase 5 payload block: confirms file-only nodes, slim layers (no nodeIds), imports+calls edges only +- Re-read tour-builder-prompt.md input schema: layers no longer have nodeIds + +### Step 5: Commit + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md \ + understand-anything-plugin/skills/understand/tour-builder-prompt.md +git commit -m "perf(understand): slim Phase 5 tour payload — file nodes only, imports+calls edges, slim layers" +``` + +--- + +## Task 4: C3 — Remove language/framework addendums from file-analyzer batches + +The addendums (`languages/typescript.md`, `frameworks/react.md`, etc.) are currently injected into every file-analyzer batch prompt. They cost ~1,300 tokens × N batches. The model already knows these languages. Replace with a compact inline reference table (~150 tokens, paid once, embedded in the base template). + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 2, lines 104–117) +- Modify: `understand-anything-plugin/skills/understand/file-analyzer-prompt.md` (add quick reference section) + +### Step 1: Update the "Build the combined prompt template" block in SKILL.md Phase 2 + +Find the block at lines 104–117: +``` +**Build the combined prompt template:** +1. Read the base template at `./file-analyzer-prompt.md`. +2. **Language context injection:** ... +3. **Framework addendum injection:** ... + +Then for each batch pass the combined template content as the subagent's prompt, appending the following additional context: + +> **Additional context from main session:** +> +> Project: `` — `` +> Frameworks detected: `` +> Languages: `` +> +> Use the language context and framework addendums (appended above) to produce more accurate summaries and better classify file roles. +``` + +Replace it with: +```markdown +**Build the prompt for each batch:** +1. Read the base template at `./file-analyzer-prompt.md`. (Language and framework hints are embedded in the template — do NOT append addendum files for Phase 2 batches. Addendums are reserved for Phase 4.) + +Then for each batch pass the template content as the subagent's prompt, appending the following additional context: + +> **Additional context from main session:** +> +> Project: `` — `` +> Languages: `` +``` + +This removes steps 2 and 3 (the addendum injection loops) entirely from Phase 2. + +### Step 2: Add Language and Framework Quick Reference to file-analyzer-prompt.md + +Open `file-analyzer-prompt.md`. Find the "## Critical Constraints" section near the bottom (around line 299). Insert the following new section **before** "## Critical Constraints": + +```markdown +## Language and Framework Quick Reference + +Use these hints to improve tag and edge accuracy for common patterns. Your training knowledge covers these — this is a fast lookup for the most impactful signals. + +**Tag signals:** + +| Signal | Tags to apply | +|---|---| +| File in `hooks/`, exports a function starting with `use` | `hook`, `service` | +| File in `contexts/` or `context/`, exports a Provider component | `service`, `state` | +| File in `pages/` or `views/` | `ui`, `routing` | +| File in `store/`, `slices/`, `reducers/`, `state/` | `state` | +| File in `services/`, `api/`, `client/` | `service` | +| `__init__.py` at a package root with re-exports | `entry-point`, `barrel` | +| `manage.py` at the project root | `entry-point` | +| `mod.rs` in a directory | `barrel` | +| `main.go` in a `cmd/` subdirectory | `entry-point` | + +**Edge signals:** + +| Pattern | Edge to create | +|---|---| +| React component renders another component in its JSX | `contains` from parent to child | +| Component/hook calls a custom hook (`useX`) | `depends_on` from consumer to hook file | +| Context provider wraps components | `publishes` from provider to context definition | +| Component calls `useContext` or custom context hook | `subscribes` from consumer to context definition | +| Python file uses `from x import y` where x is a project file | `imports` edge (same rule as JS/TS) | +| Go file `import`s an internal package path | `imports` edge to the resolved file | + +``` + +### Step 3: Verify + +- Re-read SKILL.md Phase 2 "Build the prompt" block: steps 2 and 3 (addendum loops) are gone; "Frameworks detected" line in additional context is gone +- Re-read file-analyzer-prompt.md: new "Language and Framework Quick Reference" section appears before Critical Constraints; no reference to addendum files +- Confirm Phase 4 "Build the combined prompt template" (lines 163–167) is **unchanged** — addendums still apply there + +### Step 4: Commit + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md \ + understand-anything-plugin/skills/understand/file-analyzer-prompt.md +git commit -m "perf(understand): remove addendum injection from Phase 2 batches, add compact inline hints to file-analyzer" +``` + +--- + +## Task 5: C1a — Extend scanner to pre-resolve imports + +Adds a new Step 8 to the project scanner script: parse import statements from every source file and resolve relative imports against the discovered file list. The resolved map is written into `scan-result.json` as `importMap`. This is the data that lets us eliminate `allProjectFiles` from every batch in Task 7. + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/project-scanner-prompt.md` + +### Step 1: Add Step 8 to the scanner script requirements + +Open `project-scanner-prompt.md`. Find "**Step 7 -- Project Name**" (around line 100). After its content (the priority list), add a new step: + +```markdown +**Step 8 -- Import Resolution** + +For each file in the discovered source list, extract and resolve relative import statements. The goal is to produce a map from each file's path to the list of project-internal files it imports. External package imports are ignored. + +For each file, read its content and extract import paths using language-appropriate patterns: + +| Language | Import patterns to match | +|---|---| +| TypeScript/JavaScript | `import ... from './...'` or `'../'`, `require('./...')` or `require('../...')` | +| Python | `from .x import y`, `from ..x import y`, `import .x` (relative only) | +| Go | Paths in `import (...)` blocks that start with the module path from `go.mod` | +| Rust | `use crate::`, `use super::`, `mod x` (within the same crate) | +| Java/Kotlin | Not resolvable by path — skip import resolution for these languages | +| Ruby | `require_relative '...'` paths | + +For each extracted import path: +1. Compute the resolved file path relative to project root: + - For relative imports (`./x`, `../x`): resolve from the importing file's directory + - Try these extension variants in order if the import has no extension: `.ts`, `.tsx`, `.js`, `.jsx`, `/index.ts`, `/index.js`, `/index.tsx`, `/index.jsx`, `.py`, `.go`, `.rs`, `.rb` +2. Check if the resolved path exists in the discovered file list +3. If yes: add to this file's resolved imports list +4. If no: skip (external, unresolvable, or dynamic import) + +Output format in the script result: +```json +"importMap": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/utils.ts": [], + "src/components/App.tsx": ["src/hooks/useAuth.ts", "src/store/index.ts"] +} +``` + +Keys are project-relative paths. Values are arrays of resolved project-relative paths. Every key in the file list must appear in `importMap` (use an empty array `[]` if no imports were resolved). External packages and unresolvable imports are omitted entirely. +``` + +### Step 2: Update the scanner script output format + +Find the "### Script Output Format" section (around line 109) and update the example JSON to include `importMap`: + +Find this in the example: +```json +{ + "scriptCompleted": true, + "name": "project-name", + ... + "estimatedComplexity": "moderate" +} +``` + +Add `importMap` to the example: +```json +{ + "scriptCompleted": true, + "name": "project-name", + "rawDescription": "...", + "readmeHead": "...", + "languages": ["javascript", "typescript"], + "frameworks": ["React", "Vite"], + "files": [ + {"path": "src/index.ts", "language": "typescript", "sizeLines": 150} + ], + "totalFiles": 42, + "estimatedComplexity": "moderate", + "importMap": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/utils.ts": [] + } +} +``` + +Also update the field documentation list below the example to add: +``` +- `importMap` (object) — map from every source file path to its list of resolved project-internal import paths; empty array if no resolved imports; external packages excluded +``` + +### Step 3: Update the final assembly section to preserve importMap + +Find "## Phase 2 -- Description and Final Assembly" (around line 153). Find the IMPORTANT note: +``` +**IMPORTANT:** The final output must NOT contain the `scriptCompleted`, `rawDescription`, or `readmeHead` fields. +``` + +Update it to: +``` +**IMPORTANT:** The final output must NOT contain the `scriptCompleted`, `rawDescription`, or `readmeHead` fields. All other fields — including `importMap` — MUST be preserved exactly as output by the script. +``` + +Also update the final output example to include `importMap`: +```json +{ + "name": "project-name", + "description": "...", + "languages": ["typescript"], + "frameworks": ["React"], + "files": [...], + "totalFiles": 42, + "estimatedComplexity": "moderate", + "importMap": { + "src/index.ts": ["src/utils.ts"] + } +} +``` + +### Step 4: Verify + +Re-read `project-scanner-prompt.md` and confirm: +- Step 8 is present with full import resolution logic +- Script output format includes `importMap` +- Field documentation includes `importMap` +- Final assembly section preserves `importMap` in output + +### Step 5: Commit + +```bash +git add understand-anything-plugin/skills/understand/project-scanner-prompt.md +git commit -m "perf(understand): extend scanner to pre-resolve imports, output importMap in scan-result.json" +``` + +--- + +## Task 6: C1b — Update file-analyzer to use batchImportData + +Removes `allProjectFiles` from the file-analyzer input schema and replaces it with `batchImportData` (pre-resolved imports for this batch's files only). Updates the extraction script section to skip import resolution entirely (already done by scanner). Updates the edge creation step to use `batchImportData` directly. + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/file-analyzer-prompt.md` + +### Step 1: Update the input JSON schema (Script Requirements, step 1) + +Find the input schema block around line 19: +```json +{ + "projectRoot": "/path/to/project", + "allProjectFiles": ["src/index.ts", "src/utils.ts", "..."], + "batchFiles": [ + {"path": "src/index.ts", "language": "typescript", "sizeLines": 150}, + {"path": "src/utils.ts", "language": "typescript", "sizeLines": 80} + ] +} +``` + +Replace with: +```json +{ + "projectRoot": "/path/to/project", + "batchFiles": [ + {"path": "src/index.ts", "language": "typescript", "sizeLines": 150}, + {"path": "src/utils.ts", "language": "typescript", "sizeLines": 80} + ], + "batchImportData": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/utils.ts": [] + } +} +``` + +Update the field descriptions: +- Remove: `allProjectFiles` description +- Add: `batchImportData` (object) — map from each batch file's project-relative path to its list of pre-resolved project-internal imports. Produced by the project scanner. Use this directly for import edge creation — do NOT attempt to re-resolve imports yourself. + +### Step 2: Remove the imports extraction from "What the Script Must Extract" + +Find the "**Imports:**" subsection under "What the Script Must Extract" (around lines 49–53): +``` +**Imports:** +- Source module path (exactly as written in the import statement) +- Imported specifiers (named imports, default import, namespace import) +- Line number +- For relative imports (starting with `./` or `../`), compute the resolved path... +``` + +Replace this entire subsection with: +```markdown +**Imports:** +- Do NOT extract imports in the script. Import resolution has already been performed by the project scanner. +- The pre-resolved imports for each file are provided in `batchImportData` in the input JSON. +- Do not include an `imports` field in the script output — import edges will be created in Phase 2 using `batchImportData` directly. +``` + +### Step 3: Update the script output format to remove imports + +Find the `results` array in the script output format (around line 67). The current `imports` array in the output: +```json +"imports": [ + {"source": "./utils", "resolvedPath": "src/utils.ts", "specifiers": ["formatDate"], "line": 1, "isExternal": false}, + {"source": "express", "resolvedPath": null, "specifiers": ["default"], "line": 2, "isExternal": true} +], +``` + +Remove the `imports` array from the script output format entirely. The result for each file should be: +```json +{ + "path": "src/index.ts", + "language": "typescript", + "totalLines": 150, + "nonEmptyLines": 120, + "functions": [...], + "classes": [...], + "exports": [...], + "metrics": { + "importCount": 5, + "exportCount": 3, + "functionCount": 4, + "classCount": 1 + } +} +``` + +Keep `metrics.importCount` (derived from `batchImportData[path].length`) as a useful metric. + +Update the metrics description to say: +``` +- `importCount` (integer) — use `batchImportData[file.path].length` from the input JSON +``` + +### Step 4: Update "Preparing the Script Input" section + +Find the `cat` command around line 113 that creates the input JSON: +```bash +cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-.json << 'ENDJSON' +{ + "projectRoot": "", + "allProjectFiles": [], + "batchFiles": [] +} +ENDJSON +``` + +Replace with: +```bash +cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-.json << 'ENDJSON' +{ + "projectRoot": "", + "batchFiles": [], + "batchImportData": +} +ENDJSON +``` + +### Step 5: Update Step 3 (Create Edges) — Import edge creation rule + +Find the "**Import edge creation rule:**" in the "Step 3 -- Create Edges" section (around line 213): +``` +**Import edge creation rule:** For each import in the script output where `isExternal` is `false` and `resolvedPath` is non-null, create an `imports` edge from the current file node to `file:`. Do NOT create edges for external package imports. +``` + +Replace with: +```markdown +**Import edge creation rule:** For each resolved path in `batchImportData[filePath]` (provided in the input JSON), create an `imports` edge from the current file node to `file:`. The `batchImportData` values contain only resolved project-internal paths — external packages have already been filtered out. Do NOT attempt to re-resolve imports from source. +``` + +### Step 6: Remove `allProjectFiles` references from Critical Constraints + +Find the last bullet in "## Critical Constraints" (around line 304): +``` +- For import edges, use the script's `resolvedPath` field directly. Do NOT attempt to resolve import paths yourself -- the script already did this deterministically. +``` + +Replace with: +```markdown +- For import edges, use `batchImportData[filePath]` directly from the input JSON. Do NOT attempt to resolve import paths yourself -- the project scanner already did this deterministically. +``` + +### Step 7: Verify + +Re-read `file-analyzer-prompt.md` and confirm: +- Input schema has `batchImportData`, no `allProjectFiles` +- Script "What to Extract" section: imports extraction replaced with "do not extract" +- Script output format: no `imports` array per file +- Preparing the Script Input: cat command has no `allProjectFiles` +- Import edge creation rule: uses `batchImportData` not script output +- Critical Constraints: no reference to `resolvedPath` from script + +### Step 8: Commit + +```bash +git add understand-anything-plugin/skills/understand/file-analyzer-prompt.md +git commit -m "perf(understand): replace allProjectFiles with batchImportData in file-analyzer — import resolution now done by scanner" +``` + +--- + +## Task 7: C1c + C2 — Update SKILL.md Phase 2 orchestration + +Wires up the `importMap` from Phase 1 into per-batch `batchImportData` slices. Increases batch size from 5-10 to 20-30 files. Increases concurrency from 3 to 5. Removes `allProjectFiles` from the dispatch prompt. + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 0, Phase 1, Phase 2) + +### Step 1: Update Phase 1 to note importMap is now in scan-result.json + +Find Phase 1 (around line 62) where it says: +``` +After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermediate/scan-result.json` to get: +- Project name, description +- Languages, frameworks +- File list with line counts +- Complexity estimate +``` + +Add one item to the list: +``` +- Import map (`importMap`): pre-resolved project-internal imports per file +``` + +Also add a note: +``` +Store `importMap` in memory as `$IMPORT_MAP` for use in Phase 2 batch construction. +``` + +### Step 2: Change batch size and concurrency in Phase 2 + +Find line 100: +``` +Batch the file list from Phase 1 into groups of **5-10 files each** (aim for balanced batch sizes). +``` + +Replace with: +``` +Batch the file list from Phase 1 into groups of **20-30 files each** (aim for ~25 files per batch for balanced sizes). +``` + +Find line 102: +``` +For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **3 subagents concurrently** using parallel dispatch. +``` + +Replace with: +``` +For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **5 subagents concurrently** using parallel dispatch. +``` + +### Step 3: Add batchImportData construction to the dispatch block + +Find the dispatch prompt block (around lines 119–134): +``` +Fill in batch-specific parameters below and dispatch: + +> Analyze these source files and produce GraphNode and GraphEdge objects. +> Project root: `$PROJECT_ROOT` +> Project: `` +> Languages: `` +> Batch index: `` +> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-.json` +> +> All project files (for import resolution): +> `` +> +> Files to analyze in this batch: +> 1. `` ( lines) +> ... +``` + +Replace with: +```markdown +Before dispatching each batch, construct `batchImportData` from `$IMPORT_MAP`: +```json +batchImportData = {} +for each file in this batch: + batchImportData[file.path] = $IMPORT_MAP[file.path] ?? [] +``` + +Fill in batch-specific parameters below and dispatch: + +> Analyze these source files and produce GraphNode and GraphEdge objects. +> Project root: `$PROJECT_ROOT` +> Project: `` +> Languages: `` +> Batch index: `` +> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-.json` +> +> Pre-resolved import data for this batch (use this for all import edge creation — do NOT re-resolve imports from source): +> ```json +> +> ``` +> +> Files to analyze in this batch: +> 1. `` ( lines) +> 2. `` ( lines) +> ... +``` + +### Step 4: Update incremental update path + +Find "### Incremental update path" (around line 140): +``` +Use the changed files list from Phase 0. Batch and dispatch file-analyzer subagents using the same process as above, but only for changed files. +``` + +Update to clarify that batchImportData still applies: +``` +Use the changed files list from Phase 0. Batch and dispatch file-analyzer subagents using the same process as above (20-30 files per batch, up to 5 concurrent, with batchImportData constructed from $IMPORT_MAP), but only for changed files. +``` + +### Step 5: Verify all Phase 2 changes + +Re-read SKILL.md Phase 2 in full and confirm: +- Batch size says "20-30 files" +- Concurrency says "5 subagents concurrently" +- "Build the prompt" block: only step 1 (read base template), no addendum steps +- Additional context block: no "Frameworks detected" line, no addendum reference +- Dispatch prompt: has `batchImportData` injection, no `allProjectFiles` +- Incremental path: mentions batchImportData + +### Step 6: Commit + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md +git commit -m "perf(understand): wire importMap into batchImportData per batch, increase batch size 5-10→20-30, concurrency 3→5" +``` + +--- + +## Task 8: Version bump + +Per project convention, all four version files must stay in sync when changes are pushed. + +**Files:** +- Modify: `understand-anything-plugin/package.json` +- Modify: `.claude-plugin/marketplace.json` +- Modify: `.claude-plugin/plugin.json` +- Modify: `.cursor-plugin/plugin.json` + +### Step 1: Read current version + +```bash +node -e "const p = require('./understand-anything-plugin/package.json'); console.log(p.version)" +``` + +Expected: `1.2.1` (or whatever the current version is). + +### Step 2: Bump patch version in all four files + +New version: `1.2.2` (patch bump — internal optimization, no API changes). + +Update each file: +- `understand-anything-plugin/package.json`: `"version": "1.2.2"` +- `.claude-plugin/marketplace.json`: `"version": "1.2.2"` in `plugins[0]` +- `.claude-plugin/plugin.json`: `"version": "1.2.2"` +- `.cursor-plugin/plugin.json`: `"version": "1.2.2"` + +### Step 3: Verify all four files match + +```bash +grep -r '"version"' understand-anything-plugin/package.json .claude-plugin/marketplace.json .claude-plugin/plugin.json .cursor-plugin/plugin.json +``` + +All four should show `"version": "1.2.2"`. + +### Step 4: Commit + +```bash +git add understand-anything-plugin/package.json \ + .claude-plugin/marketplace.json \ + .claude-plugin/plugin.json \ + .cursor-plugin/plugin.json +git commit -m "chore: bump version to 1.2.2" +``` + +--- + +## Task 9: Build and smoke test + +Verifies all changes work end-to-end by running `/understand --full` against a real project. + +**Files:** None (testing only) + +### Step 1: Build the packages + +```bash +pnpm --filter @understand-anything/core build +pnpm --filter @understand-anything/skill build +``` + +Expected: both build without errors. + +### Step 2: Find installed plugin version and copy to cache + +```bash +ls ~/.claude/plugins/cache/understand-anything/understand-anything/ +``` + +Note the version (e.g., `1.0.1`). Copy local build into the cache: + +```bash +VERSION=$(node -e "const p = require('./understand-anything-plugin/package.json'); console.log(p.version)") +rm -rf ~/.claude/plugins/cache/understand-anything/understand-anything/$VERSION +cp -R ./understand-anything-plugin ~/.claude/plugins/cache/understand-anything/understand-anything/$VERSION +``` + +### Step 3: Smoke test on a small project (~20 files) + +Open a fresh Claude Code session in a small TypeScript project. Run: +``` +/understand --full +``` + +Verify: +- Phases 0–7 complete without errors +- `knowledge-graph.json` is created +- Node count and edge count are reasonable +- Layers and tour are present +- No "allProjectFiles" or addendum errors in the output + +### Step 4: Smoke test on a larger project (~100+ files) + +Run `/understand --full` on a medium/large TypeScript+React project. + +Verify: +- Batch count is ~4-6 (at 20-30 files per batch for 100 files), not 10-20 +- No errors about missing import resolution +- `importMap` is present in `scan-result.json` (check `.understand-anything/intermediate/` before cleanup, or add a temporary debug log) +- Graph quality is comparable to before (summaries are descriptive, layers are correct) + +### Step 5: Test `--review` flag + +Run `/understand --full --review` on the same project. + +Verify: +- Phase 6 now dispatches the LLM graph-reviewer subagent (not the inline script) +- `review.json` is produced with `approved` field +- Pipeline completes normally + +### Step 6: Final commit (if any fixes needed from smoke test) + +```bash +git add -A +git commit -m "fix(understand): smoke test fixes for token reduction changes" +``` + +--- + +## Summary + +| Task | Change | Risk | +|---|---|---| +| 1 | C5: Gate reviewer | Low | +| 2 | C4a: Slim Phase 4 payload | Low | +| 3 | C4b: Slim Phase 5 payload | Low | +| 4 | C3: Remove addendums from batches | Low | +| 5 | C1a: Scanner import resolution | Medium | +| 6 | C1b: File-analyzer uses batchImportData | Medium | +| 7 | C1c+C2: SKILL.md orchestration + batch size | Medium | +| 8 | Version bump | Low | +| 9 | Smoke test | — | + +Tasks 1–4 are independent of Tasks 5–7. They can be shipped separately if needed. Tasks 5, 6, and 7 are tightly coupled (scanner produces importMap → SKILL.md passes batchImportData → file-analyzer consumes it) and must be shipped together. diff --git a/docs/plans/2026-03-28-understand-anything-extension-design.md b/docs/plans/2026-03-28-understand-anything-extension-design.md new file mode 100644 index 0000000..adf36b9 --- /dev/null +++ b/docs/plans/2026-03-28-understand-anything-extension-design.md @@ -0,0 +1,266 @@ +# Understand Anything: Universal File Type Support + +**Date**: 2026-03-28 +**Status**: Approved +**Approach**: Big Bang — all file types in one release + +## Goals + +1. Extend Understand Anything to analyze **any** file type, not just code +2. Support both holistic project enrichment (non-code files enrich code graphs) and standalone analysis (docs-only repos, SQL schema collections, IaC projects) +3. Maintain backward compatibility with existing code-only analysis + +## Supported File Types (26 new) + +### Documentation (3) + +| Type | Extensions | Parser | Node Types | +|------|-----------|--------|------------| +| Markdown | `.md`, `.mdx` | LLM + regex heading extraction | `document` | +| reStructuredText | `.rst` | LLM | `document` | +| Plain text | `.txt` | LLM | `document` | + +### Configuration (5) + +| Type | Extensions | Parser | Node Types | +|------|-----------|--------|------------| +| YAML | `.yaml`, `.yml` | `yaml` npm package | `config` | +| JSON | `.json`, `.jsonc` | `JSON.parse` / `jsonc-parser` | `config`, `schema` | +| TOML | `.toml` | `@iarna/toml` or similar | `config` | +| .env | `.env`, `.env.*` | Regex line parser | `config` | +| XML | `.xml` | LLM (optionally `fast-xml-parser`) | `config` | + +### Infrastructure & DevOps (7) + +| Type | Extensions | Parser | Node Types | +|------|-----------|--------|------------| +| Dockerfile | `Dockerfile`, `Dockerfile.*`, `.dockerfile` | Custom instruction parser | `service`, `pipeline` | +| Docker Compose | `docker-compose.yml`, `compose.yml` | YAML parser + service extraction | `service` | +| Terraform | `.tf`, `.tfvars` | Regex block parser | `resource` | +| Kubernetes | K8s YAML (detected by `apiVersion` field) | YAML + kind detection | `service`, `resource` | +| GitHub Actions | `.github/workflows/*.yml` | YAML + job/step extraction | `pipeline` | +| Jenkinsfile | `Jenkinsfile` | LLM (Groovy DSL) | `pipeline` | +| Makefile | `Makefile`, `*.mk` | Regex target parser | `pipeline` | + +### Data & Schema (6) + +| Type | Extensions | Parser | Node Types | +|------|-----------|--------|------------| +| SQL | `.sql` | Simple DDL parser | `table`, `endpoint` | +| GraphQL | `.graphql`, `.gql` | Regex type/query parser | `schema`, `endpoint` | +| OpenAPI/Swagger | `openapi.yaml`, `swagger.json` | YAML/JSON + path extraction | `endpoint`, `schema` | +| Protocol Buffers | `.proto` | Regex message/service parser | `schema` | +| JSON Schema | `*.schema.json` | JSON + `$ref`/`$defs` extraction | `schema` | +| CSV/TSV | `.csv`, `.tsv` | Header row extraction | `table` | + +### Shell & Scripts (3) + +| Type | Extensions | Parser | Node Types | +|------|-----------|--------|------------| +| Shell | `.sh`, `.bash`, `.zsh` | Regex function parser | `file`, `function` | +| PowerShell | `.ps1`, `.psm1` | LLM | `file`, `function` | +| Batch | `.bat`, `.cmd` | LLM | `file` | + +### Markup (2) + +| Type | Extensions | Parser | Node Types | +|------|-----------|--------|------------| +| HTML | `.html`, `.htm` | LLM (tag structure) | `document` | +| CSS/SCSS/Less | `.css`, `.scss`, `.less` | LLM | `file` | + +## Schema Extensions + +### New Node Types (8) + +Added to the existing `file | function | class | module | concept`: + +| Node Type | Purpose | Example | +|-----------|---------|---------| +| `config` | Configuration files and key settings | `package.json`, `tsconfig.json`, env vars | +| `document` | Documentation, prose, guides | `README.md`, API docs | +| `service` | Deployable services/containers | Docker containers, K8s Deployments | +| `table` | Data tables, database objects | SQL tables, CSV datasets | +| `endpoint` | API routes, queries, mutations | REST paths, GraphQL queries | +| `pipeline` | CI/CD workflows, build steps | GitHub Actions jobs, Makefile targets | +| `schema` | Type definitions for data interchange | Protobuf messages, JSON Schema | +| `resource` | Infrastructure resources | Terraform resources, K8s ConfigMaps | + +### New Edge Types (8) + +Added to the existing 18 edge types: + +| Edge Type | Category | Meaning | Example | +|-----------|----------|---------|---------| +| `deploys` | Infrastructure | Service deploys code | Dockerfile -> app source | +| `serves` | Infrastructure | Service exposes endpoint | K8s Service -> API endpoint | +| `migrates` | Data flow | Migration modifies table | SQL migration -> table | +| `documents` | Semantic | Doc describes code | README -> module | +| `provisions` | Infrastructure | IaC creates resource | Terraform -> AWS resource | +| `routes` | Behavioral | Routes traffic to service | nginx config -> service | +| `defines_schema` | Data flow | Defines data shape | Protobuf -> endpoint | +| `triggers` | Behavioral | Triggers pipeline/action | Git push -> GitHub Actions | + +### Schema Validation Auto-Fix Aliases + +New node type aliases: +- `container` -> `service`, `migration` -> `table`, `workflow` -> `pipeline` +- `route` -> `endpoint`, `doc` -> `document`, `setting` -> `config`, `infra` -> `resource` + +New edge type aliases: +- `describes` -> `documents`, `creates` -> `provisions`, `exposes` -> `serves` + +## Plugin Architecture Changes + +### Generalized AnalyzerPlugin Interface + +```typescript +interface AnalyzerPlugin { + name: string; + languages: string[]; + analyzeFile(filePath: string, content: string): StructuralAnalysis; + resolveImports?(filePath: string, content: string): ImportResolution[]; // Now optional + extractCallGraph?(filePath: string, content: string): CallGraphEntry[]; + extractReferences?(filePath: string, content: string): ReferenceResolution[]; // NEW +} + +interface ReferenceResolution { + source: string; // File making the reference + target: string; // Referenced file or identifier + type: string; // Reference type: "file", "image", "schema", "service" + line?: number; +} +``` + +### Extended StructuralAnalysis + +```typescript +interface StructuralAnalysis { + // Existing (unchanged) + functions: FunctionInfo[]; + classes: ClassInfo[]; + imports: ImportInfo[]; + exports: ExportInfo[]; + // New (all optional for backward compat) + sections?: SectionInfo[]; // Documents: headings, chapters + definitions?: DefinitionInfo[]; // Schemas: types, messages, tables + services?: ServiceInfo[]; // Infra: containers, deployments + endpoints?: EndpointInfo[]; // APIs: routes, queries + steps?: StepInfo[]; // Pipelines: jobs, stages, targets + resources?: ResourceInfo[]; // IaC: terraform resources, K8s objects +} +``` + +### Custom Parsers (12) + +All lightweight — mostly regex-based, minimal dependencies: + +| Parser | Implementation | Extracts | +|--------|---------------|----------| +| `MarkdownParser` | Regex | Headings, links, code blocks, front matter | +| `YAMLParser` | `yaml` npm | Key hierarchy, anchors, multi-doc | +| `JSONParser` | Built-in `JSON.parse` | Key structure, `$ref`/`$defs` | +| `TOMLParser` | `@iarna/toml` | Section structure | +| `EnvParser` | Regex | Variable names and references | +| `DockerfileParser` | Regex | FROM stages, EXPOSE ports, COPY sources | +| `SQLParser` | Regex | CREATE TABLE/VIEW/INDEX, columns, foreign keys | +| `GraphQLParser` | Regex | Types, queries, mutations, subscriptions | +| `ProtobufParser` | Regex | Messages, services, enums, RPCs | +| `TerraformParser` | Regex | Resources, modules, variables, outputs | +| `MakefileParser` | Regex | Targets, dependencies, variables | +| `ShellParser` | Regex | Functions, sourced files | + +## Agent Pipeline Changes + +### Project Scanner + +1. Scan ALL file types (remove code-only filter) +2. Tag each file with category: `code`, `config`, `docs`, `infra`, `data`, `script`, `markup` +3. Smart batch grouping: keep related files together (e.g., Dockerfile + docker-compose.yml) + +### File Analyzer + +Type-aware prompt templates by category: + +- **Code**: Current behavior (functions, classes, imports, call graph) +- **Config**: Extract key settings, what they configure, which code files they affect +- **Documentation**: Extract sections, key concepts, which code components are documented +- **Infrastructure**: Extract services, ports, volumes, dependencies, which code they deploy +- **Data/Schema**: Extract tables, columns, types, relationships, which code consumes this data +- **Pipelines**: Extract jobs, steps, triggers, which code/infra they build/deploy + +### Cross-Type Reference Resolution + +Post-analysis step connecting: +- Dockerfile `COPY` -> source code directories +- CI config `run: npm test` -> test files +- K8s manifest `image:` -> Dockerfile +- SQL foreign keys -> other tables +- OpenAPI `$ref` -> schema definitions +- Markdown links -> referenced files + +### Architecture Analyzer + +New pattern detection: +- Deployment topology: Dockerfile -> compose -> K8s chain +- Data flow: Schema -> migration -> API endpoint -> client code +- Documentation coverage: which modules have docs vs. not +- Configuration dependency: which config files affect which code paths + +### Tour Builder + +Include non-code tour stops: +- Project README overview +- Dockerfile containerization +- SQL migration database schema +- CI/CD pipeline explanation + +## Dashboard Visualization + +### New Node Visual Styles + +| Node Type | Shape | Color | Icon | +|-----------|-------|-------|------| +| `config` | Rounded rect | Teal (#5eead4) | Gear | +| `document` | Rounded rect | Sky blue (#7dd3fc) | Document | +| `service` | Hexagon | Violet (#a78bfa) | Container/Box | +| `table` | Rectangle | Emerald (#6ee7b7) | Grid | +| `endpoint` | Pill/Stadium | Orange (#fdba74) | Arrow-right | +| `pipeline` | Rounded rect | Rose (#fda4af) | Play/Workflow | +| `schema` | Diamond | Amber (#fcd34d) | Blueprint | +| `resource` | Cloud shape | Indigo (#a5b4fc) | Cloud | + +### Graph Layout + +1. Layer grouping by category — non-code nodes cluster separately from code nodes +2. Legend update with 8 new node types +3. Filter controls — checkboxes to show/hide each file category + +### Sidebar Enhancements + +NodeInfo panel updates per node type: +- **Config**: key-value pairs, referencing code files +- **Document**: heading outline, linked code components +- **Service**: ports, volumes, dependencies, deployed code +- **Table**: columns, types, foreign key relationships +- **Endpoint**: HTTP method, path, request/response schema +- **Pipeline**: jobs, triggers, deployed targets +- **Schema**: fields, nested types, consumers +- **Resource**: provider, type, dependencies + +ProjectOverview panel: add "File Types" breakdown (code vs. non-code distribution). + +## New Dependencies + +- `yaml` — YAML parsing (already common, ~50KB) +- `@iarna/toml` — TOML parsing (~30KB) +- `jsonc-parser` — JSON with comments (~20KB) + +No tree-sitter WASM additions. All other parsers are regex-based with zero dependencies. + +## Backward Compatibility + +- All new `StructuralAnalysis` fields are optional +- `resolveImports` becomes optional on `AnalyzerPlugin` +- Existing `LanguageConfig` entries unchanged +- Schema validation auto-fixes new type aliases +- Existing knowledge graphs remain valid (new types are additive) diff --git a/docs/plans/2026-03-28-understand-anything-extension-impl.md b/docs/plans/2026-03-28-understand-anything-extension-impl.md new file mode 100644 index 0000000..431623b --- /dev/null +++ b/docs/plans/2026-03-28-understand-anything-extension-impl.md @@ -0,0 +1,1426 @@ +# Universal File Type Support — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extend Understand Anything to analyze 26+ non-code file types (Markdown, Dockerfile, YAML, SQL, Terraform, etc.) with new graph node/edge types, custom parsers, updated agent prompts, and dashboard visualization. + +**Architecture:** Extend the existing LanguageConfig + AnalyzerPlugin pipeline. Add 8 new node types and 8 new edge types to the schema. Build 12 lightweight regex/parser-based analyzers for structured formats, LLM-only for unstructured. Update all 5 agent prompts to handle non-code files. Add new node colors and sidebar rendering to the dashboard. + +**Tech Stack:** TypeScript, Zod, Vitest, React, React Flow, Zustand, TailwindCSS v4, `yaml` npm package, `@iarna/toml`, `jsonc-parser` + +**Design doc:** `docs/plans/2026-03-28-understand-anything-extension-design.md` + +--- + +## Task 1: Extend Core Types — Node & Edge Types + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/types.ts:1-116` +- Test: `understand-anything-plugin/packages/core/src/types.test.ts` + +**Step 1: Write the failing test** + +In `types.test.ts`, add a test that imports and verifies the new node types exist on GraphNode and edge types exist on EdgeType: + +```typescript +import { describe, it, expect } from "vitest"; +import type { GraphNode, GraphEdge, EdgeType, StructuralAnalysis } from "../types.js"; + +describe("Extended types", () => { + it("accepts all 13 node types", () => { + const nodeTypes: GraphNode["type"][] = [ + "file", "function", "class", "module", "concept", + "config", "document", "service", "table", "endpoint", + "pipeline", "schema", "resource", + ]; + expect(nodeTypes).toHaveLength(13); + }); + + it("accepts all 26 edge types", () => { + const edgeTypes: EdgeType[] = [ + "imports", "exports", "contains", "inherits", "implements", + "calls", "subscribes", "publishes", "middleware", + "reads_from", "writes_to", "transforms", "validates", + "depends_on", "tested_by", "configures", + "related", "similar_to", + "deploys", "serves", "migrates", "documents", + "provisions", "routes", "defines_schema", "triggers", + ]; + expect(edgeTypes).toHaveLength(26); + }); + + it("StructuralAnalysis has optional non-code fields", () => { + const analysis: StructuralAnalysis = { + functions: [], classes: [], imports: [], exports: [], + sections: [{ name: "Introduction", level: 1, lineRange: [1, 10] }], + definitions: [{ name: "users", kind: "table", lineRange: [1, 20], fields: ["id", "name"] }], + services: [{ name: "web", image: "node:22", ports: [3000] }], + endpoints: [{ method: "GET", path: "/api/users", lineRange: [5, 15] }], + steps: [{ name: "build", lineRange: [1, 5] }], + resources: [{ name: "aws_s3_bucket.main", kind: "aws_s3_bucket", lineRange: [1, 10] }], + }; + expect(analysis.sections).toHaveLength(1); + expect(analysis.definitions).toHaveLength(1); + expect(analysis.services).toHaveLength(1); + expect(analysis.endpoints).toHaveLength(1); + expect(analysis.steps).toHaveLength(1); + expect(analysis.resources).toHaveLength(1); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm --filter @understand-anything/core test -- --run types.test` +Expected: FAIL — TypeScript compilation errors for new types that don't exist yet + +**Step 3: Implement the type extensions** + +In `types.ts`, update the `GraphNode.type` union (line 12): + +```typescript +type: "file" | "function" | "class" | "module" | "concept" + | "config" | "document" | "service" | "table" | "endpoint" + | "pipeline" | "schema" | "resource"; +``` + +Update the `EdgeType` type (lines 1-7) to add 8 new edge types: + +```typescript +export type EdgeType = + | "imports" | "exports" | "contains" | "inherits" | "implements" // Structural + | "calls" | "subscribes" | "publishes" | "middleware" // Behavioral + | "reads_from" | "writes_to" | "transforms" | "validates" // Data flow + | "depends_on" | "tested_by" | "configures" // Dependencies + | "related" | "similar_to" // Semantic + | "deploys" | "serves" | "migrates" | "documents" // Infrastructure + | "provisions" | "routes" | "defines_schema" | "triggers"; // Infrastructure +``` + +Extend `StructuralAnalysis` (after line 95) with new optional fields: + +```typescript +export interface SectionInfo { + name: string; + level: number; + lineRange: [number, number]; +} + +export interface DefinitionInfo { + name: string; + kind: string; // "table", "message", "type", "schema" + lineRange: [number, number]; + fields: string[]; +} + +export interface ServiceInfo { + name: string; + image?: string; + ports: number[]; +} + +export interface EndpointInfo { + method?: string; + path: string; + lineRange: [number, number]; +} + +export interface StepInfo { + name: string; + lineRange: [number, number]; +} + +export interface ResourceInfo { + name: string; + kind: string; + lineRange: [number, number]; +} + +export interface ReferenceResolution { + source: string; + target: string; + referenceType: string; // "file", "image", "schema", "service" + line?: number; +} + +export interface StructuralAnalysis { + functions: Array<{ name: string; lineRange: [number, number]; params: string[]; returnType?: string }>; + classes: Array<{ name: string; lineRange: [number, number]; methods: string[]; properties: string[] }>; + imports: Array<{ source: string; specifiers: string[]; lineNumber: number }>; + exports: Array<{ name: string; lineNumber: number }>; + // Non-code structural data (all optional for backward compat) + sections?: SectionInfo[]; + definitions?: DefinitionInfo[]; + services?: ServiceInfo[]; + endpoints?: EndpointInfo[]; + steps?: StepInfo[]; + resources?: ResourceInfo[]; +} +``` + +Update `AnalyzerPlugin` interface (lines 109-115) — make `resolveImports` optional, add `extractReferences`: + +```typescript +export interface AnalyzerPlugin { + name: string; + languages: string[]; + analyzeFile(filePath: string, content: string): StructuralAnalysis; + resolveImports?(filePath: string, content: string): ImportResolution[]; + extractCallGraph?(filePath: string, content: string): CallGraphEntry[]; + extractReferences?(filePath: string, content: string): ReferenceResolution[]; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm --filter @understand-anything/core test -- --run types.test` +Expected: PASS + +**Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/types.ts understand-anything-plugin/packages/core/src/types.test.ts +git commit -m "feat(core): extend GraphNode/EdgeType/StructuralAnalysis for non-code file types" +``` + +--- + +## Task 2: Extend Schema Validation — Zod Schemas & Aliases + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/schema.ts:1-554` +- Test: `understand-anything-plugin/packages/core/src/__tests__/schema.test.ts` + +**Step 1: Write the failing tests** + +Add to `schema.test.ts`: + +```typescript +describe("Extended node/edge types", () => { + it("validates nodes with new types: config, document, service, table, endpoint, pipeline, schema, resource", () => { + const newTypes = ["config", "document", "service", "table", "endpoint", "pipeline", "schema", "resource"]; + for (const type of newTypes) { + const graph = structuredClone(validGraph); + graph.nodes[0].type = type; + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.nodes[0].type).toBe(type); + } + }); + + it("validates edges with new types: deploys, serves, migrates, documents, provisions, routes, defines_schema, triggers", () => { + const newTypes = ["deploys", "serves", "migrates", "documents", "provisions", "routes", "defines_schema", "triggers"]; + for (const type of newTypes) { + const graph = structuredClone(validGraph); + graph.edges[0].type = type; + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.edges[0].type).toBe(type); + } + }); + + it("auto-fixes new node type aliases: container→service, doc→document, workflow→pipeline, etc.", () => { + const aliases = { container: "service", doc: "document", workflow: "pipeline", route: "endpoint", setting: "config", infra: "resource", migration: "table" }; + for (const [alias, canonical] of Object.entries(aliases)) { + const graph = structuredClone(validGraph); + (graph.nodes[0] as any).type = alias; + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.nodes[0].type).toBe(canonical); + } + }); + + it("auto-fixes new edge type aliases: describes→documents, creates→provisions, exposes→serves", () => { + const aliases = { describes: "documents", creates: "provisions", exposes: "serves" }; + for (const [alias, canonical] of Object.entries(aliases)) { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).type = alias; + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.edges[0].type).toBe(canonical); + } + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm --filter @understand-anything/core test -- --run schema.test` +Expected: FAIL — Zod enum rejects new types + +**Step 3: Implement schema extensions** + +In `schema.ts`: + +1. Update `EdgeTypeSchema` (line 4-10) — add 8 new edge types: +```typescript +export const EdgeTypeSchema = z.enum([ + "imports", "exports", "contains", "inherits", "implements", + "calls", "subscribes", "publishes", "middleware", + "reads_from", "writes_to", "transforms", "validates", + "depends_on", "tested_by", "configures", + "related", "similar_to", + "deploys", "serves", "migrates", "documents", + "provisions", "routes", "defines_schema", "triggers", +]); +``` + +2. Update `NODE_TYPE_ALIASES` (line 13-22) — add new aliases: +```typescript +export const NODE_TYPE_ALIASES: Record = { + func: "function", fn: "function", method: "function", + interface: "class", struct: "class", + mod: "module", pkg: "module", package: "module", + // New non-code aliases + container: "service", deployment: "service", pod: "service", + doc: "document", readme: "document", docs: "document", + workflow: "pipeline", job: "pipeline", ci: "pipeline", action: "pipeline", + route: "endpoint", api: "endpoint", query: "endpoint", mutation: "endpoint", + setting: "config", env: "config", configuration: "config", + infra: "resource", infrastructure: "resource", terraform: "resource", + migration: "table", database: "table", db: "table", view: "table", + proto: "schema", protobuf: "schema", definition: "schema", typedef: "schema", +}; +``` + +3. Update `EDGE_TYPE_ALIASES` (line 25-39) — add new aliases: +```typescript +// Add these entries: + describes: "documents", + documented_by: "documents", + creates: "provisions", + exposes: "serves", + listens: "serves", + deploys_to: "deploys", + migrates_to: "migrates", + routes_to: "routes", + triggers_on: "triggers", + fires: "triggers", + defines: "defines_schema", +``` + +4. Update `GraphNodeSchema` (line 267-277) — extend type enum: +```typescript +export const GraphNodeSchema = z.object({ + id: z.string(), + type: z.enum([ + "file", "function", "class", "module", "concept", + "config", "document", "service", "table", "endpoint", + "pipeline", "schema", "resource", + ]), + name: z.string(), + filePath: z.string().optional(), + lineRange: z.tuple([z.number(), z.number()]).optional(), + summary: z.string(), + tags: z.array(z.string()), + complexity: z.enum(["simple", "moderate", "complex"]), + languageNotes: z.string().optional(), +}); +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm --filter @understand-anything/core test -- --run schema.test` +Expected: PASS + +**Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/schema.ts understand-anything-plugin/packages/core/src/__tests__/schema.test.ts +git commit -m "feat(core): extend Zod schemas and aliases for 8 new node/edge types" +``` + +--- + +## Task 3: Update PluginRegistry — Optional resolveImports + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/plugins/registry.ts:1-76` +- Test: `understand-anything-plugin/packages/core/src/__tests__/plugin-registry.test.ts` + +**Step 1: Write the failing test** + +Add to `plugin-registry.test.ts`: + +```typescript +it("handles plugins with optional resolveImports (non-code plugins)", () => { + const markdownPlugin: AnalyzerPlugin = { + name: "markdown", + languages: ["markdown"], + analyzeFile: () => ({ functions: [], classes: [], imports: [], exports: [] }), + // No resolveImports — optional + }; + registry.register(markdownPlugin); + const result = registry.resolveImports("README.md", "# Hello"); + expect(result).toBeNull(); // Returns null for plugins without resolveImports +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm --filter @understand-anything/core test -- --run plugin-registry.test` +Expected: FAIL — current registry calls `plugin.resolveImports(...)` unconditionally + +**Step 3: Update PluginRegistry** + +In `registry.ts`, update `resolveImports` (line 62-66): + +```typescript +resolveImports(filePath: string, content: string): ImportResolution[] | null { + const plugin = this.getPluginForFile(filePath); + if (!plugin || !plugin.resolveImports) return null; + return plugin.resolveImports(filePath, content); +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm --filter @understand-anything/core test -- --run plugin-registry.test` +Expected: PASS + +**Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/plugins/registry.ts understand-anything-plugin/packages/core/src/__tests__/plugin-registry.test.ts +git commit -m "feat(core): make resolveImports optional on AnalyzerPlugin" +``` + +--- + +## Task 4: Add Non-Code Language Configs (26 configs) + +**Files:** +- Create: `understand-anything-plugin/packages/core/src/languages/configs/markdown.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/yaml.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/json-config.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/toml.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/env.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/xml.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/dockerfile.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/sql.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/graphql.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/protobuf.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/terraform.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/github-actions.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/makefile.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/shell.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/html.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/css.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/openapi.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/kubernetes.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/docker-compose.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/json-schema.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/csv.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/restructuredtext.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/powershell.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/batch.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/jenkinsfile.ts` +- Create: `understand-anything-plugin/packages/core/src/languages/configs/plaintext.ts` +- Modify: `understand-anything-plugin/packages/core/src/languages/configs/index.ts` +- Test: `understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts` + +**Step 1: Write the failing test** + +Add to `language-registry.test.ts`: + +```typescript +describe("Non-code language configs", () => { + it("detects all non-code file types via extension", () => { + const registry = LanguageRegistry.createDefault(); + const expectations: [string, string][] = [ + ["README.md", "markdown"], + ["config.yaml", "yaml"], + ["package.json", "json"], + ["config.toml", "toml"], + [".env", "env"], + ["pom.xml", "xml"], + ["Dockerfile", "dockerfile"], + ["schema.sql", "sql"], + ["schema.graphql", "graphql"], + ["types.proto", "protobuf"], + ["main.tf", "terraform"], + ["Makefile", "makefile"], + ["deploy.sh", "shell"], + ["index.html", "html"], + ["styles.css", "css"], + ["data.csv", "csv"], + ["deploy.ps1", "powershell"], + ]; + for (const [file, expectedId] of expectations) { + const config = registry.getForFile(file); + expect(config?.id, `${file} should be detected as ${expectedId}`).toBe(expectedId); + } + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm --filter @understand-anything/core test -- --run language-registry.test` +Expected: FAIL — no configs registered for non-code extensions + +**Step 3: Create all config files** + +Each config follows the same pattern as `typescript.ts`. Example for markdown: + +```typescript +// markdown.ts +import type { LanguageConfig } from "../types.js"; + +export const markdownConfig = { + id: "markdown", + displayName: "Markdown", + extensions: [".md", ".mdx"], + concepts: ["headings", "links", "code blocks", "front matter", "lists", "tables", "images"], + filePatterns: { + entryPoints: ["README.md"], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; +``` + +Create similar configs for all 26 types. Key extension mappings: +- yaml: `.yaml`, `.yml` +- json: `.json`, `.jsonc` +- toml: `.toml` +- env: `.env` (Note: LanguageRegistry needs filename match, not just extension) +- xml: `.xml` +- dockerfile: `Dockerfile` (filename-based detection — needs special handling) +- sql: `.sql` +- graphql: `.graphql`, `.gql` +- protobuf: `.proto` +- terraform: `.tf`, `.tfvars` +- github-actions: (detected by path `.github/workflows/*.yml` — defer to scanner) +- makefile: `Makefile` (filename-based — needs special handling) +- shell: `.sh`, `.bash`, `.zsh` +- html: `.html`, `.htm` +- css: `.css`, `.scss`, `.less` +- csv: `.csv`, `.tsv` +- powershell: `.ps1`, `.psm1` +- batch: `.bat`, `.cmd` +- plaintext: `.txt` +- restructuredtext: `.rst` +- jenkinsfile: (filename-based — `Jenkinsfile`) + +**Important:** For filename-based detection (Dockerfile, Makefile, Jenkinsfile), extend LanguageRegistry to support `filenames` array in addition to `extensions`. Add a `filenames?: string[]` field to `LanguageConfig` and update `getForFile()` to check basename against filenames when extension lookup fails. + +Update `configs/index.ts` to import and register all new configs in `builtinLanguageConfigs`. + +**Step 4: Run test to verify it passes** + +Run: `pnpm --filter @understand-anything/core test -- --run language-registry.test` +Expected: PASS + +**Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/languages/ +git commit -m "feat(core): add 26 non-code language configs with filename-based detection" +``` + +--- + +## Task 5: Build Custom Parsers (12 parsers) + +**Files:** +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/markdown-parser.ts` +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/yaml-parser.ts` +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/json-parser.ts` +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/toml-parser.ts` +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/env-parser.ts` +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/dockerfile-parser.ts` +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/sql-parser.ts` +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/graphql-parser.ts` +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/protobuf-parser.ts` +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/terraform-parser.ts` +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/makefile-parser.ts` +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/shell-parser.ts` +- Create: `understand-anything-plugin/packages/core/src/plugins/parsers/index.ts` +- Test: `understand-anything-plugin/packages/core/src/__tests__/parsers.test.ts` + +Each parser implements `AnalyzerPlugin`. Build them TDD-style, one at a time. + +**Step 1: Write failing tests for all 12 parsers** + +Create `parsers.test.ts` with test suites for each parser. Example for MarkdownParser: + +```typescript +import { describe, it, expect } from "vitest"; +import { MarkdownParser } from "../plugins/parsers/markdown-parser.js"; + +describe("MarkdownParser", () => { + const parser = new MarkdownParser(); + + it("extracts heading sections", () => { + const content = "# Title\n\nIntro\n\n## Section A\n\nContent A\n\n### Subsection\n\nContent B"; + const result = parser.analyzeFile("README.md", content); + expect(result.sections).toHaveLength(3); + expect(result.sections![0]).toMatchObject({ name: "Title", level: 1 }); + expect(result.sections![1]).toMatchObject({ name: "Section A", level: 2 }); + expect(result.sections![2]).toMatchObject({ name: "Subsection", level: 3 }); + }); + + it("extracts YAML front matter as imports", () => { + const content = "---\ntitle: Test\ntags: [a, b]\n---\n# Content"; + const result = parser.analyzeFile("post.md", content); + expect(result.imports).toHaveLength(0); // Front matter is metadata, not imports + }); + + it("extracts file references", () => { + const parser2 = new MarkdownParser(); + const content = "See [guide](./docs/guide.md) and ![img](./assets/logo.png)"; + const refs = parser2.extractReferences!("README.md", content); + expect(refs).toHaveLength(2); + expect(refs[0]).toMatchObject({ target: "./docs/guide.md", referenceType: "file" }); + expect(refs[1]).toMatchObject({ target: "./assets/logo.png", referenceType: "image" }); + }); +}); +``` + +Similar test suites for: +- **DockerfileParser**: Extract FROM stages, EXPOSE ports, COPY sources +- **SQLParser**: Extract CREATE TABLE, columns, foreign keys +- **YAMLParser**: Extract top-level key hierarchy +- **JSONParser**: Extract key structure, `$ref`/`$defs` +- **TerraformParser**: Extract resource/module/variable blocks +- **GraphQLParser**: Extract type/query/mutation/subscription definitions +- **ProtobufParser**: Extract message/service/enum definitions +- **MakefileParser**: Extract targets and dependencies +- **ShellParser**: Extract function definitions and source commands +- **TOMLParser**: Extract section structure +- **EnvParser**: Extract variable names + +**Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @understand-anything/core test -- --run parsers.test` +Expected: FAIL — parser modules don't exist + +**Step 3: Implement all 12 parsers** + +Each parser follows this pattern: + +```typescript +import type { AnalyzerPlugin, StructuralAnalysis, ReferenceResolution } from "../../types.js"; + +export class MarkdownParser implements AnalyzerPlugin { + name = "markdown-parser"; + languages = ["markdown"]; + + analyzeFile(filePath: string, content: string): StructuralAnalysis { + const sections = this.extractSections(content); + return { + functions: [], classes: [], imports: [], exports: [], + sections, + }; + } + + extractReferences(filePath: string, content: string): ReferenceResolution[] { + const refs: ReferenceResolution[] = []; + // Match [text](path) and ![alt](path) + const linkRegex = /!?\[([^\]]*)\]\(([^)]+)\)/g; + let match; + while ((match = linkRegex.exec(content)) !== null) { + const target = match[2]; + if (target.startsWith("http")) continue; // Skip external URLs + const line = content.slice(0, match.index).split("\n").length; + refs.push({ + source: filePath, + target, + referenceType: match[0].startsWith("!") ? "image" : "file", + line, + }); + } + return refs; + } + + private extractSections(content: string): SectionInfo[] { + const sections: SectionInfo[] = []; + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^(#{1,6})\s+(.+)/); + if (match) { + sections.push({ + name: match[2].trim(), + level: match[1].length, + lineRange: [i + 1, i + 1], + }); + } + } + // Fix lineRange end for each section (extends to next heading or EOF) + for (let i = 0; i < sections.length; i++) { + const next = sections[i + 1]; + sections[i].lineRange[1] = next ? next.lineRange[0] - 1 : lines.length; + } + return sections; + } +} +``` + +Create `parsers/index.ts` that exports all parsers and a `registerAllParsers(registry: PluginRegistry)` helper. + +**Install new dependencies:** +```bash +cd understand-anything-plugin/packages/core +pnpm add yaml @iarna/toml jsonc-parser +``` + +**Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @understand-anything/core test -- --run parsers.test` +Expected: PASS + +**Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/plugins/parsers/ understand-anything-plugin/packages/core/src/__tests__/parsers.test.ts understand-anything-plugin/packages/core/package.json understand-anything-plugin/packages/core/pnpm-lock.yaml +git commit -m "feat(core): add 12 custom parsers for non-code file types" +``` + +--- + +## Task 6: Update GraphBuilder — Support New Node Types + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/analyzer/graph-builder.ts:1-207` +- Test: `understand-anything-plugin/packages/core/src/analyzer/graph-builder.test.ts` + +**Step 1: Write the failing test** + +Add to `graph-builder.test.ts`: + +```typescript +describe("Non-code file support", () => { + it("adds non-code file nodes with correct types", () => { + const builder = new GraphBuilder("test", "abc123"); + builder.addNonCodeFile("README.md", { + nodeType: "document", + summary: "Project documentation", + tags: ["documentation"], + complexity: "simple", + }); + const graph = builder.build(); + expect(graph.nodes).toHaveLength(1); + expect(graph.nodes[0].type).toBe("document"); + expect(graph.nodes[0].id).toBe("file:README.md"); + }); + + it("adds non-code child nodes (sections, definitions, services)", () => { + const builder = new GraphBuilder("test", "abc123"); + builder.addNonCodeFileWithAnalysis("schema.sql", { + nodeType: "file", + summary: "Database schema", + tags: ["database"], + complexity: "moderate", + definitions: [{ name: "users", kind: "table", lineRange: [1, 20] as [number, number], fields: ["id", "name", "email"] }], + }); + const graph = builder.build(); + // File node + table child node + expect(graph.nodes).toHaveLength(2); + expect(graph.nodes[1].type).toBe("table"); + expect(graph.nodes[1].name).toBe("users"); + // Contains edge + expect(graph.edges.some(e => e.type === "contains" && e.target.includes("users"))).toBe(true); + }); + + it("detects non-code languages from EXTENSION_LANGUAGE map", () => { + const builder = new GraphBuilder("test", "abc123"); + builder.addFile("config.yaml", { summary: "Config", tags: [], complexity: "simple" }); + const graph = builder.build(); + expect(graph.project.languages).toContain("yaml"); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm --filter @understand-anything/core test -- --run graph-builder.test` +Expected: FAIL — `addNonCodeFile` and `addNonCodeFileWithAnalysis` methods don't exist + +**Step 3: Implement GraphBuilder extensions** + +Add new methods to GraphBuilder: + +```typescript +interface NonCodeFileMeta extends FileMeta { + nodeType: GraphNode["type"]; +} + +interface NonCodeFileAnalysisMeta extends NonCodeFileMeta { + definitions?: DefinitionInfo[]; + services?: ServiceInfo[]; + endpoints?: EndpointInfo[]; + steps?: StepInfo[]; + resources?: ResourceInfo[]; + sections?: SectionInfo[]; +} + +addNonCodeFile(filePath: string, meta: NonCodeFileMeta): void { + const lang = detectLanguage(filePath); + if (lang !== "unknown") this.languages.add(lang); + const name = filePath.split("/").pop() ?? filePath; + this.nodes.push({ + id: `file:${filePath}`, + type: meta.nodeType, + name, + filePath, + summary: meta.summary, + tags: meta.tags, + complexity: meta.complexity, + }); +} + +addNonCodeFileWithAnalysis(filePath: string, meta: NonCodeFileAnalysisMeta): void { + this.addNonCodeFile(filePath, meta); + const fileId = `file:${filePath}`; + + // Create child nodes for definitions (tables, schemas, etc.) + for (const def of meta.definitions ?? []) { + const childId = `${def.kind}:${filePath}:${def.name}`; + this.nodes.push({ + id: childId, + type: this.mapKindToNodeType(def.kind), + name: def.name, + filePath, + lineRange: def.lineRange, + summary: `${def.kind}: ${def.name} (${def.fields.length} fields)`, + tags: [], + complexity: meta.complexity, + }); + this.edges.push({ source: fileId, target: childId, type: "contains", direction: "forward", weight: 1 }); + } + + // Create child nodes for services + for (const svc of meta.services ?? []) { + const childId = `service:${filePath}:${svc.name}`; + this.nodes.push({ + id: childId, type: "service", name: svc.name, filePath, + summary: `Service ${svc.name}${svc.image ? ` (image: ${svc.image})` : ""}`, + tags: [], complexity: meta.complexity, + }); + this.edges.push({ source: fileId, target: childId, type: "contains", direction: "forward", weight: 1 }); + } + + // Similar for endpoints, steps, resources +} + +private mapKindToNodeType(kind: string): GraphNode["type"] { + const mapping: Record = { + table: "table", view: "table", index: "table", + message: "schema", type: "schema", enum: "schema", + resource: "resource", module: "resource", + service: "service", deployment: "service", + job: "pipeline", stage: "pipeline", target: "pipeline", + route: "endpoint", query: "endpoint", mutation: "endpoint", + }; + return mapping[kind] ?? "concept"; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm --filter @understand-anything/core test -- --run graph-builder.test` +Expected: PASS + +**Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/analyzer/graph-builder.ts understand-anything-plugin/packages/core/src/analyzer/graph-builder.test.ts +git commit -m "feat(core): add non-code file support to GraphBuilder" +``` + +--- + +## Task 7: Update Core Exports + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/index.ts` + +**Step 1: Update exports to include new types and parsers** + +Add to `index.ts`: + +```typescript +// New structural analysis types +export type { + SectionInfo, + DefinitionInfo, + ServiceInfo, + EndpointInfo, + StepInfo, + ResourceInfo, + ReferenceResolution, +} from "./types.js"; + +// Non-code parsers +export { + MarkdownParser, + DockerfileParser, + SQLParser, + YAMLConfigParser, + JSONConfigParser, + TOMLParser, + EnvParser, + GraphQLParser, + ProtobufParser, + TerraformParser, + MakefileParser, + ShellParser, + registerAllParsers, +} from "./plugins/parsers/index.js"; +``` + +**Step 2: Build to verify exports work** + +Run: `pnpm --filter @understand-anything/core build` +Expected: Success, no errors + +**Step 3: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/index.ts +git commit -m "feat(core): export new types and parsers from core" +``` + +--- + +## Task 8: Update Agent Prompts — Project Scanner + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/project-scanner-prompt.md` + +**Step 1: Update the scanner to discover ALL file types** + +Key changes to the prompt: +1. Remove the code-only file filter — scan `.md`, `.yaml`, `.json`, `.sql`, `.tf`, `Dockerfile`, etc. +2. Add a `fileCategory` field to each discovered file: `"code" | "config" | "docs" | "infra" | "data" | "script" | "markup"` +3. Update the exclusion list — still exclude `node_modules/`, `.git/`, binaries, but include non-code files +4. Add category detection logic in the discovery script: + - `.md`, `.rst`, `.txt` → `"docs"` + - `.yaml`, `.yml`, `.json`, `.toml`, `.env`, `.xml` → `"config"` + - `Dockerfile`, `docker-compose.*`, `.tf`, `.github/workflows/*`, `Makefile`, `Jenkinsfile` → `"infra"` + - `.sql`, `.graphql`, `.proto`, `.schema.json`, `.csv` → `"data"` + - `.sh`, `.bash`, `.ps1`, `.bat` → `"script"` + - `.html`, `.css`, `.scss` → `"markup"` + - Everything else → `"code"` +5. Update output schema to include `fileCategory` per file + +**Step 2: Commit** + +```bash +git add understand-anything-plugin/skills/understand/project-scanner-prompt.md +git commit -m "feat(agents): update project-scanner to discover all file types" +``` + +--- + +## Task 9: Update Agent Prompts — File Analyzer + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/file-analyzer-prompt.md` + +**Step 1: Add type-aware analysis prompts** + +Key changes: +1. Add a section at the top explaining file categories and how to analyze each: + - **Code files** (current behavior): Extract functions, classes, imports, call graph + - **Config files**: Extract key settings, what they configure, which code they affect + - **Documentation files**: Extract sections/headings, key concepts, referenced code components + - **Infrastructure files**: Extract services, ports, volumes, deployments, which code they deploy + - **Data/Schema files**: Extract tables, columns, types, relationships, consuming code + - **Pipeline files**: Extract jobs, steps, triggers, deployed targets + +2. Update the output JSON schema to include new fields: + - `sections` (for docs) + - `definitions` (for data/schema) + - `services` (for infra) + - `endpoints` (for API schemas) + - `steps` (for pipelines) + - `resources` (for IaC) + +3. Add `nodeType` field to output: what GraphNode type each file should become (file, config, document, service, etc.) + +4. Update edge generation guidance: + - Config files: generate `configures` edges to code files they affect + - Doc files: generate `documents` edges to described code + - Dockerfiles: generate `deploys` edges to code directories + - SQL migrations: generate `migrates` edges to tables + - CI configs: generate `triggers` edges to pipelines + - API schemas: generate `defines_schema` edges to endpoints + +5. Update tagging guidance with new tags: `documentation`, `configuration`, `infrastructure`, `database`, `api-schema`, `ci-cd`, `deployment`, `migration` + +**Step 2: Commit** + +```bash +git add understand-anything-plugin/skills/understand/file-analyzer-prompt.md +git commit -m "feat(agents): add type-aware analysis prompts for non-code files" +``` + +--- + +## Task 10: Update Agent Prompts — Architecture Analyzer + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/architecture-analyzer-prompt.md` + +**Step 1: Add non-code pattern detection** + +Key changes: +1. Add new architectural patterns to detect: + - **Deployment topology**: Dockerfile → docker-compose → K8s manifests + - **Data pipeline**: Schema definition → migration → API endpoint → client code + - **Documentation coverage**: Which modules have corresponding docs + - **Configuration graph**: Which config files affect which code paths +2. Update layer hints to include non-code layers: + - `"infrastructure"` layer for Dockerfiles, K8s, Terraform + - `"documentation"` layer for docs + - `"data"` layer for SQL, schemas + - `"ci-cd"` layer for GitHub Actions, Jenkinsfiles +3. Update script to compute cross-category dependency analysis (code→infra, code→config, etc.) + +**Step 2: Commit** + +```bash +git add understand-anything-plugin/skills/understand/architecture-analyzer-prompt.md +git commit -m "feat(agents): add non-code pattern detection to architecture analyzer" +``` + +--- + +## Task 11: Update Agent Prompts — Tour Builder + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/tour-builder-prompt.md` + +**Step 1: Add non-code tour stops** + +Key changes: +1. Update tour step guidance to include non-code files: + - Step 1 could be README.md (project overview) + - Infrastructure stops: "How the app gets containerized" + - Data stops: "The database schema" + - CI/CD stops: "How code gets deployed" +2. Update `languageLesson` to also cover non-code concepts: + - Dockerfile: multi-stage builds, layer caching + - SQL: normalization, foreign keys + - YAML: anchors, merge keys + - Terraform: state management, modules + +**Step 2: Commit** + +```bash +git add understand-anything-plugin/skills/understand/tour-builder-prompt.md +git commit -m "feat(agents): extend tour builder for non-code file stops" +``` + +--- + +## Task 12: Update Agent Prompts — Graph Reviewer + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/graph-reviewer-prompt.md` + +**Step 1: Update validation for new node/edge types** + +Key changes: +1. Add new node types to the valid type list in the validation script +2. Add new edge types to the valid type list +3. Add quality checks for non-code nodes: + - Config nodes should have `configures` edges + - Document nodes should have `documents` edges + - Service nodes should have `deploys` edges + - Table nodes should reference columns + +**Step 2: Commit** + +```bash +git add understand-anything-plugin/skills/understand/graph-reviewer-prompt.md +git commit -m "feat(agents): update graph reviewer for new node/edge types" +``` + +--- + +## Task 13: Add Language Context Snippets + +**Files:** +- Create: `understand-anything-plugin/skills/understand/languages/markdown.md` +- Create: `understand-anything-plugin/skills/understand/languages/yaml.md` +- Create: `understand-anything-plugin/skills/understand/languages/json.md` +- Create: `understand-anything-plugin/skills/understand/languages/sql.md` +- Create: `understand-anything-plugin/skills/understand/languages/dockerfile.md` +- Create: `understand-anything-plugin/skills/understand/languages/terraform.md` +- Create: `understand-anything-plugin/skills/understand/languages/graphql.md` +- Create: `understand-anything-plugin/skills/understand/languages/protobuf.md` +- Create: `understand-anything-plugin/skills/understand/languages/shell.md` +- Create: `understand-anything-plugin/skills/understand/languages/html.md` +- Create: `understand-anything-plugin/skills/understand/languages/css.md` + +Each snippet follows the pattern of existing `typescript.md` / `python.md`: + +```markdown +# Markdown + +## Key Concepts +- Heading hierarchy (# through ######) +- Front matter (YAML metadata between --- delimiters) +- Code blocks (fenced with ``` or indented) +- Reference-style links +- Tables (pipe-delimited) + +## Notable File Patterns +- `README.md` — Project overview (high-value entry point) +- `CONTRIBUTING.md` — Contribution guidelines +- `CHANGELOG.md` — Version history +- `docs/**/*.md` — Documentation directory + +## Edge Patterns +- Markdown files `documents` the code components they describe +- Links to other .md files create `related` edges +- Code block references may imply `depends_on` edges + +## Summary Style +> "Comprehensive guide document with N sections covering [topics]" +``` + +**Step 1: Create all 11 language snippets** + +**Step 2: Commit** + +```bash +git add understand-anything-plugin/skills/understand/languages/ +git commit -m "feat(agents): add language context snippets for 11 non-code file types" +``` + +--- + +## Task 14: Update SKILL.md — Main Pipeline + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` + +**Step 1: Update the pipeline to handle non-code files** + +Key changes: +1. **Phase 1 (SCAN)**: Update file batching to include non-code files. Add `fileCategory` to batch metadata. +2. **Phase 2 (ANALYZE)**: Update batch construction to group related non-code files together (e.g., Dockerfile + docker-compose.yml). Pass `fileCategory` to file-analyzer prompt. +3. **Phase 4 (ARCHITECTURE)**: Inject non-code language snippets for detected non-code languages. +4. **Phase 5 (TOUR)**: Include non-code nodes in tour candidate pool. +5. **Phase 7 (SAVE)**: No changes needed (schema handles new types). +6. **Node/Edge reference table**: Add the 8 new node types and 8 new edge types. + +**Step 2: Commit** + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md +git commit -m "feat(pipeline): update main skill pipeline for non-code file analysis" +``` + +--- + +## Task 15: Dashboard — Add Node Type Colors to Theme Presets + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/themes/presets.ts:1-143` + +**Step 1: Add 8 new node type colors to all 5 presets** + +Add these color entries to each preset's `colors` object: + +For dark presets: +```typescript +"node-config": "#5eead4", // Teal +"node-document": "#7dd3fc", // Sky blue +"node-service": "#a78bfa", // Violet +"node-table": "#6ee7b7", // Emerald +"node-endpoint": "#fdba74", // Orange +"node-pipeline": "#fda4af", // Rose +"node-schema": "#fcd34d", // Amber +"node-resource": "#a5b4fc", // Indigo +``` + +For light preset, use slightly darker versions: +```typescript +"node-config": "#14b8a6", +"node-document": "#38bdf8", +"node-service": "#8b5cf6", +"node-table": "#34d399", +"node-endpoint": "#fb923c", +"node-pipeline": "#fb7185", +"node-schema": "#facc15", +"node-resource": "#818cf8", +``` + +**Step 2: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/themes/presets.ts +git commit -m "feat(dashboard): add 8 new node type colors to all theme presets" +``` + +--- + +## Task 16: Dashboard — Update CustomNode Component + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx:1-137` + +**Step 1: Add new entries to typeColors and typeTextColors maps** + +```typescript +const typeColors: Record = { + file: "var(--color-node-file)", + function: "var(--color-node-function)", + class: "var(--color-node-class)", + module: "var(--color-node-module)", + concept: "var(--color-node-concept)", + config: "var(--color-node-config)", + document: "var(--color-node-document)", + service: "var(--color-node-service)", + table: "var(--color-node-table)", + endpoint: "var(--color-node-endpoint)", + pipeline: "var(--color-node-pipeline)", + schema: "var(--color-node-schema)", + resource: "var(--color-node-resource)", +}; + +const typeTextColors: Record = { + file: "text-node-file", + function: "text-node-function", + class: "text-node-class", + module: "text-node-module", + concept: "text-node-concept", + config: "text-node-config", + document: "text-node-document", + service: "text-node-service", + table: "text-node-table", + endpoint: "text-node-endpoint", + pipeline: "text-node-pipeline", + schema: "text-node-schema", + resource: "text-node-resource", +}; +``` + +**Step 2: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx +git commit -m "feat(dashboard): add new node type colors to CustomNode" +``` + +--- + +## Task 17: Dashboard — Update NodeInfo Sidebar + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx:1-312` + +**Step 1: Add badge colors for new node types** + +Add to `typeBadgeColors`: +```typescript +config: "text-node-config border border-node-config/30 bg-node-config/10", +document: "text-node-document border border-node-document/30 bg-node-document/10", +service: "text-node-service border border-node-service/30 bg-node-service/10", +table: "text-node-table border border-node-table/30 bg-node-table/10", +endpoint: "text-node-endpoint border border-node-endpoint/30 bg-node-endpoint/10", +pipeline: "text-node-pipeline border border-node-pipeline/30 bg-node-pipeline/10", +schema: "text-node-schema border border-node-schema/30 bg-node-schema/10", +resource: "text-node-resource border border-node-resource/30 bg-node-resource/10", +``` + +**Step 2: Add directional labels for new edge types** + +Add to `getDirectionalLabel()`: +```typescript +case "deploys": + return isSource ? "deploys" : "deployed by"; +case "serves": + return isSource ? "serves" : "served by"; +case "migrates": + return isSource ? "migrates" : "migrated by"; +case "documents": + return isSource ? "documents" : "documented by"; +case "provisions": + return isSource ? "provisions" : "provisioned by"; +case "routes": + return isSource ? "routes to" : "routed from"; +case "defines_schema": + return isSource ? "defines schema for" : "schema defined by"; +case "triggers": + return isSource ? "triggers" : "triggered by"; +``` + +**Step 3: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx +git commit -m "feat(dashboard): add new node/edge type support to NodeInfo sidebar" +``` + +--- + +## Task 18: Dashboard — Update ProjectOverview with File Type Breakdown + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/ProjectOverview.tsx` + +**Step 1: Add file type distribution** + +Add a "File Types" section after the stats grid that shows count per node type category: +- Code: file + function + class +- Config: config +- Docs: document +- Infra: service + resource + pipeline +- Data: table + endpoint + schema + +Use colored dots matching the node type colors. + +**Step 2: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/ProjectOverview.tsx +git commit -m "feat(dashboard): add file type breakdown to ProjectOverview" +``` + +--- + +## Task 19: Dashboard — Add Filter Controls + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/store.ts` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/App.tsx` + +**Step 1: Add filter state to store** + +Add to the Zustand store: +```typescript +nodeTypeFilters: Record; // { code: true, config: true, docs: true, infra: true, data: true } +toggleNodeTypeFilter: (category: string) => void; +``` + +Default all categories to `true` (visible). + +**Step 2: Apply filters in GraphView topology computation** + +In `useLayerDetailTopology`, filter nodes based on `nodeTypeFilters` before layout. + +**Step 3: Add filter checkboxes to App.tsx header** + +Add small checkbox toggles next to the layer legend for each category. + +**Step 4: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/store.ts understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx understand-anything-plugin/packages/dashboard/src/App.tsx +git commit -m "feat(dashboard): add node type category filter controls" +``` + +--- + +## Task 20: Dashboard Build Verification + +**Step 1: Build the dashboard** + +Run: `pnpm --filter @understand-anything/dashboard build` +Expected: Success, no TypeScript errors + +**Step 2: Build the core package** + +Run: `pnpm --filter @understand-anything/core build` +Expected: Success + +**Step 3: Run all core tests** + +Run: `pnpm --filter @understand-anything/core test` +Expected: All tests pass + +**Step 4: Run lint** + +Run: `pnpm lint` +Expected: No errors + +**Step 5: Commit any lint fixes** + +```bash +git add -A +git commit -m "fix: lint and build fixes for universal file type support" +``` + +--- + +## Task 21: Integration Test — End-to-End Verification + +**Step 1: Dev server smoke test** + +Run: `pnpm dev:dashboard` +- Load a knowledge graph that includes non-code nodes +- Verify new node types render with correct colors +- Verify NodeInfo sidebar shows new edge labels +- Verify filter controls work + +**Step 2: Generate test graph with non-code nodes** + +Update `scripts/generate-large-graph.mjs` to include non-code node types in the random generation, then generate a test graph and load it in the dashboard. + +**Step 3: Commit** + +```bash +git add scripts/generate-large-graph.mjs +git commit -m "feat(scripts): include non-code node types in test graph generator" +``` + +--- + +## Task 22: Version Bump & Final Commit + +**Files:** +- Modify: `understand-anything-plugin/package.json` → bump version +- Modify: `.claude-plugin/marketplace.json` → bump version +- Modify: `.claude-plugin/plugin.json` → bump version +- Modify: `.cursor-plugin/plugin.json` → bump version + +**Step 1: Bump version in all 4 files** (e.g., 1.3.0 → 1.4.0) + +**Step 2: Final commit** + +```bash +git add understand-anything-plugin/package.json .claude-plugin/marketplace.json .claude-plugin/plugin.json .cursor-plugin/plugin.json +git commit -m "chore: bump version to 1.4.0 for universal file type support" +``` + +--- + +## Summary of All Tasks + +| # | Task | Files | Depends On | +|---|------|-------|------------| +| 1 | Extend core types | types.ts | — | +| 2 | Extend schema validation | schema.ts | 1 | +| 3 | Update PluginRegistry | registry.ts | 1 | +| 4 | Add 26 language configs | languages/configs/ | 1 | +| 5 | Build 12 custom parsers | plugins/parsers/ | 1, 3 | +| 6 | Update GraphBuilder | graph-builder.ts | 1 | +| 7 | Update core exports | index.ts | 1-6 | +| 8 | Update project-scanner prompt | project-scanner-prompt.md | — | +| 9 | Update file-analyzer prompt | file-analyzer-prompt.md | — | +| 10 | Update architecture-analyzer prompt | architecture-analyzer-prompt.md | — | +| 11 | Update tour-builder prompt | tour-builder-prompt.md | — | +| 12 | Update graph-reviewer prompt | graph-reviewer-prompt.md | — | +| 13 | Add language context snippets | languages/*.md | — | +| 14 | Update SKILL.md pipeline | SKILL.md | 8-13 | +| 15 | Dashboard theme colors | presets.ts | — | +| 16 | Dashboard CustomNode | CustomNode.tsx | 15 | +| 17 | Dashboard NodeInfo | NodeInfo.tsx | 15 | +| 18 | Dashboard ProjectOverview | ProjectOverview.tsx | 15 | +| 19 | Dashboard filter controls | store.ts, GraphView.tsx, App.tsx | 15-18 | +| 20 | Build verification | — | 1-19 | +| 21 | Integration test | — | 20 | +| 22 | Version bump | package.json × 4 | 21 | + +**Parallelizable groups:** +- Tasks 1-7 (core) are sequential +- Tasks 8-14 (agent prompts) can run in parallel with each other, and in parallel with Tasks 15-19 (dashboard) +- Tasks 20-22 are sequential and depend on all prior tasks diff --git a/scripts/generate-large-graph.mjs b/scripts/generate-large-graph.mjs index 8a18360..436063f 100644 --- a/scripts/generate-large-graph.mjs +++ b/scripts/generate-large-graph.mjs @@ -1,10 +1,15 @@ #!/usr/bin/env node /** - * Generate a large fake knowledge graph for testing PR #18 - * (Web Worker layout for large graphs). + * Generate a large fake knowledge graph for testing. * * Usage: * node scripts/generate-large-graph.mjs [nodeCount] + * node scripts/generate-large-graph.mjs [nodeCount] --messy + * + * Flags: + * --messy Inject LLM-style issues into ~20% of nodes/edges to test the + * dashboard robustness pipeline (Tier 1-3: null fields, wrong cases, + * missing fields, aliases, dangling refs, unrecognizable types). * * Default: 3000 nodes. Writes to .understand-anything/knowledge-graph.json */ @@ -12,7 +17,10 @@ import { writeFileSync, mkdirSync } from "node:fs"; import { resolve } from "node:path"; -const NODE_COUNT = parseInt(process.argv[2] || "3000", 10); +const args = process.argv.slice(2); +const MESSY = args.includes("--messy"); +const numArg = args.find((a) => !a.startsWith("--")); +const NODE_COUNT = parseInt(numArg || "3000", 10); const EDGE_RATIO = 1.7; // edges per node (realistic for codebases) const nodeTypes = ["file", "function", "class", "module", "concept"]; @@ -110,6 +118,137 @@ function generateTour(nodes) { return steps; } +// ── Messy injection (--messy flag) ── + +// Tier 1: silent fixes — null optional fields, mixed-case enums +function injectTier1(node) { + const issues = []; + if (Math.random() < 0.5 && node.filePath !== undefined) { + node.filePath = null; // null on optional field + issues.push("null filePath"); + } + if (Math.random() < 0.5) { + node.type = node.type.toUpperCase(); // "FILE", "FUNCTION" + issues.push(`uppercase type "${node.type}"`); + } + if (Math.random() < 0.5) { + node.complexity = node.complexity[0].toUpperCase() + node.complexity.slice(1); // "Simple" + issues.push(`mixed-case complexity "${node.complexity}"`); + } + return issues; +} + +// Tier 2: auto-fixable — missing fields, aliases, string weights +function injectTier2Node(node) { + const issues = []; + const r = Math.random(); + if (r < 0.2) { + delete node.complexity; + issues.push("missing complexity"); + } else if (r < 0.4) { + node.complexity = pick(["low", "easy", "medium", "intermediate", "high", "hard"]); + issues.push(`complexity alias "${node.complexity}"`); + } + if (Math.random() < 0.3) { + delete node.tags; + issues.push("missing tags"); + } + if (Math.random() < 0.2) { + delete node.summary; + issues.push("missing summary"); + } + if (Math.random() < 0.15) { + node.type = pick(["func", "fn", "method", "interface", "struct", "mod", "pkg"]); + issues.push(`type alias "${node.type}"`); + } + return issues; +} + +function injectTier2Edge(edge) { + const issues = []; + if (Math.random() < 0.3) { + edge.weight = String(edge.weight); // string weight + issues.push(`string weight "${edge.weight}"`); + } + if (Math.random() < 0.2) { + delete edge.direction; + issues.push("missing direction"); + } else if (Math.random() < 0.3) { + edge.direction = pick(["to", "outbound", "from", "inbound", "both"]); + issues.push(`direction alias "${edge.direction}"`); + } + if (Math.random() < 0.15) { + edge.type = pick(["extends", "invokes", "uses", "requires", "relates_to"]); + issues.push(`edge type alias "${edge.type}"`); + } + return issues; +} + +// Tier 3: unrecoverable — missing id/name, dangling refs, bad types +function injectTier3Node(node) { + const r = Math.random(); + if (r < 0.4) { + delete node.id; + return "missing id"; + } else if (r < 0.7) { + delete node.name; + return "missing name"; + } else { + node.type = "totally_bogus_type"; + return `unrecognizable type "${node.type}"`; + } +} + +function injectTier3Edge(edge, validNodeIds) { + const r = Math.random(); + if (r < 0.4) { + edge.target = "nonexistent-node-999999"; + return "dangling target ref"; + } else if (r < 0.7) { + edge.source = "nonexistent-node-888888"; + return "dangling source ref"; + } else { + edge.weight = "not_a_number"; + return "non-coercible weight"; + } +} + +function applyMessy(nodes, edges) { + const stats = { tier1: 0, tier2: 0, tier3: 0 }; + + for (const node of nodes) { + const r = Math.random(); + if (r < 0.10) { + // ~10% get Tier 3 issues (will be dropped) + injectTier3Node(node); + stats.tier3++; + } else if (r < 0.30) { + // ~20% get Tier 2 issues (will be auto-corrected) + injectTier2Node(node); + stats.tier2++; + } else if (r < 0.40) { + // ~10% get Tier 1 issues (silently fixed) + injectTier1(node); + stats.tier1++; + } + } + + const validIds = new Set(nodes.filter((n) => n.id).map((n) => n.id)); + for (const edge of edges) { + const r = Math.random(); + if (r < 0.05) { + injectTier3Edge(edge, validIds); + stats.tier3++; + } else if (r < 0.20) { + injectTier2Edge(edge); + stats.tier2++; + } + } + + // Also set tour/layers to null (Tier 1 null-vs-empty) + return stats; +} + // ── Generate ── const nodes = generateNodes(NODE_COUNT); @@ -118,20 +257,25 @@ const edges = generateEdges(nodes, edgeCount); const layers = generateLayers(nodes); const tour = generateTour(nodes); +let messyStats = null; +if (MESSY) { + messyStats = applyMessy(nodes, edges); +} + const graph = { version: "1.0", project: { name: "large-test-project", languages: languages.slice(0, 3), frameworks: frameworks.slice(0, 2), - description: `Auto-generated project with ${NODE_COUNT} nodes for performance testing.`, + description: `Auto-generated project with ${NODE_COUNT} nodes for ${MESSY ? "robustness" : "performance"} testing.`, analyzedAt: new Date().toISOString(), gitCommitHash: "0000000000000000000000000000000000000000", }, nodes, edges, - layers, - tour, + layers: MESSY && Math.random() < 0.5 ? null : layers, + tour: MESSY && Math.random() < 0.5 ? null : tour, }; const outDir = resolve(process.cwd(), ".understand-anything"); @@ -139,9 +283,15 @@ mkdirSync(outDir, { recursive: true }); const outPath = resolve(outDir, "knowledge-graph.json"); writeFileSync(outPath, JSON.stringify(graph, null, 2)); -console.log(`Generated knowledge graph:`); +console.log(`Generated knowledge graph${MESSY ? " (messy mode)" : ""}:`); console.log(` Nodes: ${nodes.length}`); console.log(` Edges: ${edges.length}`); -console.log(` Layers: ${layers.length}`); -console.log(` Tour steps: ${tour.length}`); +console.log(` Layers: ${graph.layers === null ? "null (Tier 1 test)" : layers.length}`); +console.log(` Tour steps: ${graph.tour === null ? "null (Tier 1 test)" : tour.length}`); +if (messyStats) { + console.log(` Injected issues:`); + console.log(` Tier 1 (silent fix): ~${messyStats.tier1} items`); + console.log(` Tier 2 (auto-correct): ~${messyStats.tier2} items`); + console.log(` Tier 3 (will be dropped): ~${messyStats.tier3} items`); +} console.log(` Written to: ${outPath}`); diff --git a/understand-anything-plugin/hooks/auto-update-prompt.md b/understand-anything-plugin/hooks/auto-update-prompt.md new file mode 100644 index 0000000..a9df20b --- /dev/null +++ b/understand-anything-plugin/hooks/auto-update-prompt.md @@ -0,0 +1,226 @@ +# Auto-Update Knowledge Graph (Internal — Hook-Triggered) + +Incrementally update the knowledge graph using deterministic structural fingerprinting to minimize token usage. This prompt is triggered automatically by the post-commit hook when `autoUpdate` is enabled. It is NOT a user-facing skill. + +**Key principle:** Spend zero LLM tokens when changes are cosmetic (formatting, internal logic). Only invoke LLM agents when structural changes (new/removed functions, classes, imports, exports) are detected. + +--- + +## Phase 0 — Pre-flight (Zero Token Cost) + +1. Set `PROJECT_ROOT` to the current working directory. + +2. Check that `$PROJECT_ROOT/.understand-anything/knowledge-graph.json` exists. + - If not: report "No existing knowledge graph found. Run `/understand` first to create one." and **STOP**. + +3. Check that `$PROJECT_ROOT/.understand-anything/meta.json` exists and read `gitCommitHash`. + - If not: report "No analysis metadata found. Run `/understand` to create a baseline." and **STOP**. + +4. Get current commit hash: + ```bash + git rev-parse HEAD + ``` + +5. If commit hashes match and `--force` is NOT in `$ARGUMENTS`: report "Knowledge graph is already up to date." and **STOP**. + +6. Get changed files: + ```bash + git diff ..HEAD --name-only + ``` + If no files changed: update `meta.json` with the new commit hash and **STOP**. + +7. Filter to source files only (`.ts`, `.tsx`, `.js`, `.jsx`, `.py`, `.go`, `.rs`, `.java`, `.rb`, `.cpp`, `.c`, `.h`, `.cs`, `.swift`, `.kt`, `.php`). + If no source files changed: update `meta.json` with the new commit hash, report "Only non-source files changed. Metadata updated." and **STOP**. + +8. Create intermediate directory: + ```bash + mkdir -p $PROJECT_ROOT/.understand-anything/intermediate + ``` + +--- + +## Phase 1 — Structural Fingerprint Check (Zero LLM Tokens) + +This phase runs a deterministic Node.js script that compares file structures against stored fingerprints. It costs **zero LLM tokens** — only the script execution cost. + +1. Write and execute a Node.js script (`$PROJECT_ROOT/.understand-anything/intermediate/fingerprint-check.mjs`): + +```javascript +// The script should: +// 1. Read fingerprints.json from .understand-anything/fingerprints.json +// 2. For each changed source file: +// a. Read the file content +// b. Compute SHA-256 content hash +// c. If content hash matches stored hash → NONE (skip) +// d. Extract structural elements via regex: +// - Functions: match patterns like `function NAME(`, `const NAME = (`, `export function NAME(` +// - Classes: match `class NAME`, `export class NAME` +// - Imports: match `import ... from '...'`, `import '...'` +// - Exports: match `export { ... }`, `export default`, `export function`, `export class`, `export const` +// e. Compare extracted elements against stored fingerprint +// f. Classify as NONE, COSMETIC, or STRUCTURAL +// 3. For new files (not in fingerprints.json): classify as STRUCTURAL +// 4. For deleted files (in fingerprints.json but not on disk): classify as STRUCTURAL +// 5. Determine overall decision: +// - All NONE/COSMETIC → action: "SKIP" +// - Some STRUCTURAL, ≤10 files, same directories → action: "PARTIAL_UPDATE" +// - New/deleted directories or >10 structural files → action: "ARCHITECTURE_UPDATE" +// - >30 structural files or >50% of graph → action: "FULL_UPDATE" +// 6. Write result to .understand-anything/intermediate/change-analysis.json +``` + +The output JSON should have this shape: +```json +{ + "action": "SKIP | PARTIAL_UPDATE | ARCHITECTURE_UPDATE | FULL_UPDATE", + "filesToReanalyze": ["src/new-feature.ts"], + "rerunArchitecture": false, + "rerunTour": false, + "reason": "1 file has structural changes (new function added)", + "fileChanges": [ + { "filePath": "src/utils.ts", "changeLevel": "COSMETIC", "details": ["internal logic changed"] }, + { "filePath": "src/new-feature.ts", "changeLevel": "STRUCTURAL", "details": ["new function: handleRequest"] } + ] +} +``` + +2. Read `.understand-anything/intermediate/change-analysis.json`. + +3. **Decision gate:** + + | Action | What to do | + |---|---| + | `SKIP` | Update `meta.json` with new commit hash. Report: "No structural changes detected. Graph metadata updated. Zero tokens spent." **STOP.** | + | `FULL_UPDATE` | Report: "Major structural changes detected (reason). Recommend running `/understand --full` for a complete rebuild." **STOP.** | + | `PARTIAL_UPDATE` | Proceed to Phase 2 with `filesToReanalyze` | + | `ARCHITECTURE_UPDATE` | Proceed to Phase 2 with `filesToReanalyze`, flag architecture re-run | + +--- + +## Phase 2 — Targeted Re-Analysis (Minimal Token Cost) + +Only re-analyze files with structural changes. This is the **only** phase that costs LLM tokens. + +1. Read the existing knowledge graph from `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`. + +2. Batch the files from `filesToReanalyze` (from Phase 1). Use a single batch if ≤10 files, otherwise batch into groups of 5-10. + +3. For each batch, dispatch a subagent using the prompt template at `../skills/understand/file-analyzer-prompt.md`. Read the template file and pass the full content as the subagent's prompt, appending: + + > **Additional context from main session:** + > + > Project: `` — `` + > Frameworks detected: `` + > Languages: `` + > + > **IMPORTANT:** This is an incremental update. Only the files listed below have structural changes. Analyze them thoroughly but do not invent nodes for files not in this batch. + + Fill in batch-specific parameters: + + > Analyze these source files and produce GraphNode and GraphEdge objects. + > Project root: `$PROJECT_ROOT` + > Project: `` + > Languages: `` + > Batch index: `1` + > Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-1.json` + > + > All project files (for import resolution): + > `` + > + > Files to analyze in this batch: + > 1. `` (`` lines) + > ... + +4. After batch(es) complete, read each `batch-.json` and merge results. + +5. **Merge with existing graph:** + - Remove old nodes whose `filePath` matches any file in `filesToReanalyze` or in the deleted files list + - Remove old edges whose `source` or `target` references a removed node + - Add new nodes and edges from the fresh analysis + - Deduplicate nodes by ID (keep latest), edges by `source + target + type` + - Remove any edge with dangling `source` or `target` references + +--- + +## Phase 3 — Conditional Architecture/Tour + Save + +### 3a. Architecture update (only if `rerunArchitecture === true`) + +If the change analysis flagged `ARCHITECTURE_UPDATE`: + +1. Dispatch a subagent using the prompt template at `../skills/understand/architecture-analyzer-prompt.md`, passing the full merged node set and import edges. Include previous layer definitions for naming consistency: + + > Previous layer definitions (for naming consistency): + > ```json + > [previous layers from existing graph] + > ``` + > Maintain the same layer names and IDs where possible. Only add/remove layers if the file structure has materially changed. + +2. After completion, read and normalize layers (same normalization as `/understand` Phase 4). + +3. Optionally re-run tour builder if layers changed significantly. + +### 3b. Lite layer update (if `rerunArchitecture === false`) + +If only a partial update: +1. For **new files**: assign them to the most likely existing layer based on directory path matching +2. For **deleted files**: remove their IDs from layer `nodeIds` arrays +3. Remove any layer that ends up with zero nodeIds + +### 3c. Lite validation + +Perform lightweight validation (no graph-reviewer agent): +1. Remove any edge with dangling `source` or `target` +2. Remove any layer `nodeIds` entry that doesn't exist in the node set +3. Ensure every file node appears in exactly one layer (add to a catch-all layer if missing) + +### 3d. Save + +1. Write the final knowledge graph to `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`. + +2. Write updated metadata to `$PROJECT_ROOT/.understand-anything/meta.json`: + ```json + { + "lastAnalyzedAt": "", + "gitCommitHash": "", + "version": "1.0.0", + "analyzedFiles": + } + ``` + +3. **Update fingerprints:** Write and execute a Node.js script that: + - Reads the existing `fingerprints.json` + - For each re-analyzed file: computes new content hash and extracts structural elements via regex + - For deleted files: removes their entries + - Merges with existing fingerprints (keep unchanged files as-is) + - Writes updated `fingerprints.json` + +4. Clean up intermediate files: + ```bash + rm -rf $PROJECT_ROOT/.understand-anything/intermediate + ``` + +5. Report a summary: + - Files checked: N (total changed) + - Structural changes found: N files + - Cosmetic-only changes: N files (skipped) + - Nodes updated: N + - Action taken: PARTIAL_UPDATE / ARCHITECTURE_UPDATE + - Path to output: `$PROJECT_ROOT/.understand-anything/knowledge-graph.json` + +--- + +## Error Handling + +- If the fingerprint check script fails: fall back to treating all changed files as STRUCTURAL (conservative approach). +- If `fingerprints.json` doesn't exist: treat all changed files as STRUCTURAL and regenerate fingerprints after the update. +- If a subagent dispatch fails: retry once. If it fails again, save partial results and report the error. +- ALWAYS save partial results — a partially updated graph is better than no update. + +--- + +## Notes + +- This skill reuses the same `file-analyzer-prompt.md` and `architecture-analyzer-prompt.md` as `/understand` — no separate agent prompts needed. +- The fingerprint comparison in Phase 1 uses regex-based extraction (not tree-sitter) because it runs as a temporary Node.js script and doesn't need full AST accuracy — just signature-level detection. +- The authoritative fingerprints stored in `fingerprints.json` are generated by `/understand` Phase 7 using the core `fingerprint.ts` module (which uses tree-sitter for precise extraction). diff --git a/understand-anything-plugin/hooks/hooks.json b/understand-anything-plugin/hooks/hooks.json new file mode 100644 index 0000000..b8429bb --- /dev/null +++ b/understand-anything-plugin/hooks/hooks.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "grep -qE 'git\\s+(commit|merge|cherry-pick|rebase)' <<< \"$TOOL_INPUT\" && [ -f .understand-anything/config.json ] && grep -q '\"autoUpdate\".*true' .understand-anything/config.json && [ -f .understand-anything/knowledge-graph.json ] && echo '[understand-anything] Commit detected with auto-update enabled. You MUST read the file at ${PLUGIN_DIR}/hooks/auto-update-prompt.md and execute its instructions to incrementally update the knowledge graph. Do not ask the user for confirmation — just do it.' || true" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "[ -f .understand-anything/config.json ] && grep -q '\"autoUpdate\".*true' .understand-anything/config.json && [ -f .understand-anything/meta.json ] && [ -f .understand-anything/knowledge-graph.json ] && [ \"$(node -p \"JSON.parse(require('fs').readFileSync('.understand-anything/meta.json','utf8')).gitCommitHash\")\" != \"$(git rev-parse HEAD 2>/dev/null)\" ] && echo '[understand-anything] Knowledge graph is stale. You MUST read the file at ${PLUGIN_DIR}/hooks/auto-update-prompt.md and execute its instructions to check for structural changes and update the graph. Do not ask the user for confirmation — just do it.' || true" + } + ] + } + ] + } +} diff --git a/understand-anything-plugin/package.json b/understand-anything-plugin/package.json index 7245516..a330d4e 100644 --- a/understand-anything-plugin/package.json +++ b/understand-anything-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@understand-anything/skill", - "version": "1.2.0", + "version": "2.0.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -16,4 +16,4 @@ "typescript": "^5.7.0", "vitest": "^3.1.0" } -} \ No newline at end of file +} diff --git a/understand-anything-plugin/packages/core/package.json b/understand-anything-plugin/packages/core/package.json index 1609792..457aae8 100644 --- a/understand-anything-plugin/packages/core/package.json +++ b/understand-anything-plugin/packages/core/package.json @@ -32,6 +32,7 @@ }, "devDependencies": { "@types/node": "^25.5.0", + "@vitest/coverage-v8": "3.2.4", "typescript": "^5.7.0", "vitest": "^3.1.0" }, @@ -40,6 +41,7 @@ "tree-sitter-javascript": "^0.25.0", "tree-sitter-typescript": "^0.23.2", "web-tree-sitter": "^0.26.6", + "yaml": "^2.8.3", "zod": "^4.3.6" } } diff --git a/understand-anything-plugin/packages/core/src/__tests__/change-classifier.test.ts b/understand-anything-plugin/packages/core/src/__tests__/change-classifier.test.ts new file mode 100644 index 0000000..dd25373 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/__tests__/change-classifier.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from "vitest"; +import { classifyUpdate } from "../change-classifier.js"; +import type { ChangeAnalysis } from "../fingerprint.js"; + +function makeAnalysis(overrides: Partial = {}): ChangeAnalysis { + return { + fileChanges: [], + newFiles: [], + deletedFiles: [], + structurallyChangedFiles: [], + cosmeticOnlyFiles: [], + unchangedFiles: [], + ...overrides, + }; +} + +describe("classifyUpdate", () => { + it("returns SKIP when all files are unchanged", () => { + const analysis = makeAnalysis({ + unchangedFiles: ["src/a.ts", "src/b.ts"], + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("SKIP"); + expect(decision.filesToReanalyze).toHaveLength(0); + expect(decision.rerunArchitecture).toBe(false); + expect(decision.rerunTour).toBe(false); + }); + + it("returns SKIP when all changes are cosmetic", () => { + const analysis = makeAnalysis({ + cosmeticOnlyFiles: ["src/a.ts", "src/b.ts"], + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("SKIP"); + expect(decision.reason).toContain("cosmetic-only"); + }); + + it("returns PARTIAL_UPDATE for a few structural changes", () => { + const analysis = makeAnalysis({ + structurallyChangedFiles: ["src/a.ts", "src/b.ts"], + newFiles: ["src/c.ts"], + cosmeticOnlyFiles: ["src/d.ts"], + }); + + // src/ already exists in the project, so adding src/c.ts is not a directory change + const allKnownFiles = ["src/a.ts", "src/b.ts", "src/d.ts", "lib/util.ts"]; + const decision = classifyUpdate(analysis, 50, allKnownFiles); + + expect(decision.action).toBe("PARTIAL_UPDATE"); + expect(decision.filesToReanalyze).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]); + expect(decision.rerunArchitecture).toBe(false); + expect(decision.rerunTour).toBe(false); + }); + + it("returns ARCHITECTURE_UPDATE when >10 structural files", () => { + const files = Array.from({ length: 12 }, (_, i) => `src/file${i}.ts`); + const analysis = makeAnalysis({ + structurallyChangedFiles: files, + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("ARCHITECTURE_UPDATE"); + expect(decision.rerunArchitecture).toBe(true); + expect(decision.rerunTour).toBe(true); + }); + + it("returns ARCHITECTURE_UPDATE when new directories appear", () => { + const analysis = makeAnalysis({ + structurallyChangedFiles: ["src/existing.ts"], + newFiles: ["newdir/file.ts"], + }); + + const allKnownFiles = ["src/existing.ts", "src/other.ts", "lib/util.ts"]; + const decision = classifyUpdate(analysis, 50, allKnownFiles); + + expect(decision.action).toBe("ARCHITECTURE_UPDATE"); + expect(decision.rerunArchitecture).toBe(true); + }); + + it("returns ARCHITECTURE_UPDATE when directories are deleted", () => { + const analysis = makeAnalysis({ + structurallyChangedFiles: ["src/existing.ts"], + deletedFiles: ["olddir/removed.ts"], + }); + + const allKnownFiles = ["src/existing.ts", "src/other.ts"]; + const decision = classifyUpdate(analysis, 50, allKnownFiles); + + expect(decision.action).toBe("ARCHITECTURE_UPDATE"); + expect(decision.rerunArchitecture).toBe(true); + }); + + it("does NOT trigger ARCHITECTURE_UPDATE for new file in existing directory", () => { + const analysis = makeAnalysis({ + newFiles: ["src/newfile.ts"], + }); + + // src/ is already known via other files in the project + const allKnownFiles = ["src/a.ts", "src/b.ts", "lib/util.ts"]; + const decision = classifyUpdate(analysis, 50, allKnownFiles); + + expect(decision.action).toBe("PARTIAL_UPDATE"); + expect(decision.rerunArchitecture).toBe(false); + }); + + it("triggers ARCHITECTURE_UPDATE for new file in genuinely new directory", () => { + const analysis = makeAnalysis({ + newFiles: ["brand-new-pkg/index.ts"], + }); + + // allKnownFiles only contains src/ and lib/ — no brand-new-pkg/ + const allKnownFiles = ["src/a.ts", "src/b.ts", "lib/util.ts"]; + const decision = classifyUpdate(analysis, 50, allKnownFiles); + + expect(decision.action).toBe("ARCHITECTURE_UPDATE"); + expect(decision.rerunArchitecture).toBe(true); + }); + + it("returns FULL_UPDATE when >30 structural files", () => { + const files = Array.from({ length: 35 }, (_, i) => `src/file${i}.ts`); + const analysis = makeAnalysis({ + structurallyChangedFiles: files, + }); + + const decision = classifyUpdate(analysis, 100); + + expect(decision.action).toBe("FULL_UPDATE"); + expect(decision.rerunArchitecture).toBe(true); + expect(decision.rerunTour).toBe(true); + }); + + it("returns FULL_UPDATE when >50% of project is structurally changed", () => { + const files = Array.from({ length: 6 }, (_, i) => `src/file${i}.ts`); + const analysis = makeAnalysis({ + structurallyChangedFiles: files, + }); + + // 6 out of 10 files = 60% + const decision = classifyUpdate(analysis, 10); + + expect(decision.action).toBe("FULL_UPDATE"); + }); + + it("includes new and structural files in filesToReanalyze for PARTIAL", () => { + const analysis = makeAnalysis({ + structurallyChangedFiles: ["src/modified.ts"], + newFiles: ["src/added.ts"], + deletedFiles: ["src/removed.ts"], + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.filesToReanalyze).toContain("src/modified.ts"); + expect(decision.filesToReanalyze).toContain("src/added.ts"); + // Deleted files shouldn't be re-analyzed + expect(decision.filesToReanalyze).not.toContain("src/removed.ts"); + }); + + it("handles empty analysis (no changes at all)", () => { + const analysis = makeAnalysis(); + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("SKIP"); + expect(decision.reason).toContain("No changes detected"); + }); + + it("counts deleted files toward structural total", () => { + // 8 structural + 3 deleted = 11 total structural > 10 threshold + const analysis = makeAnalysis({ + structurallyChangedFiles: Array.from({ length: 8 }, (_, i) => `src/file${i}.ts`), + deletedFiles: ["src/old1.ts", "src/old2.ts", "src/old3.ts"], + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("ARCHITECTURE_UPDATE"); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/__tests__/fingerprint.test.ts b/understand-anything-plugin/packages/core/src/__tests__/fingerprint.test.ts new file mode 100644 index 0000000..6cd9533 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/__tests__/fingerprint.test.ts @@ -0,0 +1,427 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { StructuralAnalysis } from "../types.js"; +import { + contentHash, + extractFileFingerprint, + compareFingerprints, + analyzeChanges, + type FileFingerprint, + type FingerprintStore, +} from "../fingerprint.js"; + +// Mock fs and path for analyzeChanges +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(), + existsSync: vi.fn(), +})); + +import { readFileSync, existsSync } from "node:fs"; + +const mockedReadFileSync = vi.mocked(readFileSync); +const mockedExistsSync = vi.mocked(existsSync); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("contentHash", () => { + it("produces consistent SHA-256 hashes", () => { + const hash1 = contentHash("hello world"); + const hash2 = contentHash("hello world"); + expect(hash1).toBe(hash2); + expect(hash1).toMatch(/^[a-f0-9]{64}$/); + }); + + it("produces different hashes for different content", () => { + expect(contentHash("hello")).not.toBe(contentHash("world")); + }); +}); + +describe("extractFileFingerprint", () => { + it("extracts function fingerprints from analysis", () => { + const analysis: StructuralAnalysis = { + functions: [ + { name: "main", lineRange: [1, 20], params: ["config", "options"], returnType: "void" }, + { name: "helper", lineRange: [22, 30], params: [], returnType: "string" }, + ], + classes: [], + imports: [], + exports: [{ name: "main", lineNumber: 1 }], + }; + + const fp = extractFileFingerprint("src/index.ts", "const x = 1;\n".repeat(30), analysis); + + expect(fp.filePath).toBe("src/index.ts"); + expect(fp.functions).toHaveLength(2); + expect(fp.functions[0]).toEqual({ + name: "main", + params: ["config", "options"], + returnType: "void", + exported: true, + lineCount: 20, + }); + expect(fp.functions[1]).toEqual({ + name: "helper", + params: [], + returnType: "string", + exported: false, + lineCount: 9, + }); + }); + + it("extracts class fingerprints", () => { + const analysis: StructuralAnalysis = { + functions: [], + classes: [ + { name: "MyClass", lineRange: [1, 50], methods: ["doStuff", "init"], properties: ["name"] }, + ], + imports: [], + exports: [{ name: "MyClass", lineNumber: 1 }], + }; + + const fp = extractFileFingerprint("src/my-class.ts", "x\n".repeat(50), analysis); + + expect(fp.classes).toHaveLength(1); + expect(fp.classes[0]).toEqual({ + name: "MyClass", + methods: ["doStuff", "init"], + properties: ["name"], + exported: true, + lineCount: 50, + }); + }); + + it("extracts import and export fingerprints", () => { + const analysis: StructuralAnalysis = { + functions: [], + classes: [], + imports: [ + { source: "./utils", specifiers: ["format", "parse"], lineNumber: 1 }, + { source: "node:fs", specifiers: ["readFileSync"], lineNumber: 2 }, + ], + exports: [{ name: "main", lineNumber: 5 }, { name: "default", lineNumber: 10 }], + }; + + const fp = extractFileFingerprint("src/index.ts", "x\n", analysis); + + expect(fp.imports).toHaveLength(2); + expect(fp.imports[0]).toEqual({ source: "./utils", specifiers: ["format", "parse"] }); + expect(fp.exports).toEqual(["main", "default"]); + }); + + it("computes content hash and total lines", () => { + const content = "line1\nline2\nline3\n"; + const analysis: StructuralAnalysis = { + functions: [], + classes: [], + imports: [], + exports: [], + }; + + const fp = extractFileFingerprint("src/empty.ts", content, analysis); + + expect(fp.contentHash).toBe(contentHash(content)); + expect(fp.totalLines).toBe(4); // 3 lines + trailing newline = 4 elements + }); +}); + +describe("compareFingerprints", () => { + const baseFp: FileFingerprint = { + filePath: "src/index.ts", + contentHash: "abc123", + functions: [ + { name: "main", params: ["config"], returnType: "void", exported: true, lineCount: 20 }, + ], + classes: [], + imports: [{ source: "./utils", specifiers: ["format"] }], + exports: ["main"], + totalLines: 30, + hasStructuralAnalysis: true, + }; + + it("returns NONE when content hash is identical", () => { + const result = compareFingerprints(baseFp, { ...baseFp }); + expect(result.changeLevel).toBe("NONE"); + expect(result.details).toHaveLength(0); + }); + + it("returns COSMETIC when content changed but structure is identical", () => { + const newFp = { ...baseFp, contentHash: "different_hash" }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("COSMETIC"); + expect(result.details).toContain("internal logic changed (no structural impact)"); + }); + + it("detects new functions", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + functions: [ + ...baseFp.functions, + { name: "newFunc", params: [], exported: false, lineCount: 10 }, + ], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("new function: newFunc"); + }); + + it("detects removed functions", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + functions: [], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("removed function: main"); + }); + + it("detects parameter changes", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + functions: [ + { name: "main", params: ["config", "options"], returnType: "void", exported: true, lineCount: 20 }, + ], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("params changed: main"); + }); + + it("detects export status changes", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + functions: [ + { name: "main", params: ["config"], returnType: "void", exported: false, lineCount: 20 }, + ], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("export status changed: main"); + }); + + it("detects significant size changes (>50%)", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + functions: [ + { name: "main", params: ["config"], returnType: "void", exported: true, lineCount: 60 }, + ], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details.some((d) => d.includes("significant size change"))).toBe(true); + }); + + it("detects import changes", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + imports: [{ source: "./helpers", specifiers: ["doStuff"] }], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("imports changed"); + }); + + it("detects export list changes", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + exports: ["main", "helper"], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("exports changed"); + }); + + it("detects new and removed classes", () => { + const withClass: FileFingerprint = { + ...baseFp, + contentHash: "different", + classes: [{ name: "MyClass", methods: ["init"], properties: [], exported: true, lineCount: 30 }], + hasStructuralAnalysis: true, + }; + const result = compareFingerprints(baseFp, withClass); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("new class: MyClass"); + }); + + it("detects class method changes", () => { + const oldFp: FileFingerprint = { + ...baseFp, + classes: [{ name: "Foo", methods: ["a", "b"], properties: [], exported: true, lineCount: 30 }], + hasStructuralAnalysis: true, + }; + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + classes: [{ name: "Foo", methods: ["a", "c"], properties: [], exported: true, lineCount: 30 }], + hasStructuralAnalysis: true, + }; + const result = compareFingerprints(oldFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("methods changed: Foo"); + }); + + it("does NOT mutate input arrays (sort must use spread-copy)", () => { + const oldFp: FileFingerprint = { + ...baseFp, + classes: [{ name: "Foo", methods: ["b", "a"], properties: ["y", "x"], exported: true, lineCount: 30 }], + imports: [{ source: "./utils", specifiers: ["z", "a"] }], + hasStructuralAnalysis: true, + }; + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + classes: [{ name: "Foo", methods: ["b", "a"], properties: ["y", "x"], exported: true, lineCount: 30 }], + imports: [{ source: "./utils", specifiers: ["z", "a"] }], + hasStructuralAnalysis: true, + }; + + // Snapshot original order before comparison + const oldMethodsBefore = [...oldFp.classes[0].methods]; + const oldPropertiesBefore = [...oldFp.classes[0].properties]; + const oldSpecifiersBefore = [...oldFp.imports[0].specifiers]; + const newMethodsBefore = [...newFp.classes[0].methods]; + const newPropertiesBefore = [...newFp.classes[0].properties]; + const newSpecifiersBefore = [...newFp.imports[0].specifiers]; + + compareFingerprints(oldFp, newFp); + + // Arrays must remain in their original order (not sorted in-place) + expect(oldFp.classes[0].methods).toEqual(oldMethodsBefore); + expect(oldFp.classes[0].properties).toEqual(oldPropertiesBefore); + expect(oldFp.imports[0].specifiers).toEqual(oldSpecifiersBefore); + expect(newFp.classes[0].methods).toEqual(newMethodsBefore); + expect(newFp.classes[0].properties).toEqual(newPropertiesBefore); + expect(newFp.imports[0].specifiers).toEqual(newSpecifiersBefore); + }); + + it("classifies as STRUCTURAL when hasStructuralAnalysis is false (no tree-sitter)", () => { + const oldFp: FileFingerprint = { + filePath: "config.yaml", + contentHash: "hash_old", + functions: [], + classes: [], + imports: [], + exports: [], + totalLines: 10, + hasStructuralAnalysis: false, + }; + const newFp: FileFingerprint = { + filePath: "config.yaml", + contentHash: "hash_new", + functions: [], + classes: [], + imports: [], + exports: [], + totalLines: 12, + hasStructuralAnalysis: false, + }; + + const result = compareFingerprints(oldFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("no structural analysis available — conservative classification"); + }); +}); + +describe("analyzeChanges", () => { + const mockRegistry = { + analyzeFile: vi.fn(), + } as any; + + const existingStore: FingerprintStore = { + version: "1.0.0", + gitCommitHash: "abc123", + generatedAt: "2026-01-01T00:00:00.000Z", + files: { + "src/index.ts": { + filePath: "src/index.ts", + contentHash: "hash_a", + functions: [{ name: "main", params: [], exported: true, lineCount: 20 }], + classes: [], + imports: [], + exports: ["main"], + totalLines: 30, + hasStructuralAnalysis: true, + }, + "src/utils.ts": { + filePath: "src/utils.ts", + contentHash: "hash_b", + functions: [], + classes: [], + imports: [], + exports: [], + totalLines: 10, + hasStructuralAnalysis: true, + }, + }, + }; + + it("classifies new files as STRUCTURAL", () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue("new content"); + mockRegistry.analyzeFile.mockReturnValue({ + functions: [], + classes: [], + imports: [], + exports: [], + }); + + const result = analyzeChanges("/project", ["src/new-file.ts"], existingStore, mockRegistry); + + expect(result.newFiles).toContain("src/new-file.ts"); + expect(result.fileChanges[0].changeLevel).toBe("STRUCTURAL"); + }); + + it("classifies deleted files as STRUCTURAL", () => { + mockedExistsSync.mockReturnValue(false); + + const result = analyzeChanges("/project", ["src/utils.ts"], existingStore, mockRegistry); + + expect(result.deletedFiles).toContain("src/utils.ts"); + expect(result.fileChanges[0].changeLevel).toBe("STRUCTURAL"); + }); + + it("classifies unchanged content as NONE", () => { + mockedExistsSync.mockReturnValue(true); + // Return content that produces the same hash + const content = "test content"; + const hash = contentHash(content); + + const store: FingerprintStore = { + ...existingStore, + files: { + "src/index.ts": { + ...existingStore.files["src/index.ts"], + contentHash: hash, + }, + }, + }; + + mockedReadFileSync.mockReturnValue(content); + mockRegistry.analyzeFile.mockReturnValue({ + functions: [{ name: "main", lineRange: [1, 20], params: [] }], + classes: [], + imports: [], + exports: [{ name: "main", lineNumber: 1 }], + }); + + const result = analyzeChanges("/project", ["src/index.ts"], store, mockRegistry); + + expect(result.unchangedFiles).toContain("src/index.ts"); + }); + + it("ignores deleted files not in the store", () => { + mockedExistsSync.mockReturnValue(false); + + const result = analyzeChanges("/project", ["src/unknown.ts"], existingStore, mockRegistry); + + expect(result.deletedFiles).toHaveLength(0); + expect(result.fileChanges).toHaveLength(0); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts b/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts index c3a8b75..03617ca 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { LanguageRegistry } from "../languages/language-registry.js"; +import { StrictLanguageConfigSchema } from "../languages/types.js"; import { typescriptConfig } from "../languages/configs/typescript.js"; import { pythonConfig } from "../languages/configs/python.js"; @@ -32,9 +33,9 @@ describe("LanguageRegistry", () => { expect(registry.getForFile("file.unknown")).toBeNull(); }); - it("returns null for files without extensions", () => { + it("returns null for files without extensions and no filename match", () => { const registry = new LanguageRegistry(); - expect(registry.getForFile("Makefile")).toBeNull(); + expect(registry.getForFile("SOMEFILE")).toBeNull(); }); it("lists all registered languages", () => { @@ -48,10 +49,10 @@ describe("LanguageRegistry", () => { }); describe("createDefault", () => { - it("registers all 12 built-in language configs", () => { + it("registers all 38 built-in language configs", () => { const registry = LanguageRegistry.createDefault(); const all = registry.getAllLanguages(); - expect(all.length).toBe(12); + expect(all.length).toBe(38); }); it("maps all expected extensions", () => { @@ -88,4 +89,107 @@ describe("LanguageRegistry", () => { } }); }); + + describe("Non-code language configs", () => { + it("detects all non-code file types via extension", () => { + const registry = LanguageRegistry.createDefault(); + const expectations: [string, string][] = [ + ["README.md", "markdown"], + ["config.yaml", "yaml"], + ["package.json", "json"], + ["config.toml", "toml"], + [".env", "env"], + ["pom.xml", "xml"], + ["Dockerfile", "dockerfile"], + ["schema.sql", "sql"], + ["schema.graphql", "graphql"], + ["types.proto", "protobuf"], + ["main.tf", "terraform"], + ["Makefile", "makefile"], + ["deploy.sh", "shell"], + ["index.html", "html"], + ["styles.css", "css"], + ["data.csv", "csv"], + ["deploy.ps1", "powershell"], + ]; + for (const [file, expectedId] of expectations) { + const config = registry.getForFile(file); + expect(config?.id, `${file} should be detected as ${expectedId}`).toBe(expectedId); + } + }); + + it("detects filename-based configs (Dockerfile, Makefile, Jenkinsfile)", () => { + const registry = LanguageRegistry.createDefault(); + expect(registry.getForFile("Dockerfile")?.id).toBe("dockerfile"); + expect(registry.getForFile("Makefile")?.id).toBe("makefile"); + expect(registry.getForFile("Jenkinsfile")?.id).toBe("jenkinsfile"); + expect(registry.getForFile("src/Dockerfile")?.id).toBe("dockerfile"); + expect(registry.getForFile("build/Makefile")?.id).toBe("makefile"); + }); + + it("detects filename-based configs for docker-compose", () => { + const registry = LanguageRegistry.createDefault(); + expect(registry.getForFile("docker-compose.yml")?.id).toBe("docker-compose"); + expect(registry.getForFile("docker-compose.yaml")?.id).toBe("docker-compose"); + expect(registry.getForFile("compose.yml")?.id).toBe("docker-compose"); + }); + + it("detects .env file variants", () => { + const registry = LanguageRegistry.createDefault(); + expect(registry.getForFile(".env")?.id).toBe("env"); + expect(registry.getForFile(".env.local")?.id).toBe("env"); + expect(registry.getForFile(".env.production")?.id).toBe("env"); + }); + }); + + describe("StrictLanguageConfigSchema refinement", () => { + it("rejects configs with empty extensions AND no filenames", () => { + const result = StrictLanguageConfigSchema.safeParse({ + id: "empty-lang", + displayName: "Empty", + extensions: [], + concepts: ["nothing"], + filePatterns: { entryPoints: [], barrels: [], tests: [], config: [] }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("at least one extension or filename"); + } + }); + + it("rejects configs with empty extensions AND empty filenames", () => { + const result = StrictLanguageConfigSchema.safeParse({ + id: "empty-lang", + displayName: "Empty", + extensions: [], + filenames: [], + concepts: ["nothing"], + filePatterns: { entryPoints: [], barrels: [], tests: [], config: [] }, + }); + expect(result.success).toBe(false); + }); + + it("accepts configs with extensions but no filenames", () => { + const result = StrictLanguageConfigSchema.safeParse({ + id: "ext-lang", + displayName: "ExtLang", + extensions: [".ext"], + concepts: ["something"], + filePatterns: { entryPoints: [], barrels: [], tests: [], config: [] }, + }); + expect(result.success).toBe(true); + }); + + it("accepts configs with filenames but empty extensions", () => { + const result = StrictLanguageConfigSchema.safeParse({ + id: "filename-lang", + displayName: "FilenameLang", + extensions: [], + filenames: ["Specialfile"], + concepts: ["something"], + filePatterns: { entryPoints: [], barrels: [], tests: [], config: [] }, + }); + expect(result.success).toBe(true); + }); + }); }); diff --git a/understand-anything-plugin/packages/core/src/__tests__/parsers.test.ts b/understand-anything-plugin/packages/core/src/__tests__/parsers.test.ts new file mode 100644 index 0000000..b4e008a --- /dev/null +++ b/understand-anything-plugin/packages/core/src/__tests__/parsers.test.ts @@ -0,0 +1,503 @@ +import { describe, it, expect } from "vitest"; +import { MarkdownParser } from "../plugins/parsers/markdown-parser.js"; +import { YAMLConfigParser } from "../plugins/parsers/yaml-parser.js"; +import { JSONConfigParser } from "../plugins/parsers/json-parser.js"; +import { TOMLParser } from "../plugins/parsers/toml-parser.js"; +import { EnvParser } from "../plugins/parsers/env-parser.js"; +import { DockerfileParser } from "../plugins/parsers/dockerfile-parser.js"; +import { SQLParser } from "../plugins/parsers/sql-parser.js"; +import { GraphQLParser } from "../plugins/parsers/graphql-parser.js"; +import { ProtobufParser } from "../plugins/parsers/protobuf-parser.js"; +import { TerraformParser } from "../plugins/parsers/terraform-parser.js"; +import { MakefileParser } from "../plugins/parsers/makefile-parser.js"; +import { ShellParser } from "../plugins/parsers/shell-parser.js"; +import { registerAllParsers } from "../plugins/parsers/index.js"; +import { PluginRegistry } from "../plugins/registry.js"; + +describe("MarkdownParser", () => { + const parser = new MarkdownParser(); + + it("extracts heading sections", () => { + const content = "# Title\n\nIntro\n\n## Section A\n\nContent A\n\n### Subsection\n\nContent B"; + const result = parser.analyzeFile("README.md", content); + expect(result.sections).toHaveLength(3); + expect(result.sections![0]).toMatchObject({ name: "Title", level: 1 }); + expect(result.sections![1]).toMatchObject({ name: "Section A", level: 2 }); + expect(result.sections![2]).toMatchObject({ name: "Subsection", level: 3 }); + }); + + it("extracts YAML front matter as imports", () => { + const content = "---\ntitle: Test\ntags: [a, b]\n---\n# Content"; + const result = parser.analyzeFile("post.md", content); + expect(result.imports).toHaveLength(0); + }); + + it("extracts file references", () => { + const content = "See [guide](./docs/guide.md) and ![img](./assets/logo.png)"; + const refs = parser.extractReferences!("README.md", content); + expect(refs).toHaveLength(2); + expect(refs[0]).toMatchObject({ target: "./docs/guide.md", referenceType: "file" }); + expect(refs[1]).toMatchObject({ target: "./assets/logo.png", referenceType: "image" }); + }); + + it("skips external URLs in references", () => { + const content = "[link](https://example.com) and [local](./file.md)"; + const refs = parser.extractReferences!("README.md", content); + expect(refs).toHaveLength(1); + expect(refs[0].target).toBe("./file.md"); + }); + + it("returns empty sections for empty content", () => { + const result = parser.analyzeFile("empty.md", ""); + expect(result.sections).toHaveLength(0); + }); +}); + +describe("YAMLConfigParser", () => { + const parser = new YAMLConfigParser(); + + it("extracts top-level key sections", () => { + const content = "name: my-app\nversion: 1.0\nservices:\n web:\n image: node\n db:\n image: postgres"; + const result = parser.analyzeFile("config.yaml", content); + expect(result.sections).toBeDefined(); + expect(result.sections!.length).toBeGreaterThanOrEqual(3); + expect(result.sections!.map(s => s.name)).toContain("name"); + expect(result.sections!.map(s => s.name)).toContain("services"); + }); + + it("handles invalid YAML gracefully", () => { + const content = "invalid: yaml: content: [[["; + const result = parser.analyzeFile("broken.yaml", content); + expect(result.sections).toBeDefined(); + }); +}); + +describe("JSONConfigParser", () => { + const parser = new JSONConfigParser(); + + it("extracts top-level key sections", () => { + const content = '{\n "name": "my-app",\n "version": "1.0",\n "dependencies": {}\n}'; + const result = parser.analyzeFile("package.json", content); + expect(result.sections).toBeDefined(); + expect(result.sections!.map(s => s.name)).toContain("name"); + expect(result.sections!.map(s => s.name)).toContain("dependencies"); + }); + + it("extracts $ref references", () => { + const content = '{\n "$ref": "./common.json#/defs/User"\n}'; + const refs = parser.extractReferences!("schema.json", content); + expect(refs).toHaveLength(1); + expect(refs[0]).toMatchObject({ target: "./common.json#/defs/User", referenceType: "schema" }); + }); + + it("skips internal $ref references", () => { + const content = '{\n "$ref": "#/definitions/User"\n}'; + const refs = parser.extractReferences!("schema.json", content); + expect(refs).toHaveLength(0); + }); + + it("handles invalid JSON gracefully", () => { + const content = "not json at all"; + const result = parser.analyzeFile("broken.json", content); + expect(result.sections).toHaveLength(0); + }); +}); + +describe("TOMLParser", () => { + const parser = new TOMLParser(); + + it("extracts section headers", () => { + const content = "[package]\nname = \"my-app\"\n\n[dependencies]\nfoo = \"1.0\"\n\n[[bin]]\nname = \"cli\""; + const result = parser.analyzeFile("Cargo.toml", content); + expect(result.sections).toBeDefined(); + expect(result.sections!.length).toBe(3); + expect(result.sections![0].name).toBe("package"); + expect(result.sections![1].name).toBe("dependencies"); + expect(result.sections![2].name).toBe("[[bin]]"); + }); +}); + +describe("EnvParser", () => { + const parser = new EnvParser(); + + it("extracts variable names", () => { + const content = "# Database config\nDB_HOST=localhost\nDB_PORT=5432\n\n# API\nAPI_KEY=secret123"; + const result = parser.analyzeFile(".env", content); + expect(result.definitions).toBeDefined(); + expect(result.definitions!).toHaveLength(3); + expect(result.definitions!.map(d => d.name)).toEqual(["DB_HOST", "DB_PORT", "API_KEY"]); + }); + + it("skips comments and empty lines", () => { + const content = "# comment\n\nVAR=value"; + const result = parser.analyzeFile(".env", content); + expect(result.definitions!).toHaveLength(1); + }); +}); + +describe("DockerfileParser", () => { + const parser = new DockerfileParser(); + + it("extracts FROM stages", () => { + const content = "FROM node:22-slim AS builder\nRUN npm install\n\nFROM node:22-slim AS runner\nCOPY --from=builder /app /app\nEXPOSE 3000"; + const result = parser.analyzeFile("Dockerfile", content); + expect(result.services).toBeDefined(); + expect(result.services!).toHaveLength(2); + expect(result.services![0]).toMatchObject({ name: "builder", image: "node:22-slim" }); + expect(result.services![1]).toMatchObject({ name: "runner", image: "node:22-slim" }); + }); + + it("extracts EXPOSE ports", () => { + const content = "FROM node:22\nEXPOSE 3000 8080\nCMD [\"node\", \"server.js\"]"; + const result = parser.analyzeFile("Dockerfile", content); + expect(result.services![0].ports).toContain(3000); + expect(result.services![0].ports).toContain(8080); + }); + + it("extracts steps", () => { + const content = "FROM node:22\nWORKDIR /app\nCOPY . .\nRUN npm install\nCMD [\"node\", \"start\"]"; + const result = parser.analyzeFile("Dockerfile", content); + expect(result.steps).toBeDefined(); + expect(result.steps!.length).toBe(5); + }); +}); + +describe("SQLParser", () => { + const parser = new SQLParser(); + + it("extracts CREATE TABLE definitions with columns", () => { + const content = `CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE +); + +CREATE TABLE posts ( + id INTEGER PRIMARY KEY, + user_id INTEGER, + title TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) +);`; + const result = parser.analyzeFile("schema.sql", content); + expect(result.definitions).toBeDefined(); + expect(result.definitions!).toHaveLength(2); + expect(result.definitions![0]).toMatchObject({ name: "users", kind: "table" }); + expect(result.definitions![0].fields).toContain("id"); + expect(result.definitions![0].fields).toContain("name"); + expect(result.definitions![0].fields).toContain("email"); + expect(result.definitions![1]).toMatchObject({ name: "posts", kind: "table" }); + }); + + it("extracts CREATE VIEW", () => { + const content = "CREATE VIEW active_users AS SELECT * FROM users WHERE active = true;"; + const result = parser.analyzeFile("views.sql", content); + expect(result.definitions!.some(d => d.name === "active_users" && d.kind === "view")).toBe(true); + }); + + it("extracts CREATE INDEX", () => { + const content = "CREATE UNIQUE INDEX idx_users_email ON users(email);"; + const result = parser.analyzeFile("indexes.sql", content); + expect(result.definitions!.some(d => d.name === "idx_users_email" && d.kind === "index")).toBe(true); + }); +}); + +describe("GraphQLParser", () => { + const parser = new GraphQLParser(); + + it("extracts type definitions", () => { + const content = `type User { + id: ID! + name: String! + email: String! +} + +type Post { + id: ID! + title: String! + author: User! +}`; + const result = parser.analyzeFile("schema.graphql", content); + expect(result.definitions).toBeDefined(); + expect(result.definitions!).toHaveLength(2); + expect(result.definitions![0]).toMatchObject({ name: "User", kind: "type" }); + expect(result.definitions![0].fields).toContain("id"); + expect(result.definitions![0].fields).toContain("name"); + expect(result.definitions![1]).toMatchObject({ name: "Post", kind: "type" }); + }); + + it("extracts Query/Mutation endpoints", () => { + const content = `type Query { + users: [User!]! + user(id: ID!): User +} + +type Mutation { + createUser(name: String!): User! +}`; + const result = parser.analyzeFile("schema.graphql", content); + expect(result.endpoints).toBeDefined(); + expect(result.endpoints!.length).toBeGreaterThanOrEqual(3); + expect(result.endpoints!.some(e => e.method === "Query" && e.path === "users")).toBe(true); + expect(result.endpoints!.some(e => e.method === "Mutation" && e.path === "createUser")).toBe(true); + }); + + it("extracts enum definitions", () => { + const content = "enum Role {\n ADMIN\n USER\n GUEST\n}"; + const result = parser.analyzeFile("schema.graphql", content); + expect(result.definitions!.some(d => d.name === "Role" && d.kind === "enum")).toBe(true); + }); +}); + +describe("ProtobufParser", () => { + const parser = new ProtobufParser(); + + it("extracts message definitions with fields", () => { + const content = `message User { + string name = 1; + int32 age = 2; + repeated string emails = 3; +}`; + const result = parser.analyzeFile("user.proto", content); + expect(result.definitions).toBeDefined(); + expect(result.definitions!).toHaveLength(1); + expect(result.definitions![0]).toMatchObject({ name: "User", kind: "message" }); + expect(result.definitions![0].fields).toContain("name"); + expect(result.definitions![0].fields).toContain("age"); + expect(result.definitions![0].fields).toContain("emails"); + }); + + it("extracts enum definitions", () => { + const content = "enum Status {\n UNKNOWN = 0;\n ACTIVE = 1;\n INACTIVE = 2;\n}"; + const result = parser.analyzeFile("status.proto", content); + expect(result.definitions!.some(d => d.name === "Status" && d.kind === "enum")).toBe(true); + expect(result.definitions![0].fields).toContain("UNKNOWN"); + expect(result.definitions![0].fields).toContain("ACTIVE"); + }); + + it("extracts service RPC methods", () => { + const content = `service UserService { + rpc GetUser (GetUserRequest) returns (User); + rpc CreateUser (CreateUserRequest) returns (User); +}`; + const result = parser.analyzeFile("service.proto", content); + expect(result.endpoints).toBeDefined(); + expect(result.endpoints!).toHaveLength(2); + expect(result.endpoints![0]).toMatchObject({ method: "rpc", path: "UserService.GetUser" }); + expect(result.endpoints![1]).toMatchObject({ method: "rpc", path: "UserService.CreateUser" }); + }); +}); + +describe("TerraformParser", () => { + const parser = new TerraformParser(); + + it("extracts resource blocks", () => { + const content = `resource "aws_s3_bucket" "main" { + bucket = "my-bucket" +} + +resource "aws_iam_role" "lambda" { + name = "lambda-role" +}`; + const result = parser.analyzeFile("main.tf", content); + expect(result.resources).toBeDefined(); + expect(result.resources!).toHaveLength(2); + expect(result.resources![0]).toMatchObject({ name: "aws_s3_bucket.main", kind: "aws_s3_bucket" }); + expect(result.resources![1]).toMatchObject({ name: "aws_iam_role.lambda", kind: "aws_iam_role" }); + }); + + it("extracts data blocks", () => { + const content = 'data "aws_ami" "ubuntu" {\n most_recent = true\n}'; + const result = parser.analyzeFile("data.tf", content); + expect(result.resources!.some(r => r.name === "data.aws_ami.ubuntu")).toBe(true); + }); + + it("extracts module blocks", () => { + const content = 'module "vpc" {\n source = "./modules/vpc"\n}'; + const result = parser.analyzeFile("modules.tf", content); + expect(result.resources!.some(r => r.name === "module.vpc" && r.kind === "module")).toBe(true); + }); + + it("extracts variables and outputs", () => { + const content = 'variable "region" {\n default = "us-east-1"\n}\n\noutput "bucket_arn" {\n value = aws_s3_bucket.main.arn\n}'; + const result = parser.analyzeFile("variables.tf", content); + expect(result.definitions).toBeDefined(); + expect(result.definitions!.some(d => d.name === "region" && d.kind === "variable")).toBe(true); + expect(result.definitions!.some(d => d.name === "bucket_arn" && d.kind === "output")).toBe(true); + }); +}); + +describe("MakefileParser", () => { + const parser = new MakefileParser(); + + it("extracts make targets", () => { + const content = "build:\n\tgo build -o bin/app\n\ntest:\n\tgo test ./...\n\nclean:\n\trm -rf bin/"; + const result = parser.analyzeFile("Makefile", content); + expect(result.steps).toBeDefined(); + expect(result.steps!).toHaveLength(3); + expect(result.steps!.map(s => s.name)).toEqual(["build", "test", "clean"]); + }); + + it("does not confuse variable assignments with targets", () => { + const content = "CC := gcc\nCFLAGS := -Wall\n\nbuild:\n\t$(CC) $(CFLAGS) main.c"; + const result = parser.analyzeFile("Makefile", content); + expect(result.steps!).toHaveLength(1); + expect(result.steps![0].name).toBe("build"); + }); +}); + +describe("ShellParser", () => { + const parser = new ShellParser(); + + it("extracts function definitions", () => { + const content = "#!/bin/bash\n\ngreet() {\n echo \"Hello $1\"\n}\n\nfunction cleanup {\n rm -rf tmp/\n}"; + const result = parser.analyzeFile("script.sh", content); + expect(result.functions).toHaveLength(2); + expect(result.functions[0].name).toBe("greet"); + expect(result.functions[1].name).toBe("cleanup"); + }); + + it("extracts source references", () => { + const content = "#!/bin/bash\nsource ./lib/utils.sh\n. ./lib/config.sh"; + const refs = parser.extractReferences!("script.sh", content); + expect(refs).toHaveLength(2); + expect(refs[0]).toMatchObject({ target: "./lib/utils.sh", referenceType: "file" }); + expect(refs[1]).toMatchObject({ target: "./lib/config.sh", referenceType: "file" }); + }); +}); + +// --- Edge case tests --- + +describe("SQLParser edge cases", () => { + const parser = new SQLParser(); + + it("handles CREATE TABLE IF NOT EXISTS", () => { + const content = "CREATE TABLE IF NOT EXISTS users (id INT);"; + const result = parser.analyzeFile("schema.sql", content); + expect(result.definitions).toBeDefined(); + expect(result.definitions!).toHaveLength(1); + expect(result.definitions![0]).toMatchObject({ name: "users", kind: "table" }); + expect(result.definitions![0].fields).toContain("id"); + }); + + it("handles CREATE OR REPLACE VIEW", () => { + const content = "CREATE OR REPLACE VIEW active AS SELECT * FROM users;"; + const result = parser.analyzeFile("views.sql", content); + expect(result.definitions).toBeDefined(); + expect(result.definitions!.some(d => d.name === "active" && d.kind === "view")).toBe(true); + }); +}); + +describe("GraphQLParser edge cases", () => { + const parser = new GraphQLParser(); + + it("extracts input type definitions", () => { + const content = "input CreateUserInput {\n name: String!\n email: String!\n}"; + const result = parser.analyzeFile("schema.graphql", content); + expect(result.definitions).toBeDefined(); + const inputDef = result.definitions!.find(d => d.name === "CreateUserInput"); + expect(inputDef).toBeDefined(); + expect(inputDef!.kind).toBe("input"); + expect(inputDef!.fields).toContain("name"); + }); +}); + +describe("MakefileParser edge cases", () => { + const parser = new MakefileParser(); + + it("does not extract .PHONY as a target", () => { + const content = ".PHONY: build test\n\nbuild:\n\tgo build\n\ntest:\n\tgo test"; + const result = parser.analyzeFile("Makefile", content); + expect(result.steps).toBeDefined(); + const targetNames = result.steps!.map(s => s.name); + expect(targetNames).not.toContain(".PHONY"); + expect(targetNames).toContain("build"); + expect(targetNames).toContain("test"); + }); +}); + +describe("ShellParser edge cases", () => { + const parser = new ShellParser(); + + it("handles function with opening brace on next line", () => { + const content = "greet()\n{\n echo \"Hello\"\n}"; + const result = parser.analyzeFile("script.sh", content); + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("greet"); + expect(result.functions[0].lineRange[1]).toBeGreaterThan(result.functions[0].lineRange[0]); + }); +}); + +describe("TOMLParser edge cases", () => { + const parser = new TOMLParser(); + + it("returns empty sections for empty string", () => { + const result = parser.analyzeFile("empty.toml", ""); + expect(result.sections).toBeDefined(); + expect(result.sections).toHaveLength(0); + }); + + it("returns empty sections for garbage text", () => { + const result = parser.analyzeFile("garbage.toml", "this is not toml at all\nrandom garbage 123"); + expect(result.sections).toBeDefined(); + expect(result.sections).toHaveLength(0); + }); +}); + +describe("DockerfileParser edge cases", () => { + const parser = new DockerfileParser(); + + it("assigns EXPOSE ports to the correct stage in multi-stage build", () => { + const content = "FROM node:22 AS builder\nRUN npm install\n\nFROM node:22-slim AS runner\nCOPY --from=builder /app /app\nEXPOSE 3000 8080\nCMD [\"node\", \"server.js\"]"; + const result = parser.analyzeFile("Dockerfile", content); + expect(result.services).toBeDefined(); + expect(result.services!).toHaveLength(2); + // Ports should be on the runner stage (second stage), not the builder + expect(result.services![0].ports).toHaveLength(0); // builder has no EXPOSE + expect(result.services![1].ports).toContain(3000); + expect(result.services![1].ports).toContain(8080); + }); + + it("includes lineRange for each stage", () => { + const content = "FROM node:22 AS builder\nRUN npm install\n\nFROM node:22-slim AS runner\nCOPY . .\nCMD [\"node\", \"start\"]"; + const result = parser.analyzeFile("Dockerfile", content); + expect(result.services).toBeDefined(); + expect(result.services!).toHaveLength(2); + expect(result.services![0].lineRange).toBeDefined(); + expect(result.services![0].lineRange![0]).toBe(1); + expect(result.services![1].lineRange).toBeDefined(); + expect(result.services![1].lineRange![0]).toBe(4); + }); +}); + +describe("EnvParser edge cases", () => { + const parser = new EnvParser(); + + it("does not handle export VAR=value syntax", () => { + const content = "export DB_HOST=localhost\nAPI_KEY=secret"; + const result = parser.analyzeFile(".env", content); + // The `export` prefix is not handled — only plain KEY=value is parsed + const names = result.definitions!.map(d => d.name); + expect(names).toContain("API_KEY"); + expect(names).not.toContain("DB_HOST"); + }); +}); + +describe("registerAllParsers", () => { + it("registers all 12 parsers with a PluginRegistry", () => { + const registry = new PluginRegistry(); + registerAllParsers(registry); + expect(registry.getPlugins()).toHaveLength(12); + expect(registry.getSupportedLanguages()).toContain("markdown"); + expect(registry.getSupportedLanguages()).toContain("yaml"); + expect(registry.getSupportedLanguages()).toContain("json"); + expect(registry.getSupportedLanguages()).toContain("toml"); + expect(registry.getSupportedLanguages()).toContain("env"); + expect(registry.getSupportedLanguages()).toContain("dockerfile"); + expect(registry.getSupportedLanguages()).toContain("sql"); + expect(registry.getSupportedLanguages()).toContain("graphql"); + expect(registry.getSupportedLanguages()).toContain("protobuf"); + expect(registry.getSupportedLanguages()).toContain("terraform"); + expect(registry.getSupportedLanguages()).toContain("makefile"); + expect(registry.getSupportedLanguages()).toContain("shell"); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/__tests__/plugin-discovery.test.ts b/understand-anything-plugin/packages/core/src/__tests__/plugin-discovery.test.ts index 1dfdf40..aa47112 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/plugin-discovery.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/plugin-discovery.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { parsePluginConfig, + serializePluginConfig, type PluginConfig, type PluginEntry, DEFAULT_PLUGIN_CONFIG, @@ -53,6 +54,22 @@ describe("plugin-discovery", () => { const config = parsePluginConfig(json); expect(config.plugins[0].enabled).toBe(true); }); + + it("returns default config when plugins field is not an array", () => { + const json = JSON.stringify({ + plugins: "not an array", + }); + const config = parsePluginConfig(json); + expect(config).toEqual(DEFAULT_PLUGIN_CONFIG); + }); + + it("returns default config when plugins field is missing", () => { + const json = JSON.stringify({ + someOtherField: "value", + }); + const config = parsePluginConfig(json); + expect(config).toEqual(DEFAULT_PLUGIN_CONFIG); + }); }); describe("DEFAULT_PLUGIN_CONFIG", () => { @@ -62,4 +79,38 @@ describe("plugin-discovery", () => { expect(DEFAULT_PLUGIN_CONFIG.plugins[0].enabled).toBe(true); }); }); + + describe("serializePluginConfig", () => { + it("serializes plugin config to formatted JSON", () => { + const config: PluginConfig = { + plugins: [ + { + name: "tree-sitter", + enabled: true, + languages: ["typescript", "javascript"], + }, + ], + }; + const json = serializePluginConfig(config); + expect(json).toContain('"name": "tree-sitter"'); + expect(json).toContain('"enabled": true'); + expect(json).toContain('"languages"'); + }); + + it("serializes config with options field", () => { + const config: PluginConfig = { + plugins: [ + { + name: "custom-plugin", + enabled: true, + languages: ["python"], + options: { strict: true, timeout: 5000 }, + }, + ], + }; + const json = serializePluginConfig(config); + expect(json).toContain('"options"'); + expect(json).toContain('"strict": true'); + }); + }); }); diff --git a/understand-anything-plugin/packages/core/src/__tests__/plugin-registry.test.ts b/understand-anything-plugin/packages/core/src/__tests__/plugin-registry.test.ts index f9909ef..39fec82 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/plugin-registry.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/plugin-registry.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { PluginRegistry } from "../plugins/registry.js"; +import { registerAllParsers } from "../plugins/parsers/index.js"; import type { AnalyzerPlugin, StructuralAnalysis, ImportResolution } from "../types.js"; const emptyAnalysis: StructuralAnalysis = { @@ -110,4 +111,118 @@ describe("PluginRegistry", () => { const result = registry.analyzeFile("main.py", "print('hello')"); expect(result).toBeNull(); }); + + it("unregister rebuilds language map correctly", () => { + const registry = new PluginRegistry(); + const plugin1 = createMockPlugin("plugin1", ["typescript", "javascript"]); + const plugin2 = createMockPlugin("plugin2", ["python"]); + + registry.register(plugin1); + registry.register(plugin2); + + expect(registry.getPluginForLanguage("typescript")).toBe(plugin1); + expect(registry.getPluginForLanguage("python")).toBe(plugin2); + + registry.unregister("plugin1"); + + expect(registry.getPluginForLanguage("typescript")).toBeNull(); + expect(registry.getPluginForLanguage("python")).toBe(plugin2); + }); + + it("unregister does nothing for non-existent plugin", () => { + const registry = new PluginRegistry(); + const plugin = createMockPlugin("existing", ["typescript"]); + registry.register(plugin); + + registry.unregister("non-existent"); + + expect(registry.getPlugins()).toHaveLength(1); + expect(registry.getPluginForLanguage("typescript")).toBe(plugin); + }); + + it("getLanguageForFile returns correct language id", () => { + const registry = new PluginRegistry(); + registry.register(createMockPlugin("ts-plugin", ["typescript"])); + + expect(registry.getLanguageForFile("src/index.ts")).toBe("typescript"); + expect(registry.getLanguageForFile("src/component.tsx")).toBe("typescript"); + }); + + it("getLanguageForFile returns null for unsupported extensions", () => { + const registry = new PluginRegistry(); + registry.register(createMockPlugin("ts-plugin", ["typescript"])); + + expect(registry.getLanguageForFile("unknown.xyz")).toBeNull(); + }); + + it("resolveImports delegates to correct plugin", () => { + const registry = new PluginRegistry(); + const plugin = createMockPlugin("ts-plugin", ["typescript"]); + const mockImports: ImportResolution[] = [ + { + source: "./utils", + resolvedPath: "./utils.ts", + specifiers: [], + }, + ]; + plugin.resolveImports = () => mockImports; + registry.register(plugin); + + const result = registry.resolveImports("src/index.ts", "import './utils'"); + expect(result).toEqual(mockImports); + }); + + it("resolveImports returns null for unsupported files", () => { + const registry = new PluginRegistry(); + registry.register(createMockPlugin("ts-plugin", ["typescript"])); + + const result = registry.resolveImports("main.py", "import os"); + expect(result).toBeNull(); + }); + + it("handles plugins with optional resolveImports (non-code plugins)", () => { + const markdownPlugin: AnalyzerPlugin = { + name: "markdown", + languages: ["markdown"], + analyzeFile: () => ({ functions: [], classes: [], imports: [], exports: [] }), + // No resolveImports — optional for non-code plugins + }; + const registry = new PluginRegistry(); + registry.register(markdownPlugin); + const result = registry.resolveImports("README.md", "# Hello"); + expect(result).toBeNull(); + }); +}); + +describe("registerAllParsers smoke test", () => { + it("all registered parsers return valid StructuralAnalysis for minimal content", () => { + const registry = new PluginRegistry(); + registerAllParsers(registry); + + // Map of file extension -> minimal content for each parser + const testCases: [string, string][] = [ + ["README.md", "# Hello"], + ["config.yaml", "key: value"], + ["config.json", '{"key": "value"}'], + ["config.toml", 'key = "value"'], + [".env", "KEY=value"], + ["Dockerfile", "FROM node:22"], + ["schema.sql", "CREATE TABLE t (id INT);"], + ["schema.graphql", "type Query { hello: String }"], + ["types.proto", 'syntax = "proto3";'], + ["main.tf", 'resource "null" "r" {}'], + ["Makefile", "build:\n\techo build"], + ["script.sh", "#!/bin/bash\necho hello"], + ]; + + for (const [filePath, content] of testCases) { + const result = registry.analyzeFile(filePath, content); + expect(result, `analyzeFile should return a result for ${filePath}`).not.toBeNull(); + // Verify basic structural analysis shape + expect(result).toHaveProperty("functions"); + expect(result).toHaveProperty("classes"); + expect(result).toHaveProperty("imports"); + expect(result).toHaveProperty("exports"); + } + }); }); diff --git a/understand-anything-plugin/packages/core/src/__tests__/schema.test.ts b/understand-anything-plugin/packages/core/src/__tests__/schema.test.ts index 24edb51..fe5e205 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/schema.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/schema.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { validateGraph, normalizeGraph, + sanitizeGraph, + autoFixGraph, NODE_TYPE_ALIASES, EDGE_TYPE_ALIASES, } from "../schema.js"; @@ -32,7 +34,7 @@ const validGraph: KnowledgeGraph = { edges: [ { source: "node-1", - target: "node-2", + target: "node-1", type: "imports", direction: "forward", weight: 0.8, @@ -62,57 +64,60 @@ describe("schema validation", () => { expect(result.success).toBe(true); expect(result.data).toBeDefined(); expect(result.data!.version).toBe("1.0.0"); - expect(result.errors).toBeUndefined(); + expect(result.issues).toEqual([]); }); it("rejects graph with missing required fields", () => { - const incomplete = { - version: "1.0.0", - // missing project, nodes, edges, layers, tour - }; - + const incomplete = { version: "1.0.0" }; const result = validateGraph(incomplete); expect(result.success).toBe(false); - expect(result.errors).toBeDefined(); - expect(result.errors!.length).toBeGreaterThan(0); + expect(result.fatal).toBeDefined(); }); - it("rejects node with invalid type", () => { + it("rejects node with invalid type — drops node, fatal if none remain", () => { const graph = structuredClone(validGraph); (graph.nodes[0] as any).type = "invalid_type"; const result = validateGraph(graph); expect(result.success).toBe(false); - expect(result.errors).toBeDefined(); - expect(result.errors!.some((e) => e.includes("type"))).toBe(true); + expect(result.fatal).toContain("No valid nodes"); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "dropped", category: "invalid-node" }) + ); }); - it("rejects edge with invalid EdgeType", () => { + it("drops edge with invalid EdgeType but loads graph", () => { const graph = structuredClone(validGraph); (graph.edges[0] as any).type = "not_a_real_edge_type"; const result = validateGraph(graph); - expect(result.success).toBe(false); - expect(result.errors).toBeDefined(); - expect(result.errors!.some((e) => e.includes("type"))).toBe(true); + expect(result.success).toBe(true); + expect(result.data!.edges.length).toBe(0); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "dropped", category: "invalid-edge" }) + ); }); - it("rejects weight out of range (>1)", () => { + it("auto-corrects weight >1 by clamping", () => { const graph = structuredClone(validGraph); graph.edges[0].weight = 1.5; const result = validateGraph(graph); - expect(result.success).toBe(false); - expect(result.errors).toBeDefined(); + expect(result.success).toBe(true); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "out-of-range" }) + ); }); - it("rejects weight out of range (<0)", () => { + it("auto-corrects weight <0 by clamping", () => { const graph = structuredClone(validGraph); graph.edges[0].weight = -0.1; const result = validateGraph(graph); - expect(result.success).toBe(false); - expect(result.errors).toBeDefined(); + expect(result.success).toBe(true); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "out-of-range" }) + ); }); it('normalizes "func" node type to "function"', () => { @@ -229,20 +234,28 @@ describe("schema validation", () => { expect(result.data!.edges[0].type).toBe("depends_on"); }); - it('rejects "tests" edge type — direction-inverting alias is unsafe', () => { + it('drops "tests" edge type — direction-inverting alias is unsafe', () => { const graph = structuredClone(validGraph); (graph.edges[0] as any).type = "tests"; const result = validateGraph(graph); - expect(result.success).toBe(false); + expect(result.success).toBe(true); + expect(result.data!.edges.length).toBe(0); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "dropped" }) + ); }); - it("still rejects truly invalid edge types after normalization", () => { + it("drops truly invalid edge types after normalization", () => { const graph = structuredClone(validGraph); (graph.edges[0] as any).type = "totally_bogus"; const result = validateGraph(graph); - expect(result.success).toBe(false); + expect(result.success).toBe(true); + expect(result.data!.edges.length).toBe(0); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "dropped" }) + ); }); it("NODE_TYPE_ALIASES values are never alias keys (no chains)", () => { @@ -263,3 +276,447 @@ describe("schema validation", () => { } }); }); + +describe("sanitizeGraph", () => { + it("converts null optional node fields to undefined", () => { + const graph = structuredClone(validGraph); + (graph.nodes[0] as any).filePath = null; + (graph.nodes[0] as any).lineRange = null; + (graph.nodes[0] as any).languageNotes = null; + + const result = sanitizeGraph(graph as any); + const node = (result as any).nodes[0]; + expect(node.filePath).toBeUndefined(); + expect(node.lineRange).toBeUndefined(); + expect(node.languageNotes).toBeUndefined(); + }); + + it("converts null optional edge fields to undefined", () => { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).description = null; + + const result = sanitizeGraph(graph as any); + const edge = (result as any).edges[0]; + expect(edge.description).toBeUndefined(); + }); + + it("lowercases enum-like strings on nodes", () => { + const graph = structuredClone(validGraph); + (graph.nodes[0] as any).type = "FILE"; + (graph.nodes[0] as any).complexity = "Simple"; + + const result = sanitizeGraph(graph as any); + const node = (result as any).nodes[0]; + expect(node.type).toBe("file"); + expect(node.complexity).toBe("simple"); + }); + + it("lowercases enum-like strings on edges", () => { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).type = "IMPORTS"; + (graph.edges[0] as any).direction = "Forward"; + + const result = sanitizeGraph(graph as any); + const edge = (result as any).edges[0]; + expect(edge.type).toBe("imports"); + expect(edge.direction).toBe("forward"); + }); + + it("converts null tour/layers to empty arrays", () => { + const graph = structuredClone(validGraph); + (graph as any).tour = null; + (graph as any).layers = null; + + const result = sanitizeGraph(graph as any); + expect((result as any).tour).toEqual([]); + expect((result as any).layers).toEqual([]); + }); + + it("converts null optional tour step fields to undefined", () => { + const graph = structuredClone(validGraph); + (graph.tour[0] as any).languageLesson = null; + + const result = sanitizeGraph(graph as any); + expect((result as any).tour[0].languageLesson).toBeUndefined(); + }); + + it("passes through non-object node/edge items unchanged", () => { + const graph = { nodes: [null, "garbage", 42], edges: [null], tour: [], layers: [] }; + const result = sanitizeGraph(graph as any); + expect((result as any).nodes).toEqual([null, "garbage", 42]); + expect((result as any).edges).toEqual([null]); + }); +}); + +describe("autoFixGraph", () => { + it("defaults missing complexity to moderate with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.nodes[0] as any).complexity; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).nodes[0].complexity).toBe("moderate"); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].complexity" }) + ); + }); + + it("maps complexity aliases with issue", () => { + const graph = structuredClone(validGraph); + (graph.nodes[0] as any).complexity = "low"; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).nodes[0].complexity).toBe("simple"); + expect(issues.length).toBe(1); + expect(issues[0].level).toBe("auto-corrected"); + }); + + it("maps all complexity aliases correctly", () => { + const mapping: Record = { + low: "simple", easy: "simple", + medium: "moderate", intermediate: "moderate", + high: "complex", hard: "complex", difficult: "complex", + }; + for (const [alias, expected] of Object.entries(mapping)) { + const graph = structuredClone(validGraph); + (graph.nodes[0] as any).complexity = alias; + const { data } = autoFixGraph(graph as any); + expect((data as any).nodes[0].complexity).toBe(expected); + } + }); + + it("defaults missing tags to empty array with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.nodes[0] as any).tags; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).nodes[0].tags).toEqual([]); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].tags" }) + ); + }); + + it("defaults missing summary to node name with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.nodes[0] as any).summary; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).nodes[0].summary).toBe("index.ts"); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].summary" }) + ); + }); + + it("defaults missing node type to file with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.nodes[0] as any).type; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).nodes[0].type).toBe("file"); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].type" }) + ); + }); + + it("defaults missing direction to forward with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.edges[0] as any).direction; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).edges[0].direction).toBe("forward"); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "edges[0].direction" }) + ); + }); + + it("maps direction aliases with issue", () => { + const mapping: Record = { + to: "forward", outbound: "forward", + from: "backward", inbound: "backward", + both: "bidirectional", mutual: "bidirectional", + }; + for (const [alias, expected] of Object.entries(mapping)) { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).direction = alias; + const { data } = autoFixGraph(graph as any); + expect((data as any).edges[0].direction).toBe(expected); + } + }); + + it("defaults missing weight to 0.5 with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.edges[0] as any).weight; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).edges[0].weight).toBe(0.5); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "edges[0].weight" }) + ); + }); + + it("coerces string weight to number with issue", () => { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).weight = "0.8"; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).edges[0].weight).toBe(0.8); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "type-coercion", path: "edges[0].weight" }) + ); + }); + + it("clamps out-of-range weight with issue", () => { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).weight = 1.5; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).edges[0].weight).toBe(1); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "out-of-range", path: "edges[0].weight" }) + ); + }); + + it("defaults missing edge type to depends_on with issue", () => { + const graph = structuredClone(validGraph); + delete (graph.edges[0] as any).type; + + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).edges[0].type).toBe("depends_on"); + expect(issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "edges[0].type" }) + ); + }); + + it("returns no issues for a valid graph", () => { + const { issues } = autoFixGraph(validGraph as any); + expect(issues).toEqual([]); + }); + + it("passes through non-object node/edge items unchanged", () => { + const graph = { nodes: [null, "garbage"], edges: [null], tour: [], layers: [] }; + const { data, issues } = autoFixGraph(graph as any); + expect((data as any).nodes).toEqual([null, "garbage"]); + expect((data as any).edges).toEqual([null]); + expect(issues).toEqual([]); + }); +}); + +describe("permissive validation", () => { + it("drops nodes missing id with dropped issue", () => { + const graph = structuredClone(validGraph); + delete (graph.nodes[0] as any).id; + // Add a second valid node so graph isn't fatal + graph.nodes.push({ + id: "node-2", type: "file", name: "other.ts", + summary: "Other file", tags: ["util"], complexity: "simple", + }); + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.nodes.length).toBe(1); + expect(result.data!.nodes[0].id).toBe("node-2"); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "dropped", category: "invalid-node" }) + ); + }); + + it("drops edges referencing non-existent nodes with dropped issue", () => { + const graph = structuredClone(validGraph); + graph.edges[0].target = "non-existent-node"; + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.edges.length).toBe(0); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "dropped", category: "invalid-reference" }) + ); + }); + + it("returns fatal when 0 valid nodes remain", () => { + const graph = structuredClone(validGraph); + delete (graph.nodes[0] as any).id; + + const result = validateGraph(graph); + expect(result.success).toBe(false); + expect(result.fatal).toContain("No valid nodes"); + }); + + it("returns fatal when project metadata is missing", () => { + const graph = structuredClone(validGraph); + delete (graph as any).project; + + const result = validateGraph(graph); + expect(result.success).toBe(false); + expect(result.fatal).toContain("project metadata"); + }); + + it("returns fatal when input is not an object", () => { + const result = validateGraph("not an object"); + expect(result.success).toBe(false); + expect(result.fatal).toContain("Invalid input"); + }); + + it("loads graph with mixed good and bad nodes", () => { + const graph = structuredClone(validGraph); + // Add a good node + graph.nodes.push({ + id: "node-2", type: "function", name: "doThing", + summary: "Does a thing", tags: ["util"], complexity: "moderate", + }); + // Add a bad node (missing id AND name -- unrecoverable) + (graph.nodes as any[]).push({ type: "file", summary: "broken" }); + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.nodes.length).toBe(2); + expect(result.issues.some((i) => i.level === "dropped")).toBe(true); + }); + + it("filters dangling nodeIds from layers", () => { + const graph = structuredClone(validGraph); + graph.layers[0].nodeIds.push("non-existent-node"); + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.layers[0].nodeIds).toEqual(["node-1"]); + }); + + it("filters dangling nodeIds from tour steps", () => { + const graph = structuredClone(validGraph); + graph.tour[0].nodeIds.push("non-existent-node"); + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.tour[0].nodeIds).toEqual(["node-1"]); + }); + + it("returns empty issues array for a perfect graph", () => { + const result = validateGraph(validGraph); + expect(result.success).toBe(true); + expect(result.issues).toEqual([]); + expect(result.errors).toBeUndefined(); + }); + + it("auto-corrects and loads graph that would have failed strict validation", () => { + // Graph with many Tier 2 issues: missing complexity, weight as string, null filePath + const messy = { + version: "1.0.0", + project: validGraph.project, + nodes: [{ + id: "n1", type: "FILE", name: "app.ts", + filePath: null, summary: "App entry", + tags: null, complexity: "HIGH", + }], + edges: [{ + source: "n1", target: "n1", type: "CALLS", + direction: "TO", weight: "0.9", + }], + layers: [{ id: "l1", name: "Core", description: "Core", nodeIds: ["n1"] }], + tour: [], + }; + + const result = validateGraph(messy); + expect(result.success).toBe(true); + expect(result.data!.nodes[0].complexity).toBe("complex"); + expect(result.data!.nodes[0].tags).toEqual([]); + expect(result.data!.edges[0].weight).toBe(0.9); + expect(result.data!.edges[0].direction).toBe("forward"); + expect(result.issues.length).toBeGreaterThan(0); + expect(result.issues.every((i) => i.level === "auto-corrected")).toBe(true); + }); + + it("handles non-parseable string weight by defaulting to 0.5", () => { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).weight = "not_a_number"; + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.edges[0].weight).toBe(0.5); + expect(result.issues).toContainEqual( + expect.objectContaining({ level: "auto-corrected", category: "type-coercion" }) + ); + }); + + it("returns fatal when edges is present but not an array", () => { + const graph = structuredClone(validGraph) as any; + graph.edges = { source: "node-1", target: "node-1" }; + + const result = validateGraph(graph); + expect(result.success).toBe(false); + expect(result.fatal).toContain('"edges" must be an array'); + expect(result.errors).toContain('"edges" must be an array when present'); + expect(result.issues).toContainEqual( + expect.objectContaining({ + level: "fatal", + category: "invalid-collection", + path: "edges", + }) + ); + }); + + it("preserves deprecated errors for dropped-item callers", () => { + const graph = structuredClone(validGraph); + graph.edges[0].target = "non-existent-node"; + + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.errors).toContain('edges[0]: target "non-existent-node" does not exist in nodes — removed'); + }); +}); + +describe("Extended node/edge types", () => { + it("validates nodes with new types: config, document, service, table, endpoint, pipeline, schema, resource", () => { + const newTypes = ["config", "document", "service", "table", "endpoint", "pipeline", "schema", "resource"]; + for (const type of newTypes) { + const graph = structuredClone(validGraph); + (graph.nodes[0] as any).type = type; + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.nodes[0].type).toBe(type); + } + }); + + it("validates edges with new types: deploys, serves, migrates, documents, provisions, routes, defines_schema, triggers", () => { + const newTypes = ["deploys", "serves", "migrates", "documents", "provisions", "routes", "defines_schema", "triggers"]; + for (const type of newTypes) { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).type = type; + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.edges[0].type).toBe(type); + } + }); + + it("auto-fixes new node type aliases: container->service, doc->document, workflow->pipeline, etc.", () => { + const aliases: Record = { + container: "service", + doc: "document", + workflow: "pipeline", + route: "endpoint", + setting: "config", + infra: "resource", + migration: "table", + }; + for (const [alias, canonical] of Object.entries(aliases)) { + const graph = structuredClone(validGraph); + (graph.nodes[0] as any).type = alias; + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.nodes[0].type).toBe(canonical); + } + }); + + it("auto-fixes new edge type aliases: describes->documents, creates->provisions, exposes->serves", () => { + const aliases: Record = { + describes: "documents", + creates: "provisions", + exposes: "serves", + }; + for (const [alias, canonical] of Object.entries(aliases)) { + const graph = structuredClone(validGraph); + (graph.edges[0] as any).type = alias; + const result = validateGraph(graph); + expect(result.success).toBe(true); + expect(result.data!.edges[0].type).toBe(canonical); + } + }); +}); diff --git a/understand-anything-plugin/packages/core/src/analyzer/graph-builder.test.ts b/understand-anything-plugin/packages/core/src/analyzer/graph-builder.test.ts index 461e252..a9dc9c9 100644 --- a/understand-anything-plugin/packages/core/src/analyzer/graph-builder.test.ts +++ b/understand-anything-plugin/packages/core/src/analyzer/graph-builder.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { GraphBuilder } from "./graph-builder.js"; import type { StructuralAnalysis } from "../types.js"; @@ -212,4 +212,193 @@ describe("GraphBuilder", () => { const graph = builder.build(); expect(graph.project.languages).toEqual(["go", "javascript", "rust"]); }); + + describe("Non-code file support", () => { + it("adds non-code file nodes with correct types and nodeType-prefixed ID", () => { + const builder = new GraphBuilder("test", "abc123"); + builder.addNonCodeFile("README.md", { + nodeType: "document", + summary: "Project documentation", + tags: ["documentation"], + complexity: "simple", + }); + const graph = builder.build(); + expect(graph.nodes).toHaveLength(1); + expect(graph.nodes[0].type).toBe("document"); + expect(graph.nodes[0].id).toBe("document:README.md"); + }); + + it("adds non-code child nodes (definitions)", () => { + const builder = new GraphBuilder("test", "abc123"); + builder.addNonCodeFileWithAnalysis("schema.sql", { + nodeType: "file", + summary: "Database schema", + tags: ["database"], + complexity: "moderate", + definitions: [ + { name: "users", kind: "table", lineRange: [1, 20] as [number, number], fields: ["id", "name", "email"] }, + ], + }); + const graph = builder.build(); + // File node + table child node + expect(graph.nodes).toHaveLength(2); + expect(graph.nodes[1].type).toBe("table"); + expect(graph.nodes[1].name).toBe("users"); + // Contains edge + expect(graph.edges.some(e => e.type === "contains" && e.target.includes("users"))).toBe(true); + }); + + it("adds service child nodes", () => { + const builder = new GraphBuilder("test", "abc123"); + builder.addNonCodeFileWithAnalysis("docker-compose.yml", { + nodeType: "config", + summary: "Docker compose config", + tags: ["infra"], + complexity: "moderate", + services: [ + { name: "web", image: "node:22", ports: [3000] }, + { name: "db", image: "postgres:15", ports: [5432] }, + ], + }); + const graph = builder.build(); + // File node + 2 service child nodes + expect(graph.nodes).toHaveLength(3); + expect(graph.nodes[1].type).toBe("service"); + expect(graph.nodes[1].name).toBe("web"); + expect(graph.nodes[2].type).toBe("service"); + expect(graph.nodes[2].name).toBe("db"); + }); + + it("adds endpoint child nodes", () => { + const builder = new GraphBuilder("test", "abc123"); + builder.addNonCodeFileWithAnalysis("schema.graphql", { + nodeType: "schema", + summary: "GraphQL schema", + tags: ["api"], + complexity: "moderate", + endpoints: [ + { method: "Query", path: "users", lineRange: [5, 5] as [number, number] }, + ], + }); + const graph = builder.build(); + expect(graph.nodes).toHaveLength(2); + expect(graph.nodes[1].type).toBe("endpoint"); + }); + + it("adds resource child nodes", () => { + const builder = new GraphBuilder("test", "abc123"); + builder.addNonCodeFileWithAnalysis("main.tf", { + nodeType: "resource", + summary: "Terraform config", + tags: ["infra"], + complexity: "moderate", + resources: [ + { name: "aws_s3_bucket.main", kind: "aws_s3_bucket", lineRange: [1, 10] as [number, number] }, + ], + }); + const graph = builder.build(); + expect(graph.nodes).toHaveLength(2); + expect(graph.nodes[1].type).toBe("resource"); + expect(graph.nodes[1].name).toBe("aws_s3_bucket.main"); + }); + + it("adds step child nodes", () => { + const builder = new GraphBuilder("test", "abc123"); + builder.addNonCodeFileWithAnalysis("Makefile", { + nodeType: "pipeline", + summary: "Build targets", + tags: ["build"], + complexity: "simple", + steps: [ + { name: "build", lineRange: [1, 3] as [number, number] }, + { name: "test", lineRange: [5, 7] as [number, number] }, + ], + }); + const graph = builder.build(); + expect(graph.nodes).toHaveLength(3); + expect(graph.nodes[1].type).toBe("pipeline"); + expect(graph.nodes[1].name).toBe("build"); + }); + + it("detects non-code languages from EXTENSION_LANGUAGE map", () => { + const builder = new GraphBuilder("test", "abc123"); + builder.addFile("config.yaml", { summary: "Config", tags: [], complexity: "simple" }); + const graph = builder.build(); + expect(graph.project.languages).toContain("yaml"); + }); + + it("detects new non-code extensions", () => { + const builder = new GraphBuilder("test", "abc123"); + builder.addFile("schema.graphql", { summary: "Schema", tags: [], complexity: "simple" }); + builder.addFile("main.tf", { summary: "Terraform", tags: [], complexity: "simple" }); + builder.addFile("types.proto", { summary: "Protobuf", tags: [], complexity: "simple" }); + const graph = builder.build(); + expect(graph.project.languages).toContain("graphql"); + expect(graph.project.languages).toContain("terraform"); + expect(graph.project.languages).toContain("protobuf"); + }); + + it("mapKindToNodeType falls back to concept for unknown kinds and warns", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const builder = new GraphBuilder("test", "abc123"); + builder.addNonCodeFileWithAnalysis("schema.sql", { + nodeType: "file", + summary: "Schema", + tags: [], + complexity: "simple", + definitions: [ + { name: "doStuff", kind: "procedure", lineRange: [1, 10] as [number, number], fields: [] }, + ], + }); + const graph = builder.build(); + const childNode = graph.nodes.find(n => n.name === "doStuff"); + expect(childNode).toBeDefined(); + expect(childNode!.type).toBe("concept"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Unknown definition kind "procedure"'), + ); + warnSpy.mockRestore(); + }); + + it("skips duplicate node IDs in addNonCodeFileWithAnalysis and warns", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const builder = new GraphBuilder("test", "abc123"); + builder.addNonCodeFileWithAnalysis("schema.sql", { + nodeType: "file", + summary: "Schema", + tags: [], + complexity: "simple", + definitions: [ + { name: "users", kind: "table", lineRange: [1, 10] as [number, number], fields: ["id"] }, + { name: "users", kind: "table", lineRange: [12, 20] as [number, number], fields: ["id", "name"] }, + ], + }); + const graph = builder.build(); + // Only the file node + one table node (duplicate skipped) + const tableNodes = graph.nodes.filter(n => n.name === "users"); + expect(tableNodes).toHaveLength(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Duplicate node ID "table:schema.sql:users"'), + ); + warnSpy.mockRestore(); + }); + + it("uses nodeType in fileId for contains edges", () => { + const builder = new GraphBuilder("test", "abc123"); + builder.addNonCodeFileWithAnalysis("docker-compose.yml", { + nodeType: "config", + summary: "Docker compose config", + tags: [], + complexity: "simple", + services: [ + { name: "web", ports: [3000] }, + ], + }); + const graph = builder.build(); + const containsEdge = graph.edges.find(e => e.type === "contains"); + expect(containsEdge).toBeDefined(); + expect(containsEdge!.source).toBe("config:docker-compose.yml"); + expect(containsEdge!.target).toBe("service:docker-compose.yml:web"); + }); + }); }); diff --git a/understand-anything-plugin/packages/core/src/analyzer/graph-builder.ts b/understand-anything-plugin/packages/core/src/analyzer/graph-builder.ts index aaa170b..423b49f 100644 --- a/understand-anything-plugin/packages/core/src/analyzer/graph-builder.ts +++ b/understand-anything-plugin/packages/core/src/analyzer/graph-builder.ts @@ -3,6 +3,12 @@ import type { GraphNode, GraphEdge, StructuralAnalysis, + DefinitionInfo, + ServiceInfo, + EndpointInfo, + StepInfo, + ResourceInfo, + SectionInfo, } from "../types.js"; interface FileMeta { @@ -16,7 +22,21 @@ interface FileAnalysisMeta extends FileMeta { fileSummary: string; } +interface NonCodeFileMeta extends FileMeta { + nodeType: GraphNode["type"]; +} + +interface NonCodeFileAnalysisMeta extends NonCodeFileMeta { + definitions?: DefinitionInfo[]; + services?: ServiceInfo[]; + endpoints?: EndpointInfo[]; + steps?: StepInfo[]; + resources?: ResourceInfo[]; + sections?: SectionInfo[]; +} + const EXTENSION_LANGUAGE: Record = { + // Code languages ".ts": "typescript", ".tsx": "typescript", ".js": "javascript", @@ -37,20 +57,41 @@ const EXTENSION_LANGUAGE: Record = { ".cs": "csharp", ".php": "php", ".lua": "lua", + // Non-code languages ".sh": "shell", ".bash": "shell", ".zsh": "shell", ".json": "json", + ".jsonc": "json", ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".xml": "xml", ".html": "html", + ".htm": "html", ".css": "css", - ".scss": "scss", - ".less": "less", + ".scss": "css", + ".less": "css", ".md": "markdown", + ".mdx": "markdown", ".sql": "sql", + ".graphql": "graphql", + ".gql": "graphql", + ".proto": "protobuf", + ".tf": "terraform", + ".tfvars": "terraform", + ".mk": "makefile", + ".env": "env", + ".csv": "csv", + ".tsv": "csv", + ".rst": "restructuredtext", + ".ps1": "powershell", + ".psm1": "powershell", + ".psd1": "powershell", + ".bat": "batch", + ".cmd": "batch", + ".txt": "plaintext", + ".svg": "xml", }; function detectLanguage(filePath: string): string { @@ -187,6 +228,160 @@ export class GraphBuilder { }); } + addNonCodeFile(filePath: string, meta: NonCodeFileMeta): void { + const lang = detectLanguage(filePath); + if (lang !== "unknown") this.languages.add(lang); + const name = filePath.split("/").pop() ?? filePath; + this.nodes.push({ + id: `${meta.nodeType ?? "file"}:${filePath}`, + type: meta.nodeType, + name, + filePath, + summary: meta.summary, + tags: meta.tags, + complexity: meta.complexity, + }); + } + + addNonCodeFileWithAnalysis(filePath: string, meta: NonCodeFileAnalysisMeta): void { + this.addNonCodeFile(filePath, meta); + const fileId = `${meta.nodeType ?? "file"}:${filePath}`; + + const existingIds = new Set(this.nodes.map(n => n.id)); + + // Create child nodes for definitions (tables, schemas, etc.) + for (const def of meta.definitions ?? []) { + const childId = `${def.kind}:${filePath}:${def.name}`; + if (existingIds.has(childId)) { + console.warn(`[GraphBuilder] Duplicate node ID "${childId}" — skipping`); + continue; + } + existingIds.add(childId); + this.nodes.push({ + id: childId, + type: this.mapKindToNodeType(def.kind), + name: def.name, + filePath, + lineRange: def.lineRange, + summary: `${def.kind}: ${def.name} (${def.fields.length} fields)`, + tags: [], + complexity: meta.complexity, + }); + this.edges.push({ source: fileId, target: childId, type: "contains", direction: "forward", weight: 1 }); + } + + // Create child nodes for services + for (const svc of meta.services ?? []) { + const childId = `service:${filePath}:${svc.name}`; + if (existingIds.has(childId)) { + console.warn(`[GraphBuilder] Duplicate node ID "${childId}" — skipping`); + continue; + } + existingIds.add(childId); + this.nodes.push({ + id: childId, + type: "service", + name: svc.name, + filePath, + summary: `Service ${svc.name}${svc.image ? ` (image: ${svc.image})` : ""}`, + tags: [], + complexity: meta.complexity, + }); + this.edges.push({ source: fileId, target: childId, type: "contains", direction: "forward", weight: 1 }); + } + + // Create child nodes for endpoints + for (const ep of meta.endpoints ?? []) { + const childId = `endpoint:${filePath}:${ep.path}`; + if (existingIds.has(childId)) { + console.warn(`[GraphBuilder] Duplicate node ID "${childId}" — skipping`); + continue; + } + existingIds.add(childId); + this.nodes.push({ + id: childId, + type: "endpoint", + name: `${ep.method ?? ""} ${ep.path}`.trim(), + filePath, + lineRange: ep.lineRange, + summary: `Endpoint: ${ep.method ?? ""} ${ep.path}`.trim(), + tags: [], + complexity: meta.complexity, + }); + this.edges.push({ source: fileId, target: childId, type: "contains", direction: "forward", weight: 1 }); + } + + // Create child nodes for steps (pipeline/makefile targets) + for (const step of meta.steps ?? []) { + const childId = `step:${filePath}:${step.name}`; + if (existingIds.has(childId)) { + console.warn(`[GraphBuilder] Duplicate node ID "${childId}" — skipping`); + continue; + } + existingIds.add(childId); + this.nodes.push({ + id: childId, + type: "pipeline", + name: step.name, + filePath, + lineRange: step.lineRange, + summary: `Step: ${step.name}`, + tags: [], + complexity: meta.complexity, + }); + this.edges.push({ source: fileId, target: childId, type: "contains", direction: "forward", weight: 1 }); + } + + // Create child nodes for resources (Terraform, etc.) + for (const res of meta.resources ?? []) { + const childId = `resource:${filePath}:${res.name}`; + if (existingIds.has(childId)) { + console.warn(`[GraphBuilder] Duplicate node ID "${childId}" — skipping`); + continue; + } + existingIds.add(childId); + this.nodes.push({ + id: childId, + type: "resource", + name: res.name, + filePath, + lineRange: res.lineRange, + summary: `Resource: ${res.name} (${res.kind})`, + tags: [], + complexity: meta.complexity, + }); + this.edges.push({ source: fileId, target: childId, type: "contains", direction: "forward", weight: 1 }); + } + } + + private mapKindToNodeType(kind: string): GraphNode["type"] { + const mapping: Record = { + table: "table", + view: "table", + index: "table", + message: "schema", + type: "schema", + enum: "schema", + resource: "resource", + module: "resource", + service: "service", + deployment: "service", + job: "pipeline", + stage: "pipeline", + target: "pipeline", + route: "endpoint", + query: "endpoint", + mutation: "endpoint", + variable: "config", + output: "config", + }; + const mapped = mapping[kind]; + if (!mapped) { + console.warn(`[GraphBuilder] Unknown definition kind "${kind}" — falling back to "concept" node type`); + } + return mapped ?? "concept"; + } + build(): KnowledgeGraph { return { version: "1.0.0", diff --git a/understand-anything-plugin/packages/core/src/analyzer/layer-detector.ts b/understand-anything-plugin/packages/core/src/analyzer/layer-detector.ts index 07eb89f..e50e94f 100644 --- a/understand-anything-plugin/packages/core/src/analyzer/layer-detector.ts +++ b/understand-anything-plugin/packages/core/src/analyzer/layer-detector.ts @@ -39,6 +39,16 @@ const LAYER_PATTERNS: Array<{ patterns: string[]; layerName: string; description layerName: "Middleware Layer", description: "Request/response middleware and interceptors", }, + { + patterns: ["client", "integration", "external", "sdk", "vendor", "adapter"], + layerName: "External Services", + description: "External service integrations, SDKs, and third-party adapters", + }, + { + patterns: ["worker", "job", "queue", "cron", "consumer", "processor", "scheduler", "background"], + layerName: "Background Tasks", + description: "Background workers, job processors, and scheduled tasks", + }, { patterns: ["util", "helper", "lib", "common", "shared"], layerName: "Utility Layer", diff --git a/understand-anything-plugin/packages/core/src/change-classifier.ts b/understand-anything-plugin/packages/core/src/change-classifier.ts new file mode 100644 index 0000000..41660a6 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/change-classifier.ts @@ -0,0 +1,143 @@ +import { dirname } from "node:path"; +import type { ChangeAnalysis } from "./fingerprint.js"; + +export interface UpdateDecision { + action: "SKIP" | "PARTIAL_UPDATE" | "ARCHITECTURE_UPDATE" | "FULL_UPDATE"; + filesToReanalyze: string[]; + rerunArchitecture: boolean; + rerunTour: boolean; + reason: string; +} + +/** + * Classify the type of graph update needed based on structural change analysis. + * + * Decision matrix: + * - SKIP: all files NONE or COSMETIC only + * - PARTIAL_UPDATE: some STRUCTURAL, same directories + * - ARCHITECTURE_UPDATE: new/deleted directories or >10 structural files + * - FULL_UPDATE: >30 structural files or >50% of total files changed structurally + */ +export function classifyUpdate( + analysis: ChangeAnalysis, + totalFilesInGraph: number, + allKnownFiles: string[] = [], +): UpdateDecision { + const { newFiles, deletedFiles, structurallyChangedFiles, cosmeticOnlyFiles, unchangedFiles } = analysis; + const structuralCount = structurallyChangedFiles.length + newFiles.length + deletedFiles.length; + + // No structural changes at all — skip + if (structuralCount === 0) { + const cosmeticCount = cosmeticOnlyFiles.length; + const reason = cosmeticCount > 0 + ? `${cosmeticCount} file(s) have cosmetic-only changes (no structural impact)` + : "No changes detected"; + + return { + action: "SKIP", + filesToReanalyze: [], + rerunArchitecture: false, + rerunTour: false, + reason, + }; + } + + // Too many structural changes — suggest full rebuild + const triggeredByCount = structuralCount > 30; + const triggeredByPercentage = totalFilesInGraph > 0 && structuralCount / totalFilesInGraph > 0.5; + if (triggeredByCount || triggeredByPercentage) { + const thresholdReason = + triggeredByCount && triggeredByPercentage + ? ">30 files and >50% of project" + : triggeredByCount + ? ">30 files" + : ">50% of project"; + return { + action: "FULL_UPDATE", + filesToReanalyze: [...structurallyChangedFiles, ...newFiles], + rerunArchitecture: true, + rerunTour: true, + reason: `${structuralCount} files have structural changes (${thresholdReason}) — full rebuild recommended`, + }; + } + + // Check if directory structure changed (new/deleted top-level directories) + const hasDirectoryChanges = detectDirectoryChanges(newFiles, deletedFiles, allKnownFiles); + + if (hasDirectoryChanges || structuralCount > 10) { + return { + action: "ARCHITECTURE_UPDATE", + filesToReanalyze: [...structurallyChangedFiles, ...newFiles], + rerunArchitecture: true, + rerunTour: true, + reason: hasDirectoryChanges + ? `Directory structure changed (${newFiles.length} new, ${deletedFiles.length} deleted files)` + : `${structuralCount} files have structural changes — architecture re-analysis needed`, + }; + } + + // Localized structural changes — partial update + return { + action: "PARTIAL_UPDATE", + filesToReanalyze: [...structurallyChangedFiles, ...newFiles], + rerunArchitecture: false, + rerunTour: false, + reason: `${structuralCount} file(s) have structural changes: ${summarizeChanges(analysis)}`, + }; +} + +/** + * Detect if the changes affect the directory structure (new or removed directories). + * Uses all known files in the project as the baseline for existing directories, + * then checks if any new/deleted files introduce or remove a top-level source directory. + */ +function detectDirectoryChanges( + newFiles: string[], + deletedFiles: string[], + allKnownFiles: string[], +): boolean { + const existingDirs = new Set( + allKnownFiles.map((f) => topDirectory(f)).filter(Boolean), + ); + + for (const f of newFiles) { + const dir = topDirectory(f); + if (dir && !existingDirs.has(dir)) return true; + } + + for (const f of deletedFiles) { + const dir = topDirectory(f); + if (dir && !existingDirs.has(dir)) return true; + } + + return false; +} + +/** + * Get the top-level directory of a file path (first path segment). + */ +function topDirectory(filePath: string): string | null { + const dir = dirname(filePath); + if (dir === "." || dir === "") return null; + const segments = dir.split("/"); + return segments[0] || null; +} + +/** + * Produce a concise human-readable summary of structural changes. + */ +function summarizeChanges(analysis: ChangeAnalysis): string { + const parts: string[] = []; + + if (analysis.newFiles.length > 0) { + parts.push(`${analysis.newFiles.length} new`); + } + if (analysis.deletedFiles.length > 0) { + parts.push(`${analysis.deletedFiles.length} deleted`); + } + if (analysis.structurallyChangedFiles.length > 0) { + parts.push(`${analysis.structurallyChangedFiles.length} modified`); + } + + return parts.join(", "); +} diff --git a/understand-anything-plugin/packages/core/src/fingerprint.ts b/understand-anything-plugin/packages/core/src/fingerprint.ts new file mode 100644 index 0000000..cc66dd0 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/fingerprint.ts @@ -0,0 +1,385 @@ +import { createHash } from "node:crypto"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import type { StructuralAnalysis } from "./types.js"; +import type { PluginRegistry } from "./plugins/registry.js"; + +// ---- Fingerprint types ---- + +export interface FunctionFingerprint { + name: string; + params: string[]; + returnType?: string; + exported: boolean; + lineCount: number; +} + +export interface ClassFingerprint { + name: string; + methods: string[]; + properties: string[]; + exported: boolean; + lineCount: number; +} + +export interface ImportFingerprint { + source: string; + specifiers: string[]; +} + +export interface FileFingerprint { + filePath: string; + contentHash: string; + functions: FunctionFingerprint[]; + classes: ClassFingerprint[]; + imports: ImportFingerprint[]; + exports: string[]; + totalLines: number; + hasStructuralAnalysis: boolean; +} + +export interface FingerprintStore { + version: "1.0.0"; + gitCommitHash: string; + generatedAt: string; + files: Record; +} + +export type ChangeLevel = "NONE" | "COSMETIC" | "STRUCTURAL"; + +export interface FileChangeResult { + filePath: string; + changeLevel: ChangeLevel; + details: string[]; +} + +export interface ChangeAnalysis { + fileChanges: FileChangeResult[]; + newFiles: string[]; + deletedFiles: string[]; + structurallyChangedFiles: string[]; + cosmeticOnlyFiles: string[]; + unchangedFiles: string[]; +} + +// ---- Core functions ---- + +/** + * Compute SHA-256 content hash for a file's content. + */ +export function contentHash(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +/** + * Extract a structural fingerprint from a file using its tree-sitter analysis. + * The fingerprint captures only the elements that affect the knowledge graph + * (function/class/import/export signatures), not implementation details. + */ +export function extractFileFingerprint( + filePath: string, + content: string, + analysis: StructuralAnalysis, +): FileFingerprint { + const hash = contentHash(content); + const exportedNames = new Set(analysis.exports.map((e) => e.name)); + + const functions: FunctionFingerprint[] = analysis.functions.map((fn) => ({ + name: fn.name, + params: [...fn.params], + returnType: fn.returnType, + exported: exportedNames.has(fn.name), + lineCount: fn.lineRange[1] - fn.lineRange[0] + 1, + })); + + const classes: ClassFingerprint[] = analysis.classes.map((cls) => ({ + name: cls.name, + methods: [...cls.methods], + properties: [...cls.properties], + exported: exportedNames.has(cls.name), + lineCount: cls.lineRange[1] - cls.lineRange[0] + 1, + })); + + const imports: ImportFingerprint[] = analysis.imports.map((imp) => ({ + source: imp.source, + specifiers: [...imp.specifiers], + })); + + const exports = analysis.exports.map((e) => e.name); + + const totalLines = content.split("\n").length; + + return { + filePath, + contentHash: hash, + functions, + classes, + imports, + exports, + totalLines, + hasStructuralAnalysis: true, + }; +} + +/** + * Compare two file fingerprints and determine the change level. + * + * - NONE: content hash identical (file unchanged) + * - COSMETIC: content differs but structural signatures match (internal logic only) + * - STRUCTURAL: signature-level changes detected + */ +export function compareFingerprints( + oldFp: FileFingerprint, + newFp: FileFingerprint, +): FileChangeResult { + const details: string[] = []; + + // Fast path: identical content + if (oldFp.contentHash === newFp.contentHash) { + return { filePath: newFp.filePath, changeLevel: "NONE", details: [] }; + } + + // Conservative path: if either fingerprint lacks structural analysis, + // we cannot verify structure didn't change — classify as STRUCTURAL. + if (!oldFp.hasStructuralAnalysis || !newFp.hasStructuralAnalysis) { + return { + filePath: newFp.filePath, + changeLevel: "STRUCTURAL", + details: ["no structural analysis available — conservative classification"], + }; + } + + // Compare function signatures + const oldFuncNames = new Set(oldFp.functions.map((f) => f.name)); + const newFuncNames = new Set(newFp.functions.map((f) => f.name)); + + for (const name of newFuncNames) { + if (!oldFuncNames.has(name)) { + details.push(`new function: ${name}`); + } + } + for (const name of oldFuncNames) { + if (!newFuncNames.has(name)) { + details.push(`removed function: ${name}`); + } + } + + // Compare shared functions for signature changes + for (const newFn of newFp.functions) { + const oldFn = oldFp.functions.find((f) => f.name === newFn.name); + if (!oldFn) continue; + + if (JSON.stringify(oldFn.params) !== JSON.stringify(newFn.params)) { + details.push(`params changed: ${newFn.name}`); + } + if (oldFn.returnType !== newFn.returnType) { + details.push(`return type changed: ${newFn.name}`); + } + if (oldFn.exported !== newFn.exported) { + details.push(`export status changed: ${newFn.name}`); + } + // Flag large line count changes (>50% growth or shrink) + if (oldFn.lineCount > 0) { + const ratio = newFn.lineCount / oldFn.lineCount; + if (ratio > 1.5 || ratio < 0.5) { + details.push(`significant size change: ${newFn.name} (${oldFn.lineCount} → ${newFn.lineCount} lines)`); + } + } + } + + // Compare class signatures + const oldClassNames = new Set(oldFp.classes.map((c) => c.name)); + const newClassNames = new Set(newFp.classes.map((c) => c.name)); + + for (const name of newClassNames) { + if (!oldClassNames.has(name)) { + details.push(`new class: ${name}`); + } + } + for (const name of oldClassNames) { + if (!newClassNames.has(name)) { + details.push(`removed class: ${name}`); + } + } + + for (const newCls of newFp.classes) { + const oldCls = oldFp.classes.find((c) => c.name === newCls.name); + if (!oldCls) continue; + + if (JSON.stringify([...oldCls.methods].sort()) !== JSON.stringify([...newCls.methods].sort())) { + details.push(`methods changed: ${newCls.name}`); + } + if (JSON.stringify([...oldCls.properties].sort()) !== JSON.stringify([...newCls.properties].sort())) { + details.push(`properties changed: ${newCls.name}`); + } + if (oldCls.exported !== newCls.exported) { + details.push(`export status changed: ${newCls.name}`); + } + } + + // Compare imports + const oldImports = oldFp.imports.map((i) => `${i.source}:${[...i.specifiers].sort().join(",")}`).sort(); + const newImports = newFp.imports.map((i) => `${i.source}:${[...i.specifiers].sort().join(",")}`).sort(); + + if (JSON.stringify(oldImports) !== JSON.stringify(newImports)) { + details.push("imports changed"); + } + + // Compare exports + const oldExports = [...oldFp.exports].sort(); + const newExports = [...newFp.exports].sort(); + + if (JSON.stringify(oldExports) !== JSON.stringify(newExports)) { + details.push("exports changed"); + } + + if (details.length > 0) { + return { filePath: newFp.filePath, changeLevel: "STRUCTURAL", details }; + } + + // Content changed but structure is identical + return { + filePath: newFp.filePath, + changeLevel: "COSMETIC", + details: ["internal logic changed (no structural impact)"], + }; +} + +/** + * Build a fingerprint store for a set of files. + * Files without tree-sitter support get content-hash-only fingerprints + * (conservative: any change is treated as STRUCTURAL). + */ +export function buildFingerprintStore( + projectDir: string, + filePaths: string[], + registry: PluginRegistry, + gitCommitHash: string, +): FingerprintStore { + const files: Record = {}; + + for (const filePath of filePaths) { + const absolutePath = join(projectDir, filePath); + if (!existsSync(absolutePath)) continue; + + const content = readFileSync(absolutePath, "utf-8"); + const analysis = registry.analyzeFile(filePath, content); + + if (analysis) { + files[filePath] = extractFileFingerprint(filePath, content, analysis); + } else { + // No tree-sitter support: content hash only (conservative) + files[filePath] = { + filePath, + contentHash: contentHash(content), + functions: [], + classes: [], + imports: [], + exports: [], + totalLines: content.split("\n").length, + hasStructuralAnalysis: false, + }; + } + } + + return { + version: "1.0.0", + gitCommitHash, + generatedAt: new Date().toISOString(), + files, + }; +} + +/** + * Analyze changes between the current state of files and stored fingerprints. + * Returns a detailed breakdown of what changed and at what level. + */ +export function analyzeChanges( + projectDir: string, + changedFiles: string[], + existingStore: FingerprintStore, + registry: PluginRegistry, +): ChangeAnalysis { + const fileChanges: FileChangeResult[] = []; + const newFiles: string[] = []; + const deletedFiles: string[] = []; + const structurallyChangedFiles: string[] = []; + const cosmeticOnlyFiles: string[] = []; + const unchangedFiles: string[] = []; + + for (const filePath of changedFiles) { + const absolutePath = join(projectDir, filePath); + const existedBefore = filePath in existingStore.files; + const existsNow = existsSync(absolutePath); + + // File was deleted + if (!existsNow) { + if (existedBefore) { + deletedFiles.push(filePath); + fileChanges.push({ + filePath, + changeLevel: "STRUCTURAL", + details: ["file deleted"], + }); + } + continue; + } + + // File is new + if (!existedBefore) { + newFiles.push(filePath); + fileChanges.push({ + filePath, + changeLevel: "STRUCTURAL", + details: ["new file"], + }); + continue; + } + + // File exists in both — compare fingerprints + const content = readFileSync(absolutePath, "utf-8"); + const analysis = registry.analyzeFile(filePath, content); + const oldFp = existingStore.files[filePath]; + + let newFp: FileFingerprint; + if (analysis) { + newFp = extractFileFingerprint(filePath, content, analysis); + } else { + // No tree-sitter support: content hash only + newFp = { + filePath, + contentHash: contentHash(content), + functions: [], + classes: [], + imports: [], + exports: [], + totalLines: content.split("\n").length, + hasStructuralAnalysis: false, + }; + } + + const result = compareFingerprints(oldFp, newFp); + fileChanges.push(result); + + switch (result.changeLevel) { + case "NONE": + unchangedFiles.push(filePath); + break; + case "COSMETIC": + cosmeticOnlyFiles.push(filePath); + break; + case "STRUCTURAL": + structurallyChangedFiles.push(filePath); + break; + } + } + + return { + fileChanges, + newFiles, + deletedFiles, + structurallyChangedFiles, + cosmeticOnlyFiles, + unchangedFiles, + }; +} diff --git a/understand-anything-plugin/packages/core/src/index.ts b/understand-anything-plugin/packages/core/src/index.ts index 6438720..0213ad5 100644 --- a/understand-anything-plugin/packages/core/src/index.ts +++ b/understand-anything-plugin/packages/core/src/index.ts @@ -1,6 +1,15 @@ export * from "./types.js"; export * from "./persistence/index.js"; -export { KnowledgeGraphSchema, validateGraph, type ValidationResult } from "./schema.js"; +export { + KnowledgeGraphSchema, + validateGraph, + sanitizeGraph, + autoFixGraph, + COMPLEXITY_ALIASES, + DIRECTION_ALIASES, + type ValidationResult, + type GraphIssue, +} from "./schema.js"; export { TreeSitterPlugin } from "./plugins/tree-sitter-plugin.js"; export { GraphBuilder } from "./analyzer/graph-builder.js"; export { @@ -62,3 +71,38 @@ export { cosineSimilarity, type SemanticSearchOptions, } from "./embedding-search.js"; +export { + extractFileFingerprint, + compareFingerprints, + analyzeChanges, + buildFingerprintStore, + contentHash, + type FunctionFingerprint, + type ClassFingerprint, + type ImportFingerprint, + type FileFingerprint, + type FingerprintStore, + type ChangeLevel, + type FileChangeResult, + type ChangeAnalysis, +} from "./fingerprint.js"; +export { + classifyUpdate, + type UpdateDecision, +} from "./change-classifier.js"; +// Non-code parsers +export { + MarkdownParser, + YAMLConfigParser, + JSONConfigParser, + TOMLParser, + EnvParser, + DockerfileParser, + SQLParser, + GraphQLParser, + ProtobufParser, + TerraformParser, + MakefileParser, + ShellParser, + registerAllParsers, +} from "./plugins/parsers/index.js"; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/batch.ts b/understand-anything-plugin/packages/core/src/languages/configs/batch.ts new file mode 100644 index 0000000..3fc1905 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/batch.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const batchConfig = { + id: "batch", + displayName: "Batch Script", + extensions: [".bat", ".cmd"], + concepts: ["commands", "variables", "labels", "goto", "call", "echo", "set", "for loops", "if conditions"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/css.ts b/understand-anything-plugin/packages/core/src/languages/configs/css.ts new file mode 100644 index 0000000..e8b4de4 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/css.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const cssConfig = { + id: "css", + displayName: "CSS", + extensions: [".css", ".scss", ".less"], + concepts: ["selectors", "properties", "media queries", "flexbox", "grid", "variables", "animations", "specificity"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/csv.ts b/understand-anything-plugin/packages/core/src/languages/configs/csv.ts new file mode 100644 index 0000000..d7f6e06 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/csv.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const csvConfig = { + id: "csv", + displayName: "CSV", + extensions: [".csv", ".tsv"], + concepts: ["headers", "rows", "delimiters", "quoting", "escaping"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/docker-compose.ts b/understand-anything-plugin/packages/core/src/languages/configs/docker-compose.ts new file mode 100644 index 0000000..d9fadd4 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/docker-compose.ts @@ -0,0 +1,15 @@ +import type { LanguageConfig } from "../types.js"; + +export const dockerComposeConfig = { + id: "docker-compose", + displayName: "Docker Compose", + extensions: [], + filenames: ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"], + concepts: ["services", "networks", "volumes", "ports", "environment", "depends_on", "build context", "healthchecks"], + filePatterns: { + entryPoints: ["docker-compose.yml", "compose.yml"], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/dockerfile.ts b/understand-anything-plugin/packages/core/src/languages/configs/dockerfile.ts new file mode 100644 index 0000000..918bcbc --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/dockerfile.ts @@ -0,0 +1,15 @@ +import type { LanguageConfig } from "../types.js"; + +export const dockerfileConfig = { + id: "dockerfile", + displayName: "Dockerfile", + extensions: [], + filenames: ["Dockerfile", "Dockerfile.dev", "Dockerfile.prod", "Dockerfile.test"], + concepts: ["multi-stage builds", "layers", "base images", "COPY/ADD", "EXPOSE", "ENTRYPOINT", "CMD", "ARG", "ENV"], + filePatterns: { + entryPoints: ["Dockerfile"], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/env.ts b/understand-anything-plugin/packages/core/src/languages/configs/env.ts new file mode 100644 index 0000000..791b4b0 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/env.ts @@ -0,0 +1,15 @@ +import type { LanguageConfig } from "../types.js"; + +export const envConfig = { + id: "env", + displayName: "Environment Variables", + extensions: [".env"], + filenames: [".env", ".env.local", ".env.development", ".env.production", ".env.test", ".env.example"], + concepts: ["key-value pairs", "variable interpolation", "secrets", "environment-specific config"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: [".env", ".env.*"], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/github-actions.ts b/understand-anything-plugin/packages/core/src/languages/configs/github-actions.ts new file mode 100644 index 0000000..a37a566 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/github-actions.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const githubActionsConfig = { + id: "github-actions", + displayName: "GitHub Actions", + extensions: [], + concepts: ["workflows", "jobs", "steps", "actions", "triggers", "secrets", "matrix strategy", "artifacts"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: [".github/workflows/*.yml"], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/graphql.ts b/understand-anything-plugin/packages/core/src/languages/configs/graphql.ts new file mode 100644 index 0000000..b91e862 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/graphql.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const graphqlConfig = { + id: "graphql", + displayName: "GraphQL", + extensions: [".graphql", ".gql"], + concepts: ["types", "queries", "mutations", "subscriptions", "resolvers", "directives", "fragments", "schema"], + filePatterns: { + entryPoints: ["schema.graphql"], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/html.ts b/understand-anything-plugin/packages/core/src/languages/configs/html.ts new file mode 100644 index 0000000..9708f85 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/html.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const htmlConfig = { + id: "html", + displayName: "HTML", + extensions: [".html", ".htm"], + concepts: ["elements", "attributes", "semantic tags", "forms", "meta tags", "scripts", "stylesheets", "accessibility"], + filePatterns: { + entryPoints: ["index.html"], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/index.ts b/understand-anything-plugin/packages/core/src/languages/configs/index.ts index f0a7676..5db6616 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/index.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/index.ts @@ -11,8 +11,36 @@ import { swiftConfig } from "./swift.js"; import { kotlinConfig } from "./kotlin.js"; import { cppConfig } from "./cpp.js"; import { csharpConfig } from "./csharp.js"; +// Non-code language configs +import { markdownConfig } from "./markdown.js"; +import { yamlConfig } from "./yaml.js"; +import { jsonConfigConfig } from "./json-config.js"; +import { tomlConfig } from "./toml.js"; +import { envConfig } from "./env.js"; +import { xmlConfig } from "./xml.js"; +import { dockerfileConfig } from "./dockerfile.js"; +import { sqlConfig } from "./sql.js"; +import { graphqlConfig } from "./graphql.js"; +import { protobufConfig } from "./protobuf.js"; +import { terraformConfig } from "./terraform.js"; +import { githubActionsConfig } from "./github-actions.js"; +import { makefileConfig } from "./makefile.js"; +import { shellConfig } from "./shell.js"; +import { htmlConfig } from "./html.js"; +import { cssConfig } from "./css.js"; +import { openapiConfig } from "./openapi.js"; +import { kubernetesConfig } from "./kubernetes.js"; +import { dockerComposeConfig } from "./docker-compose.js"; +import { jsonSchemaConfig } from "./json-schema.js"; +import { csvConfig } from "./csv.js"; +import { restructuredtextConfig } from "./restructuredtext.js"; +import { powershellConfig } from "./powershell.js"; +import { batchConfig } from "./batch.js"; +import { jenkinsfileConfig } from "./jenkinsfile.js"; +import { plaintextConfig } from "./plaintext.js"; export const builtinLanguageConfigs: LanguageConfig[] = [ + // Code languages typescriptConfig, javascriptConfig, pythonConfig, @@ -25,9 +53,37 @@ export const builtinLanguageConfigs: LanguageConfig[] = [ kotlinConfig, cppConfig, csharpConfig, + // Non-code languages + markdownConfig, + yamlConfig, + jsonConfigConfig, + tomlConfig, + envConfig, + xmlConfig, + dockerfileConfig, + sqlConfig, + graphqlConfig, + protobufConfig, + terraformConfig, + githubActionsConfig, + makefileConfig, + shellConfig, + htmlConfig, + cssConfig, + openapiConfig, + kubernetesConfig, + dockerComposeConfig, + jsonSchemaConfig, + csvConfig, + restructuredtextConfig, + powershellConfig, + batchConfig, + jenkinsfileConfig, + plaintextConfig, ]; export { + // Code languages typescriptConfig, javascriptConfig, pythonConfig, @@ -40,4 +96,31 @@ export { kotlinConfig, cppConfig, csharpConfig, + // Non-code languages + markdownConfig, + yamlConfig, + jsonConfigConfig, + tomlConfig, + envConfig, + xmlConfig, + dockerfileConfig, + sqlConfig, + graphqlConfig, + protobufConfig, + terraformConfig, + githubActionsConfig, + makefileConfig, + shellConfig, + htmlConfig, + cssConfig, + openapiConfig, + kubernetesConfig, + dockerComposeConfig, + jsonSchemaConfig, + csvConfig, + restructuredtextConfig, + powershellConfig, + batchConfig, + jenkinsfileConfig, + plaintextConfig, }; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/jenkinsfile.ts b/understand-anything-plugin/packages/core/src/languages/configs/jenkinsfile.ts new file mode 100644 index 0000000..383fb36 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/jenkinsfile.ts @@ -0,0 +1,15 @@ +import type { LanguageConfig } from "../types.js"; + +export const jenkinsfileConfig = { + id: "jenkinsfile", + displayName: "Jenkinsfile", + extensions: [], + filenames: ["Jenkinsfile"], + concepts: ["pipeline", "stages", "steps", "agents", "environment", "post actions", "parallel execution", "shared libraries"], + filePatterns: { + entryPoints: ["Jenkinsfile"], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/json-config.ts b/understand-anything-plugin/packages/core/src/languages/configs/json-config.ts new file mode 100644 index 0000000..549deec --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/json-config.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const jsonConfigConfig = { + id: "json", + displayName: "JSON", + extensions: [".json", ".jsonc"], + concepts: ["objects", "arrays", "nesting", "schema references", "comments (JSONC)"], + filePatterns: { + entryPoints: ["package.json"], + barrels: [], + tests: [], + config: ["tsconfig.json", "package.json", ".eslintrc.json"], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/json-schema.ts b/understand-anything-plugin/packages/core/src/languages/configs/json-schema.ts new file mode 100644 index 0000000..4e0d9d3 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/json-schema.ts @@ -0,0 +1,18 @@ +import type { LanguageConfig } from "../types.js"; + +// TODO: JSON Schema files have no unique extension — *.schema.json files will match +// `jsonConfigConfig` by the `.json` extension. Detection requires content-based +// heuristics (e.g., checking for `"$schema"` or `"type"` keys at the root level). +// A future content-based detection pass could re-classify them as JSON Schema. +export const jsonSchemaConfig = { + id: "json-schema", + displayName: "JSON Schema", + extensions: [], + concepts: ["types", "properties", "required fields", "$ref", "$defs", "allOf/anyOf/oneOf", "patterns", "validation"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/kubernetes.ts b/understand-anything-plugin/packages/core/src/languages/configs/kubernetes.ts new file mode 100644 index 0000000..94e03af --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/kubernetes.ts @@ -0,0 +1,19 @@ +import type { LanguageConfig } from "../types.js"; + +// TODO: Kubernetes manifests are YAML files with no unique extension or filename. +// Detection requires content-based or path-pattern heuristics (e.g., checking for +// `apiVersion`/`kind` fields in YAML, or matching paths like `k8s/`, `kubernetes/`, +// `deploy/`). Currently these files will match `yamlConfig` by extension (.yaml/.yml). +// A future content-based detection pass could re-classify them as Kubernetes. +export const kubernetesConfig = { + id: "kubernetes", + displayName: "Kubernetes", + extensions: [], + concepts: ["deployments", "services", "pods", "configmaps", "secrets", "ingress", "volumes", "namespaces"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: ["k8s/*.yaml", "kubernetes/*.yaml"], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/makefile.ts b/understand-anything-plugin/packages/core/src/languages/configs/makefile.ts new file mode 100644 index 0000000..f8c679a --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/makefile.ts @@ -0,0 +1,15 @@ +import type { LanguageConfig } from "../types.js"; + +export const makefileConfig = { + id: "makefile", + displayName: "Makefile", + extensions: [".mk"], + filenames: ["Makefile", "GNUmakefile", "makefile"], + concepts: ["targets", "dependencies", "recipes", "variables", "pattern rules", "phony targets", "includes"], + filePatterns: { + entryPoints: ["Makefile"], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/markdown.ts b/understand-anything-plugin/packages/core/src/languages/configs/markdown.ts new file mode 100644 index 0000000..f20de85 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/markdown.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const markdownConfig = { + id: "markdown", + displayName: "Markdown", + extensions: [".md", ".mdx"], + concepts: ["headings", "links", "code blocks", "front matter", "lists", "tables", "images"], + filePatterns: { + entryPoints: ["README.md"], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/openapi.ts b/understand-anything-plugin/packages/core/src/languages/configs/openapi.ts new file mode 100644 index 0000000..ad026be --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/openapi.ts @@ -0,0 +1,15 @@ +import type { LanguageConfig } from "../types.js"; + +export const openapiConfig = { + id: "openapi", + displayName: "OpenAPI", + extensions: [], + filenames: ["openapi.yaml", "openapi.json", "swagger.yaml", "swagger.json"], + concepts: ["paths", "operations", "schemas", "parameters", "responses", "security schemes", "tags", "servers"], + filePatterns: { + entryPoints: ["openapi.yaml", "swagger.yaml"], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/plaintext.ts b/understand-anything-plugin/packages/core/src/languages/configs/plaintext.ts new file mode 100644 index 0000000..e9e737d --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/plaintext.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const plaintextConfig = { + id: "plaintext", + displayName: "Plain Text", + extensions: [".txt", ".text"], + concepts: ["paragraphs", "lists", "sections"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/powershell.ts b/understand-anything-plugin/packages/core/src/languages/configs/powershell.ts new file mode 100644 index 0000000..409c30d --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/powershell.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const powershellConfig = { + id: "powershell", + displayName: "PowerShell", + extensions: [".ps1", ".psm1", ".psd1"], + concepts: ["cmdlets", "pipelines", "modules", "functions", "parameters", "variables", "error handling"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/protobuf.ts b/understand-anything-plugin/packages/core/src/languages/configs/protobuf.ts new file mode 100644 index 0000000..4ae5fe9 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/protobuf.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const protobufConfig = { + id: "protobuf", + displayName: "Protocol Buffers", + extensions: [".proto"], + concepts: ["messages", "services", "enums", "oneof", "repeated fields", "maps", "packages", "imports"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/restructuredtext.ts b/understand-anything-plugin/packages/core/src/languages/configs/restructuredtext.ts new file mode 100644 index 0000000..42cc732 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/restructuredtext.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const restructuredtextConfig = { + id: "restructuredtext", + displayName: "reStructuredText", + extensions: [".rst"], + concepts: ["headings", "directives", "roles", "cross-references", "toctree", "code blocks", "admonitions"], + filePatterns: { + entryPoints: ["index.rst"], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/shell.ts b/understand-anything-plugin/packages/core/src/languages/configs/shell.ts new file mode 100644 index 0000000..e3448fc --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/shell.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const shellConfig = { + id: "shell", + displayName: "Shell Script", + extensions: [".sh", ".bash", ".zsh"], + concepts: ["variables", "functions", "conditionals", "loops", "pipes", "redirection", "subshells", "exit codes"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: [".bashrc", ".zshrc", ".profile"], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/sql.ts b/understand-anything-plugin/packages/core/src/languages/configs/sql.ts new file mode 100644 index 0000000..cad3ca4 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/sql.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const sqlConfig = { + id: "sql", + displayName: "SQL", + extensions: [".sql"], + concepts: ["tables", "columns", "indexes", "foreign keys", "views", "stored procedures", "triggers", "migrations"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: [], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/terraform.ts b/understand-anything-plugin/packages/core/src/languages/configs/terraform.ts new file mode 100644 index 0000000..23edb2f --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/terraform.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const terraformConfig = { + id: "terraform", + displayName: "Terraform", + extensions: [".tf", ".tfvars"], + concepts: ["resources", "data sources", "variables", "outputs", "modules", "providers", "state", "workspaces"], + filePatterns: { + entryPoints: ["main.tf"], + barrels: [], + tests: [], + config: ["terraform.tfvars", "variables.tf"], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/toml.ts b/understand-anything-plugin/packages/core/src/languages/configs/toml.ts new file mode 100644 index 0000000..70e444c --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/toml.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const tomlConfig = { + id: "toml", + displayName: "TOML", + extensions: [".toml"], + concepts: ["tables", "inline tables", "arrays of tables", "key-value pairs", "dotted keys"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: ["Cargo.toml", "pyproject.toml", "netlify.toml"], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/xml.ts b/understand-anything-plugin/packages/core/src/languages/configs/xml.ts new file mode 100644 index 0000000..31b512a --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/xml.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const xmlConfig = { + id: "xml", + displayName: "XML", + extensions: [".xml", ".xsl", ".xsd", ".svg", ".plist"], + concepts: ["elements", "attributes", "namespaces", "DTD", "XPath", "XSLT", "schemas"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: ["pom.xml", "web.xml", "AndroidManifest.xml"], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/configs/yaml.ts b/understand-anything-plugin/packages/core/src/languages/configs/yaml.ts new file mode 100644 index 0000000..1fb5427 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/yaml.ts @@ -0,0 +1,14 @@ +import type { LanguageConfig } from "../types.js"; + +export const yamlConfig = { + id: "yaml", + displayName: "YAML", + extensions: [".yaml", ".yml"], + concepts: ["mappings", "sequences", "anchors", "aliases", "multi-document", "tags"], + filePatterns: { + entryPoints: [], + barrels: [], + tests: [], + config: ["*.yaml", "*.yml"], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/languages/language-registry.ts b/understand-anything-plugin/packages/core/src/languages/language-registry.ts index 542cd1d..d8eb0c2 100644 --- a/understand-anything-plugin/packages/core/src/languages/language-registry.ts +++ b/understand-anything-plugin/packages/core/src/languages/language-registry.ts @@ -9,6 +9,7 @@ import { builtinLanguageConfigs } from "./configs/index.js"; export class LanguageRegistry { private byId = new Map(); private byExtension = new Map(); + private byFilename = new Map(); register(config: LanguageConfig): void { const parsed = LanguageConfigSchema.parse(config); @@ -18,6 +19,11 @@ export class LanguageRegistry { const key = ext.startsWith(".") ? ext : `.${ext}`; this.byExtension.set(key, parsed); } + if (parsed.filenames) { + for (const filename of parsed.filenames) { + this.byFilename.set(filename.toLowerCase(), parsed); + } + } } getById(id: string): LanguageConfig | null { @@ -30,6 +36,11 @@ export class LanguageRegistry { } getForFile(filePath: string): LanguageConfig | null { + // Try filename-based lookup first (more specific: docker-compose.yml, Makefile, etc.) + const basename = filePath.split("/").pop() ?? filePath; + const filenameMatch = this.byFilename.get(basename.toLowerCase()); + if (filenameMatch) return filenameMatch; + // Fall back to extension-based lookup const lastDot = filePath.lastIndexOf("."); if (lastDot === -1) return null; const ext = filePath.slice(lastDot).toLowerCase(); diff --git a/understand-anything-plugin/packages/core/src/languages/types.ts b/understand-anything-plugin/packages/core/src/languages/types.ts index 7d06c3a..61a35fc 100644 --- a/understand-anything-plugin/packages/core/src/languages/types.ts +++ b/understand-anything-plugin/packages/core/src/languages/types.ts @@ -22,11 +22,12 @@ export const FilePatternConfigSchema = z.object({ export type FilePatternConfig = z.infer; -// Complete language configuration +// Complete language configuration (base schema — used by LanguageRegistry.register()) export const LanguageConfigSchema = z.object({ id: z.string().min(1), displayName: z.string().min(1), - extensions: z.array(z.string()).min(1), + extensions: z.array(z.string()), + filenames: z.array(z.string()).optional(), treeSitter: TreeSitterConfigSchema.optional(), concepts: z.array(z.string()), filePatterns: FilePatternConfigSchema, @@ -34,6 +35,18 @@ export const LanguageConfigSchema = z.object({ export type LanguageConfig = z.infer; +/** + * Strict schema with refinement: ensures at least one extension or filename + * is provided so the config can actually be detected by the registry. + * Use this for validating new/user-supplied configs (some builtin configs like + * kubernetes/github-actions intentionally lack both and rely on future + * content-based detection). + */ +export const StrictLanguageConfigSchema = LanguageConfigSchema.refine( + (c) => c.extensions.length > 0 || (c.filenames !== undefined && c.filenames.length > 0), + { message: "LanguageConfig must have at least one extension or filename for detection" } +); + // Framework configuration export const FrameworkConfigSchema = z.object({ id: z.string().min(1), diff --git a/understand-anything-plugin/packages/core/src/persistence/index.ts b/understand-anything-plugin/packages/core/src/persistence/index.ts index 36ba5cf..cf1cab6 100644 --- a/understand-anything-plugin/packages/core/src/persistence/index.ts +++ b/understand-anything-plugin/packages/core/src/persistence/index.ts @@ -1,11 +1,14 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import type { KnowledgeGraph, AnalysisMeta } from "../types.js"; +import { join, isAbsolute, relative, basename } from "node:path"; +import type { KnowledgeGraph, AnalysisMeta, ProjectConfig } from "../types.js"; +import type { FingerprintStore } from "../fingerprint.js"; import { validateGraph } from "../schema.js"; const UA_DIR = ".understand-anything"; const GRAPH_FILE = "knowledge-graph.json"; const META_FILE = "meta.json"; +const FINGERPRINT_FILE = "fingerprints.json"; +const CONFIG_FILE = "config.json"; function ensureDir(projectRoot: string): string { const dir = join(projectRoot, UA_DIR); @@ -15,9 +18,68 @@ function ensureDir(projectRoot: string): string { return dir; } +/** + * Sanitise every node's filePath before writing to disk. + * + * The analysis agent produces absolute paths like: + * /Users/alice/company/src/auth.ts + * + * We convert them to paths relative to projectRoot: + * src/auth.ts + * + * Three cases are handled: + * 1. Path is inside projectRoot → make it relative + * 2. Path is absolute but outside → keep only the filename (last segment) + * 3. Path is already relative → leave it untouched + * + * This means the developer's home directory, username, and company + * directory layout are never written to knowledge-graph.json. + */ +function sanitiseFilePaths( + graph: KnowledgeGraph, + projectRoot: string, +): KnowledgeGraph { + const normalRoot = projectRoot.endsWith("/") + ? projectRoot + : projectRoot + "/"; + + const sanitisedNodes = graph.nodes.map((node) => { + if (typeof node.filePath !== "string") return node; + + const fp = node.filePath; + + if (!isAbsolute(fp)) { + // Already relative — nothing to do. + return node; + } + + if (fp.startsWith(normalRoot) || fp.startsWith(projectRoot)) { + // Inside the project root — make it relative. + return { ...node, filePath: relative(projectRoot, fp) }; + } + + // Absolute but outside the project root — use only the filename + // so we leak as little as possible. + return { ...node, filePath: basename(fp) }; + }); + + return { ...graph, nodes: sanitisedNodes }; +} + export function saveGraph(projectRoot: string, graph: KnowledgeGraph): void { const dir = ensureDir(projectRoot); - writeFileSync(join(dir, GRAPH_FILE), JSON.stringify(graph, null, 2), "utf-8"); + + // FIX — sanitise absolute file paths before persisting. + // Without this, absolute paths like /Users/alice/company/src/auth.ts + // are written verbatim into knowledge-graph.json and later served + // by the dashboard server, leaking the developer's directory layout. + const sanitised = sanitiseFilePaths(graph, projectRoot); + + writeFileSync( + join(dir, GRAPH_FILE), + JSON.stringify(sanitised, null, 2), + "utf-8", + ); } export function loadGraph( @@ -33,7 +95,7 @@ export function loadGraph( const result = validateGraph(data); if (!result.success) { throw new Error( - `Invalid knowledge graph: ${result.errors!.join("; ")}`, + `Invalid knowledge graph: ${result.fatal ?? "unknown error"}`, ); } return result.data as KnowledgeGraph; @@ -52,3 +114,35 @@ export function loadMeta(projectRoot: string): AnalysisMeta | null { if (!existsSync(filePath)) return null; return JSON.parse(readFileSync(filePath, "utf-8")) as AnalysisMeta; } + +export function saveFingerprints(projectRoot: string, store: FingerprintStore): void { + const dir = ensureDir(projectRoot); + writeFileSync(join(dir, FINGERPRINT_FILE), JSON.stringify(store, null, 2), "utf-8"); +} + +export function loadFingerprints(projectRoot: string): FingerprintStore | null { + const filePath = join(projectRoot, UA_DIR, FINGERPRINT_FILE); + if (!existsSync(filePath)) return null; + try { + return JSON.parse(readFileSync(filePath, "utf-8")) as FingerprintStore; + } catch { + return null; + } +} + +const DEFAULT_CONFIG: ProjectConfig = { autoUpdate: false }; + +export function saveConfig(projectRoot: string, config: ProjectConfig): void { + const dir = ensureDir(projectRoot); + writeFileSync(join(dir, CONFIG_FILE), JSON.stringify(config, null, 2), "utf-8"); +} + +export function loadConfig(projectRoot: string): ProjectConfig { + const filePath = join(projectRoot, UA_DIR, CONFIG_FILE); + if (!existsSync(filePath)) return { ...DEFAULT_CONFIG }; + try { + return JSON.parse(readFileSync(filePath, "utf-8")) as ProjectConfig; + } catch { + return { ...DEFAULT_CONFIG }; + } +} diff --git a/understand-anything-plugin/packages/core/src/persistence/persistence.test.ts b/understand-anything-plugin/packages/core/src/persistence/persistence.test.ts index ce159d4..02e0a1c 100644 --- a/understand-anything-plugin/packages/core/src/persistence/persistence.test.ts +++ b/understand-anything-plugin/packages/core/src/persistence/persistence.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { saveGraph, loadGraph, saveMeta, loadMeta } from "./index.js"; +import { writeFileSync } from "node:fs"; +import { saveGraph, loadGraph, saveMeta, loadMeta, saveFingerprints, loadFingerprints, saveConfig, loadConfig } from "./index.js"; import type { KnowledgeGraph, AnalysisMeta } from "../types.js"; +import type { FingerprintStore } from "../fingerprint.js"; describe("persistence", () => { let tempDir: string; @@ -41,7 +43,7 @@ describe("persistence", () => { edges: [ { source: "node-1", - target: "node-2", + target: "node-1", type: "imports", direction: "forward", weight: 0.8, @@ -92,6 +94,24 @@ describe("persistence", () => { const loaded = loadGraph(tempDir); expect(loaded).toBeNull(); }); + + it("should throw error when loading a fatally invalid graph", () => { + const invalidGraph = { ...sampleGraph, project: null }; + saveGraph(tempDir, invalidGraph as unknown as KnowledgeGraph); + + expect(() => { + loadGraph(tempDir); + }).toThrow(/Invalid knowledge graph/); + }); + + it("should skip validation when validate option is false", () => { + const invalidGraph = { ...sampleGraph, version: 123 }; + saveGraph(tempDir, invalidGraph as unknown as KnowledgeGraph); + + const loaded = loadGraph(tempDir, { validate: false }); + expect(loaded).not.toBeNull(); + expect(loaded?.version).toBe(123); + }); }); describe("saveMeta / loadMeta", () => { @@ -115,4 +135,70 @@ describe("persistence", () => { expect(loaded).toBeNull(); }); }); + + describe("saveFingerprints / loadFingerprints", () => { + const sampleFingerprints: FingerprintStore = { + version: "1.0.0", + gitCommitHash: "abc123", + generatedAt: "2026-03-14T00:00:00.000Z", + files: { + "src/index.ts": { + filePath: "src/index.ts", + contentHash: "deadbeef", + functions: [], + classes: [], + imports: [], + exports: [], + totalLines: 10, + hasStructuralAnalysis: false, + }, + }, + }; + + it("should round-trip fingerprints correctly", () => { + saveFingerprints(tempDir, sampleFingerprints); + const loaded = loadFingerprints(tempDir); + + expect(loaded).toEqual(sampleFingerprints); + }); + + it("should return null when no fingerprints file exists", () => { + const loaded = loadFingerprints(tempDir); + expect(loaded).toBeNull(); + }); + + it("should return null when fingerprints.json is corrupted", () => { + const dir = join(tempDir, ".understand-anything"); + // Ensure the directory exists by saving first, then overwrite with garbage + saveFingerprints(tempDir, sampleFingerprints); + writeFileSync(join(dir, "fingerprints.json"), "{{not valid json!!", "utf-8"); + + const loaded = loadFingerprints(tempDir); + expect(loaded).toBeNull(); + }); + }); + + describe("saveConfig / loadConfig", () => { + it("should round-trip config correctly", () => { + saveConfig(tempDir, { autoUpdate: true }); + const loaded = loadConfig(tempDir); + + expect(loaded).toEqual({ autoUpdate: true }); + }); + + it("should return default config when no file exists", () => { + const loaded = loadConfig(tempDir); + + expect(loaded).toEqual({ autoUpdate: false }); + }); + + it("should return default config when config.json is corrupted", () => { + saveConfig(tempDir, { autoUpdate: true }); + const dir = join(tempDir, ".understand-anything"); + writeFileSync(join(dir, "config.json"), "not json!!", "utf-8"); + + const loaded = loadConfig(tempDir); + expect(loaded).toEqual({ autoUpdate: false }); + }); + }); }); diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/dockerfile-parser.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/dockerfile-parser.ts new file mode 100644 index 0000000..cba4b24 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/dockerfile-parser.ts @@ -0,0 +1,86 @@ +import type { AnalyzerPlugin, StructuralAnalysis, ServiceInfo, StepInfo } from "../../types.js"; + +/** + * Parses Dockerfiles to extract multi-stage build stages, EXPOSE ports, and instruction steps. + * Associates EXPOSE ports with the correct stage based on FROM directive ordering. + * Does not parse ARG/ENV variable substitution or heredoc syntax. + */ +export class DockerfileParser implements AnalyzerPlugin { + name = "dockerfile-parser"; + languages = ["dockerfile"]; + + analyzeFile(_filePath: string, content: string): StructuralAnalysis { + const services = this.extractStages(content); + const steps = this.extractSteps(content); + return { + functions: [], + classes: [], + imports: [], + exports: [], + services, + steps, + }; + } + + private extractStages(content: string): ServiceInfo[] { + const stages: ServiceInfo[] = []; + const lines = content.split("\n"); + + // First pass: find FROM line indices + const fromLines: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (/^FROM\s+/i.test(lines[i])) { + fromLines.push(i); + } + } + + // Second pass: for each stage, collect EXPOSE ports within its range and build ServiceInfo + for (let s = 0; s < fromLines.length; s++) { + const stageStartLine = fromLines[s]; + const stageEndLine = s + 1 < fromLines.length ? fromLines[s + 1] - 1 : lines.length - 1; + + const fromMatch = lines[stageStartLine].match(/^FROM\s+(\S+)(?:\s+[Aa][Ss]\s+(\S+))?/i); + if (!fromMatch) continue; + + const image = fromMatch[1]; + const name = fromMatch[2] ?? image.split(":")[0].split("/").pop() ?? image; + + // Collect EXPOSE ports that appear within this stage's range + const ports: number[] = []; + for (let i = stageStartLine; i <= stageEndLine; i++) { + const exposeMatch = lines[i].match(/^EXPOSE\s+(.+)/i); + if (exposeMatch) { + const portValues = exposeMatch[1].split(/\s+/); + for (const p of portValues) { + const num = parseInt(p, 10); + if (!isNaN(num)) ports.push(num); + } + } + } + + stages.push({ + name, + image, + ports, + lineRange: [stageStartLine + 1, stageEndLine + 1], + }); + } + + return stages; + } + + private extractSteps(content: string): StepInfo[] { + const steps: StepInfo[] = []; + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^(FROM|RUN|COPY|ADD|WORKDIR|CMD|ENTRYPOINT|ENV|ARG|EXPOSE|VOLUME|USER|HEALTHCHECK)\s/i); + if (match) { + steps.push({ + name: `${match[1].toUpperCase()} ${lines[i].slice(match[1].length + 1).trim().slice(0, 60)}`, + lineRange: [i + 1, i + 1], + }); + } + } + return steps; + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/env-parser.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/env-parser.ts new file mode 100644 index 0000000..c39a6b0 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/env-parser.ts @@ -0,0 +1,41 @@ +import type { AnalyzerPlugin, StructuralAnalysis, DefinitionInfo } from "../../types.js"; + +/** + * Parses .env files to extract environment variable definitions. + * Handles KEY=value syntax, skipping comments and empty lines. + * Does not handle `export VAR=value` syntax or multi-line values. + */ +export class EnvParser implements AnalyzerPlugin { + name = "env-parser"; + languages = ["env"]; + + analyzeFile(_filePath: string, content: string): StructuralAnalysis { + const definitions = this.extractVariables(content); + return { + functions: [], + classes: [], + imports: [], + exports: [], + definitions, + }; + } + + private extractVariables(content: string): DefinitionInfo[] { + const definitions: DefinitionInfo[] = []; + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line.startsWith("#") || line === "") continue; + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/); + if (match) { + definitions.push({ + name: match[1], + kind: "variable", + lineRange: [i + 1, i + 1], + fields: [], + }); + } + } + return definitions; + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/graphql-parser.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/graphql-parser.ts new file mode 100644 index 0000000..6985514 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/graphql-parser.ts @@ -0,0 +1,123 @@ +import type { AnalyzerPlugin, StructuralAnalysis, DefinitionInfo, EndpointInfo } from "../../types.js"; + +/** + * Parses GraphQL schema files to extract type, input, enum, interface, union, and scalar definitions. + * Extracts Query, Mutation, and Subscription endpoints as separate endpoint entries. + * Does not handle schema directives, fragments, or inline union members. + */ +export class GraphQLParser implements AnalyzerPlugin { + name = "graphql-parser"; + languages = ["graphql"]; + + analyzeFile(_filePath: string, content: string): StructuralAnalysis { + const definitions = this.extractDefinitions(content); + const endpoints = this.extractEndpoints(content); + return { + functions: [], + classes: [], + imports: [], + exports: [], + definitions, + endpoints, + }; + } + + private extractDefinitions(content: string): DefinitionInfo[] { + const definitions: DefinitionInfo[] = []; + const lines = content.split("\n"); + + // Match type, input, enum, interface, union, scalar definitions + const typeRegex = /^(type|input|enum|interface|union|scalar)\s+(\w+)/gm; + let match; + while ((match = typeRegex.exec(content)) !== null) { + const kind = match[1]; + const name = match[2]; + if (name === "Query" || name === "Mutation" || name === "Subscription") continue; + const startLine = content.slice(0, match.index).split("\n").length; + + // Extract fields (for type/input/interface/enum) + const fields = this.extractFields(content, match.index); + + // Find closing brace + const afterMatch = content.slice(match.index); + const closeBrace = afterMatch.indexOf("}"); + const endLine = closeBrace !== -1 + ? content.slice(0, match.index + closeBrace + 1).split("\n").length + : startLine; + + definitions.push({ + name, + kind, + lineRange: [startLine, endLine], + fields, + }); + } + + return definitions; + } + + private extractEndpoints(content: string): EndpointInfo[] { + const endpoints: EndpointInfo[] = []; + + // Find Query, Mutation, Subscription blocks and extract their fields + const blockRegex = /^(type)\s+(Query|Mutation|Subscription)\s*\{/gm; + let match; + while ((match = blockRegex.exec(content)) !== null) { + const method = match[2]; // Query, Mutation, Subscription + const startIdx = match.index + match[0].length; + + // Find closing brace + let depth = 1; + let i = startIdx; + while (i < content.length && depth > 0) { + if (content[i] === "{") depth++; + if (content[i] === "}") depth--; + i++; + } + + const blockContent = content.slice(startIdx, i - 1); + const blockLines = blockContent.split("\n"); + const blockStartLine = content.slice(0, startIdx).split("\n").length; + + for (let j = 0; j < blockLines.length; j++) { + const fieldMatch = blockLines[j].trim().match(/^(\w+)/); + if (fieldMatch && fieldMatch[1]) { + const lineNum = blockStartLine + j; + endpoints.push({ + method, + path: fieldMatch[1], + lineRange: [lineNum, lineNum], + }); + } + } + } + + return endpoints; + } + + private extractFields(content: string, startIdx: number): string[] { + const fields: string[] = []; + const afterType = content.slice(startIdx); + const openBrace = afterType.indexOf("{"); + if (openBrace === -1) return fields; + + let depth = 1; + let i = openBrace + 1; + while (i < afterType.length && depth > 0) { + if (afterType[i] === "{") depth++; + if (afterType[i] === "}") depth--; + i++; + } + + const body = afterType.slice(openBrace + 1, i - 1); + const lines = body.split("\n"); + for (const line of lines) { + const fieldMatch = line.trim().match(/^(\w+)/); + if (fieldMatch) { + fields.push(fieldMatch[1]); + } + } + + return fields; + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/index.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/index.ts new file mode 100644 index 0000000..5832091 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/index.ts @@ -0,0 +1,44 @@ +export { MarkdownParser } from "./markdown-parser.js"; +export { YAMLConfigParser } from "./yaml-parser.js"; +export { JSONConfigParser } from "./json-parser.js"; +export { TOMLParser } from "./toml-parser.js"; +export { EnvParser } from "./env-parser.js"; +export { DockerfileParser } from "./dockerfile-parser.js"; +export { SQLParser } from "./sql-parser.js"; +export { GraphQLParser } from "./graphql-parser.js"; +export { ProtobufParser } from "./protobuf-parser.js"; +export { TerraformParser } from "./terraform-parser.js"; +export { MakefileParser } from "./makefile-parser.js"; +export { ShellParser } from "./shell-parser.js"; + +import type { PluginRegistry } from "../registry.js"; +import { MarkdownParser } from "./markdown-parser.js"; +import { YAMLConfigParser } from "./yaml-parser.js"; +import { JSONConfigParser } from "./json-parser.js"; +import { TOMLParser } from "./toml-parser.js"; +import { EnvParser } from "./env-parser.js"; +import { DockerfileParser } from "./dockerfile-parser.js"; +import { SQLParser } from "./sql-parser.js"; +import { GraphQLParser } from "./graphql-parser.js"; +import { ProtobufParser } from "./protobuf-parser.js"; +import { TerraformParser } from "./terraform-parser.js"; +import { MakefileParser } from "./makefile-parser.js"; +import { ShellParser } from "./shell-parser.js"; + +/** + * Register all built-in non-code parsers with a PluginRegistry. + */ +export function registerAllParsers(registry: PluginRegistry): void { + registry.register(new MarkdownParser()); + registry.register(new YAMLConfigParser()); + registry.register(new JSONConfigParser()); + registry.register(new TOMLParser()); + registry.register(new EnvParser()); + registry.register(new DockerfileParser()); + registry.register(new SQLParser()); + registry.register(new GraphQLParser()); + registry.register(new ProtobufParser()); + registry.register(new TerraformParser()); + registry.register(new MakefileParser()); + registry.register(new ShellParser()); +} diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/json-parser.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/json-parser.ts new file mode 100644 index 0000000..36db1b7 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/json-parser.ts @@ -0,0 +1,70 @@ +import type { AnalyzerPlugin, StructuralAnalysis, SectionInfo, ReferenceResolution } from "../../types.js"; + +/** + * Parses JSON configuration files to extract top-level key sections and $ref references. + * Handles package.json, tsconfig.json, JSON Schema, and OpenAPI spec files. + * Does not descend into nested object structures beyond top-level keys. + */ +export class JSONConfigParser implements AnalyzerPlugin { + name = "json-config-parser"; + languages = ["json"]; + + analyzeFile(_filePath: string, content: string): StructuralAnalysis { + const sections = this.extractSections(content); + return { + functions: [], + classes: [], + imports: [], + exports: [], + sections, + }; + } + + extractReferences(filePath: string, content: string): ReferenceResolution[] { + const refs: ReferenceResolution[] = []; + // Match $ref values (JSON Schema / OpenAPI) + const refRegex = /"\$ref"\s*:\s*"([^"]+)"/g; + let match; + while ((match = refRegex.exec(content)) !== null) { + const target = match[1]; + if (target.startsWith("#")) continue; // Skip internal refs + const line = content.slice(0, match.index).split("\n").length; + refs.push({ + source: filePath, + target, + referenceType: "schema", + line, + }); + } + return refs; + } + + private extractSections(content: string): SectionInfo[] { + const sections: SectionInfo[] = []; + try { + const doc = JSON.parse(content); + if (doc && typeof doc === "object" && !Array.isArray(doc)) { + const lines = content.split("\n"); + for (const key of Object.keys(doc)) { + const escapedKey = JSON.stringify(key); + const lineIdx = lines.findIndex((l) => l.includes(escapedKey)); + if (lineIdx !== -1) { + sections.push({ + name: key, + level: 1, + lineRange: [lineIdx + 1, lineIdx + 1], + }); + } + } + // Fix lineRange end + for (let i = 0; i < sections.length; i++) { + const next = sections[i + 1]; + sections[i].lineRange[1] = next ? next.lineRange[0] - 1 : lines.length; + } + } + } catch (err) { + console.warn(`[json-parser] Failed to parse JSON: ${err instanceof Error ? err.message : String(err)}`); + } + return sections; + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/makefile-parser.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/makefile-parser.ts new file mode 100644 index 0000000..5b20139 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/makefile-parser.ts @@ -0,0 +1,51 @@ +import type { AnalyzerPlugin, StructuralAnalysis, StepInfo } from "../../types.js"; + +/** + * Parses Makefiles to extract build targets and their line ranges. + * Filters out special Make targets (e.g., .PHONY, .DEFAULT, .SUFFIXES) and variable assignments. + * Does not parse target dependencies or recipe commands. + */ +export class MakefileParser implements AnalyzerPlugin { + name = "makefile-parser"; + languages = ["makefile"]; + + analyzeFile(_filePath: string, content: string): StructuralAnalysis { + const steps = this.extractTargets(content); + return { + functions: [], + classes: [], + imports: [], + exports: [], + steps, + }; + } + + private extractTargets(content: string): StepInfo[] { + const targets: StepInfo[] = []; + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + // Match target: dependencies (not variable assignments or comments) + const match = lines[i].match(/^([a-zA-Z_.][a-zA-Z0-9_.-]*)(?:\s+.*)?:/); + if (match && !lines[i].includes(":=") && !lines[i].includes("?=")) { + const name = match[1]; + // Skip special Make targets (.PHONY, .DEFAULT, .SUFFIXES, etc.) + if (name.startsWith(".")) continue; + // Find end of target (next non-indented non-empty line or EOF) + let endLine = i + 1; + while (endLine < lines.length) { + const nextLine = lines[endLine]; + if (nextLine === "" || nextLine.startsWith("\t") || nextLine.startsWith(" ")) { + endLine++; + } else { + break; + } + } + targets.push({ + name, + lineRange: [i + 1, endLine], + }); + } + } + return targets; + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/markdown-parser.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/markdown-parser.ts new file mode 100644 index 0000000..23008b8 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/markdown-parser.ts @@ -0,0 +1,61 @@ +import type { AnalyzerPlugin, StructuralAnalysis, ReferenceResolution, SectionInfo } from "../../types.js"; + +/** + * Parses Markdown files to extract heading sections and local file/image references. + * Supports ATX-style headings (# through ######) with line range computation. + * Does not extract code blocks, front matter fields, or external URL references. + */ +export class MarkdownParser implements AnalyzerPlugin { + name = "markdown-parser"; + languages = ["markdown"]; + + analyzeFile(_filePath: string, content: string): StructuralAnalysis { + const sections = this.extractSections(content); + return { + functions: [], + classes: [], + imports: [], + exports: [], + sections, + }; + } + + extractReferences(filePath: string, content: string): ReferenceResolution[] { + const refs: ReferenceResolution[] = []; + const linkRegex = /!?\[([^\]]*)\]\(([^)]+)\)/g; + let match; + while ((match = linkRegex.exec(content)) !== null) { + const target = match[2]; + if (target.startsWith("http")) continue; // Skip external URLs + const line = content.slice(0, match.index).split("\n").length; + refs.push({ + source: filePath, + target, + referenceType: match[0].startsWith("!") ? "image" : "file", + line, + }); + } + return refs; + } + + private extractSections(content: string): SectionInfo[] { + const sections: SectionInfo[] = []; + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^(#{1,6})\s+(.+)/); + if (match) { + sections.push({ + name: match[2].trim(), + level: match[1].length, + lineRange: [i + 1, i + 1], + }); + } + } + // Fix lineRange end for each section (extends to next heading or EOF) + for (let i = 0; i < sections.length; i++) { + const next = sections[i + 1]; + sections[i].lineRange[1] = next ? next.lineRange[0] - 1 : lines.length; + } + return sections; + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/protobuf-parser.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/protobuf-parser.ts new file mode 100644 index 0000000..1df1b55 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/protobuf-parser.ts @@ -0,0 +1,141 @@ +import type { AnalyzerPlugin, StructuralAnalysis, DefinitionInfo, EndpointInfo } from "../../types.js"; + +/** + * Parses Protocol Buffer (.proto) files to extract message, enum, and service definitions. + * Extracts message fields, enum values, and service RPC method endpoints. + * Does not handle nested message types, oneof fields, or proto2 extensions. + */ +export class ProtobufParser implements AnalyzerPlugin { + name = "protobuf-parser"; + languages = ["protobuf"]; + + analyzeFile(_filePath: string, content: string): StructuralAnalysis { + const definitions = this.extractDefinitions(content); + const endpoints = this.extractServiceMethods(content); + return { + functions: [], + classes: [], + imports: [], + exports: [], + definitions, + endpoints, + }; + } + + private extractDefinitions(content: string): DefinitionInfo[] { + const definitions: DefinitionInfo[] = []; + + // Match message definitions + const messageRegex = /^message\s+(\w+)\s*\{/gm; + let match; + while ((match = messageRegex.exec(content)) !== null) { + const startLine = content.slice(0, match.index).split("\n").length; + const fields = this.extractMessageFields(content, match.index); + const afterMatch = content.slice(match.index); + const closeBrace = this.findClosingBrace(afterMatch); + const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length; + + definitions.push({ + name: match[1], + kind: "message", + lineRange: [startLine, endLine], + fields, + }); + } + + // Match enum definitions + const enumRegex = /^enum\s+(\w+)\s*\{/gm; + while ((match = enumRegex.exec(content)) !== null) { + const startLine = content.slice(0, match.index).split("\n").length; + const fields = this.extractEnumValues(content, match.index); + const afterMatch = content.slice(match.index); + const closeBrace = this.findClosingBrace(afterMatch); + const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length; + + definitions.push({ + name: match[1], + kind: "enum", + lineRange: [startLine, endLine], + fields, + }); + } + + return definitions; + } + + private extractServiceMethods(content: string): EndpointInfo[] { + const endpoints: EndpointInfo[] = []; + const serviceRegex = /^service\s+(\w+)\s*\{/gm; + let match; + while ((match = serviceRegex.exec(content)) !== null) { + const serviceName = match[1]; + const startIdx = match.index + match[0].length; + const afterService = content.slice(match.index); + const closeBrace = this.findClosingBrace(afterService); + const body = afterService.slice(match[0].length, closeBrace); + + const rpcRegex = /rpc\s+(\w+)\s*\(/g; + let rpcMatch; + while ((rpcMatch = rpcRegex.exec(body)) !== null) { + const lineNum = content.slice(0, startIdx + rpcMatch.index).split("\n").length; + endpoints.push({ + method: "rpc", + path: `${serviceName}.${rpcMatch[1]}`, + lineRange: [lineNum, lineNum], + }); + } + } + return endpoints; + } + + private extractMessageFields(content: string, startIdx: number): string[] { + const fields: string[] = []; + const afterMsg = content.slice(startIdx); + const openBrace = afterMsg.indexOf("{"); + if (openBrace === -1) return fields; + + const closeBrace = this.findClosingBrace(afterMsg); + const body = afterMsg.slice(openBrace + 1, closeBrace); + + const fieldRegex = /^\s*(?:repeated\s+|optional\s+|required\s+|map<[^>]+>\s+)?\w+\s+(\w+)\s*=/gm; + let match; + while ((match = fieldRegex.exec(body)) !== null) { + fields.push(match[1]); + } + + return fields; + } + + private extractEnumValues(content: string, startIdx: number): string[] { + const values: string[] = []; + const afterEnum = content.slice(startIdx); + const openBrace = afterEnum.indexOf("{"); + if (openBrace === -1) return values; + + const closeBrace = this.findClosingBrace(afterEnum); + const body = afterEnum.slice(openBrace + 1, closeBrace); + + const valueRegex = /^\s*(\w+)\s*=/gm; + let match; + while ((match = valueRegex.exec(body)) !== null) { + values.push(match[1]); + } + + return values; + } + + private findClosingBrace(content: string): number { + let depth = 0; + for (let i = 0; i < content.length; i++) { + if (content[i] === "{") depth++; + if (content[i] === "}") { + depth--; + if (depth === 0) return i; + } + } + if (depth !== 0) { + console.warn(`[protobuf-parser] Unbalanced braces detected (depth=${depth}), results may be incomplete`); + } + return content.length; + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/shell-parser.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/shell-parser.ts new file mode 100644 index 0000000..8090c08 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/shell-parser.ts @@ -0,0 +1,76 @@ +import type { AnalyzerPlugin, StructuralAnalysis, ReferenceResolution } from "../../types.js"; + +/** + * Parses shell scripts (.sh, .bash) to extract function definitions and source references. + * Handles both `name() {` and `function name {` styles, including brace on next line. + * Does not extract variable declarations, aliases, or trap handlers. + */ +export class ShellParser implements AnalyzerPlugin { + name = "shell-parser"; + languages = ["shell"]; + + analyzeFile(_filePath: string, content: string): StructuralAnalysis { + const functions = this.extractFunctions(content); + return { + functions, + classes: [], + imports: [], + exports: [], + }; + } + + extractReferences(filePath: string, content: string): ReferenceResolution[] { + const refs: ReferenceResolution[] = []; + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + // Match source/. commands + const sourceMatch = lines[i].match(/^\s*(?:source|\.)[ \t]+["']?([^"'\s]+)["']?/); + if (sourceMatch) { + refs.push({ + source: filePath, + target: sourceMatch[1], + referenceType: "file", + line: i + 1, + }); + } + } + return refs; + } + + private extractFunctions(content: string): Array<{ name: string; lineRange: [number, number]; params: string[] }> { + const functions: Array<{ name: string; lineRange: [number, number]; params: string[] }> = []; + const lines = content.split("\n"); + + for (let i = 0; i < lines.length; i++) { + // Match function name() { or function name { + const match = lines[i].match(/^(?:function\s+)?(\w+)\s*\(\s*\)\s*\{?/) || + lines[i].match(/^function\s+(\w+)\s*\{?/); + if (match) { + const name = match[1]; + // Find closing brace (handle brace on same line or next line) + let endLine = i; + if (lines[i].includes("{") || (i + 1 < lines.length && lines[i + 1]?.trim() === "{")) { + const startBraceLine = lines[i].includes("{") ? i : i + 1; + let depth = 0; + for (let j = startBraceLine; j < lines.length; j++) { + for (const ch of lines[j]) { + if (ch === "{") depth++; + if (ch === "}") depth--; + } + if (depth === 0) { + endLine = j; + break; + } + } + } + functions.push({ + name, + lineRange: [i + 1, endLine + 1], + params: [], + }); + } + } + + return functions; + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/sql-parser.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/sql-parser.ts new file mode 100644 index 0000000..eb741a2 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/sql-parser.ts @@ -0,0 +1,103 @@ +import type { AnalyzerPlugin, StructuralAnalysis, DefinitionInfo } from "../../types.js"; + +/** + * Parses SQL files to extract table, view, and index definitions. + * Handles CREATE TABLE, CREATE VIEW, CREATE INDEX with IF NOT EXISTS and OR REPLACE variants. + * Does not handle stored procedures, triggers, or schema-qualified names (e.g., public.users). + */ +export class SQLParser implements AnalyzerPlugin { + name = "sql-parser"; + languages = ["sql"]; + + analyzeFile(_filePath: string, content: string): StructuralAnalysis { + const definitions = this.extractDefinitions(content); + return { + functions: [], + classes: [], + imports: [], + exports: [], + definitions, + }; + } + + private extractDefinitions(content: string): DefinitionInfo[] { + const definitions: DefinitionInfo[] = []; + const lines = content.split("\n"); + + // Match CREATE TABLE statements + const tableRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:`|")?(\w+)(?:`|")?/gi; + let match; + while ((match = tableRegex.exec(content)) !== null) { + const tableName = match[1]; + const startLine = content.slice(0, match.index).split("\n").length; + + // Extract columns (simplified: look for column names in parenthesized block) + const fields = this.extractColumns(content, match.index); + + // Find the end of the CREATE TABLE statement + const afterMatch = content.slice(match.index); + const endParen = afterMatch.indexOf(");"); + const endLine = endParen !== -1 + ? content.slice(0, match.index + endParen + 2).split("\n").length + : startLine + 5; + + definitions.push({ + name: tableName, + kind: "table", + lineRange: [startLine, endLine], + fields, + }); + } + + // Match CREATE VIEW + const viewRegex = /CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+(?:`|")?(\w+)(?:`|")?/gi; + while ((match = viewRegex.exec(content)) !== null) { + const startLine = content.slice(0, match.index).split("\n").length; + definitions.push({ + name: match[1], + kind: "view", + lineRange: [startLine, startLine], + fields: [], + }); + } + + // Match CREATE INDEX + const indexRegex = /CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:`|")?(\w+)(?:`|")?/gi; + while ((match = indexRegex.exec(content)) !== null) { + const startLine = content.slice(0, match.index).split("\n").length; + definitions.push({ + name: match[1], + kind: "index", + lineRange: [startLine, startLine], + fields: [], + }); + } + + return definitions; + } + + private extractColumns(content: string, startIdx: number): string[] { + const fields: string[] = []; + const afterCreate = content.slice(startIdx); + const openParen = afterCreate.indexOf("("); + if (openParen === -1) return fields; + + const closeParen = afterCreate.indexOf(");", openParen); + if (closeParen === -1) return fields; + + const body = afterCreate.slice(openParen + 1, closeParen); + const lines = body.split(","); + + for (const line of lines) { + const trimmed = line.trim(); + // Skip constraints + if (/^(PRIMARY|FOREIGN|UNIQUE|CHECK|CONSTRAINT|INDEX|KEY)/i.test(trimmed)) continue; + const colMatch = trimmed.match(/^(?:`|")?(\w+)(?:`|")?\s+/); + if (colMatch) { + fields.push(colMatch[1]); + } + } + + return fields; + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/terraform-parser.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/terraform-parser.ts new file mode 100644 index 0000000..902b6bb --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/terraform-parser.ts @@ -0,0 +1,130 @@ +import type { AnalyzerPlugin, StructuralAnalysis, ResourceInfo, DefinitionInfo } from "../../types.js"; + +/** + * Parses Terraform (.tf) files to extract resource, data, module, variable, and output blocks. + * Handles HCL block syntax with brace-matching for line range computation. + * Does not handle provider blocks, locals, or terraform configuration blocks. + */ +export class TerraformParser implements AnalyzerPlugin { + name = "terraform-parser"; + languages = ["terraform"]; + + analyzeFile(_filePath: string, content: string): StructuralAnalysis { + const resources = this.extractResources(content); + const definitions = this.extractVariablesAndOutputs(content); + return { + functions: [], + classes: [], + imports: [], + exports: [], + resources, + definitions, + }; + } + + private extractResources(content: string): ResourceInfo[] { + const resources: ResourceInfo[] = []; + + // Match resource blocks: resource "type" "name" { + const resourceRegex = /^resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/gm; + let match; + while ((match = resourceRegex.exec(content)) !== null) { + const startLine = content.slice(0, match.index).split("\n").length; + const afterMatch = content.slice(match.index); + const closeBrace = this.findClosingBrace(afterMatch); + const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length; + + resources.push({ + name: `${match[1]}.${match[2]}`, + kind: match[1], + lineRange: [startLine, endLine], + }); + } + + // Match data blocks: data "type" "name" { + const dataRegex = /^data\s+"([^"]+)"\s+"([^"]+)"\s*\{/gm; + while ((match = dataRegex.exec(content)) !== null) { + const startLine = content.slice(0, match.index).split("\n").length; + const afterMatch = content.slice(match.index); + const closeBrace = this.findClosingBrace(afterMatch); + const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length; + + resources.push({ + name: `data.${match[1]}.${match[2]}`, + kind: `data.${match[1]}`, + lineRange: [startLine, endLine], + }); + } + + // Match module blocks: module "name" { + const moduleRegex = /^module\s+"([^"]+)"\s*\{/gm; + while ((match = moduleRegex.exec(content)) !== null) { + const startLine = content.slice(0, match.index).split("\n").length; + const afterMatch = content.slice(match.index); + const closeBrace = this.findClosingBrace(afterMatch); + const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length; + + resources.push({ + name: `module.${match[1]}`, + kind: "module", + lineRange: [startLine, endLine], + }); + } + + return resources; + } + + private extractVariablesAndOutputs(content: string): DefinitionInfo[] { + const definitions: DefinitionInfo[] = []; + + // Match variable blocks + const varRegex = /^variable\s+"([^"]+)"\s*\{/gm; + let match; + while ((match = varRegex.exec(content)) !== null) { + const startLine = content.slice(0, match.index).split("\n").length; + const afterMatch = content.slice(match.index); + const closeBrace = this.findClosingBrace(afterMatch); + const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length; + + definitions.push({ + name: match[1], + kind: "variable", + lineRange: [startLine, endLine], + fields: [], + }); + } + + // Match output blocks + const outputRegex = /^output\s+"([^"]+)"\s*\{/gm; + while ((match = outputRegex.exec(content)) !== null) { + const startLine = content.slice(0, match.index).split("\n").length; + const afterMatch = content.slice(match.index); + const closeBrace = this.findClosingBrace(afterMatch); + const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length; + + definitions.push({ + name: match[1], + kind: "output", + lineRange: [startLine, endLine], + fields: [], + }); + } + + return definitions; + } + + private findClosingBrace(content: string): number { + let depth = 0; + for (let i = 0; i < content.length; i++) { + if (content[i] === "{") depth++; + if (content[i] === "}") { + depth--; + if (depth === 0) return i; + } + } + if (depth !== 0) { + console.warn(`[terraform-parser] Unbalanced braces detected (depth=${depth}), results may be incomplete`); + } + return content.length; + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/toml-parser.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/toml-parser.ts new file mode 100644 index 0000000..b54456b --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/toml-parser.ts @@ -0,0 +1,46 @@ +import type { AnalyzerPlugin, StructuralAnalysis, SectionInfo } from "../../types.js"; + +/** + * Parses TOML files to extract section headers ([section] and [[array-of-tables]]). + * Computes section nesting level from dotted key paths (e.g., [tool.poetry] = level 2). + * Does not parse individual key-value pairs within sections. + */ +export class TOMLParser implements AnalyzerPlugin { + name = "toml-parser"; + languages = ["toml"]; + + analyzeFile(_filePath: string, content: string): StructuralAnalysis { + const sections = this.extractSections(content); + return { + functions: [], + classes: [], + imports: [], + exports: [], + sections, + }; + } + + private extractSections(content: string): SectionInfo[] { + const sections: SectionInfo[] = []; + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + // Match [section] and [[array-of-tables]] headers + const match = lines[i].match(/^\s*\[(\[?)([^\]]+)\]?\]/); + if (match) { + const isArray = match[1] === "["; + const name = match[2].trim(); + sections.push({ + name: isArray ? `[[${name}]]` : name, + level: name.split(".").length, + lineRange: [i + 1, i + 1], + }); + } + } + // Fix lineRange end + for (let i = 0; i < sections.length; i++) { + const next = sections[i + 1]; + sections[i].lineRange[1] = next ? next.lineRange[0] - 1 : lines.length; + } + return sections; + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/parsers/yaml-parser.ts b/understand-anything-plugin/packages/core/src/plugins/parsers/yaml-parser.ts new file mode 100644 index 0000000..bbbaa13 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/parsers/yaml-parser.ts @@ -0,0 +1,72 @@ +import type { AnalyzerPlugin, StructuralAnalysis, SectionInfo } from "../../types.js"; +import { parse as parseYAML } from "yaml"; + +/** + * Parses YAML configuration files to extract top-level key sections. + * Uses the `yaml` library for parsing with a regex fallback for malformed input. + * Only extracts top-level keys; does not descend into nested structures. + */ +export class YAMLConfigParser implements AnalyzerPlugin { + name = "yaml-config-parser"; + languages = ["yaml"]; + + analyzeFile(_filePath: string, content: string): StructuralAnalysis { + const sections = this.extractSections(content); + return { + functions: [], + classes: [], + imports: [], + exports: [], + sections, + }; + } + + private extractSections(content: string): SectionInfo[] { + const sections: SectionInfo[] = []; + try { + const doc = parseYAML(content); + if (doc && typeof doc === "object" && !Array.isArray(doc)) { + const lines = content.split("\n"); + for (const key of Object.keys(doc)) { + // Find the line where this top-level key appears + const lineIdx = lines.findIndex((l) => l.match(new RegExp(`^${this.escapeRegex(key)}\\s*:`))); + if (lineIdx !== -1) { + sections.push({ + name: key, + level: 1, + lineRange: [lineIdx + 1, lineIdx + 1], + }); + } + } + // Fix lineRange end + for (let i = 0; i < sections.length; i++) { + const next = sections[i + 1]; + sections[i].lineRange[1] = next ? next.lineRange[0] - 1 : lines.length; + } + } + } catch (err) { + console.warn(`[yaml-parser] YAML parse failed, falling back to regex extraction: ${err instanceof Error ? err.message : String(err)}`); + // If YAML parsing fails, fall back to regex + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^(\w[\w-]*)\s*:/); + if (match) { + sections.push({ + name: match[1], + level: 1, + lineRange: [i + 1, i + 1], + }); + } + } + for (let i = 0; i < sections.length; i++) { + const next = sections[i + 1]; + sections[i].lineRange[1] = next ? next.lineRange[0] - 1 : lines.length; + } + } + return sections; + } + + private escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/registry.ts b/understand-anything-plugin/packages/core/src/plugins/registry.ts index 71233fe..67261f5 100644 --- a/understand-anything-plugin/packages/core/src/plugins/registry.ts +++ b/understand-anything-plugin/packages/core/src/plugins/registry.ts @@ -61,7 +61,7 @@ export class PluginRegistry { resolveImports(filePath: string, content: string): ImportResolution[] | null { const plugin = this.getPluginForFile(filePath); - if (!plugin) return null; + if (!plugin || !plugin.resolveImports) return null; return plugin.resolveImports(filePath, content); } diff --git a/understand-anything-plugin/packages/core/src/plugins/tree-sitter-plugin.test.ts b/understand-anything-plugin/packages/core/src/plugins/tree-sitter-plugin.test.ts index 00724f5..7595f33 100644 --- a/understand-anything-plugin/packages/core/src/plugins/tree-sitter-plugin.test.ts +++ b/understand-anything-plugin/packages/core/src/plugins/tree-sitter-plugin.test.ts @@ -142,6 +142,31 @@ export default class AppController {} expect(exportNames).toContain("default"); }); + it("should handle export with aliases", () => { + const code = ` +const originalName = () => true; +export { originalName as renamedExport }; +`; + const result = plugin.analyzeFile("test.ts", code); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("renamedExport"); + }); + + it("should handle arrow functions without parameters", () => { + const code = ` +const noParams = () => { return 42; }; +const withReturn = () => "hello"; +`; + const result = plugin.analyzeFile("test.ts", code); + + expect(result.functions).toHaveLength(2); + expect(result.functions[0].name).toBe("noParams"); + expect(result.functions[0].params).toEqual([]); + expect(result.functions[1].name).toBe("withReturn"); + expect(result.functions[1].params).toEqual([]); + }); + it("should extract functions from JavaScript files", () => { const code = ` function hello() { diff --git a/understand-anything-plugin/packages/core/src/schema.ts b/understand-anything-plugin/packages/core/src/schema.ts index c42af9e..5b9ad1a 100644 --- a/understand-anything-plugin/packages/core/src/schema.ts +++ b/understand-anything-plugin/packages/core/src/schema.ts @@ -1,12 +1,14 @@ import { z } from "zod"; -// Edge types (18 values across 5 categories) +// Edge types (26 values across 6 categories) export const EdgeTypeSchema = z.enum([ "imports", "exports", "contains", "inherits", "implements", // Structural "calls", "subscribes", "publishes", "middleware", // Behavioral "reads_from", "writes_to", "transforms", "validates", // Data flow "depends_on", "tested_by", "configures", // Dependencies "related", "similar_to", // Semantic + "deploys", "serves", "provisions", "triggers", // Infrastructure + "migrates", "documents", "routes", "defines_schema", // Schema/Data ]); // Aliases that LLMs commonly generate instead of canonical node types @@ -19,6 +21,35 @@ export const NODE_TYPE_ALIASES: Record = { mod: "module", pkg: "module", package: "module", + // Non-code aliases + container: "service", + deployment: "service", + pod: "service", + doc: "document", + readme: "document", + docs: "document", + workflow: "pipeline", + job: "pipeline", + ci: "pipeline", + action: "pipeline", + route: "endpoint", + api: "endpoint", + query: "endpoint", + mutation: "endpoint", + setting: "config", + env: "config", + configuration: "config", + infra: "resource", + infrastructure: "resource", + terraform: "resource", + migration: "table", + database: "table", + db: "table", + view: "table", + proto: "schema", + protobuf: "schema", + definition: "schema", + typedef: "schema", }; // Aliases that LLMs commonly generate instead of canonical edge types @@ -36,11 +67,253 @@ export const EDGE_TYPE_ALIASES: Record = { contain: "contains", publish: "publishes", subscribe: "subscribes", + // Non-code aliases + describes: "documents", + documented_by: "documents", + creates: "provisions", + exposes: "serves", + listens: "serves", + deploys_to: "deploys", + migrates_to: "migrates", + routes_to: "routes", + triggers_on: "triggers", + fires: "triggers", + defines: "defines_schema", }; +// Aliases for complexity values LLMs commonly generate +export const COMPLEXITY_ALIASES: Record = { + low: "simple", + easy: "simple", + medium: "moderate", + intermediate: "moderate", + high: "complex", + hard: "complex", + difficult: "complex", +}; + +// Aliases for direction values LLMs commonly generate +export const DIRECTION_ALIASES: Record = { + to: "forward", + outbound: "forward", + from: "backward", + inbound: "backward", + both: "bidirectional", + mutual: "bidirectional", +}; + +export function sanitizeGraph(data: Record): Record { + const result = { ...data }; + + // Null → empty array for top-level collections + if (data.tour === null || data.tour === undefined) result.tour = []; + if (data.layers === null || data.layers === undefined) result.layers = []; + + // Sanitize nodes + if (Array.isArray(data.nodes)) { + result.nodes = (data.nodes as Record[]).map((node) => { + if (typeof node !== "object" || node === null) return node; + const n = { ...node }; + // Null → undefined for optional fields + if (n.filePath === null) delete n.filePath; + if (n.lineRange === null) delete n.lineRange; + if (n.languageNotes === null) delete n.languageNotes; + // Lowercase enum-like strings + if (typeof n.type === "string") n.type = n.type.toLowerCase(); + if (typeof n.complexity === "string") n.complexity = n.complexity.toLowerCase(); + return n; + }); + } + + // Sanitize edges + if (Array.isArray(data.edges)) { + result.edges = (data.edges as Record[]).map((edge) => { + if (typeof edge !== "object" || edge === null) return edge; + const e = { ...edge }; + if (e.description === null) delete e.description; + if (typeof e.type === "string") e.type = e.type.toLowerCase(); + if (typeof e.direction === "string") e.direction = e.direction.toLowerCase(); + return e; + }); + } + + // Sanitize tour steps + if (Array.isArray(result.tour)) { + result.tour = (result.tour as Record[]).map((step) => { + if (typeof step !== "object" || step === null) return step; + const s = { ...step }; + if (s.languageLesson === null) delete s.languageLesson; + return s; + }); + } + + return result; +} + +export function autoFixGraph(data: Record): { + data: Record; + issues: GraphIssue[]; +} { + const issues: GraphIssue[] = []; + const result = { ...data }; + + if (Array.isArray(data.nodes)) { + result.nodes = (data.nodes as Record[]).map((node, i) => { + if (typeof node !== "object" || node === null) return node; + const n = { ...node }; + const name = (n.name as string) || (n.id as string) || `index ${i}`; + + // Missing or empty type + if (!n.type || typeof n.type !== "string") { + n.type = "file"; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `nodes[${i}] ("${name}"): missing "type" — defaulted to "file"`, + path: `nodes[${i}].type`, + }); + } + + // Missing or empty complexity + if (!n.complexity || n.complexity === "") { + n.complexity = "moderate"; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `nodes[${i}] ("${name}"): missing "complexity" — defaulted to "moderate"`, + path: `nodes[${i}].complexity`, + }); + } else if (typeof n.complexity === "string" && n.complexity in COMPLEXITY_ALIASES) { + const original = n.complexity; + n.complexity = COMPLEXITY_ALIASES[n.complexity]; + issues.push({ + level: "auto-corrected", + category: "alias", + message: `nodes[${i}] ("${name}"): complexity "${original}" — mapped to "${n.complexity}"`, + path: `nodes[${i}].complexity`, + }); + } + + // Missing tags + if (!Array.isArray(n.tags)) { + n.tags = []; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `nodes[${i}] ("${name}"): missing "tags" — defaulted to []`, + path: `nodes[${i}].tags`, + }); + } + + // Missing summary + if (!n.summary || typeof n.summary !== "string") { + n.summary = (n.name as string) || "No summary"; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `nodes[${i}] ("${name}"): missing "summary" — defaulted to name`, + path: `nodes[${i}].summary`, + }); + } + + return n; + }); + } + + if (Array.isArray(data.edges)) { + result.edges = (data.edges as Record[]).map((edge, i) => { + if (typeof edge !== "object" || edge === null) return edge; + const e = { ...edge }; + + // Missing type + if (!e.type || typeof e.type !== "string") { + e.type = "depends_on"; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `edges[${i}]: missing "type" — defaulted to "depends_on"`, + path: `edges[${i}].type`, + }); + } + + // Missing direction + if (!e.direction || typeof e.direction !== "string") { + e.direction = "forward"; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `edges[${i}]: missing "direction" — defaulted to "forward"`, + path: `edges[${i}].direction`, + }); + } else if (e.direction in DIRECTION_ALIASES) { + const original = e.direction; + e.direction = DIRECTION_ALIASES[e.direction as string]; + issues.push({ + level: "auto-corrected", + category: "alias", + message: `edges[${i}]: direction "${original}" — mapped to "${e.direction}"`, + path: `edges[${i}].direction`, + }); + } + + // Missing weight + if (e.weight === undefined || e.weight === null) { + e.weight = 0.5; + issues.push({ + level: "auto-corrected", + category: "missing-field", + message: `edges[${i}]: missing "weight" — defaulted to 0.5`, + path: `edges[${i}].weight`, + }); + } else if (typeof e.weight === "string") { + const parsed = parseFloat(e.weight as string); + if (!isNaN(parsed)) { + const original = e.weight; + e.weight = parsed; + issues.push({ + level: "auto-corrected", + category: "type-coercion", + message: `edges[${i}]: weight was string "${original}" — coerced to number`, + path: `edges[${i}].weight`, + }); + } else { + const original = e.weight; + e.weight = 0.5; + issues.push({ + level: "auto-corrected", + category: "type-coercion", + message: `edges[${i}]: weight "${original}" is not a valid number — defaulted to 0.5`, + path: `edges[${i}].weight`, + }); + } + } + + // Clamp weight to [0, 1] + if (typeof e.weight === "number" && (e.weight < 0 || e.weight > 1)) { + const original = e.weight; + e.weight = Math.max(0, Math.min(1, e.weight)); + issues.push({ + level: "auto-corrected", + category: "out-of-range", + message: `edges[${i}]: weight ${original} clamped to ${e.weight}`, + path: `edges[${i}].weight`, + }); + } + + return e; + }); + } + + return { data: result, issues }; +} + export const GraphNodeSchema = z.object({ id: z.string(), - type: z.enum(["file", "function", "class", "module", "concept"]), + type: z.enum([ + "file", "function", "class", "module", "concept", + "config", "document", "service", "table", "endpoint", + "pipeline", "schema", "resource", + ]), name: z.string(), filePath: z.string().optional(), lineRange: z.tuple([z.number(), z.number()]).optional(), @@ -92,10 +365,35 @@ export const KnowledgeGraphSchema = z.object({ tour: z.array(TourStepSchema), }); +export interface GraphIssue { + level: "auto-corrected" | "dropped" | "fatal"; + category: string; + message: string; + path?: string; +} + export interface ValidationResult { success: boolean; data?: z.infer; + /** @deprecated Use issues/fatal instead */ errors?: string[]; + issues: GraphIssue[]; + fatal?: string; +} + +function buildInvalidCollectionIssue(name: string): GraphIssue { + return { + level: "fatal", + category: "invalid-collection", + message: `"${name}" must be an array when present`, + path: name, + }; +} + +function buildErrors(issues: GraphIssue[], fatal?: string): string[] | undefined { + const messages = issues.map((issue) => issue.message); + if (fatal && !messages.includes(fatal)) messages.unshift(fatal); + return messages.length > 0 ? messages : undefined; } export function normalizeGraph(data: unknown): unknown { @@ -136,16 +434,167 @@ export function normalizeGraph(data: unknown): unknown { } export function validateGraph(data: unknown): ValidationResult { - const result = KnowledgeGraphSchema.safeParse(normalizeGraph(data)); - - if (result.success) { - return { success: true, data: result.data }; + // Tier 4: Fatal — not even an object + if (typeof data !== "object" || data === null) { + const fatal = "Invalid input: not an object"; + return { success: false, issues: [], fatal, errors: buildErrors([], fatal) }; } - const errors = result.error.issues.map((issue) => { - const path = issue.path.join("."); - return path ? `${path}: ${issue.message}` : issue.message; - }); + const raw = data as Record; - return { success: false, errors }; + // Tier 1: Sanitize + const sanitized = sanitizeGraph(raw); + + // Existing: Normalize type aliases + const normalized = normalizeGraph(sanitized) as Record; + + // Tier 2: Auto-fix defaults and coercion + const { data: fixed, issues } = autoFixGraph(normalized); + + // Tier 4: Fatal — malformed top-level collections + const requiredCollections = ["nodes", "edges", "layers", "tour"] as const; + for (const collection of requiredCollections) { + if (collection in fixed && fixed[collection] !== undefined && !Array.isArray(fixed[collection])) { + const issue = buildInvalidCollectionIssue(collection); + issues.push(issue); + return { + success: false, + errors: buildErrors(issues, issue.message), + issues, + fatal: issue.message, + }; + } + } + + // Tier 4: Fatal — missing project metadata + const projectResult = ProjectMetaSchema.safeParse(fixed.project); + if (!projectResult.success) { + return { + success: false, + errors: buildErrors(issues, "Missing or invalid project metadata"), + issues, + fatal: "Missing or invalid project metadata", + }; + } + + // Tier 3: Validate nodes individually, drop broken + const validNodes: z.infer[] = []; + if (Array.isArray(fixed.nodes)) { + for (let i = 0; i < fixed.nodes.length; i++) { + const node = fixed.nodes[i] as Record; + const result = GraphNodeSchema.safeParse(node); + if (result.success) { + validNodes.push(result.data); + } else { + const name = node?.name || node?.id || `index ${i}`; + issues.push({ + level: "dropped", + category: "invalid-node", + message: `nodes[${i}] ("${name}"): ${result.error.issues[0]?.message ?? "validation failed"} — removed`, + path: `nodes[${i}]`, + }); + } + } + } + + // Tier 4: Fatal — no valid nodes + if (validNodes.length === 0) { + return { + success: false, + errors: buildErrors(issues, "No valid nodes found in knowledge graph"), + issues, + fatal: "No valid nodes found in knowledge graph", + }; + } + + // Tier 3: Validate edges + referential integrity + const nodeIds = new Set(validNodes.map((n) => n.id)); + const validEdges: z.infer[] = []; + if (Array.isArray(fixed.edges)) { + for (let i = 0; i < fixed.edges.length; i++) { + const edge = fixed.edges[i] as Record; + const result = GraphEdgeSchema.safeParse(edge); + if (!result.success) { + issues.push({ + level: "dropped", + category: "invalid-edge", + message: `edges[${i}]: ${result.error.issues[0]?.message ?? "validation failed"} — removed`, + path: `edges[${i}]`, + }); + continue; + } + if (!nodeIds.has(result.data.source)) { + issues.push({ + level: "dropped", + category: "invalid-reference", + message: `edges[${i}]: source "${result.data.source}" does not exist in nodes — removed`, + path: `edges[${i}].source`, + }); + continue; + } + if (!nodeIds.has(result.data.target)) { + issues.push({ + level: "dropped", + category: "invalid-reference", + message: `edges[${i}]: target "${result.data.target}" does not exist in nodes — removed`, + path: `edges[${i}].target`, + }); + continue; + } + validEdges.push(result.data); + } + } + + // Validate layers (drop broken, filter dangling nodeIds) + const validLayers: z.infer[] = []; + if (Array.isArray(fixed.layers)) { + for (let i = 0; i < (fixed.layers as unknown[]).length; i++) { + const result = LayerSchema.safeParse((fixed.layers as unknown[])[i]); + if (result.success) { + validLayers.push({ + ...result.data, + nodeIds: result.data.nodeIds.filter((id) => nodeIds.has(id)), + }); + } else { + issues.push({ + level: "dropped", + category: "invalid-layer", + message: `layers[${i}]: ${result.error.issues[0]?.message ?? "validation failed"} — removed`, + path: `layers[${i}]`, + }); + } + } + } + + // Validate tour steps (drop broken, filter dangling nodeIds) + const validTour: z.infer[] = []; + if (Array.isArray(fixed.tour)) { + for (let i = 0; i < (fixed.tour as unknown[]).length; i++) { + const result = TourStepSchema.safeParse((fixed.tour as unknown[])[i]); + if (result.success) { + validTour.push({ + ...result.data, + nodeIds: result.data.nodeIds.filter((id) => nodeIds.has(id)), + }); + } else { + issues.push({ + level: "dropped", + category: "invalid-tour-step", + message: `tour[${i}]: ${result.error.issues[0]?.message ?? "validation failed"} — removed`, + path: `tour[${i}]`, + }); + } + } + } + + const graph = { + version: typeof fixed.version === "string" ? fixed.version : "1.0.0", + project: projectResult.data, + nodes: validNodes, + edges: validEdges, + layers: validLayers, + tour: validTour, + }; + + return { success: true, data: graph, issues, errors: buildErrors(issues) }; } diff --git a/understand-anything-plugin/packages/core/src/types.test.ts b/understand-anything-plugin/packages/core/src/types.test.ts index e155b8f..2407d8d 100644 --- a/understand-anything-plugin/packages/core/src/types.test.ts +++ b/understand-anything-plugin/packages/core/src/types.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import type { KnowledgeGraph, GraphNode, GraphEdge } from "./types.js"; +import type { KnowledgeGraph, GraphNode, GraphEdge, EdgeType, NodeType, StructuralAnalysis, AnalyzerPlugin, ReferenceResolution } from "./types.js"; describe("KnowledgeGraph types", () => { it("should create a valid empty KnowledgeGraph", () => { @@ -115,3 +115,89 @@ describe("KnowledgeGraph types", () => { expect(maxWeightEdge.weight).toBe(1); }); }); + +describe("Extended types", () => { + it("accepts all 13 node types via NodeType alias", () => { + const nodeTypes: NodeType[] = [ + "file", "function", "class", "module", "concept", + "config", "document", "service", "table", "endpoint", + "pipeline", "schema", "resource", + ]; + expect(nodeTypes).toHaveLength(13); + // NodeType and GraphNode["type"] should be interchangeable + const check: GraphNode["type"] = nodeTypes[0]; + expect(check).toBe("file"); + }); + + it("accepts all 26 edge types", () => { + const edgeTypes: EdgeType[] = [ + "imports", "exports", "contains", "inherits", "implements", + "calls", "subscribes", "publishes", "middleware", + "reads_from", "writes_to", "transforms", "validates", + "depends_on", "tested_by", "configures", + "related", "similar_to", + "deploys", "serves", "migrates", "documents", + "provisions", "routes", "defines_schema", "triggers", + ]; + expect(edgeTypes).toHaveLength(26); + }); + + it("StructuralAnalysis has optional non-code fields", () => { + const analysis: StructuralAnalysis = { + functions: [], classes: [], imports: [], exports: [], + sections: [{ name: "Introduction", level: 1, lineRange: [1, 10] }], + definitions: [{ name: "users", kind: "table", lineRange: [1, 20], fields: ["id", "name"] }], + services: [{ name: "web", image: "node:22", ports: [3000], lineRange: [1, 5] }], + endpoints: [{ method: "GET", path: "/api/users", lineRange: [5, 15] }], + steps: [{ name: "build", lineRange: [1, 5] }], + resources: [{ name: "aws_s3_bucket.main", kind: "aws_s3_bucket", lineRange: [1, 10] }], + }; + expect(analysis.sections).toHaveLength(1); + expect(analysis.definitions).toHaveLength(1); + expect(analysis.services).toHaveLength(1); + expect(analysis.services![0].lineRange).toEqual([1, 5]); + expect(analysis.endpoints).toHaveLength(1); + expect(analysis.steps).toHaveLength(1); + expect(analysis.resources).toHaveLength(1); + }); + + it("ServiceInfo.lineRange is optional for backward compat", () => { + const svcWithout: import("./types.js").ServiceInfo = { name: "web", ports: [3000] }; + const svcWith: import("./types.js").ServiceInfo = { name: "db", ports: [5432], lineRange: [10, 20] }; + expect(svcWithout.lineRange).toBeUndefined(); + expect(svcWith.lineRange).toEqual([10, 20]); + }); + + it("StructuralAnalysis is backward compatible (non-code fields are optional)", () => { + const analysis: StructuralAnalysis = { + functions: [], classes: [], imports: [], exports: [], + }; + expect(analysis.sections).toBeUndefined(); + expect(analysis.definitions).toBeUndefined(); + expect(analysis.services).toBeUndefined(); + }); + + it("AnalyzerPlugin allows optional resolveImports", () => { + const plugin: AnalyzerPlugin = { + name: "test-plugin", + languages: ["markdown"], + analyzeFile: () => ({ functions: [], classes: [], imports: [], exports: [] }), + // resolveImports is optional — not provided + }; + expect(plugin.resolveImports).toBeUndefined(); + expect(plugin.analyzeFile).toBeDefined(); + }); + + it("AnalyzerPlugin supports extractReferences", () => { + const refs: ReferenceResolution[] = [ + { source: "README.md", target: "./docs/guide.md", referenceType: "file", line: 5 }, + ]; + const plugin: AnalyzerPlugin = { + name: "test-plugin", + languages: ["markdown"], + analyzeFile: () => ({ functions: [], classes: [], imports: [], exports: [] }), + extractReferences: () => refs, + }; + expect(plugin.extractReferences!("README.md", "")).toEqual(refs); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/types.ts b/understand-anything-plugin/packages/core/src/types.ts index f5fef33..ab92cd7 100644 --- a/understand-anything-plugin/packages/core/src/types.ts +++ b/understand-anything-plugin/packages/core/src/types.ts @@ -1,15 +1,23 @@ -// Edge types (18 total in 5 categories: Structural, Behavioral, Data flow, Dependencies, Semantic) +// Node types (13 total: 5 code + 8 non-code) +export type NodeType = + | "file" | "function" | "class" | "module" | "concept" + | "config" | "document" | "service" | "table" | "endpoint" + | "pipeline" | "schema" | "resource"; + +// Edge types (26 total in 6 categories: Structural, Behavioral, Data flow, Dependencies, Semantic, Infrastructure/Schema) export type EdgeType = | "imports" | "exports" | "contains" | "inherits" | "implements" // Structural | "calls" | "subscribes" | "publishes" | "middleware" // Behavioral | "reads_from" | "writes_to" | "transforms" | "validates" // Data flow | "depends_on" | "tested_by" | "configures" // Dependencies - | "related" | "similar_to"; // Semantic + | "related" | "similar_to" // Semantic + | "deploys" | "serves" | "provisions" | "triggers" // Infrastructure + | "migrates" | "documents" | "routes" | "defines_schema"; // Schema/Data -// GraphNode with 5 types: file, function, class, module, concept +// GraphNode with 13 types: 5 code + 8 non-code export interface GraphNode { id: string; - type: "file" | "function" | "class" | "module" | "concept"; + type: NodeType; name: string; filePath?: string; lineRange?: [number, number]; @@ -66,12 +74,70 @@ export interface KnowledgeGraph { tour: TourStep[]; } +// Theme configuration (for dashboard customization) +export interface ThemeConfig { + presetId: string; + accentId: string; +} + // AnalysisMeta (for persistence) export interface AnalysisMeta { lastAnalyzedAt: string; gitCommitHash: string; version: string; analyzedFiles: number; + theme?: ThemeConfig; +} + +// Project config (for auto-update opt-in) +export interface ProjectConfig { + autoUpdate: boolean; +} + +// Non-code structural sub-interfaces +export interface SectionInfo { + name: string; + level: number; + lineRange: [number, number]; +} + +export interface DefinitionInfo { + name: string; + /** Parser-reported definition kind. Known values: "table", "view", "index", "message", "enum", "type", "input", "interface", "union", "scalar", "variable", "output", "resource", "data", "section", "target", "stage" */ + kind: string; + lineRange: [number, number]; + fields: string[]; +} + +export interface ServiceInfo { + name: string; + image?: string; + ports: number[]; + lineRange?: [number, number]; +} + +export interface EndpointInfo { + method?: string; + path: string; + lineRange: [number, number]; +} + +export interface StepInfo { + name: string; + lineRange: [number, number]; +} + +export interface ResourceInfo { + name: string; + kind: string; + lineRange: [number, number]; +} + +export interface ReferenceResolution { + source: string; + target: string; + referenceType: string; // "file", "image", "schema", "service" + line?: number; } // Plugin interfaces @@ -80,6 +146,13 @@ export interface StructuralAnalysis { classes: Array<{ name: string; lineRange: [number, number]; methods: string[]; properties: string[] }>; imports: Array<{ source: string; specifiers: string[]; lineNumber: number }>; exports: Array<{ name: string; lineNumber: number }>; + // Non-code structural data (all optional for backward compat) + sections?: SectionInfo[]; + definitions?: DefinitionInfo[]; + services?: ServiceInfo[]; + endpoints?: EndpointInfo[]; + steps?: StepInfo[]; + resources?: ResourceInfo[]; } export interface ImportResolution { @@ -98,6 +171,7 @@ export interface AnalyzerPlugin { name: string; languages: string[]; analyzeFile(filePath: string, content: string): StructuralAnalysis; - resolveImports(filePath: string, content: string): ImportResolution[]; + resolveImports?(filePath: string, content: string): ImportResolution[]; extractCallGraph?(filePath: string, content: string): CallGraphEntry[]; + extractReferences?(filePath: string, content: string): ReferenceResolution[]; } diff --git a/understand-anything-plugin/packages/dashboard/src/App.tsx b/understand-anything-plugin/packages/dashboard/src/App.tsx index 4f541f8..a17dbf5 100644 --- a/understand-anything-plugin/packages/dashboard/src/App.tsx +++ b/understand-anything-plugin/packages/dashboard/src/App.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState, useMemo, useCallback } from "react"; import { validateGraph } from "@understand-anything/core/schema"; +import type { GraphIssue } from "@understand-anything/core/schema"; import { useDashboardStore } from "./store"; import GraphView from "./components/GraphView"; import CodeViewer from "./components/CodeViewer"; @@ -14,10 +15,58 @@ import LearnPanel from "./components/LearnPanel"; import PersonaSelector from "./components/PersonaSelector"; import ProjectOverview from "./components/ProjectOverview"; import KeyboardShortcutsHelp from "./components/KeyboardShortcutsHelp"; +import WarningBanner from "./components/WarningBanner"; +import TokenGate from "./components/TokenGate"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import type { KeyboardShortcut } from "./hooks/useKeyboardShortcuts"; +import { ThemeProvider } from "./themes/index.ts"; +import { ThemePicker } from "./components/ThemePicker.tsx"; +import type { ThemeConfig } from "./themes/index.ts"; + +const SESSION_TOKEN_KEY = "understand-anything-token"; + +/** + * Resolve the access token from the URL query string or sessionStorage. + * If found in the URL, persist to sessionStorage and strip the param from the address bar. + */ +function resolveInitialToken(): string | null { + const params = new URLSearchParams(window.location.search); + const urlToken = params.get("token"); + if (urlToken) { + sessionStorage.setItem(SESSION_TOKEN_KEY, urlToken); + // Clean the URL + params.delete("token"); + const cleanSearch = params.toString(); + const newUrl = + window.location.pathname + (cleanSearch ? `?${cleanSearch}` : "") + window.location.hash; + window.history.replaceState(null, "", newUrl); + return urlToken; + } + return sessionStorage.getItem(SESSION_TOKEN_KEY); +} + +/** Build a URL with the token query param appended. */ +function tokenUrl(path: string, token: string | null): string { + return token ? `${path}?token=${encodeURIComponent(token)}` : path; +} function App() { + const [accessToken, setAccessToken] = useState(resolveInitialToken); + + const handleTokenValid = useCallback((token: string) => { + sessionStorage.setItem(SESSION_TOKEN_KEY, token); + setAccessToken(token); + }, []); + + // Show the token gate when no token is available + if (accessToken === null) { + return ; + } + + return ; +} + +function Dashboard({ accessToken }: { accessToken: string }) { const graph = useDashboardStore((s) => s.graph); const setGraph = useDashboardStore((s) => s.setGraph); const selectedNodeId = useDashboardStore((s) => s.selectedNodeId); @@ -28,8 +77,21 @@ function App() { const setDiffOverlay = useDashboardStore((s) => s.setDiffOverlay); const pathFinderOpen = useDashboardStore((s) => s.pathFinderOpen); const togglePathFinder = useDashboardStore((s) => s.togglePathFinder); + const nodeTypeFilters = useDashboardStore((s) => s.nodeTypeFilters); + const toggleNodeTypeFilter = useDashboardStore((s) => s.toggleNodeTypeFilter); const [loadError, setLoadError] = useState(null); + const [graphIssues, setGraphIssues] = useState([]); const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); + const [metaTheme, setMetaTheme] = useState(null); + + useEffect(() => { + fetch(tokenUrl("/meta.json", accessToken)) + .then((r) => (r.ok ? r.json() : null)) + .then((meta) => { + if (meta?.theme) setMetaTheme(meta.theme); + }) + .catch(() => {}); + }, []); // Define keyboard shortcuts const shortcuts = useMemo( @@ -45,7 +107,7 @@ function App() { // Navigation { key: "Escape", - description: "Close panels and modals", + description: "Close panels and modals / go back to overview", action: () => { // Read from store at invocation time to avoid stale closures const state = useDashboardStore.getState(); @@ -59,6 +121,8 @@ function App() { state.closeCodeViewer(); } else if (state.selectedNodeId) { state.selectNode(null); + } else if (state.navigationLevel === "layer-detail") { + state.navigateToOverview(); } else if (state.tourActive) { state.stopTour(); } else { @@ -102,15 +166,6 @@ function App() { category: "Tour", }, // View toggles - { - key: "l", - description: "Toggle layer visualization", - action: () => { - const state = useDashboardStore.getState(); - state.toggleLayers(); - }, - category: "View", - }, { key: "d", description: "Toggle diff mode", @@ -155,16 +210,26 @@ function App() { useKeyboardShortcuts(shortcuts); useEffect(() => { - fetch("/knowledge-graph.json") + fetch(tokenUrl("/knowledge-graph.json", accessToken)) .then((res) => res.json()) .then((data: unknown) => { const result = validateGraph(data); if (result.success && result.data) { setGraph(result.data); + setGraphIssues(result.issues); + for (const issue of result.issues) { + if (issue.level === "auto-corrected") { + console.warn(`[graph] auto-corrected: ${issue.message}`); + } else if (issue.level === "dropped") { + console.error(`[graph] dropped: ${issue.message}`); + } + } + } else if (result.fatal) { + console.error("Knowledge graph validation failed:", result.fatal); + setLoadError(`Invalid knowledge graph: ${result.fatal}`); } else { - const errorMsg = result.errors?.join("; ") ?? "Unknown validation error"; - console.error("Knowledge graph validation failed:", errorMsg); - setLoadError(`Invalid knowledge graph: ${errorMsg}`); + console.error("Knowledge graph validation failed: unknown error"); + setLoadError("Invalid knowledge graph: unknown validation error"); } }) .catch((err) => { @@ -174,7 +239,7 @@ function App() { }, [setGraph]); useEffect(() => { - fetch("/diff-overlay.json") + fetch(tokenUrl("/diff-overlay.json", accessToken)) .then((res) => { if (!res.ok) return null; return res.json(); @@ -200,16 +265,19 @@ function App() { }, [setDiffOverlay]); // Determine sidebar content - // Learn persona always shows LearnPanel; tour active overrides everything - const sidebarContent = tourActive || persona === "junior" ? ( - - ) : selectedNodeId ? ( - - ) : ( - + // NodeInfo always takes priority when a node is selected. + // Learn mode adds LearnPanel below it; otherwise ProjectOverview shows when idle. + const isLearnMode = tourActive || persona === "junior"; + const sidebarContent = ( + <> + {selectedNodeId && } + {isLearnMode && } + {!selectedNodeId && !isLearnMode && } + ); return ( +
{/* Header */}
@@ -222,6 +290,35 @@ function App() {
+
+ {([ + { key: "code", label: "Code", color: "var(--color-node-file)" }, + { key: "config", label: "Config", color: "var(--color-node-config)" }, + { key: "docs", label: "Docs", color: "var(--color-node-document)" }, + { key: "infra", label: "Infra", color: "var(--color-node-service)" }, + { key: "data", label: "Data", color: "var(--color-node-table)" }, + ] as const).map((cat) => ( + + ))} +
@@ -245,9 +342,10 @@ function App() { Path +
+
); } diff --git a/understand-anything-plugin/packages/dashboard/src/components/Breadcrumb.tsx b/understand-anything-plugin/packages/dashboard/src/components/Breadcrumb.tsx new file mode 100644 index 0000000..9bbb47b --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/Breadcrumb.tsx @@ -0,0 +1,38 @@ +import { useDashboardStore } from "../store"; + +export default function Breadcrumb() { + const navigationLevel = useDashboardStore((s) => s.navigationLevel); + const activeLayerId = useDashboardStore((s) => s.activeLayerId); + const graph = useDashboardStore((s) => s.graph); + const navigateToOverview = useDashboardStore((s) => s.navigateToOverview); + + const activeLayer = graph?.layers.find((l) => l.id === activeLayerId); + + return ( +
+ {navigationLevel === "overview" && ( +
+ Project Overview +
+ )} + + {navigationLevel === "layer-detail" && ( +
+ + + + {activeLayer?.name ?? "Layer"} + + + (Esc to go back) + +
+ )} +
+ ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/components/CodeViewer.tsx b/understand-anything-plugin/packages/dashboard/src/components/CodeViewer.tsx index d1df7c3..64fe249 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/CodeViewer.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/CodeViewer.tsx @@ -27,8 +27,8 @@ export default function CodeViewer() { className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded border" style={{ color: "var(--color-node-file)", - borderColor: "rgba(74,124,155,0.3)", - backgroundColor: "rgba(74,124,155,0.1)", + borderColor: "color-mix(in srgb, var(--color-node-file) 30%, transparent)", + backgroundColor: "color-mix(in srgb, var(--color-node-file) 10%, transparent)", }} > {node.type} @@ -56,14 +56,14 @@ export default function CodeViewer() {
{/* Summary */}
-

Summary

+

Summary

{node.summary}

{/* Language notes callout */} {node.languageNotes && ( -
-

Language Notes

+
+

Language Notes

{node.languageNotes}

)} @@ -71,7 +71,7 @@ export default function CodeViewer() { {/* Tags */} {node.tags.length > 0 && (
-

Tags

+

Tags

{node.tags.map((tag) => ( diff --git a/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx index 14b1a83..1cae970 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx @@ -1,26 +1,44 @@ import { memo } from "react"; import { Handle, Position } from "@xyflow/react"; import type { NodeProps, Node } from "@xyflow/react"; +import type { NodeType } from "@understand-anything/core/types"; -const typeColors: Record = { +// Color maps keyed by NodeType — must be kept in sync with core NodeType union. +const typeColors: Record = { file: "var(--color-node-file)", function: "var(--color-node-function)", class: "var(--color-node-class)", module: "var(--color-node-module)", concept: "var(--color-node-concept)", + config: "var(--color-node-config)", + document: "var(--color-node-document)", + service: "var(--color-node-service)", + table: "var(--color-node-table)", + endpoint: "var(--color-node-endpoint)", + pipeline: "var(--color-node-pipeline)", + schema: "var(--color-node-schema)", + resource: "var(--color-node-resource)", }; -const typeTextColors: Record = { +const typeTextColors: Record = { file: "text-node-file", function: "text-node-function", class: "text-node-class", module: "text-node-module", concept: "text-node-concept", + config: "text-node-config", + document: "text-node-document", + service: "text-node-service", + table: "text-node-table", + endpoint: "text-node-endpoint", + pipeline: "text-node-pipeline", + schema: "text-node-schema", + resource: "text-node-resource", }; const complexityColors: Record = { simple: "text-node-function", - moderate: "text-gold-dim", + moderate: "text-accent-dim", complex: "text-[#c97070]", }; @@ -36,6 +54,8 @@ export interface CustomNodeData extends Record { isDiffChanged: boolean; isDiffAffected: boolean; isDiffFaded: boolean; + isNeighbor: boolean; + isSelectionFaded: boolean; onNodeClick?: (nodeId: string) => void; incomingCount?: number; outgoingCount?: number; @@ -48,23 +68,28 @@ function CustomNodeComponent({ id, data, }: NodeProps) { - const barColor = typeColors[data.nodeType] ?? typeColors.file; - const textColor = typeTextColors[data.nodeType] ?? typeTextColors.file; + const knownType = data.nodeType as NodeType; + const barColor = typeColors[knownType] ?? typeColors.file; + const textColor = typeTextColors[knownType] ?? typeTextColors.file; const complexityColor = complexityColors[data.complexity] ?? complexityColors.simple; + if (import.meta.env.DEV && !(knownType in typeColors)) { + console.warn(`[CustomNode] Unknown node type "${data.nodeType}" — using "file" colors`); + } + let extraClass = ""; if (data.isSelected) { - extraClass = "ring-2 ring-gold node-glow"; + extraClass = "ring-2 ring-accent node-glow"; } else if (data.isTourHighlighted) { - extraClass = "ring-2 ring-gold-dim animate-gold-pulse"; + extraClass = "ring-2 ring-accent-dim animate-accent-pulse"; } else if (data.isHighlighted) { const score = data.searchScore ?? 1; if (score <= 0.1) { - extraClass = "ring-2 ring-gold-bright"; + extraClass = "ring-2 ring-accent-bright"; } else if (score <= 0.3) { - extraClass = "ring-2 ring-gold"; + extraClass = "ring-2 ring-accent"; } else { - extraClass = "ring-1 ring-gold-dim/60"; + extraClass = "ring-1 ring-accent-dim/60"; } } @@ -77,6 +102,13 @@ function CustomNodeComponent({ extraClass += " diff-faded"; } + // Selection-based dimming (when another node is selected, fade unrelated nodes) + if (data.isSelectionFaded) { + extraClass += " opacity-20 pointer-events-auto"; + } else if (data.isNeighbor) { + extraClass += " ring-1 ring-gold-dim/50"; + } + const name = data.label ?? "unnamed"; const truncatedName = name.length > 24 ? name.slice(0, 22) + "..." : name; diff --git a/understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx b/understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx index adc7ce0..4b15fdd 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx @@ -3,6 +3,10 @@ import { useDashboardStore } from "../store"; import type { KnowledgeGraph } from "@understand-anything/core/types"; import { filterNodes, filterEdges } from "../utils/filters"; +function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -97,23 +101,32 @@ export default function ExportMenu() { // Create an image and draw to canvas const img = new Image(); + img.onerror = () => { + URL.revokeObjectURL(url); + console.error("PNG export failed: Image failed to load from SVG blob"); + alert("Failed to export PNG: could not render graph as image. Try SVG export instead."); + }; img.onload = () => { const canvas = document.createElement("canvas"); canvas.width = width * 2; canvas.height = height * 2; const ctx = canvas.getContext("2d"); if (!ctx) { + URL.revokeObjectURL(url); alert("Failed to create canvas context"); return; } ctx.drawImage(img, 0, 0); URL.revokeObjectURL(url); + const filename = `${graph?.project.name ?? "knowledge-graph"}-export.png`; canvas.toBlob((blob) => { if (blob) { - const filename = `${graph?.project.name ?? "knowledge-graph"}-export.png`; downloadBlob(blob, filename); toggleExportMenu(); + } else { + console.error("PNG export failed: canvas.toBlob returned null (canvas may be tainted)"); + alert("Failed to export PNG: image encoding failed. Try SVG export instead."); } }, "image/png"); }; @@ -186,7 +199,7 @@ export default function ExportMenu() { const h = node.height ?? 80; svgContent += ``; - svgContent += `${node.data.label ?? node.id}`; + svgContent += `${escapeXml(String(node.data.label ?? node.id))}`; }); svgContent += ``; diff --git a/understand-anything-plugin/packages/dashboard/src/components/FilterPanel.tsx b/understand-anything-plugin/packages/dashboard/src/components/FilterPanel.tsx index e4f5c23..fe5df09 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/FilterPanel.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/FilterPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { useDashboardStore } from "../store"; +import { useDashboardStore, ALL_NODE_TYPES, ALL_COMPLEXITIES, ALL_EDGE_CATEGORIES } from "../store"; import type { NodeType, Complexity, EdgeCategory } from "../store"; export default function FilterPanel() { @@ -13,9 +13,9 @@ export default function FilterPanel() { const containerRef = useRef(null); - const allNodeTypes: NodeType[] = ["file", "function", "class", "module", "concept"]; - const allComplexities: Complexity[] = ["simple", "moderate", "complex"]; - const allEdgeCategories: EdgeCategory[] = ["structural", "behavioral", "data-flow", "dependencies", "semantic"]; + const allNodeTypes = ALL_NODE_TYPES; + const allComplexities = ALL_COMPLEXITIES; + const allEdgeCategories = ALL_EDGE_CATEGORIES; const layers = graph?.layers ?? []; // Close dropdown on outside click @@ -193,7 +193,7 @@ export default function FilterPanel() { className="w-3.5 h-3.5 rounded border-border-subtle bg-elevated checked:bg-gold checked:border-gold focus:ring-0 focus:ring-offset-0 cursor-pointer" /> - {category.replace("-", " ")} + {category.replace(/-/g, " ")} ))} diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index bc4780e..7e61e51 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { ReactFlow, ReactFlowProvider, @@ -15,26 +15,52 @@ import "@xyflow/react/dist/style.css"; import CustomNode from "./CustomNode"; import type { CustomFlowNode } from "./CustomNode"; -import NodeTooltip from "./NodeTooltip"; +import LayerClusterNode from "./LayerClusterNode"; +import type { LayerClusterFlowNode } from "./LayerClusterNode"; +import PortalNode from "./PortalNode"; +import type { PortalFlowNode } from "./PortalNode"; +import Breadcrumb from "./Breadcrumb"; import { useDashboardStore } from "../store"; -import type { FilterState } from "../store"; -import { applyDagreLayout, applyDagreLayoutAsync, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout"; -import { filterNodes, filterEdges } from "../utils/filters"; +import type { KnowledgeGraph, NodeType } from "@understand-anything/core/types"; +import { useTheme } from "../themes/index.ts"; +import { + applyDagreLayout, + NODE_WIDTH, + NODE_HEIGHT, + LAYER_CLUSTER_WIDTH, + LAYER_CLUSTER_HEIGHT, + PORTAL_NODE_WIDTH, + PORTAL_NODE_HEIGHT, +} from "../utils/layout"; +import { + aggregateLayerEdges, + computePortals, + findCrossLayerFileNodes, +} from "../utils/edgeAggregation"; -const LAYER_PADDING = 40; +const nodeTypes = { + custom: CustomNode, + "layer-cluster": LayerClusterNode, + portal: PortalNode, +}; + +import type { NodeCategory } from "../store"; /** - * Node count above which layout runs in a Web Worker - * to avoid blocking the main thread. + * Maps each NodeType to a filter category. Must be kept in sync with core NodeType. + * Unknown types default to "code" with a development warning. */ -const ASYNC_LAYOUT_THRESHOLD = 200; +const NODE_TYPE_TO_CATEGORY: Record = { + file: "code", function: "code", class: "code", module: "code", concept: "code", + config: "config", + document: "docs", + service: "infra", resource: "infra", pipeline: "infra", + table: "data", endpoint: "data", schema: "data", +} as const; -const nodeTypes = { custom: CustomNode }; +// ── Helper components that must live inside ──────────────── -/** - * Inner component that pans/zooms to tour-highlighted nodes. - * Must be rendered inside so useReactFlow() works. - */ +/** Pans/zooms to tour-highlighted nodes. */ function TourFitView() { const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds); const { fitView } = useReactFlow(); @@ -49,7 +75,6 @@ function TourFitView() { prevRef.current = tourHighlightedNodeIds; if (changed) { - // Small delay to ensure nodes are rendered before fitting requestAnimationFrame(() => { fitView({ nodes: tourHighlightedNodeIds.map((id) => ({ id })), @@ -65,10 +90,7 @@ function TourFitView() { return null; } -/** - * Centers the graph on the selected node (e.g. from search). - * Must be rendered inside so useReactFlow() works. - */ +/** Centers the graph on the selected node (e.g. from search). */ function SelectedNodeFitView() { const selectedNodeId = useDashboardStore((s) => s.selectedNodeId); const { fitView } = useReactFlow(); @@ -92,345 +114,419 @@ function SelectedNodeFitView() { return null; } -/** - * Build topology-only flow data: nodes and edges without visual-only state - * (selection, tour highlights, search results). This output drives dagre - * layout and should only recompute when the graph structure changes. - */ -function buildTopologyData( - graph: NonNullable["graph"]>, - persona: string, - diffMode: boolean, - changedNodeIds: Set, - affectedNodeIds: Set, - handleNodeSelect: (nodeId: string) => void, - filters: FilterState, -) { - // Step 1: Apply persona filtering - let filteredGraphNodes = - persona === "non-technical" - ? graph.nodes.filter( - (n) => - n.type === "concept" || n.type === "module" || n.type === "file", - ) - : graph.nodes; +// ── Overview level: layers as cluster nodes ──────────────────────────── - // Step 2: Apply filter panel filters - filteredGraphNodes = filterNodes(filteredGraphNodes, graph.layers ?? [], filters); +function useOverviewGraph() { + const graph = useDashboardStore((s) => s.graph); + const searchResults = useDashboardStore((s) => s.searchResults); + const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer); - const filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id)); + return useMemo(() => { + if (!graph) return { nodes: [] as Node[], edges: [] as Edge[] }; - // Step 3: Filter edges based on visible nodes and edge categories - let filteredGraphEdges = graph.edges.filter( - (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target), - ); - filteredGraphEdges = filterEdges(filteredGraphEdges, filteredNodeIds, filters); + const layers = graph.layers ?? []; + if (layers.length === 0) return { nodes: [] as Node[], edges: [] as Edge[] }; - // Compute connection counts for each node - const incomingCounts = new Map(); - const outgoingCounts = new Map(); - for (const edge of filteredGraphEdges) { - outgoingCounts.set(edge.source, (outgoingCounts.get(edge.source) ?? 0) + 1); - incomingCounts.set(edge.target, (incomingCounts.get(edge.target) ?? 0) + 1); - } - - const flowNodes: CustomFlowNode[] = filteredGraphNodes.map((node) => ({ - id: node.id, - type: "custom" as const, - position: { x: 0, y: 0 }, - data: { - label: node.name ?? node.filePath?.split("/").pop() ?? node.id, - nodeType: node.type, - summary: node.summary, - complexity: node.complexity, - isHighlighted: false, - searchScore: undefined, - isSelected: false, - isTourHighlighted: false, - isDiffChanged: diffMode && changedNodeIds.has(node.id), - isDiffAffected: diffMode && affectedNodeIds.has(node.id), - isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id), - onNodeClick: handleNodeSelect, - incomingCount: incomingCounts.get(node.id) ?? 0, - outgoingCount: outgoingCounts.get(node.id) ?? 0, - tags: node.tags ?? [], - }, - })); - - const diffNodeIds = diffMode ? new Set([...changedNodeIds, ...affectedNodeIds]) : new Set(); - const flowEdges: Edge[] = filteredGraphEdges.map((edge, i) => { - const sourceInDiff = diffMode && diffNodeIds.has(edge.source); - const targetInDiff = diffMode && diffNodeIds.has(edge.target); - const isImpacted = diffMode && (sourceInDiff || targetInDiff); - - return { - id: `e-${i}`, - source: edge.source, - target: edge.target, - label: edge.type, - animated: edge.type === "calls" || isImpacted, - style: isImpacted - ? { - stroke: sourceInDiff && targetInDiff - ? "rgba(224, 82, 82, 0.7)" - : "rgba(212, 160, 48, 0.5)", - strokeWidth: 2.5, - } - : diffMode - ? { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 } - : { stroke: "rgba(212,165,116,0.3)", strokeWidth: 1.5 }, - labelStyle: diffMode && !isImpacted - ? { fill: "rgba(163,151,135,0.3)", fontSize: 10 } - : { fill: "#a39787", fontSize: 10 }, - }; - }); - - return { flowNodes, flowEdges }; -} - -/** - * Lightweight overlay of visual-only state onto already-positioned nodes. - * This is O(n) object spreads — cheap even for thousands of nodes — and - * avoids triggering a dagre relayout when selection/highlight/search changes. - */ -function applyVisualState( - nodes: (CustomFlowNode | Node)[], - selectedNodeId: string | null, - tourHighlightedNodeIds: string[], - searchResults: Array<{ nodeId: string; score: number }>, -): (CustomFlowNode | Node)[] { - const searchMap = new Map(searchResults.map((r) => [r.nodeId, r.score])); - const tourSet = new Set(tourHighlightedNodeIds); - - return nodes.map((node) => { - // Skip group nodes (layer containers) — they have no CustomNodeData - if (node.type === "group") return node; - - const searchScore = searchMap.get(node.id); - const isHighlighted = searchScore !== undefined; - const isSelected = selectedNodeId === node.id; - const isTourHighlighted = tourSet.has(node.id); - - const data = node.data as CustomFlowNode["data"]; - - // Skip creating a new object if nothing visual changed - if ( - data.isHighlighted === isHighlighted && - data.searchScore === searchScore && - data.isSelected === isSelected && - data.isTourHighlighted === isTourHighlighted - ) { - return node; + // Build search match counts per layer + const searchMatchByLayer = new Map(); + if (searchResults.length > 0) { + const nodeToLayer = new Map(); + for (const layer of layers) { + for (const nid of layer.nodeIds) { + nodeToLayer.set(nid, layer.id); + } + } + for (const result of searchResults) { + const lid = nodeToLayer.get(result.nodeId); + if (lid) { + searchMatchByLayer.set(lid, (searchMatchByLayer.get(lid) ?? 0) + 1); + } + } } - return { - ...node, - data: { - ...data, - isHighlighted, - searchScore, - isSelected, - isTourHighlighted, - }, - }; - }); -} + // Create cluster nodes + const clusterNodes: LayerClusterFlowNode[] = layers.map((layer, i) => { + const memberNodes = graph.nodes.filter((n) => layer.nodeIds.includes(n.id)); + const complexCounts = { simple: 0, moderate: 0, complex: 0 }; + for (const n of memberNodes) { + complexCounts[n.complexity]++; + } + const aggregateComplexity = + complexCounts.complex > memberNodes.length * 0.3 + ? "complex" + : complexCounts.moderate > memberNodes.length * 0.3 + ? "moderate" + : "simple"; -function applyLayerGroups( - laidNodes: CustomFlowNode[], - edges: Edge[], - layers: Array<{ id: string; name: string; nodeIds: string[] }>, - showLayers: boolean, -): { initialNodes: (CustomFlowNode | Node)[]; initialEdges: Edge[] } { - if (!showLayers || layers.length === 0) { - return { initialNodes: laidNodes, initialEdges: edges }; - } - - const nodeToLayer = new Map(); - for (const layer of layers) { - for (const nodeId of layer.nodeIds) { - nodeToLayer.set(nodeId, layer.id); - } - } - - const groupNodes: Node[] = []; - const adjustedNodes: (CustomFlowNode | Node)[] = []; - - for (const layer of layers) { - const memberNodes = laidNodes.filter((n) => layer.nodeIds.includes(n.id)); - if (memberNodes.length === 0) continue; - - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const node of memberNodes) { - minX = Math.min(minX, node.position.x); - minY = Math.min(minY, node.position.y); - maxX = Math.max(maxX, node.position.x + NODE_WIDTH); - maxY = Math.max(maxY, node.position.y + NODE_HEIGHT); - } - - const groupX = minX - LAYER_PADDING; - const groupY = minY - LAYER_PADDING - 24; - const groupWidth = maxX - minX + LAYER_PADDING * 2; - const groupHeight = maxY - minY + LAYER_PADDING * 2 + 24; - - groupNodes.push({ - id: layer.id, - type: "group", - position: { x: groupX, y: groupY }, - data: { label: layer.name }, - style: { - width: groupWidth, - height: groupHeight, - backgroundColor: "rgba(212,165,116,0.05)", - borderRadius: 12, - border: "2px dashed rgba(212,165,116,0.25)", - padding: 8, - fontSize: 13, - fontWeight: 600, - color: "#d4a574", - }, + return { + id: layer.id, + type: "layer-cluster" as const, + position: { x: 0, y: 0 }, + data: { + layerId: layer.id, + layerName: layer.name, + layerDescription: layer.description, + fileCount: layer.nodeIds.length, + aggregateComplexity, + layerColorIndex: i, + searchMatchCount: searchMatchByLayer.get(layer.id), + onDrillIn: drillIntoLayer, + }, + }; }); - for (const node of memberNodes) { - adjustedNodes.push({ - ...node, - parentId: layer.id, - extent: "parent" as const, - position: { - x: node.position.x - groupX, - y: node.position.y - groupY, - }, - }); - } - } + // Aggregate edges between layers + const aggregated = aggregateLayerEdges(graph); + const flowEdges: Edge[] = aggregated.map((agg, i) => ({ + id: `le-${i}`, + source: agg.sourceLayerId, + target: agg.targetLayerId, + label: `${agg.count}`, + style: { + stroke: "rgba(212,165,116,0.4)", + strokeWidth: Math.min(1 + Math.log2(agg.count + 1), 5), + }, + labelStyle: { fill: "#a39787", fontSize: 11, fontWeight: 600 }, + })); - for (const node of laidNodes) { - if (!nodeToLayer.has(node.id)) { - adjustedNodes.push(node); + const dims = new Map(); + for (const n of clusterNodes) { + dims.set(n.id, { width: LAYER_CLUSTER_WIDTH, height: LAYER_CLUSTER_HEIGHT }); } - } - - return { - initialNodes: [...groupNodes, ...adjustedNodes], - initialEdges: edges, - }; + const laid = applyDagreLayout(clusterNodes as unknown as Node[], flowEdges, "TB", dims); + return { nodes: laid.nodes, edges: laid.edges }; + }, [graph, searchResults, drillIntoLayer]); } -function GraphViewInner() { +// ── Layer detail level: topology (dagre) + visual overlay ─────────────── + +/** + * Topology memo: computes node positions via dagre. Only recomputes when + * the graph structure, active layer, persona, diff, or focus changes. + * Does NOT depend on selectedNodeId, searchResults, or tourHighlightedNodeIds. + */ +function useLayerDetailTopology() { const graph = useDashboardStore((s) => s.graph); - const selectedNodeId = useDashboardStore((s) => s.selectedNodeId); - const searchResults = useDashboardStore((s) => s.searchResults); + const activeLayerId = useDashboardStore((s) => s.activeLayerId); const selectNode = useDashboardStore((s) => s.selectNode); - const openCodeViewer = useDashboardStore((s) => s.openCodeViewer); - const showLayers = useDashboardStore((s) => s.showLayers); - const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds); const persona = useDashboardStore((s) => s.persona); const diffMode = useDashboardStore((s) => s.diffMode); const changedNodeIds = useDashboardStore((s) => s.changedNodeIds); const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds); - const filters = useDashboardStore((s) => s.filters); - const setReactFlowInstance = useDashboardStore((s) => s.setReactFlowInstance); - - const [layouting, setLayouting] = useState(false); + const focusNodeId = useDashboardStore((s) => s.focusNodeId); + const nodeTypeFilters = useDashboardStore((s) => s.nodeTypeFilters); + const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer); const handleNodeSelect = useCallback( (nodeId: string) => { selectNode(nodeId); - openCodeViewer(nodeId); }, - [selectNode, openCodeViewer], + [selectNode], ); - // ── Topology memo: only recomputes when graph structure changes ── - // Does NOT depend on selectedNodeId, tourHighlightedNodeIds, or searchResults. - const { topoNodes, topoEdges, needsAsyncLayout } = useMemo(() => { - if (!graph) { - return { topoNodes: [] as CustomFlowNode[], topoEdges: [] as Edge[], needsAsyncLayout: false }; - } - const { flowNodes, flowEdges } = buildTopologyData( - graph, persona, diffMode, changedNodeIds, affectedNodeIds, - handleNodeSelect, filters, - ); - return { topoNodes: flowNodes, topoEdges: flowEdges, needsAsyncLayout: flowNodes.length > ASYNC_LAYOUT_THRESHOLD }; - }, [graph, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds, filters]); + return useMemo(() => { + if (!graph || !activeLayerId) + return { nodes: [] as CustomFlowNode[], edges: [] as Edge[], portalNodes: [] as PortalFlowNode[], portalEdges: [] as Edge[], filteredEdges: [] as KnowledgeGraph["edges"] }; - // ── Laid-out nodes from the last completed layout pass ── - // Stored in a ref so layout results persist across visual-state changes. - const laidOutRef = useRef<{ initialNodes: (CustomFlowNode | Node)[]; initialEdges: Edge[] } | null>(null); + const activeLayer = graph.layers.find((l) => l.id === activeLayerId); + if (!activeLayer) return { nodes: [] as CustomFlowNode[], edges: [] as Edge[], portalNodes: [] as PortalFlowNode[], portalEdges: [] as Edge[], filteredEdges: [] as KnowledgeGraph["edges"] }; - // ── Sync layout: for small graphs, run dagre on the main thread ── - const syncResult = useMemo(() => { - if (!graph || needsAsyncLayout || topoNodes.length === 0) return null; - const laid = applyDagreLayout(topoNodes, topoEdges); - const layers = graph.layers ?? []; - return applyLayerGroups(laid.nodes as CustomFlowNode[], laid.edges, layers, showLayers); - }, [graph, topoNodes, topoEdges, needsAsyncLayout, showLayers]); + const layerNodeIds = new Set(activeLayer.nodeIds); - // Keep laidOutRef in sync with sync layout results - if (syncResult) { - laidOutRef.current = syncResult; - } + // All top-level (file-level) node types that should appear in the graph. + // This includes the 8 new non-code types plus the original "file" type. + const fileLevelTypes = new Set([ + "file", "config", "document", "service", "table", + "endpoint", "pipeline", "schema", "resource", + ]); - // ── Visual memo: cheap overlay of selection/highlight/search state ── - const visualNodes = useMemo(() => { - const base = laidOutRef.current; - if (!base) return [] as (CustomFlowNode | Node)[]; - return applyVisualState(base.initialNodes, selectedNodeId, tourHighlightedNodeIds, searchResults); - }, [laidOutRef.current, selectedNodeId, tourHighlightedNodeIds, searchResults]); + // Non-technical persona: show module, concept, and file-level types (hide function/class) + // Junior/experienced persona: show everything including function/class + let filteredGraphNodes = persona === "non-technical" + ? graph.nodes.filter( + (n) => layerNodeIds.has(n.id) && (n.type === "concept" || n.type === "module" || fileLevelTypes.has(n.type)), + ) + : graph.nodes.filter((n) => layerNodeIds.has(n.id) && (fileLevelTypes.has(n.type) || n.type === "module" || n.type === "concept" || n.type === "function" || n.type === "class")); - const [nodes, setNodes, onNodesChange] = useNodesState(visualNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(laidOutRef.current?.initialEdges ?? []); - - // ── Push sync layout + visual state to ReactFlow ── - useEffect(() => { - if (syncResult) { - const withVisual = applyVisualState(syncResult.initialNodes, selectedNodeId, tourHighlightedNodeIds, searchResults); - setNodes(withVisual); - setEdges(syncResult.initialEdges); - } - }, [syncResult, selectedNodeId, tourHighlightedNodeIds, searchResults, setNodes, setEdges]); - - // ── Push visual-only changes (no relayout) ── - useEffect(() => { - if (laidOutRef.current && !layouting) { - const withVisual = applyVisualState(laidOutRef.current.initialNodes, selectedNodeId, tourHighlightedNodeIds, searchResults); - setNodes(withVisual); - } - }, [selectedNodeId, tourHighlightedNodeIds, searchResults, setNodes, layouting]); - - // ── Async layout: for large graphs, run dagre in a Web Worker ── - useEffect(() => { - if (!graph || !needsAsyncLayout || topoNodes.length === 0) return; - - let cancelled = false; - setLayouting(true); - - applyDagreLayoutAsync(topoNodes, topoEdges).then((laid) => { - if (cancelled) return; - const layers = graph.layers ?? []; - const result = applyLayerGroups(laid.nodes as CustomFlowNode[], laid.edges, layers, showLayers); - laidOutRef.current = result; - const withVisual = applyVisualState(result.initialNodes, selectedNodeId, tourHighlightedNodeIds, searchResults); - setNodes(withVisual); - setEdges(result.initialEdges); - setLayouting(false); - }).catch(() => { - if (cancelled) return; - setLayouting(false); + // Apply node type category filters + filteredGraphNodes = filteredGraphNodes.filter((n) => { + const category = NODE_TYPE_TO_CATEGORY[n.type as NodeType]; + if (!category) { + if (import.meta.env.DEV) { + console.warn(`[GraphView] Unknown node type "${n.type}" — defaulting to "code" category`); + } + } + const effectiveCategory = category ?? "code"; + return nodeTypeFilters[effectiveCategory] !== false; }); - return () => { cancelled = true; setLayouting(false); }; - }, [graph, topoNodes, topoEdges, needsAsyncLayout, showLayers, setNodes, setEdges]); + let filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id)); + + let filteredGraphEdges = graph.edges.filter( + (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target), + ); + + // Focus mode: 1-hop neighborhood within the layer + if (focusNodeId && filteredNodeIds.has(focusNodeId)) { + const focusNeighborIds = new Set([focusNodeId]); + for (const edge of filteredGraphEdges) { + if (edge.source === focusNodeId) focusNeighborIds.add(edge.target); + if (edge.target === focusNodeId) focusNeighborIds.add(edge.source); + } + filteredGraphNodes = filteredGraphNodes.filter((n) => + focusNeighborIds.has(n.id), + ); + filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id)); + filteredGraphEdges = filteredGraphEdges.filter( + (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target), + ); + } + + const diffNodeIds = diffMode + ? new Set([...changedNodeIds, ...affectedNodeIds]) + : new Set(); + + const flowNodes: CustomFlowNode[] = filteredGraphNodes.map((node) => ({ + id: node.id, + type: "custom" as const, + position: { x: 0, y: 0 }, + data: { + label: node.name ?? node.filePath?.split("/").pop() ?? node.id, + nodeType: node.type, + summary: node.summary, + complexity: node.complexity, + isHighlighted: false, + searchScore: undefined, + isSelected: false, + isTourHighlighted: false, + isDiffChanged: diffMode && changedNodeIds.has(node.id), + isDiffAffected: diffMode && affectedNodeIds.has(node.id), + isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id), + isNeighbor: false, + isSelectionFaded: false, + onNodeClick: handleNodeSelect, + }, + })); + + const flowEdges: Edge[] = filteredGraphEdges.map((edge, i) => { + const sourceInDiff = diffMode && diffNodeIds.has(edge.source); + const targetInDiff = diffMode && diffNodeIds.has(edge.target); + const isImpacted = diffMode && (sourceInDiff || targetInDiff); + + let edgeStyle: React.CSSProperties; + let edgeLabelStyle: React.CSSProperties; + let edgeAnimated: boolean; + + if (isImpacted) { + edgeStyle = { + stroke: sourceInDiff && targetInDiff ? "rgba(224, 82, 82, 0.7)" : "rgba(212, 160, 48, 0.5)", + strokeWidth: 2.5, + }; + edgeLabelStyle = { fill: "#a39787", fontSize: 10 }; + edgeAnimated = true; + } else if (diffMode) { + edgeStyle = { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }; + edgeLabelStyle = { fill: "rgba(163,151,135,0.3)", fontSize: 10 }; + edgeAnimated = false; + } else { + edgeStyle = { stroke: "rgba(212,165,116,0.3)", strokeWidth: 1.5 }; + edgeLabelStyle = { fill: "#a39787", fontSize: 10 }; + edgeAnimated = edge.type === "calls"; + } + + return { + id: `e-${i}`, + source: edge.source, + target: edge.target, + label: edge.type, + animated: edgeAnimated, + style: edgeStyle, + labelStyle: edgeLabelStyle, + }; + }); + + // Portal nodes for connected external layers + const portals = computePortals(graph, activeLayerId); + const layerIndexMap = new Map(graph.layers.map((l, i) => [l.id, i])); + + const portalNodes: PortalFlowNode[] = portals.map((portal) => ({ + id: `portal:${portal.layerId}`, + type: "portal" as const, + position: { x: 0, y: 0 }, + data: { + targetLayerId: portal.layerId, + targetLayerName: portal.layerName, + connectionCount: portal.connectionCount, + layerColorIndex: layerIndexMap.get(portal.layerId) ?? 0, + onNavigate: drillIntoLayer, + }, + })); + + const portalEdges: Edge[] = []; + let portalEdgeIdx = flowEdges.length; + for (const portal of portals) { + const crossFiles = findCrossLayerFileNodes(graph, activeLayerId, portal.layerId); + for (const fileId of crossFiles) { + if (filteredNodeIds.has(fileId)) { + portalEdges.push({ + id: `e-${portalEdgeIdx++}`, + source: fileId, + target: `portal:${portal.layerId}`, + style: { stroke: "rgba(212,165,116,0.2)", strokeWidth: 1, strokeDasharray: "4 4" }, + animated: false, + }); + } + } + } + + const allFlowNodes: Node[] = [ + ...(flowNodes as unknown as Node[]), + ...(portalNodes as unknown as Node[]), + ]; + const allFlowEdges = [...flowEdges, ...portalEdges]; + + const dims = new Map(); + for (const n of flowNodes) { + dims.set(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + } + for (const n of portalNodes) { + dims.set(n.id, { width: PORTAL_NODE_WIDTH, height: PORTAL_NODE_HEIGHT }); + } + + const laid = applyDagreLayout(allFlowNodes, allFlowEdges, "TB", dims); + return { nodes: laid.nodes, edges: laid.edges, portalNodes, portalEdges, filteredEdges: filteredGraphEdges }; + }, [graph, activeLayerId, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds, focusNodeId, nodeTypeFilters, drillIntoLayer]); +} + +/** + * Visual overlay: cheap O(n) pass that applies selection, search, and tour + * state onto already-positioned nodes. Avoids triggering dagre relayout. + */ +function useLayerDetailGraph() { + const selectedNodeId = useDashboardStore((s) => s.selectedNodeId); + const searchResults = useDashboardStore((s) => s.searchResults); + const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds); + + const topo = useLayerDetailTopology(); + + const nodes = useMemo(() => { + const searchMap = new Map(searchResults.map((r) => [r.nodeId, r.score])); + const tourSet = new Set(tourHighlightedNodeIds); + + // Build neighbor set for selection highlighting + const neighborNodeIds = new Set(); + if (selectedNodeId) { + for (const edge of topo.filteredEdges) { + if (edge.source === selectedNodeId) neighborNodeIds.add(edge.target); + if (edge.target === selectedNodeId) neighborNodeIds.add(edge.source); + } + neighborNodeIds.add(selectedNodeId); + } + + return topo.nodes.map((node) => { + // Skip portal nodes — they have no CustomNodeData + if (node.type === "portal") return node; + + const searchScore = searchMap.get(node.id); + const isHighlighted = searchScore !== undefined; + const isSelected = selectedNodeId === node.id; + const isTourHighlighted = tourSet.has(node.id); + const hasSelection = !!selectedNodeId; + const isNeighbor = hasSelection && neighborNodeIds.has(node.id) && !isSelected; + const isSelectionFaded = hasSelection && !neighborNodeIds.has(node.id); + + const data = node.data as CustomFlowNode["data"]; + + // Skip creating a new object if nothing visual changed + if ( + data.isHighlighted === isHighlighted && + data.searchScore === searchScore && + data.isSelected === isSelected && + data.isTourHighlighted === isTourHighlighted && + data.isNeighbor === isNeighbor && + data.isSelectionFaded === isSelectionFaded + ) { + return node; + } + + return { ...node, data: { ...data, isHighlighted, searchScore, isSelected, isTourHighlighted, isNeighbor, isSelectionFaded } }; + }); + }, [topo.nodes, topo.filteredEdges, selectedNodeId, searchResults, tourHighlightedNodeIds]); + + const edges = useMemo(() => { + if (!selectedNodeId) return topo.edges; + + // Apply selection-based edge styling on top of topology edges + return topo.edges.map((edge) => { + const isSelectedEdge = edge.source === selectedNodeId || edge.target === selectedNodeId; + // Don't restyle diff-impacted or portal edges + if ((edge.style as Record)?.strokeDasharray) return edge; + + if (isSelectedEdge) { + return { ...edge, animated: true, style: { stroke: "rgba(212,165,116,0.8)", strokeWidth: 2.5 }, labelStyle: { fill: "#d4a574", fontSize: 11, fontWeight: 600 } }; + } + // Fade unrelated edges + return { ...edge, animated: false, style: { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }, labelStyle: { fill: "rgba(163,151,135,0.2)", fontSize: 10 } }; + }); + }, [topo.edges, selectedNodeId]); + + return { nodes, edges }; +} + +// ── Main inner component (must be inside ReactFlowProvider) ──────────── + +function GraphViewInner() { + const graph = useDashboardStore((s) => s.graph); + const navigationLevel = useDashboardStore((s) => s.navigationLevel); + const activeLayerId = useDashboardStore((s) => s.activeLayerId); + const selectNode = useDashboardStore((s) => s.selectNode); + const openCodeViewer = useDashboardStore((s) => s.openCodeViewer); + const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer); + const focusNodeId = useDashboardStore((s) => s.focusNodeId); + const setFocusNode = useDashboardStore((s) => s.setFocusNode); + const setReactFlowInstance = useDashboardStore((s) => s.setReactFlowInstance); + const { preset } = useTheme(); + + const overviewGraph = useOverviewGraph(); + const detailGraph = useLayerDetailGraph(); + + const { nodes: initialNodes, edges: initialEdges } = + navigationLevel === "overview" ? overviewGraph : detailGraph; + + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + const { fitView } = useReactFlow(); + + useEffect(() => { + setNodes(initialNodes); + }, [initialNodes, setNodes]); + + useEffect(() => { + setEdges(initialEdges); + }, [initialEdges, setEdges]); + + // Fit view on level/layer transitions + useEffect(() => { + const timer = setTimeout(() => { + fitView({ duration: 400, padding: 0.2 }); + }, 50); + return () => clearTimeout(timer); + }, [navigationLevel, activeLayerId, fitView]); const onNodeClick = useCallback( (_: React.MouseEvent, node: { id: string }) => { - // Ignore clicks on group nodes - const isGroupNode = graph?.layers?.some((l) => l.id === node.id); - if (isGroupNode) return; - selectNode(node.id); - openCodeViewer(node.id); + if (navigationLevel === "overview") { + drillIntoLayer(node.id); + } else if (node.id.startsWith("portal:")) { + const targetLayerId = node.id.replace("portal:", ""); + drillIntoLayer(targetLayerId); + } else { + selectNode(node.id); + openCodeViewer(node.id); + } }, - [selectNode, openCodeViewer, graph], + [navigationLevel, drillIntoLayer, selectNode, openCodeViewer], ); const onPaneClick = useCallback(() => { @@ -447,14 +543,16 @@ function GraphViewInner() { return (
- {layouting && ( -
-
-
-

- Laying out {topoNodes.length.toLocaleString()} nodes... -

-
+ + {focusNodeId && navigationLevel === "layer-detail" && ( +
+
)} - + - - {/* Node tooltips */} - {nodes - .filter((n) => n.type === "custom") - .map((node) => { - const data = node.data as CustomFlowNode["data"]; - return ( - - ); - })}
); } diff --git a/understand-anything-plugin/packages/dashboard/src/components/KeyboardShortcutsHelp.tsx b/understand-anything-plugin/packages/dashboard/src/components/KeyboardShortcutsHelp.tsx index 22178f5..c9fcd1f 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/KeyboardShortcutsHelp.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/KeyboardShortcutsHelp.tsx @@ -62,7 +62,7 @@ export default function KeyboardShortcutsHelp({
{Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => (
-

+

{category}

diff --git a/understand-anything-plugin/packages/dashboard/src/components/LayerClusterNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/LayerClusterNode.tsx new file mode 100644 index 0000000..c63cad9 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/LayerClusterNode.tsx @@ -0,0 +1,104 @@ +import { memo } from "react"; +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps, Node } from "@xyflow/react"; +import { getLayerColor } from "./LayerLegend"; + +const complexityColors: Record = { + simple: "text-node-function", + moderate: "text-gold-dim", + complex: "text-[#c97070]", +}; + +export interface LayerClusterData extends Record { + layerId: string; + layerName: string; + layerDescription: string; + fileCount: number; + aggregateComplexity: string; + layerColorIndex: number; + searchMatchCount?: number; + onDrillIn: (layerId: string) => void; +} + +export type LayerClusterFlowNode = Node; + +function LayerClusterNode({ + data, +}: NodeProps) { + const color = getLayerColor(data.layerColorIndex); + const complexityColor = + complexityColors[data.aggregateComplexity] ?? complexityColors.simple; + + return ( +
data.onDrillIn(data.layerId)} + > + {/* Left color bar */} +
+ + + +
+ {/* Header row */} +
+ + Layer + +
+ {data.searchMatchCount != null && data.searchMatchCount > 0 && ( + + {data.searchMatchCount} match{data.searchMatchCount !== 1 ? "es" : ""} + + )} + + {data.aggregateComplexity} + +
+
+ + {/* Layer name */} +
+ {data.layerName} +
+ + {/* Description */} +
+ {data.layerDescription} +
+ + {/* Footer */} +
+ + {data.fileCount} file{data.fileCount !== 1 ? "s" : ""} + + + Click to explore → + +
+
+ + +
+ ); +} + +export default memo(LayerClusterNode); diff --git a/understand-anything-plugin/packages/dashboard/src/components/LayerLegend.tsx b/understand-anything-plugin/packages/dashboard/src/components/LayerLegend.tsx index 6d4fc24..90d12a1 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/LayerLegend.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/LayerLegend.tsx @@ -1,86 +1,70 @@ import { useDashboardStore } from "../store"; -const LAYER_COLORS = [ - "rgba(59, 130, 246, 0.08)", // blue - "rgba(16, 185, 129, 0.08)", // green - "rgba(245, 158, 11, 0.08)", // amber - "rgba(139, 92, 246, 0.08)", // violet - "rgba(236, 72, 153, 0.08)", // pink - "rgba(6, 182, 212, 0.08)", // cyan - "rgba(249, 115, 22, 0.08)", // orange - "rgba(168, 162, 158, 0.08)", // stone +// Shared layer color palette — used by LayerLegend, LayerClusterNode, PortalNode, and GraphView +export const LAYER_PALETTE = [ + { bg: "rgba(74, 124, 155, 0.12)", border: "rgba(74, 124, 155, 0.4)", label: "#4a7c9b" }, // blue (API) + { bg: "rgba(90, 158, 111, 0.12)", border: "rgba(90, 158, 111, 0.4)", label: "#5a9e6f" }, // green (Data) + { bg: "rgba(139, 111, 176, 0.12)", border: "rgba(139, 111, 176, 0.4)", label: "#8b6fb0" }, // purple (Service) + { bg: "rgba(201, 160, 108, 0.12)", border: "rgba(201, 160, 108, 0.4)", label: "#c9a06c" }, // gold (Config) + { bg: "rgba(176, 122, 138, 0.12)", border: "rgba(176, 122, 138, 0.4)", label: "#b07a8a" }, // pink (UI) + { bg: "rgba(74, 155, 140, 0.12)", border: "rgba(74, 155, 140, 0.4)", label: "#4a9b8c" }, // teal (Middleware) + { bg: "rgba(120, 130, 145, 0.12)", border: "rgba(120, 130, 145, 0.4)", label: "#788291" }, // slate (Test) ]; -export const LAYER_BORDER_COLORS = [ - "rgba(59, 130, 246, 0.5)", // blue - "rgba(16, 185, 129, 0.5)", // green - "rgba(245, 158, 11, 0.5)", // amber - "rgba(139, 92, 246, 0.5)", // violet - "rgba(236, 72, 153, 0.5)", // pink - "rgba(6, 182, 212, 0.5)", // cyan - "rgba(249, 115, 22, 0.5)", // orange - "rgba(168, 162, 158, 0.5)", // stone -]; - -export { LAYER_COLORS }; - -export function getLayerColor(index: number): string { - return LAYER_COLORS[index % LAYER_COLORS.length]; -} - -export function getLayerBorderColor(index: number): string { - return LAYER_BORDER_COLORS[index % LAYER_BORDER_COLORS.length]; +export function getLayerColor(index: number) { + return LAYER_PALETTE[index % LAYER_PALETTE.length]; } export default function LayerLegend() { const graph = useDashboardStore((s) => s.graph); - const showLayers = useDashboardStore((s) => s.showLayers); - const toggleLayers = useDashboardStore((s) => s.toggleLayers); + const navigationLevel = useDashboardStore((s) => s.navigationLevel); + const activeLayerId = useDashboardStore((s) => s.activeLayerId); const layers = graph?.layers ?? []; const hasLayers = layers.length > 0; + if (!hasLayers) return null; + + const activeLayer = layers.find((l) => l.id === activeLayerId); + return (
- + + {navigationLevel === "overview" + ? `${layers.length} layers` + : activeLayer?.name ?? "Layer"} + - {showLayers && hasLayers && ( -
- {layers.map((layer, i) => ( +
+ {layers.map((layer, i) => { + const color = getLayerColor(i); + const isActive = navigationLevel === "layer-detail" && layer.id === activeLayerId; + return (
- + {layer.name} ({layer.nodeIds.length})
- ))} -
- )} + ); + })} +
); } diff --git a/understand-anything-plugin/packages/dashboard/src/components/LearnPanel.tsx b/understand-anything-plugin/packages/dashboard/src/components/LearnPanel.tsx index 2534fd4..7e4344b 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/LearnPanel.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/LearnPanel.tsx @@ -47,13 +47,13 @@ export default function LearnPanel() {
-

+

Steps

{tourSteps.map((step, i) => ( @@ -61,7 +61,7 @@ export default function LearnPanel() { key={step.order} className="flex items-start gap-2 text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle" > - + {i + 1}. {step.title} @@ -86,7 +86,7 @@ export default function LearnPanel() { {/* Header with progress counter and exit */}
-

+

Tour

@@ -104,7 +104,7 @@ export default function LearnPanel() { {/* Progress bar */}
@@ -122,16 +122,16 @@ export default function LearnPanel() {

{children}

), strong: ({ children }) => ( - {children} + {children} ), code: ({ className, children }) => { const isBlock = className?.includes("language-"); return isBlock ? ( - + {children} ) : ( - + {children} ); @@ -154,8 +154,8 @@ export default function LearnPanel() { {/* Language lesson */} {step.languageLesson && ( -
-

+
+

Language Lesson

@@ -167,7 +167,7 @@ export default function LearnPanel() { {/* Referenced component pills */} {step.nodeIds.length > 0 && (

-

+

Referenced Components

@@ -198,7 +198,7 @@ export default function LearnPanel() { onClick={() => setTourStep(i)} className={`w-2 h-2 rounded-full transition-colors ${ i === currentTourStep - ? "bg-gold" + ? "bg-accent" : "bg-elevated hover:bg-surface" }`} aria-label={`Go to step ${i + 1}`} @@ -217,7 +217,7 @@ export default function LearnPanel() { diff --git a/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx b/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx index 78c1041..6bee4b4 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx @@ -1,27 +1,96 @@ import { useState } from "react"; import { useDashboardStore } from "../store"; +import type { NodeType, EdgeType } from "@understand-anything/core/types"; -const typeBadgeColors: Record = { +// Badge color classes keyed by NodeType — must be kept in sync with core NodeType union. +const typeBadgeColors: Record = { file: "text-node-file border border-node-file/30 bg-node-file/10", function: "text-node-function border border-node-function/30 bg-node-function/10", class: "text-node-class border border-node-class/30 bg-node-class/10", module: "text-node-module border border-node-module/30 bg-node-module/10", concept: "text-node-concept border border-node-concept/30 bg-node-concept/10", + config: "text-node-config border border-node-config/30 bg-node-config/10", + document: "text-node-document border border-node-document/30 bg-node-document/10", + service: "text-node-service border border-node-service/30 bg-node-service/10", + table: "text-node-table border border-node-table/30 bg-node-table/10", + endpoint: "text-node-endpoint border border-node-endpoint/30 bg-node-endpoint/10", + pipeline: "text-node-pipeline border border-node-pipeline/30 bg-node-pipeline/10", + schema: "text-node-schema border border-node-schema/30 bg-node-schema/10", + resource: "text-node-resource border border-node-resource/30 bg-node-resource/10", }; const complexityBadgeColors: Record = { simple: "text-node-function border border-node-function/30 bg-node-function/10", - moderate: "text-gold-dim border border-gold-dim/30 bg-gold-dim/10", + moderate: "text-accent-dim border border-accent-dim/30 bg-accent-dim/10", complex: "text-[#c97070] border border-[#c97070]/30 bg-[#c97070]/10", }; +/** + * Human-readable directional labels for all 26 edge types. + * Must be kept in sync with core EdgeType. + */ +const EDGE_LABELS: Record = { + imports: { forward: "imports", backward: "imported by" }, + exports: { forward: "exports to", backward: "exported by" }, + contains: { forward: "contains", backward: "contained in" }, + inherits: { forward: "inherits from", backward: "inherited by" }, + implements: { forward: "implements", backward: "implemented by" }, + calls: { forward: "calls", backward: "called by" }, + subscribes: { forward: "subscribes to", backward: "subscribed by" }, + publishes: { forward: "publishes to", backward: "consumed by" }, + middleware: { forward: "middleware for", backward: "uses middleware" }, + reads_from: { forward: "reads from", backward: "read by" }, + writes_to: { forward: "writes to", backward: "written by" }, + transforms: { forward: "transforms", backward: "transformed by" }, + validates: { forward: "validates", backward: "validated by" }, + depends_on: { forward: "depends on", backward: "depended on by" }, + tested_by: { forward: "tested by", backward: "tests" }, + configures: { forward: "configures", backward: "configured by" }, + related: { forward: "related to", backward: "related to" }, + similar_to: { forward: "similar to", backward: "similar to" }, + deploys: { forward: "deploys", backward: "deployed by" }, + serves: { forward: "serves", backward: "served by" }, + migrates: { forward: "migrates", backward: "migrated by" }, + documents: { forward: "documents", backward: "documented by" }, + provisions: { forward: "provisions", backward: "provisioned by" }, + routes: { forward: "routes to", backward: "routed from" }, + defines_schema: { forward: "defines schema for", backward: "schema defined by" }, + triggers: { forward: "triggers", backward: "triggered by" }, +}; + +/** + * Returns a human-readable directional label for an edge type. + * Falls back to formatted type name for unknown edge types. + */ +function getDirectionalLabel(edgeType: string, isSource: boolean): string { + const labels = (EDGE_LABELS as Record)[edgeType]; + if (!labels) { + // Fallback for unknown edge types + const formatted = edgeType.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + return isSource ? formatted : `${formatted} (reverse)`; + } + return isSource ? labels.forward : labels.backward; +} + export default function NodeInfo() { const graph = useDashboardStore((s) => s.graph); const selectedNodeId = useDashboardStore((s) => s.selectedNodeId); + const nodeHistory = useDashboardStore((s) => s.nodeHistory); + const goBackNode = useDashboardStore((s) => s.goBackNode); const [languageExpanded, setLanguageExpanded] = useState(true); + const navigateToNode = useDashboardStore((s) => s.navigateToNode); + const navigateToHistoryIndex = useDashboardStore((s) => s.navigateToHistoryIndex); + const setFocusNode = useDashboardStore((s) => s.setFocusNode); + const focusNodeId = useDashboardStore((s) => s.focusNodeId); const node = graph?.nodes.find((n) => n.id === selectedNodeId) ?? null; + // Resolve history node names for the breadcrumb trail + const historyNodes = nodeHistory.map((id) => { + const n = graph?.nodes.find((gn) => gn.id === id); + return { id, name: n?.name ?? id }; + }); + if (!node) { return (
@@ -30,16 +99,70 @@ export default function NodeInfo() { ); } - const connections = (graph?.edges ?? []).filter( + const allEdges = graph?.edges ?? []; + const connections = allEdges.filter( (e) => e.source === node.id || e.target === node.id, ); - const typeBadge = typeBadgeColors[node.type] ?? typeBadgeColors.file; + // Separate child nodes (contained IN this file) from other connections + const childEdges = connections.filter( + (e) => e.type === "contains" && e.source === node.id, + ); + const otherConnections = connections.filter( + (e) => !(e.type === "contains" && e.source === node.id), + ); + + // Resolve child nodes + const childNodes = childEdges + .map((e) => graph?.nodes.find((n) => n.id === e.target)) + .filter(Boolean); + + const knownType = node.type as NodeType; + const typeBadge = typeBadgeColors[knownType] ?? typeBadgeColors.file; const complexityBadge = complexityBadgeColors[node.complexity] ?? complexityBadgeColors.simple; + if (import.meta.env.DEV && !(knownType in typeBadgeColors)) { + console.warn(`[NodeInfo] Unknown node type "${node.type}" — using "file" badge colors`); + } + return (
+ {/* Navigation history trail */} + {historyNodes.length > 0 && ( +
+ + + {historyNodes.slice(-3).map((h, i, arr) => ( + + + {i < arr.length - 1 && ( + + )} + + ))} + + + {node.name} + +
+ )} +
-

{node.name}

+
+

{node.name}

+ +

{node.summary} @@ -75,7 +210,7 @@ export default function NodeInfo() {

diff --git a/understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx b/understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx index 8a8f245..abc5eae 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx @@ -14,7 +14,7 @@ export default function SearchBar() { const searchResults = useDashboardStore((s) => s.searchResults); const graph = useDashboardStore((s) => s.graph); const setSearchQuery = useDashboardStore((s) => s.setSearchQuery); - const selectNode = useDashboardStore((s) => s.selectNode); + const navigateToNodeInLayer = useDashboardStore((s) => s.navigateToNodeInLayer); const searchMode = useDashboardStore((s) => s.searchMode); const setSearchMode = useDashboardStore((s) => s.setSearchMode); @@ -40,10 +40,10 @@ export default function SearchBar() { const handleResultClick = useCallback( (nodeId: string) => { - selectNode(nodeId); + navigateToNodeInLayer(nodeId); setDropdownOpen(false); }, - [selectNode], + [navigateToNodeInLayer], ); // Close dropdown on Escape @@ -72,7 +72,7 @@ export default function SearchBar() { const showDropdown = dropdownOpen && searchQuery.trim() && topResults.length > 0; return ( -
+
setDropdownOpen(true)} placeholder="Search nodes by name, summary, or tags..." - className="flex-1 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-gold/50 placeholder-text-muted" + className="flex-1 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50 placeholder-text-muted" />
+ + {open && ( +
+ {/* Presets */} +
+
+ Theme +
+
+ {PRESETS.map((p) => ( + + ))} +
+
+ + {/* Accent swatches */} +
+
+ Accent Color +
+
+ {preset.accentSwatches.map((swatch) => ( +
+
+
+ )} +
+ ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/components/TokenGate.tsx b/understand-anything-plugin/packages/dashboard/src/components/TokenGate.tsx new file mode 100644 index 0000000..5d74d67 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/TokenGate.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; + +interface TokenGateProps { + onTokenValid: (token: string) => void; +} + +export default function TokenGate({ onTokenValid }: TokenGateProps) { + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const token = input.trim(); + if (!token) return; + + setLoading(true); + setError(null); + + try { + const res = await fetch(`/knowledge-graph.json?token=${encodeURIComponent(token)}`); + if (res.ok) { + onTokenValid(token); + } else if (res.status === 403) { + setError("Invalid token. Please check and try again."); + } else { + setError(`Unexpected response (${res.status}). Is the dashboard server running?`); + } + } catch (err) { + setError( + `Could not reach the server: ${err instanceof Error ? err.message : String(err)}` + ); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Heading */} +

+ Access Token Required +

+

+ Paste the access token from your terminal. Look for the{" "} + 🔑 line. +

+ + {/* Form */} +
+ { + setInput(e.target.value); + if (error) setError(null); + }} + placeholder="Paste token here..." + autoFocus + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded text-text-primary placeholder:text-text-muted/50 font-mono text-sm focus:outline-none focus:border-accent transition-colors" + /> + + {error && ( +

{error}

+ )} + + +
+
+
+ ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/components/WarningBanner.tsx b/understand-anything-plugin/packages/dashboard/src/components/WarningBanner.tsx new file mode 100644 index 0000000..753a2c5 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/WarningBanner.tsx @@ -0,0 +1,193 @@ +import { useState, useCallback } from "react"; +import type { GraphIssue } from "@understand-anything/core/schema"; + +interface WarningBannerProps { + issues: GraphIssue[]; +} + +function buildCopyText(issues: GraphIssue[]): string { + const lines = [ + "The following issues were found in your knowledge-graph.json.", + "These are LLM generation errors — not a system bug.", + "You can ask your agent to fix these specific issues in the knowledge-graph.json file:", + "", + ]; + + // Auto-corrected first, then dropped + const sorted = [...issues].sort((a, b) => { + const order: Record = { "auto-corrected": 0, dropped: 1, fatal: 2 }; + return (order[a.level] ?? 2) - (order[b.level] ?? 2); + }); + + for (const issue of sorted) { + const label = + issue.level === "auto-corrected" + ? "Auto-corrected" + : issue.level === "dropped" + ? "Dropped" + : "Fatal"; + lines.push(`[${label}] ${issue.message}`); + } + + return lines.join("\n"); +} + +export default function WarningBanner({ issues }: WarningBannerProps) { + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + + const autoCorrected = issues.filter((i) => i.level === "auto-corrected"); + const dropped = issues.filter((i) => i.level === "dropped"); + + // Build summary text — only mention counts > 0 + const parts: string[] = []; + if (autoCorrected.length > 0) { + parts.push(`${autoCorrected.length} auto-correction${autoCorrected.length !== 1 ? "s" : ""}`); + } + if (dropped.length > 0) { + parts.push(`${dropped.length} dropped item${dropped.length !== 1 ? "s" : ""}`); + } + const summary = `Knowledge graph loaded with ${parts.join(" and ")}`; + + const handleCopy = useCallback(async () => { + const text = buildCopyText(issues); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + console.warn("Clipboard write failed — copy text manually from the expanded issue list"); + } + }, [issues]); + + if (issues.length === 0) return null; + + return ( +
+ {/* Collapsed summary row */} + + + {/* Expanded detail panel */} + {expanded && ( +
+ {/* Issue list */} +
+ {/* Auto-corrected issues */} + {autoCorrected.length > 0 && ( +
+

+ Auto-corrected ({autoCorrected.length}) +

+ {autoCorrected.map((issue, i) => ( +
+ + + + + + {issue.message} +
+ ))} +
+ )} + + {/* Dropped issues */} + {dropped.length > 0 && ( +
0 ? "mt-2" : ""}> +

+ Dropped ({dropped.length}) +

+ {dropped.map((issue, i) => ( +
+ + + + + + {issue.message} +
+ ))} +
+ )} +
+ + {/* Footer with copy button and actionable message */} +
+

+ Copy these issues and ask your agent to fix them in knowledge-graph.json +

+ +
+
+ )} +
+ ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/index.css b/understand-anything-plugin/packages/dashboard/src/index.css index a0f8ffa..bb8f99d 100644 --- a/understand-anything-plugin/packages/dashboard/src/index.css +++ b/understand-anything-plugin/packages/dashboard/src/index.css @@ -1,46 +1,85 @@ @import "tailwindcss"; @theme { - /* Dark luxury color palette */ + /* Base */ --color-root: #0a0a0a; --color-surface: #111111; --color-elevated: #1a1a1a; --color-panel: #141414; - /* Gold accent spectrum */ - --color-gold: #d4a574; - --color-gold-dim: #c9a96e; - --color-gold-bright: #e8c49a; + /* Accent */ + --color-accent: #d4a574; + --color-accent-dim: #c9a96e; + --color-accent-bright: #e8c49a; - /* Text hierarchy */ + /* Text */ --color-text-primary: #f5f0eb; --color-text-secondary: #a39787; --color-text-muted: #6b5f53; - /* Border tokens */ + /* Borders */ --color-border-subtle: rgba(212, 165, 116, 0.12); --color-border-medium: rgba(212, 165, 116, 0.25); - /* Node type colors (muted, refined) */ + /* Node types */ --color-node-file: #4a7c9b; --color-node-function: #5a9e6f; --color-node-class: #8b6fb0; --color-node-module: #c9a06c; --color-node-concept: #b07a8a; + --color-node-config: #5eead4; + --color-node-document: #7dd3fc; + --color-node-service: #a78bfa; + --color-node-table: #6ee7b7; + --color-node-endpoint: #fdba74; + --color-node-pipeline: #fda4af; + --color-node-schema: #fcd34d; + --color-node-resource: #a5b4fc; - /* Diff overlay colors */ + /* Diff */ --color-diff-changed: #e05252; --color-diff-affected: #d4a030; --color-diff-changed-dim: rgba(224, 82, 82, 0.25); --color-diff-affected-dim: rgba(212, 160, 48, 0.25); - /* Fonts */ + /* Glass */ + --glass-bg: rgba(20, 20, 20, 0.8); + --glass-bg-heavy: rgba(20, 20, 20, 0.95); + --glass-border: rgba(212, 165, 116, 0.1); + --glass-border-heavy: rgba(212, 165, 116, 0.15); + + /* Scrollbar */ + --scrollbar-thumb: rgba(212, 165, 116, 0.2); + --scrollbar-thumb-hover: rgba(212, 165, 116, 0.35); + + /* Glow */ + --glow-accent: rgba(212, 165, 116, 0.15); + --glow-accent-strong: rgba(212, 165, 116, 0.4); + --glow-accent-pulse: rgba(212, 165, 116, 0.6); + + /* Edges */ + --color-edge: rgba(212, 165, 116, 0.3); + --color-edge-dim: rgba(212, 165, 116, 0.08); + --color-edge-dot: rgba(212, 165, 116, 0.15); + + /* Accent overlays */ + --color-accent-overlay-bg: rgba(212, 165, 116, 0.05); + --color-accent-overlay-border: rgba(212, 165, 116, 0.25); + + /* Kbd */ + --kbd-bg: rgba(212, 165, 116, 0.1); + + /* Typography */ --font-serif: 'DM Serif Display', Georgia, serif; --font-mono: 'JetBrains Mono', 'Fira Code', monospace; --font-sans: 'Inter', system-ui, sans-serif; } /* Base styles */ +html { + transition: background-color 0.2s ease, color 0.2s ease; +} + body { font-family: var(--font-sans); background-color: var(--color-root); @@ -65,15 +104,15 @@ body { /* Glass utility */ .glass { - background: rgba(20, 20, 20, 0.8); - border: 1px solid rgba(212, 165, 116, 0.1); + background: var(--glass-bg); + border: 1px solid var(--glass-border); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } .glass-heavy { - background: rgba(20, 20, 20, 0.95); - border: 1px solid rgba(212, 165, 116, 0.15); + background: var(--glass-bg-heavy); + border: 1px solid var(--glass-border-heavy); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); } @@ -89,11 +128,11 @@ body { font-family: var(--font-mono); font-size: 0.75rem; font-weight: 600; - color: var(--color-gold); - background: rgba(212, 165, 116, 0.1); - border: 1px solid rgba(212, 165, 116, 0.3); + color: var(--color-accent); + background: var(--kbd-bg); + border: 1px solid var(--color-border-medium); border-radius: 0.25rem; - box-shadow: 0 1px 0 rgba(212, 165, 116, 0.2); + box-shadow: 0 1px 0 var(--scrollbar-thumb); } /* Animation keyframes */ @@ -117,12 +156,12 @@ body { } } -@keyframes goldPulse { +@keyframes accentPulse { 0%, 100% { - box-shadow: 0 0 0 0 rgba(212, 165, 116, 0.4); + box-shadow: 0 0 8px var(--glow-accent-strong); } 50% { - box-shadow: 0 0 20px 4px rgba(212, 165, 116, 0.15); + box-shadow: 0 0 20px var(--glow-accent-pulse); } } @@ -135,13 +174,13 @@ body { animation: slideUp 0.3s ease-out forwards; } -.animate-gold-pulse { - animation: goldPulse 2s ease-in-out infinite; +.animate-accent-pulse { + animation: accentPulse 2s ease-in-out infinite; } /* Node selection glow */ .node-glow { - box-shadow: 0 0 20px rgba(212, 165, 116, 0.15); + box-shadow: 0 0 20px var(--glow-accent); } /* Diff overlay glow effects */ @@ -169,14 +208,37 @@ body { background: transparent; } ::-webkit-scrollbar-thumb { - background: rgba(212, 165, 116, 0.2); - border-radius: 3px; + background: var(--scrollbar-thumb); + border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: rgba(212, 165, 116, 0.35); + background: var(--scrollbar-thumb-hover); } /* Override React Flow dark theme */ .react-flow__background { background-color: var(--color-root) !important; } + +/* Light theme overrides */ +[data-theme="light"] { + color-scheme: light; +} + +[data-theme="light"] .diff-faded { + opacity: 0.35; +} + +[data-theme="light"] ::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="light"] .warning-banner { + background: rgba(180, 130, 30, 0.1); + border-color: rgba(180, 130, 30, 0.3); + color: #92600a; +} + +[data-theme="dark"] { + color-scheme: dark; +} diff --git a/understand-anything-plugin/packages/dashboard/src/store.ts b/understand-anything-plugin/packages/dashboard/src/store.ts index e912e4b..a2c1051 100644 --- a/understand-anything-plugin/packages/dashboard/src/store.ts +++ b/understand-anything-plugin/packages/dashboard/src/store.ts @@ -8,7 +8,8 @@ import type { import type { ReactFlowInstance } from "@xyflow/react"; export type Persona = "non-technical" | "junior" | "experienced"; -export type NodeType = "file" | "function" | "class" | "module" | "concept"; +export type NavigationLevel = "overview" | "layer-detail"; +export type NodeType = "file" | "function" | "class" | "module" | "concept" | "config" | "document" | "service" | "table" | "endpoint" | "pipeline" | "schema" | "resource"; export type Complexity = "simple" | "moderate" | "complex"; export type EdgeCategory = "structural" | "behavioral" | "data-flow" | "dependencies" | "semantic"; @@ -19,6 +20,10 @@ export interface FilterState { edgeCategories: Set; } +export const ALL_NODE_TYPES: NodeType[] = ["file", "function", "class", "module", "concept", "config", "document", "service", "table", "endpoint", "pipeline", "schema", "resource"]; +export const ALL_COMPLEXITIES: Complexity[] = ["simple", "moderate", "complex"]; +export const ALL_EDGE_CATEGORIES: EdgeCategory[] = ["structural", "behavioral", "data-flow", "dependencies", "semantic"]; + export const EDGE_CATEGORY_MAP: Record = { structural: ["imports", "exports", "contains", "inherits", "implements"], behavioral: ["calls", "subscribes", "publishes", "middleware"], @@ -27,6 +32,27 @@ export const EDGE_CATEGORY_MAP: Record = { semantic: ["related", "similar_to"], }; +const DEFAULT_FILTERS: FilterState = { + nodeTypes: new Set(ALL_NODE_TYPES), + complexities: new Set(ALL_COMPLEXITIES), + layerIds: new Set(), + edgeCategories: new Set(ALL_EDGE_CATEGORIES), +}; + +/** Categories used for node type filter toggles. Single source of truth for NodeCategory. */ +export type NodeCategory = "code" | "config" | "docs" | "infra" | "data"; + +/** Find which layer a node belongs to. Returns layerId or null. */ +function findNodeLayer(graph: KnowledgeGraph, nodeId: string): string | null { + for (const layer of graph.layers) { + if (layer.nodeIds.includes(nodeId)) return layer.id; + } + return null; +} + +/** Maximum number of entries in the sidebar navigation history. */ +const MAX_HISTORY = 50; + interface DashboardStore { graph: KnowledgeGraph | null; selectedNodeId: string | null; @@ -36,7 +62,9 @@ interface DashboardStore { searchMode: "fuzzy" | "semantic"; setSearchMode: (mode: "fuzzy" | "semantic") => void; - showLayers: boolean; + // Lens navigation + navigationLevel: NavigationLevel; + activeLayerId: string | null; codeViewerOpen: boolean; codeViewerNodeId: string | null; @@ -51,16 +79,33 @@ interface DashboardStore { changedNodeIds: Set; affectedNodeIds: Set; + // Focus mode: isolate a node's 1-hop neighborhood + focusNodeId: string | null; + + // Sidebar navigation history (stack of visited node IDs) + nodeHistory: string[]; + + // Filter & Export features filters: FilterState; filterPanelOpen: boolean; exportMenuOpen: boolean; pathFinderOpen: boolean; reactFlowInstance: ReactFlowInstance | null; + // Node type category filters + nodeTypeFilters: Record; + toggleNodeTypeFilter: (category: NodeCategory) => void; + setGraph: (graph: KnowledgeGraph) => void; selectNode: (nodeId: string | null) => void; + navigateToNode: (nodeId: string) => void; + navigateToNodeInLayer: (nodeId: string) => void; + navigateToHistoryIndex: (index: number) => void; + goBackNode: () => void; + drillIntoLayer: (layerId: string) => void; + navigateToOverview: () => void; + setFocusNode: (nodeId: string | null) => void; setSearchQuery: (query: string) => void; - toggleLayers: () => void; setPersona: (persona: Persona) => void; openCodeViewer: (nodeId: string) => void; closeCodeViewer: () => void; @@ -89,6 +134,22 @@ function getSortedTour(graph: KnowledgeGraph): TourStep[] { return [...tour].sort((a, b) => a.order - b.order); } +/** Navigate tour step to the correct layer for the first highlighted node. */ +function navigateTourToLayer( + graph: KnowledgeGraph, + nodeIds: string[], +): Partial { + if (nodeIds.length === 0) return {}; + const layerId = findNodeLayer(graph, nodeIds[0]); + if (layerId) { + return { + navigationLevel: "layer-detail" as const, + activeLayerId: layerId, + }; + } + return {}; +} + export const useDashboardStore = create()((set, get) => ({ graph: null, selectedNodeId: null, @@ -97,8 +158,8 @@ export const useDashboardStore = create()((set, get) => ({ searchEngine: null, searchMode: "fuzzy", - showLayers: false, - + navigationLevel: "overview", + activeLayerId: null, codeViewerOpen: false, codeViewerNodeId: null, @@ -112,24 +173,139 @@ export const useDashboardStore = create()((set, get) => ({ changedNodeIds: new Set(), affectedNodeIds: new Set(), - filters: { - nodeTypes: new Set(["file", "function", "class", "module", "concept"]), - complexities: new Set(["simple", "moderate", "complex"]), - layerIds: new Set(), - edgeCategories: new Set(["structural", "behavioral", "data-flow", "dependencies", "semantic"]), - }, + focusNodeId: null, + nodeHistory: [], + + filters: { ...DEFAULT_FILTERS, nodeTypes: new Set(DEFAULT_FILTERS.nodeTypes), complexities: new Set(DEFAULT_FILTERS.complexities), layerIds: new Set(DEFAULT_FILTERS.layerIds), edgeCategories: new Set(DEFAULT_FILTERS.edgeCategories) }, filterPanelOpen: false, exportMenuOpen: false, pathFinderOpen: false, reactFlowInstance: null, + nodeTypeFilters: { code: true, config: true, docs: true, infra: true, data: true }, + + toggleNodeTypeFilter: (category) => + set((state) => ({ + nodeTypeFilters: { + ...state.nodeTypeFilters, + [category]: !state.nodeTypeFilters[category], + }, + })), + setGraph: (graph) => { const searchEngine = new SearchEngine(graph.nodes); const query = get().searchQuery; const searchResults = query.trim() ? searchEngine.search(query) : []; - set({ graph, searchEngine, searchResults }); + set({ + graph, + searchEngine, + searchResults, + navigationLevel: "overview", + activeLayerId: null, + selectedNodeId: null, + focusNodeId: null, + nodeHistory: [], + }); }, - selectNode: (nodeId) => set({ selectedNodeId: nodeId }), + + selectNode: (nodeId) => { + const { selectedNodeId, nodeHistory } = get(); + if (nodeId && selectedNodeId && nodeId !== selectedNodeId) { + // Push current node to history before navigating away + set({ + selectedNodeId: nodeId, + nodeHistory: [...nodeHistory, selectedNodeId].slice(-MAX_HISTORY), + }); + } else { + set({ selectedNodeId: nodeId }); + } + }, + + navigateToNode: (nodeId) => { + get().navigateToNodeInLayer(nodeId); + }, + + navigateToNodeInLayer: (nodeId) => { + const { graph, selectedNodeId, nodeHistory } = get(); + if (!graph) return; + const layerId = findNodeLayer(graph, nodeId); + const newHistory = + selectedNodeId && nodeId !== selectedNodeId + ? [...nodeHistory, selectedNodeId].slice(-MAX_HISTORY) + : nodeHistory; + if (layerId) { + set({ + navigationLevel: "layer-detail", + activeLayerId: layerId, + selectedNodeId: nodeId, + focusNodeId: null, + codeViewerOpen: false, + codeViewerNodeId: null, + nodeHistory: newHistory, + }); + } else { + set({ + selectedNodeId: nodeId, + nodeHistory: newHistory, + }); + } + }, + + navigateToHistoryIndex: (index) => { + const { nodeHistory, graph } = get(); + if (!graph || index < 0 || index >= nodeHistory.length) return; + const targetId = nodeHistory[index]; + const newHistory = nodeHistory.slice(0, index); + const layerId = findNodeLayer(graph, targetId); + set({ + selectedNodeId: targetId, + nodeHistory: newHistory, + ...(layerId ? { navigationLevel: "layer-detail" as const, activeLayerId: layerId } : {}), + }); + }, + + goBackNode: () => { + const { nodeHistory, graph } = get(); + if (nodeHistory.length === 0 || !graph) return; + const prevNodeId = nodeHistory[nodeHistory.length - 1]; + const newHistory = nodeHistory.slice(0, -1); + const layerId = findNodeLayer(graph, prevNodeId); + if (layerId) { + set({ + navigationLevel: "layer-detail", + activeLayerId: layerId, + selectedNodeId: prevNodeId, + nodeHistory: newHistory, + }); + } else { + set({ + selectedNodeId: prevNodeId, + nodeHistory: newHistory, + }); + } + }, + + drillIntoLayer: (layerId) => + set({ + navigationLevel: "layer-detail", + activeLayerId: layerId, + selectedNodeId: null, + focusNodeId: null, + codeViewerOpen: false, + codeViewerNodeId: null, + }), + + navigateToOverview: () => + set({ + navigationLevel: "overview", + activeLayerId: null, + selectedNodeId: null, + focusNodeId: null, + codeViewerOpen: false, + codeViewerNodeId: null, + }), + + setFocusNode: (nodeId) => set({ focusNodeId: nodeId, selectedNodeId: nodeId }), setSearchMode: (mode) => set({ searchMode: mode }), setSearchQuery: (query) => { const engine = get().searchEngine; @@ -145,8 +321,6 @@ export const useDashboardStore = create()((set, get) => ({ set({ searchQuery: query, searchResults }); }, - toggleLayers: () => set((state) => ({ showLayers: !state.showLayers })), - setPersona: (persona) => set({ persona }), openCodeViewer: (nodeId) => set({ codeViewerOpen: true, codeViewerNodeId: nodeId }), @@ -190,36 +364,32 @@ export const useDashboardStore = create()((set, get) => ({ resetFilters: () => set({ filters: { - nodeTypes: new Set(["file", "function", "class", "module", "concept"]), - complexities: new Set(["simple", "moderate", "complex"]), + nodeTypes: new Set(ALL_NODE_TYPES), + complexities: new Set(ALL_COMPLEXITIES), layerIds: new Set(), - edgeCategories: new Set(["structural", "behavioral", "data-flow", "dependencies", "semantic"]), + edgeCategories: new Set(ALL_EDGE_CATEGORIES), }, }), hasActiveFilters: () => { const { filters } = get(); - const allNodeTypes = new Set(["file", "function", "class", "module", "concept"]); - const allComplexities = new Set(["simple", "moderate", "complex"]); - const allEdgeCategories = new Set(["structural", "behavioral", "data-flow", "dependencies", "semantic"]); - - const hasNodeTypeFilter = filters.nodeTypes.size !== allNodeTypes.size; - const hasComplexityFilter = filters.complexities.size !== allComplexities.size; - const hasLayerFilter = filters.layerIds.size > 0; - const hasEdgeCategoryFilter = filters.edgeCategories.size !== allEdgeCategories.size; - - return hasNodeTypeFilter || hasComplexityFilter || hasLayerFilter || hasEdgeCategoryFilter; + return filters.nodeTypes.size !== ALL_NODE_TYPES.length + || filters.complexities.size !== ALL_COMPLEXITIES.length + || filters.layerIds.size > 0 + || filters.edgeCategories.size !== ALL_EDGE_CATEGORIES.length; }, startTour: () => { const { graph } = get(); if (!graph || !graph.tour || graph.tour.length === 0) return; const sorted = getSortedTour(graph); + const layerNav = navigateTourToLayer(graph, sorted[0].nodeIds); set({ tourActive: true, currentTourStep: 0, tourHighlightedNodeIds: sorted[0].nodeIds, selectedNodeId: null, + ...layerNav, }); }, @@ -235,9 +405,11 @@ export const useDashboardStore = create()((set, get) => ({ if (!graph || !graph.tour || graph.tour.length === 0) return; const sorted = getSortedTour(graph); if (step < 0 || step >= sorted.length) return; + const layerNav = navigateTourToLayer(graph, sorted[step].nodeIds); set({ currentTourStep: step, tourHighlightedNodeIds: sorted[step].nodeIds, + ...layerNav, }); }, @@ -247,9 +419,11 @@ export const useDashboardStore = create()((set, get) => ({ const sorted = getSortedTour(graph); if (currentTourStep < sorted.length - 1) { const next = currentTourStep + 1; + const layerNav = navigateTourToLayer(graph, sorted[next].nodeIds); set({ currentTourStep: next, tourHighlightedNodeIds: sorted[next].nodeIds, + ...layerNav, }); } }, @@ -260,9 +434,11 @@ export const useDashboardStore = create()((set, get) => ({ if (currentTourStep > 0) { const sorted = getSortedTour(graph); const prev = currentTourStep - 1; + const layerNav = navigateTourToLayer(graph, sorted[prev].nodeIds); set({ currentTourStep: prev, tourHighlightedNodeIds: sorted[prev].nodeIds, + ...layerNav, }); } }, diff --git a/understand-anything-plugin/packages/dashboard/src/themes/ThemeContext.tsx b/understand-anything-plugin/packages/dashboard/src/themes/ThemeContext.tsx new file mode 100644 index 0000000..dc12fcc --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/themes/ThemeContext.tsx @@ -0,0 +1,101 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import type { PresetId, ThemeConfig, ThemePreset } from "./types.ts"; +import { DEFAULT_THEME_CONFIG } from "./types.ts"; +import { getPreset } from "./presets.ts"; +import { applyTheme } from "./theme-engine.ts"; + +const STORAGE_KEY = "ua-theme"; + +interface ThemeContextValue { + config: ThemeConfig; + preset: ThemePreset; + setPreset: (presetId: PresetId) => void; + setAccent: (accentId: string) => void; +} + +const ThemeContext = createContext(null); + +function loadFromLocalStorage(): ThemeConfig | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.presetId === "string" && typeof parsed.accentId === "string") { + return parsed as ThemeConfig; + } + return null; + } catch { + return null; + } +} + +function saveToLocalStorage(config: ThemeConfig): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); + } catch { + // Storage full or unavailable — ignore + } +} + +function resolveInitialTheme(metaTheme?: ThemeConfig | null): ThemeConfig { + return loadFromLocalStorage() ?? metaTheme ?? DEFAULT_THEME_CONFIG; +} + +interface ThemeProviderProps { + metaTheme?: ThemeConfig | null; + children: ReactNode; +} + +export function ThemeProvider({ metaTheme, children }: ThemeProviderProps) { + const [config, setConfig] = useState(() => resolveInitialTheme(metaTheme)); + const initialized = useRef(false); + + // Apply theme on mount and config changes + useEffect(() => { + applyTheme(config); + if (initialized.current) { + saveToLocalStorage(config); + } + initialized.current = true; + }, [config]); + + // Update if metaTheme arrives later (async fetch) and no localStorage preference exists + useEffect(() => { + if (metaTheme && !loadFromLocalStorage()) { + setConfig(metaTheme); + } + }, [metaTheme]); + + const setPreset = useCallback((presetId: PresetId) => { + setConfig((_prev) => { + const newPreset = getPreset(presetId); + return { presetId, accentId: newPreset.defaultAccentId }; + }); + }, []); + + const setAccent = useCallback((accentId: string) => { + setConfig((prev) => ({ ...prev, accentId })); + }, []); + + const preset = getPreset(config.presetId); + + return ( + + {children} + + ); +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within ThemeProvider"); + return ctx; +} diff --git a/understand-anything-plugin/packages/dashboard/src/themes/index.ts b/understand-anything-plugin/packages/dashboard/src/themes/index.ts new file mode 100644 index 0000000..c033d59 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/themes/index.ts @@ -0,0 +1,5 @@ +export { ThemeProvider, useTheme } from "./ThemeContext.tsx"; +export { PRESETS, getPreset, getAccent } from "./presets.ts"; +export { applyTheme } from "./theme-engine.ts"; +export type { PresetId, ThemeConfig, ThemePreset, AccentSwatch } from "./types.ts"; +export { DEFAULT_THEME_CONFIG } from "./types.ts"; diff --git a/understand-anything-plugin/packages/dashboard/src/themes/presets.ts b/understand-anything-plugin/packages/dashboard/src/themes/presets.ts new file mode 100644 index 0000000..b1f9c05 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/themes/presets.ts @@ -0,0 +1,183 @@ +import type { AccentSwatch, ThemePreset } from "./types.ts"; + +const DARK_ACCENT_SWATCHES: AccentSwatch[] = [ + { id: "gold", name: "Gold", accent: "#d4a574", accentDim: "#c9a96e", accentBright: "#e8c49a" }, + { id: "ocean", name: "Ocean", accent: "#5ba4cf", accentDim: "#4e93ba", accentBright: "#7abce0" }, + { id: "emerald", name: "Emerald", accent: "#5ea67a", accentDim: "#4e9468", accentBright: "#78c492" }, + { id: "rose", name: "Rose", accent: "#cf7a8a", accentDim: "#b96e7e", accentBright: "#e094a4" }, + { id: "purple", name: "Purple", accent: "#9b7abf", accentDim: "#876bb0", accentBright: "#b494d4" }, + { id: "amber", name: "Amber", accent: "#c9963a", accentDim: "#b5862e", accentBright: "#ddb05c" }, + { id: "teal", name: "Teal", accent: "#4aab9a", accentDim: "#3d9686", accentBright: "#68c4b4" }, + { id: "silver", name: "Silver", accent: "#a0a8b0", accentDim: "#8e959c", accentBright: "#b8bfc6" }, +]; + +const LIGHT_ACCENT_SWATCHES: AccentSwatch[] = [ + { id: "indigo", name: "Indigo", accent: "#4a6fa5", accentDim: "#3d5f8f", accentBright: "#6088bf" }, + { id: "ocean", name: "Ocean", accent: "#3a8ab5", accentDim: "#2e7aa0", accentBright: "#55a0cc" }, + { id: "emerald", name: "Emerald", accent: "#3a8a5c", accentDim: "#2e7a4e", accentBright: "#55a878" }, + { id: "rose", name: "Rose", accent: "#a5566a", accentDim: "#8f4a5c", accentBright: "#bf6e82" }, + { id: "purple", name: "Purple", accent: "#6b5a9e", accentDim: "#5c4d8a", accentBright: "#8474b5" }, + { id: "amber", name: "Amber", accent: "#9e7a30", accentDim: "#8a6a28", accentBright: "#b5923e" }, + { id: "teal", name: "Teal", accent: "#2e8a7a", accentDim: "#267a6c", accentBright: "#45a595" }, + { id: "slate", name: "Slate", accent: "#5a6570", accentDim: "#4e5860", accentBright: "#6e7a85" }, +]; + +export const PRESETS: ThemePreset[] = [ + { + id: "dark-gold", + name: "Dark Gold", + isDark: true, + defaultAccentId: "gold", + accentSwatches: DARK_ACCENT_SWATCHES, + colors: { + root: "#0a0a0a", + surface: "#111111", + elevated: "#1a1a1a", + panel: "#141414", + "text-primary": "#f5f0eb", + "text-secondary": "#a39787", + "text-muted": "#6b5f53", + "node-file": "#4a7c9b", + "node-function": "#5a9e6f", + "node-class": "#8b6fb0", + "node-module": "#c9a06c", + "node-concept": "#b07a8a", + "node-config": "#5eead4", + "node-document": "#7dd3fc", + "node-service": "#a78bfa", + "node-table": "#6ee7b7", + "node-endpoint": "#fdba74", + "node-pipeline": "#fda4af", + "node-schema": "#fcd34d", + "node-resource": "#a5b4fc", + }, + }, + { + id: "dark-ocean", + name: "Dark Ocean", + isDark: true, + defaultAccentId: "ocean", + accentSwatches: DARK_ACCENT_SWATCHES, + colors: { + root: "#0a0e14", + surface: "#111820", + elevated: "#1a222c", + panel: "#141c24", + "text-primary": "#e8edf2", + "text-secondary": "#87939f", + "text-muted": "#536b7a", + "node-file": "#4a7c9b", + "node-function": "#5a9e6f", + "node-class": "#8b6fb0", + "node-module": "#c9a06c", + "node-concept": "#b07a8a", + "node-config": "#5eead4", + "node-document": "#7dd3fc", + "node-service": "#a78bfa", + "node-table": "#6ee7b7", + "node-endpoint": "#fdba74", + "node-pipeline": "#fda4af", + "node-schema": "#fcd34d", + "node-resource": "#a5b4fc", + }, + }, + { + id: "dark-forest", + name: "Dark Forest", + isDark: true, + defaultAccentId: "emerald", + accentSwatches: DARK_ACCENT_SWATCHES, + colors: { + root: "#0a100a", + surface: "#111811", + elevated: "#1a241a", + panel: "#141c14", + "text-primary": "#ebf0eb", + "text-secondary": "#87a38f", + "text-muted": "#536b5a", + "node-file": "#4a7c9b", + "node-function": "#5a9e6f", + "node-class": "#8b6fb0", + "node-module": "#c9a06c", + "node-concept": "#b07a8a", + "node-config": "#5eead4", + "node-document": "#7dd3fc", + "node-service": "#a78bfa", + "node-table": "#6ee7b7", + "node-endpoint": "#fdba74", + "node-pipeline": "#fda4af", + "node-schema": "#fcd34d", + "node-resource": "#a5b4fc", + }, + }, + { + id: "dark-rose", + name: "Dark Rose", + isDark: true, + defaultAccentId: "rose", + accentSwatches: DARK_ACCENT_SWATCHES, + colors: { + root: "#100a0a", + surface: "#181111", + elevated: "#221a1a", + panel: "#1c1414", + "text-primary": "#f2e8ea", + "text-secondary": "#9f8790", + "text-muted": "#6b535a", + "node-file": "#4a7c9b", + "node-function": "#5a9e6f", + "node-class": "#8b6fb0", + "node-module": "#c9a06c", + "node-concept": "#b07a8a", + "node-config": "#5eead4", + "node-document": "#7dd3fc", + "node-service": "#a78bfa", + "node-table": "#6ee7b7", + "node-endpoint": "#fdba74", + "node-pipeline": "#fda4af", + "node-schema": "#fcd34d", + "node-resource": "#a5b4fc", + }, + }, + { + id: "light-minimal", + name: "Light Minimal", + isDark: false, + defaultAccentId: "indigo", + accentSwatches: LIGHT_ACCENT_SWATCHES, + colors: { + root: "#f5f3f0", + surface: "#eae7e3", + elevated: "#ffffff", + panel: "#f0ede9", + "text-primary": "#1a1a1a", + "text-secondary": "#6b6b6b", + "text-muted": "#a0a0a0", + "node-file": "#3a6a87", + "node-function": "#488a5b", + "node-class": "#755d99", + "node-module": "#a88a56", + "node-concept": "#966674", + "node-config": "#14b8a6", + "node-document": "#38bdf8", + "node-service": "#8b5cf6", + "node-table": "#34d399", + "node-endpoint": "#fb923c", + "node-pipeline": "#fb7185", + "node-schema": "#facc15", + "node-resource": "#818cf8", + }, + }, +]; + +export function getPreset(id: string): ThemePreset { + return PRESETS.find((p) => p.id === id) ?? PRESETS[0]; +} + +export function getAccent(preset: ThemePreset, accentId: string): AccentSwatch { + return ( + preset.accentSwatches.find((s) => s.id === accentId) ?? + preset.accentSwatches.find((s) => s.id === preset.defaultAccentId) ?? + preset.accentSwatches[0] + ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/themes/theme-engine.ts b/understand-anything-plugin/packages/dashboard/src/themes/theme-engine.ts new file mode 100644 index 0000000..23004ca --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/themes/theme-engine.ts @@ -0,0 +1,56 @@ +import type { ThemeConfig } from "./types.ts"; +import { getAccent, getPreset } from "./presets.ts"; + +export function hexToRgb(hex: string): string { + const h = hex.replace("#", ""); + const n = parseInt(h, 16); + return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`; +} + +function deriveFromAccent(accentHex: string, isDark: boolean): Record { + const rgb = hexToRgb(accentHex); + return { + "color-border-subtle": `rgba(${rgb}, ${isDark ? 0.12 : 0.1})`, + "color-border-medium": `rgba(${rgb}, ${isDark ? 0.25 : 0.18})`, + "glass-bg": isDark ? "rgba(20, 20, 20, 0.8)" : "rgba(255, 255, 255, 0.8)", + "glass-bg-heavy": isDark ? "rgba(20, 20, 20, 0.95)" : "rgba(255, 255, 255, 0.95)", + "glass-border": `rgba(${rgb}, ${isDark ? 0.1 : 0.08})`, + "glass-border-heavy": `rgba(${rgb}, ${isDark ? 0.15 : 0.12})`, + "scrollbar-thumb": `rgba(${rgb}, 0.2)`, + "scrollbar-thumb-hover": `rgba(${rgb}, 0.35)`, + "glow-accent": `rgba(${rgb}, 0.15)`, + "glow-accent-strong": `rgba(${rgb}, 0.4)`, + "glow-accent-pulse": `rgba(${rgb}, 0.6)`, + "color-edge": `rgba(${rgb}, 0.3)`, + "color-edge-dim": `rgba(${rgb}, 0.08)`, + "color-edge-dot": `rgba(${rgb}, 0.15)`, + "color-accent-overlay-bg": `rgba(${rgb}, 0.05)`, + "color-accent-overlay-border": `rgba(${rgb}, 0.25)`, + "kbd-bg": `rgba(${rgb}, 0.1)`, + }; +} + +export function applyTheme(config: ThemeConfig): void { + const preset = getPreset(config.presetId); + const accent = getAccent(preset, config.accentId); + const style = document.documentElement.style; + + // 1. Apply base preset colors + for (const [key, value] of Object.entries(preset.colors)) { + style.setProperty(`--color-${key}`, value); + } + + // 2. Apply accent colors from swatch + style.setProperty("--color-accent", accent.accent); + style.setProperty("--color-accent-dim", accent.accentDim); + style.setProperty("--color-accent-bright", accent.accentBright); + + // 3. Apply derived values + const derived = deriveFromAccent(accent.accent, preset.isDark); + for (const [key, value] of Object.entries(derived)) { + style.setProperty(`--${key}`, value); + } + + // 4. Set data-theme for CSS-only selectors + document.documentElement.setAttribute("data-theme", preset.isDark ? "dark" : "light"); +} diff --git a/understand-anything-plugin/packages/dashboard/src/themes/types.ts b/understand-anything-plugin/packages/dashboard/src/themes/types.ts new file mode 100644 index 0000000..2d09590 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/themes/types.ts @@ -0,0 +1,33 @@ +export type PresetId = + | "dark-gold" + | "dark-ocean" + | "dark-forest" + | "dark-rose" + | "light-minimal"; + +export interface AccentSwatch { + id: string; + name: string; + accent: string; + accentDim: string; + accentBright: string; +} + +export interface ThemePreset { + id: PresetId; + name: string; + isDark: boolean; + colors: Record; + accentSwatches: AccentSwatch[]; + defaultAccentId: string; +} + +export interface ThemeConfig { + presetId: PresetId; + accentId: string; +} + +export const DEFAULT_THEME_CONFIG: ThemeConfig = { + presetId: "dark-gold", + accentId: "gold", +}; diff --git a/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts b/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts new file mode 100644 index 0000000..29b72d5 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts @@ -0,0 +1,135 @@ +import type { KnowledgeGraph } from "@understand-anything/core/types"; + +export interface LayerEdgeAggregation { + sourceLayerId: string; + targetLayerId: string; + count: number; + edgeTypes: string[]; +} + +export interface PortalInfo { + layerId: string; + layerName: string; + connectionCount: number; +} + +/** + * Aggregate edges between layers. Counts how many graph edges cross + * from one layer to another. Only considers edges where both endpoints + * are assigned to a layer. + */ +export function aggregateLayerEdges( + graph: KnowledgeGraph, +): LayerEdgeAggregation[] { + const nodeToLayer = new Map(); + for (const layer of graph.layers) { + for (const nodeId of layer.nodeIds) { + nodeToLayer.set(nodeId, layer.id); + } + } + + // Key: "layerA|layerB" (sorted) → aggregation + const pairMap = new Map< + string, + { sourceLayerId: string; targetLayerId: string; count: number; edgeTypes: Set } + >(); + + for (const edge of graph.edges) { + const sourceLayer = nodeToLayer.get(edge.source); + const targetLayer = nodeToLayer.get(edge.target); + if (!sourceLayer || !targetLayer) continue; + if (sourceLayer === targetLayer) continue; + + // Canonical key so A→B and B→A merge + const [a, b] = + sourceLayer < targetLayer + ? [sourceLayer, targetLayer] + : [targetLayer, sourceLayer]; + const key = `${a}|${b}`; + + const existing = pairMap.get(key); + if (existing) { + existing.count++; + existing.edgeTypes.add(edge.type); + } else { + pairMap.set(key, { + sourceLayerId: a, + targetLayerId: b, + count: 1, + edgeTypes: new Set([edge.type]), + }); + } + } + + return Array.from(pairMap.values()).map((p) => ({ + sourceLayerId: p.sourceLayerId, + targetLayerId: p.targetLayerId, + count: p.count, + edgeTypes: Array.from(p.edgeTypes), + })); +} + +/** + * Compute portal info for a given layer: which other layers are connected + * and how many edges cross the boundary. + * Accepts optional pre-computed aggregation to avoid redundant work. + */ +export function computePortals( + graph: KnowledgeGraph, + activeLayerId: string, + precomputed?: LayerEdgeAggregation[], +): PortalInfo[] { + const aggregated = precomputed ?? aggregateLayerEdges(graph); + const layerNameMap = new Map(graph.layers.map((l) => [l.id, l.name])); + + const portalMap = new Map(); + + for (const agg of aggregated) { + if (agg.sourceLayerId === activeLayerId) { + portalMap.set( + agg.targetLayerId, + (portalMap.get(agg.targetLayerId) ?? 0) + agg.count, + ); + } else if (agg.targetLayerId === activeLayerId) { + portalMap.set( + agg.sourceLayerId, + (portalMap.get(agg.sourceLayerId) ?? 0) + agg.count, + ); + } + } + + return Array.from(portalMap.entries()).map(([layerId, count]) => ({ + layerId, + layerName: layerNameMap.get(layerId) ?? layerId, + connectionCount: count, + })); +} + +/** + * For a given layer, find which file nodes in that layer connect to a + * specific external layer. Returns the set of node IDs in activeLayer + * that have edges crossing to targetLayerId. + */ +export function findCrossLayerFileNodes( + graph: KnowledgeGraph, + activeLayerId: string, + targetLayerId: string, +): Set { + const activeNodeIds = new Set( + graph.layers.find((l) => l.id === activeLayerId)?.nodeIds ?? [], + ); + const targetNodeIds = new Set( + graph.layers.find((l) => l.id === targetLayerId)?.nodeIds ?? [], + ); + + const result = new Set(); + for (const edge of graph.edges) { + if (activeNodeIds.has(edge.source) && targetNodeIds.has(edge.target)) { + result.add(edge.source); + } + if (activeNodeIds.has(edge.target) && targetNodeIds.has(edge.source)) { + result.add(edge.target); + } + } + return result; +} diff --git a/understand-anything-plugin/packages/dashboard/src/utils/layout.ts b/understand-anything-plugin/packages/dashboard/src/utils/layout.ts index 6f55b53..c1644bc 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/layout.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/layout.ts @@ -1,9 +1,12 @@ import dagre from "@dagrejs/dagre"; import type { Node, Edge } from "@xyflow/react"; -import type { LayoutMessage, LayoutResult } from "./layout.worker"; export const NODE_WIDTH = 280; export const NODE_HEIGHT = 120; +export const LAYER_CLUSTER_WIDTH = 320; +export const LAYER_CLUSTER_HEIGHT = 180; +export const PORTAL_NODE_WIDTH = 240; +export const PORTAL_NODE_HEIGHT = 80; /** * Synchronous dagre layout — used for small graphs. @@ -12,19 +15,26 @@ export function applyDagreLayout( nodes: Node[], edges: Edge[], direction: "TB" | "LR" = "TB", + nodeDimensions?: Map, ): { nodes: Node[]; edges: Edge[] } { const g = new dagre.graphlib.Graph(); g.setDefaultEdgeLabel(() => ({})); + + // Scale spacing for larger graphs to reduce overlap + const isLarge = nodes.length > 50; g.setGraph({ rankdir: direction, - nodesep: 60, - ranksep: 80, + nodesep: isLarge ? 80 : 60, + ranksep: isLarge ? 120 : 80, marginx: 20, marginy: 20, }); nodes.forEach((node) => { - g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + const dims = nodeDimensions?.get(node.id); + const w = dims?.width ?? NODE_WIDTH; + const h = dims?.height ?? NODE_HEIGHT; + g.setNode(node.id, { width: w, height: h }); }); edges.forEach((edge) => { @@ -36,11 +46,14 @@ export function applyDagreLayout( const layoutedNodes = nodes.map((node) => { const pos = g.node(node.id); if (!pos) return { ...node, position: { x: 0, y: 0 } }; + const dims = nodeDimensions?.get(node.id); + const w = dims?.width ?? NODE_WIDTH; + const h = dims?.height ?? NODE_HEIGHT; return { ...node, position: { - x: pos.x - NODE_WIDTH / 2, - y: pos.y - NODE_HEIGHT / 2, + x: pos.x - w / 2, + y: pos.y - h / 2, }, }; }); @@ -48,78 +61,4 @@ export function applyDagreLayout( return { nodes: layoutedNodes, edges }; } -let _worker: Worker | null = null; -let _nextRequestId = 0; -let _latestRequestId = -1; -const _pending = new Map< - number, - { - nodes: Node[]; - edges: Edge[]; - resolve: (v: { nodes: Node[]; edges: Edge[] }) => void; - reject: (reason?: unknown) => void; - } ->(); -function getWorker(): Worker { - if (!_worker) { - _worker = new Worker( - new URL("./layout.worker.ts", import.meta.url), - { type: "module" }, - ); - - _worker.onmessage = (e: MessageEvent) => { - const { requestId, positions } = e.data; - const entry = _pending.get(requestId); - _pending.delete(requestId); - - // S1: Discard stale results — only honour the latest request. - if (!entry || requestId !== _latestRequestId) return; - - const layoutedNodes = entry.nodes.map((node) => ({ - ...node, - position: positions[node.id] ?? { x: 0, y: 0 }, - })); - - entry.resolve({ nodes: layoutedNodes, edges: entry.edges }); - }; - - _worker.onerror = (err: ErrorEvent) => { - for (const [, entry] of _pending) { - entry.reject(err); - } - _pending.clear(); - }; - } - return _worker; -} - -/** - * Async dagre layout via Web Worker — used for large graphs. - * Keeps the main thread responsive while dagre computes positions. - * - * Uses request-ID correlation so concurrent calls never cross-wire, - * and only the latest request's result is honoured (stale ones are discarded). - */ -export function applyDagreLayoutAsync( - nodes: Node[], - edges: Edge[], - direction: "TB" | "LR" = "TB", -): Promise<{ nodes: Node[]; edges: Edge[] }> { - return new Promise((resolve, reject) => { - const worker = getWorker(); - const requestId = _nextRequestId++; - _latestRequestId = requestId; - - _pending.set(requestId, { nodes, edges, resolve, reject }); - - const msg: LayoutMessage = { - requestId, - nodes: nodes.map((n) => ({ id: n.id, width: NODE_WIDTH, height: NODE_HEIGHT })), - edges: edges.map((e) => ({ source: e.source, target: e.target })), - direction, - }; - - worker.postMessage(msg); - }); -} diff --git a/understand-anything-plugin/packages/dashboard/vite.config.ts b/understand-anything-plugin/packages/dashboard/vite.config.ts index aa60f93..cd43a83 100644 --- a/understand-anything-plugin/packages/dashboard/vite.config.ts +++ b/understand-anything-plugin/packages/dashboard/vite.config.ts @@ -3,8 +3,21 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import path from "path"; import fs from "fs"; +import crypto from "crypto"; + +// Generate a one-time token when the server process starts. +// This token is printed to the terminal and must be in the URL +// to fetch knowledge-graph.json or diff-overlay.json. +const ACCESS_TOKEN = crypto.randomBytes(16).toString("hex"); export default defineConfig({ + // FIX 1 — bind only to localhost, not 0.0.0.0 + // This blocks access from any other device on the same LAN / WiFi. + server: { + host: "127.0.0.1", + port: 5173, + }, + resolve: { alias: { "@understand-anything/core/schema": path.resolve(__dirname, "../core/dist/schema.js"), @@ -12,53 +25,119 @@ export default defineConfig({ "@understand-anything/core/types": path.resolve(__dirname, "../core/dist/types.js"), }, }, + plugins: [ react(), tailwindcss(), { name: "serve-knowledge-graph", configureServer(server) { + // Print the access URL once so the developer can open it. + server.httpServer?.once("listening", () => { + console.log( + `\n 🔑 Dashboard URL: http://127.0.0.1:5173?token=${ACCESS_TOKEN}\n` + ); + }); + server.middlewares.use((req, res, next) => { - if (req.url === "/knowledge-graph.json") { - // GRAPH_DIR env var points to the project being analyzed - // Falls back to monorepo root, then public/ (demo) - const graphDir = process.env.GRAPH_DIR; - const candidates = [ - ...(graphDir - ? [path.resolve(graphDir, ".understand-anything/knowledge-graph.json")] - : []), - path.resolve(process.cwd(), ".understand-anything/knowledge-graph.json"), - path.resolve(process.cwd(), "../../../.understand-anything/knowledge-graph.json"), - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - res.setHeader("Content-Type", "application/json"); - fs.createReadStream(candidate).pipe(res); - return; - } - } - } - if (req.url === "/diff-overlay.json") { - const graphDir = process.env.GRAPH_DIR; - const candidates = [ - ...(graphDir - ? [path.resolve(graphDir, ".understand-anything/diff-overlay.json")] - : []), - path.resolve(process.cwd(), ".understand-anything/diff-overlay.json"), - path.resolve(process.cwd(), "../../../.understand-anything/diff-overlay.json"), - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - res.setHeader("Content-Type", "application/json"); - fs.createReadStream(candidate).pipe(res); - return; - } - } - res.statusCode = 404; - res.end(); + const url = new URL(req.url ?? "/", "http://127.0.0.1:5173"); + const pathname = url.pathname; + const isProtectedEndpoint = + pathname === "/knowledge-graph.json" || + pathname === "/diff-overlay.json" || + pathname === "/meta.json"; + + if (!isProtectedEndpoint) { + next(); return; } - next(); + + // FIX 3 — require the one-time token on all data endpoints. + // Requests without a matching ?token= get a 403. + if (url.searchParams.get("token") !== ACCESS_TOKEN) { + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Forbidden: missing or invalid token" })); + return; + } + + const fileName = + pathname === "/diff-overlay.json" + ? "diff-overlay.json" + : pathname === "/meta.json" + ? "meta.json" + : "knowledge-graph.json"; + + const graphDir = process.env.GRAPH_DIR; + const candidates = [ + ...(graphDir + ? [path.resolve(graphDir, `.understand-anything/${fileName}`)] + : []), + path.resolve(process.cwd(), `.understand-anything/${fileName}`), + path.resolve( + process.cwd(), + `../../../.understand-anything/${fileName}` + ), + ]; + + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) continue; + + // FIX 2 — sanitise absolute file paths before sending the JSON. + // Nodes can contain filePath values like /Users/alice/company/src/auth.ts. + // We convert those to relative paths (src/auth.ts) so the developer's + // home directory and company directory layout are not leaked. + try { + const raw = JSON.parse(fs.readFileSync(candidate, "utf-8")) as { + nodes?: Array>; + [key: string]: unknown; + }; + + // Derive the project root from the candidate path so we can + // make file paths relative to it. + const projectRoot = path.dirname( + candidate.replace( + `${path.sep}.understand-anything${path.sep}${fileName}`, + "" + ) + ); + + if (Array.isArray(raw.nodes)) { + raw.nodes = raw.nodes.map((node) => { + if (typeof node.filePath !== "string") return node; + const abs = node.filePath; + // Only relativise paths that actually sit inside projectRoot. + // Leave external or already-relative paths untouched. + const rel = abs.startsWith(projectRoot) + ? abs.slice(projectRoot.length).replace(/^[\\/]/, "") + : path.isAbsolute(abs) + ? path.basename(abs) // absolute but outside root — use filename only + : abs; // already relative — keep as-is + return { ...node, filePath: rel }; + }); + } + + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(raw)); + } catch (err) { + // If we cannot parse or sanitise the file, refuse to serve it + // rather than accidentally leaking raw content. + console.error("[understand-anything] Failed to sanitise graph file:", err); + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Failed to read graph file" })); + } + return; + } + + // No matching file found on disk. + res.statusCode = 404; + if (pathname === "/knowledge-graph.json") { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "No knowledge graph found. Run /understand first." })); + } else { + res.end(); + } }); }, }, diff --git a/understand-anything-plugin/pnpm-lock.yaml b/understand-anything-plugin/pnpm-lock.yaml new file mode 100644 index 0000000..d2f0f61 --- /dev/null +++ b/understand-anything-plugin/pnpm-lock.yaml @@ -0,0 +1,3253 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@understand-anything/core': + specifier: workspace:* + version: link:packages/core + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.1.0 + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + + packages/core: + dependencies: + yaml: + specifier: ^2.8.3 + version: 2.8.3 + devDependencies: + '@types/node': + specifier: ^25.5.0 + version: 25.5.0 + '@vitest/coverage-v8': + specifier: 3.2.4 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + vitest: + specifier: ^3.1.0 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + + packages/dashboard: + dependencies: + '@dagrejs/dagre': + specifier: ^2.0.4 + version: 2.0.4 + '@understand-anything/core': + specifier: workspace:* + version: link:../core + '@xyflow/react': + specifier: ^12.0.0 + version: 12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + devlop: + specifier: ^1.1.0 + version: 1.1.0 + hast-util-to-jsx-runtime: + specifier: ^2.3.6 + version: 2.3.6 + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.14)(react@19.2.4) + zustand: + specifier: ^5.0.0 + version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.2.2(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.3.0 + version: 4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + tailwindcss: + specifier: ^4.0.0 + version: 4.2.2 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@dagrejs/dagre@2.0.4': + resolution: {integrity: sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==} + + '@dagrejs/graphlib@3.0.4': + resolution: {integrity: sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + '@xyflow/react@12.10.1': + resolution: {integrity: sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.75': + resolution: {integrity: sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.10: + resolution: {integrity: sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.5.325: + resolution: {integrity: sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@dagrejs/dagre@2.0.4': + dependencies: + '@dagrejs/graphlib': 3.0.4 + + '@dagrejs/graphlib@3.0.4': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + + '@types/estree@1.0.8': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + + '@types/node@25.5.0': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@xyflow/react@12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@xyflow/system': 0.0.75 + classcat: 5.0.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + zustand: 4.5.7(@types/react@19.2.14)(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.75': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.10: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.10 + caniuse-lite: 1.0.30001781 + electron-to-chromium: 1.5.325 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + cac@6.7.14: {} + + caniuse-lite@1.0.30001781: {} + + ccount@2.0.1: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + check-error@2.1.3: {} + + classcat@5.0.5: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comma-separated-tokens@2.0.3: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deep-eql@5.0.2: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.5.325: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + es-module-lexer@1.7.0: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + estree-util-is-identifier-name@3.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + extend@3.0.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + html-escaper@2.0.2: {} + + html-url-attributes@3.0.1: {} + + inline-style-parser@0.2.7: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@2.6.1: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + longest-streak@3.1.0: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.5 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.3: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-releases@2.0.36: {} + + package-json-from-dist@1.0.1: {} + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + property-information@7.1.0: {} + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.14 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.4 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + react-refresh@0.17.0: {} + + react@19.2.4: {} + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 10.2.4 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + undici-types@7.18.2: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite-node@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-node@3.2.4(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + yaml: 2.8.3 + + vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.5.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + yaml: 2.8.3 + + vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 22.19.15 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 25.5.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + yallist@3.1.1: {} + + yaml@2.8.3: {} + + zustand@4.5.7(@types/react@19.2.14)(react@19.2.4): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + + zwitch@2.0.4: {} diff --git a/understand-anything-plugin/skills/understand-dashboard/SKILL.md b/understand-anything-plugin/skills/understand-dashboard/SKILL.md index e0614d7..4c20dbb 100644 --- a/understand-anything-plugin/skills/understand-dashboard/SKILL.md +++ b/understand-anything-plugin/skills/understand-dashboard/SKILL.md @@ -56,7 +56,7 @@ Start the Understand Anything dashboard to visualize the knowledge graph for the 5. Start the Vite dev server pointing at the project's knowledge graph: ```bash - cd && GRAPH_DIR= npx vite --open + cd && GRAPH_DIR= npx vite --host 127.0.0.1 --open ``` Run this in the background so the user can continue working. diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 71f188f..b6503dc 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -12,6 +12,9 @@ Analyze the current codebase and produce a `knowledge-graph.json` file in `.unde - `$ARGUMENTS` may contain: - `--full` — Force a full rebuild, ignoring any existing graph + - `--auto-update` — Enable automatic graph updates on commit (writes `autoUpdate: true` to `.understand-anything/config.json`) + - `--no-auto-update` — Disable automatic graph updates (writes `autoUpdate: false` to `.understand-anything/config.json`) + - `--review` — Run full LLM graph-reviewer instead of inline deterministic validation - A directory path — Scope analysis to a specific subdirectory --- @@ -30,6 +33,11 @@ Determine whether to run a full analysis or incremental update. mkdir -p $PROJECT_ROOT/.understand-anything/intermediate mkdir -p $PROJECT_ROOT/.understand-anything/tmp ``` +3.5. **Auto-update configuration:** + - If `--auto-update` is in `$ARGUMENTS`: write `{"autoUpdate": true}` to `$PROJECT_ROOT/.understand-anything/config.json` + - If `--no-auto-update` is in `$ARGUMENTS`: write `{"autoUpdate": false}` to `$PROJECT_ROOT/.understand-anything/config.json` + - These flags only set the config — analysis proceeds normally regardless. + 4. Check if `$PROJECT_ROOT/.understand-anything/knowledge-graph.json` exists. If it does, read it. 5. Check if `$PROJECT_ROOT/.understand-anything/meta.json` exists. If it does, read it to get `gitCommitHash`. 6. **Decision logic:** @@ -38,9 +46,12 @@ Determine whether to run a full analysis or incremental update. |---|---| | `--full` flag in `$ARGUMENTS` | Full analysis (all phases) | | No existing graph or meta | Full analysis (all phases) | - | Existing graph + unchanged commit hash | Report "Graph is up to date" and STOP | + | `--review` flag + existing graph + unchanged commit hash | Skip to Phase 6 (review-only — reuse existing assembled graph) | + | Existing graph + unchanged commit hash | Ask the user: "The graph is up to date at this commit. Would you like to: **(a)** run a full rebuild (`--full`), **(b)** run the LLM graph reviewer (`--review`), or **(c)** do nothing?" Then follow their choice. If they pick (c), STOP. | | Existing graph + changed files | Incremental update (re-analyze changed files only) | + **Review-only path:** Copy the existing `knowledge-graph.json` to `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`, then jump directly to Phase 6 step 3. + For incremental updates, get the changed file list: ```bash git diff ..HEAD --name-only @@ -79,15 +90,19 @@ Dispatch a subagent using the prompt template at `./project-scanner-prompt.md`. Pass these parameters in the dispatch prompt: -> Scan this project directory to discover all source files, detect languages and frameworks. +> Scan this project directory to discover all project files (including non-code files like configs, docs, infrastructure), detect languages and frameworks. > Project root: `$PROJECT_ROOT` > Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/scan-result.json` After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermediate/scan-result.json` to get: - Project name, description - Languages, frameworks -- File list with line counts +- File list with line counts and `fileCategory` per file (`code`, `config`, `docs`, `infra`, `data`, `script`, `markup`) - Complexity estimate +- Import map (`importMap`): pre-resolved project-internal imports per file (non-code files have empty arrays) + +Store `importMap` in memory as `$IMPORT_MAP` for use in Phase 2 batch construction. +Store the file list as `$FILE_LIST` with `fileCategory` metadata for use in Phase 2 batch construction. **Gate check:** If >200 files, inform the user and suggest scoping with a subdirectory argument. Proceed only if user confirms or add guidance that this may take a while. @@ -97,40 +112,49 @@ After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermedi ### Full analysis path -Batch the file list from Phase 1 into groups of **5-10 files each** (aim for balanced batch sizes). +Batch the file list from Phase 1 into groups of **20-30 files each** (aim for ~25 files per batch for balanced sizes). -For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **3 subagents concurrently** using parallel dispatch. +**Batching strategy for non-code files:** +- Group related non-code files together in the same batch when possible: + - Dockerfile + docker-compose.yml + .dockerignore → same batch + - SQL migration files → same batch (ordered by filename) + - CI/CD config files (.github/workflows/*) → same batch + - Documentation files (docs/*.md) → same batch +- This allows the file-analyzer to create cross-file edges (e.g., docker-compose `depends_on` Dockerfile) +- Non-code files can be mixed with code files in the same batch if batch sizes are small +- Each file's `fileCategory` from Phase 1 must be included in the batch file list -**Build the combined prompt template:** -1. Read the base template at `./file-analyzer-prompt.md`. -2. **Language context injection:** For each language detected in Phase 1 (e.g., `python`), read the file at `./languages/.md` (e.g., `./languages/python.md`) and append its content after the base template under a `## Language Context` header. If the file does not exist for a detected language, skip it silently and continue. These files are in the `languages/` subdirectory next to this SKILL.md file. Use `ls ./languages/` to discover available language files if needed. -3. **Framework addendum injection:** For each framework detected in Phase 1 (e.g., `Django`), read the file at `./frameworks/.md` (e.g., `./frameworks/django.md`) and append its full content after the language context. If the file does not exist for a detected framework, skip it silently and continue. These files are in the `frameworks/` subdirectory next to this SKILL.md file. Use `ls ./frameworks/` to discover available framework files if needed. - -Then for each batch pass the combined template content as the subagent's prompt, appending the following additional context: +For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **5 subagents concurrently** using parallel dispatch. Pass the template as the subagent's prompt, appending the following additional context: > **Additional context from main session:** > > Project: `` — `` -> Frameworks detected: `` > Languages: `` -> -> Use the language context and framework addendums (appended above) to produce more accurate summaries and better classify file roles. + +Before dispatching each batch, construct `batchImportData` from `$IMPORT_MAP`: +```json +batchImportData = {} +for each file in this batch: + batchImportData[file.path] = $IMPORT_MAP[file.path] ?? [] +``` Fill in batch-specific parameters below and dispatch: -> Analyze these source files and produce GraphNode and GraphEdge objects. +> Analyze these files and produce GraphNode and GraphEdge objects. > Project root: `$PROJECT_ROOT` > Project: `` > Languages: `` > Batch index: `` > Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-.json` > -> All project files (for import resolution): -> `` +> Pre-resolved import data for this batch (use this for all import edge creation — do NOT re-resolve imports from source): +> ```json +> +> ``` > > Files to analyze in this batch: -> 1. `` ( lines) -> 2. `` ( lines) +> 1. `` ( lines, fileCategory: ``) +> 2. `` ( lines, fileCategory: ``) > ... After ALL batches complete, read each `batch-.json` file and merge: @@ -139,7 +163,7 @@ After ALL batches complete, read each `batch-.json` file and merge: ### Incremental update path -Use the changed files list from Phase 0. Batch and dispatch file-analyzer subagents using the same process as above, but only for changed files. +Use the changed files list from Phase 0. Batch and dispatch file-analyzer subagents using the same process as above (20-30 files per batch, up to 5 concurrent, with batchImportData constructed from $IMPORT_MAP), but only for changed files. After batches complete, merge with the existing graph: 1. Remove old nodes whose `filePath` matches any changed file @@ -162,7 +186,7 @@ Merge all file-analyzer results into a single set of nodes and edges. Then perfo **Build the combined prompt template:** 1. Read the base template at `./architecture-analyzer-prompt.md`. -2. **Language context injection:** For each language detected in Phase 1 (e.g., `python`), read the file at `./languages/.md` (e.g., `./languages/python.md`) and append its content after the base template under a `## Language Context` header. If the file does not exist for a detected language, skip it silently and continue. These files are in the `languages/` subdirectory next to this SKILL.md file. +2. **Language context injection:** For each language detected in Phase 1 (e.g., `python`, `markdown`, `dockerfile`, `yaml`, `sql`, `terraform`, `graphql`, `protobuf`, `shell`, `html`, `css`), read the file at `./languages/.md` (e.g., `./languages/python.md`, `./languages/dockerfile.md`) and append its content after the base template under a `## Language Context` header. If the file does not exist for a detected language, skip it silently and continue. These files are in the `languages/` subdirectory next to this SKILL.md file. **Include non-code language snippets** — they provide edge patterns and summary styles for non-code files. 3. **Framework addendum injection:** For each framework detected in Phase 1 (e.g., `Django`), read the file at `./frameworks/.md` (e.g., `./frameworks/django.md`) and append its full content after the language context. If the file does not exist for a detected framework, skip it silently and continue. These files are in the `frameworks/` subdirectory next to this SKILL.md file. Pass the combined content as the subagent's prompt, appending the following additional context: @@ -176,7 +200,7 @@ Pass the combined content as the subagent's prompt, appending the following addi > $DIR_TREE > ``` > -> Use the directory tree, language context, and framework addendums (appended above) to inform layer assignments. Directory structure is strong evidence for layer boundaries. +> Use the directory tree, language context, and framework addendums (appended above) to inform layer assignments. Directory structure is strong evidence for layer boundaries. Non-code files (config, docs, infrastructure, data) should be assigned to appropriate layers — see the prompt template for guidance. Pass these parameters in the dispatch prompt: @@ -185,22 +209,27 @@ Pass these parameters in the dispatch prompt: > Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/layers.json` > Project: `` — `` > -> File nodes: +> File nodes (all node types — includes code files, config, document, service, pipeline, table, schema, resource, endpoint): > ```json -> [list of {id, name, filePath, summary, tags} for all file-type nodes] +> [list of {id, type, name, filePath, summary, tags} for ALL file-level nodes — omit complexity, languageNotes] > ``` > > Import edges: > ```json > [list of edges with type "imports"] > ``` +> +> All edges (for cross-category analysis — includes configures, documents, deploys, triggers, etc.): +> ```json +> [list of ALL edges — include all edge types] +> ``` After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermediate/layers.json` and normalize it into a final `layers` array. Apply these steps **in order**: 1. **Unwrap envelope:** If the file contains `{ "layers": [...] }` instead of a plain array, extract the inner array. (The prompt requests a plain array, but LLMs may still produce an envelope.) 2. **Rename legacy fields:** If any layer object has a `nodes` field instead of `nodeIds`, rename `nodes` → `nodeIds`. If `nodes` entries are objects with an `id` field rather than plain strings, extract just the `id` values into `nodeIds`. 3. **Synthesize missing IDs:** If any layer is missing an `id`, generate one as `layer:`. -4. **Convert file paths:** If `nodeIds` entries are raw file paths (not prefixed with `file:`), convert them to `file:`. +4. **Convert file paths:** If `nodeIds` entries are raw file paths without a known prefix (`file:`, `config:`, `document:`, `service:`, `pipeline:`, `table:`, `schema:`, `resource:`, `endpoint:`), convert them to `file:`. 5. **Drop dangling refs:** Remove any `nodeIds` entries that do not exist in the merged node set. Each element of the final `layers` array MUST have this shape: @@ -211,7 +240,7 @@ Each element of the final `layers` array MUST have this shape: "id": "layer:", "name": "", "description": "", - "nodeIds": ["file:src/App.tsx", "file:src/main.tsx"] + "nodeIds": ["file:src/App.tsx", "config:tsconfig.json", "document:README.md"] } ] ``` @@ -254,26 +283,26 @@ Pass these parameters in the dispatch prompt: > Project: `` — `` > Languages: `` > -> Nodes (summarized): +> Nodes (all file-level nodes — includes code files, config, document, service, pipeline, table, schema, resource, endpoint): > ```json -> [list of {id, name, filePath, summary, type} for key nodes] +> [list of {id, name, filePath, summary, type} for ALL file-level nodes — do NOT include function or class nodes] > ``` > > Layers: > ```json -> [layers from Phase 4] +> [list of {id, name, description} for each layer — omit nodeIds] > ``` > -> Key edges: +> Edges (all types — includes imports, calls, configures, documents, deploys, triggers, etc.): > ```json -> [imports and calls edges] +> [list of ALL edges — include all edge types for complete graph topology analysis] > ``` After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermediate/tour.json` and normalize it into a final `tour` array. Apply these steps **in order**: 1. **Unwrap envelope:** If the file contains `{ "steps": [...] }` instead of a plain array, extract the inner array. (The prompt requests a plain array, but LLMs may still produce an envelope.) 2. **Rename legacy fields:** If any step has `nodesToInspect` instead of `nodeIds`, rename it → `nodeIds`. If any step has `whyItMatters` instead of `description`, rename it → `description`. -3. **Convert file paths:** If `nodeIds` entries are raw file paths, convert them to `file:`. +3. **Convert file paths:** If `nodeIds` entries are raw file paths without a known prefix (`file:`, `config:`, `document:`, `service:`, `pipeline:`, `table:`, `schema:`, `resource:`, `endpoint:`), convert them to `file:`. 4. **Drop dangling refs:** Remove any `nodeIds` entries that do not exist in the merged node set. 5. **Sort** by `order` before saving. @@ -283,7 +312,13 @@ Each element of the final `tour` array MUST have this shape: [ { "order": 1, - "title": "Start at the app entry", + "title": "Project Overview", + "description": "Start with the README to understand the project's purpose and architecture.", + "nodeIds": ["document:README.md"] + }, + { + "order": 2, + "title": "Application Entry Point", "description": "This step explains how the frontend boots and mounts.", "nodeIds": ["file:src/main.tsx", "file:src/App.tsx"] } @@ -327,7 +362,96 @@ Assemble the full KnowledgeGraph JSON object: 2. Write the assembled graph to `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`. -3. Dispatch a subagent using the prompt template at `./graph-reviewer-prompt.md`. Read the template file and pass the full content as the subagent's prompt, appending the following additional context: +3. **Check `$ARGUMENTS` for `--review` flag.** Then run the appropriate validation path: + +--- + +#### Default path (no `--review`): inline deterministic validation + +Write the following Node.js script to `$PROJECT_ROOT/.understand-anything/tmp/ua-inline-validate.cjs`: + +```javascript +#!/usr/bin/env node +const fs = require('fs'); +const graphPath = process.argv[2]; +const outputPath = process.argv[3]; +try { + const graph = JSON.parse(fs.readFileSync(graphPath, 'utf8')); + const issues = [], warnings = []; + if (!Array.isArray(graph.nodes)) { issues.push('graph.nodes is missing or not an array'); graph.nodes = []; } + if (!Array.isArray(graph.edges)) { issues.push('graph.edges is missing or not an array'); graph.edges = []; } + const nodeIds = new Set(); + const seen = new Map(); + graph.nodes.forEach((n, i) => { + if (!n.id) { issues.push(`Node[${i}] missing id`); return; } + if (!n.type) issues.push(`Node[${i}] '${n.id}' missing type`); + if (!n.name) issues.push(`Node[${i}] '${n.id}' missing name`); + if (!n.summary) issues.push(`Node[${i}] '${n.id}' missing summary`); + if (!n.tags || !n.tags.length) issues.push(`Node[${i}] '${n.id}' missing tags`); + if (seen.has(n.id)) issues.push(`Duplicate node ID '${n.id}' at indices ${seen.get(n.id)} and ${i}`); + else seen.set(n.id, i); + nodeIds.add(n.id); + }); + graph.edges.forEach((e, i) => { + if (!nodeIds.has(e.source)) issues.push(`Edge[${i}] source '${e.source}' not found`); + if (!nodeIds.has(e.target)) issues.push(`Edge[${i}] target '${e.target}' not found`); + }); + const fileLevelTypes = new Set(['file', 'config', 'document', 'service', 'pipeline', 'table', 'schema', 'resource', 'endpoint']); + const fileNodes = graph.nodes.filter(n => fileLevelTypes.has(n.type)).map(n => n.id); + const assigned = new Map(); + if (!Array.isArray(graph.layers)) { if (graph.layers) warnings.push('graph.layers is not an array'); graph.layers = []; } + if (!Array.isArray(graph.tour)) { if (graph.tour) warnings.push('graph.tour is not an array'); graph.tour = []; } + graph.layers.forEach(layer => { + (layer.nodeIds || []).forEach(id => { + if (!nodeIds.has(id)) issues.push(`Layer '${layer.id}' refs missing node '${id}'`); + if (assigned.has(id)) issues.push(`Node '${id}' appears in multiple layers`); + assigned.set(id, layer.id); + }); + }); + fileNodes.forEach(id => { + if (!assigned.has(id)) issues.push(`File node '${id}' not in any layer`); + }); + graph.tour.forEach((step, i) => { + (step.nodeIds || []).forEach(id => { + if (!nodeIds.has(id)) issues.push(`Tour step[${i}] refs missing node '${id}'`); + }); + }); + const withEdges = new Set([ + ...graph.edges.map(e => e.source), + ...graph.edges.map(e => e.target) + ]); + graph.nodes.forEach(n => { + if (!withEdges.has(n.id)) warnings.push(`Node '${n.id}' has no edges (orphan)`); + }); + const stats = { + totalNodes: graph.nodes.length, + totalEdges: graph.edges.length, + totalLayers: graph.layers.length, + tourSteps: graph.tour.length, + nodeTypes: graph.nodes.reduce((a, n) => { a[n.type] = (a[n.type]||0)+1; return a; }, {}), + edgeTypes: graph.edges.reduce((a, e) => { a[e.type] = (a[e.type]||0)+1; return a; }, {}) + }; + fs.writeFileSync(outputPath, JSON.stringify({ issues, warnings, stats }, null, 2)); + process.exit(0); +} catch (err) { process.stderr.write(err.message + '\n'); process.exit(1); } +``` + +Execute it: +```bash +node $PROJECT_ROOT/.understand-anything/tmp/ua-inline-validate.cjs \ + "$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json" \ + "$PROJECT_ROOT/.understand-anything/intermediate/review.json" +``` + +If the script exits non-zero, read stderr, fix the script, and retry once. + +--- + +#### `--review` path: full LLM reviewer + +If `--review` IS in `$ARGUMENTS`, dispatch the LLM graph-reviewer subagent as follows: + +Dispatch a subagent using the prompt template at `./graph-reviewer-prompt.md`. Read the template file and pass the full content as the subagent's prompt, appending the following additional context: > **Additional context from main session:** > @@ -339,18 +463,20 @@ Assemble the full KnowledgeGraph JSON object: > Phase warnings/errors accumulated during analysis: > - [list any batch failures, skipped files, or warnings from Phases 2-5] > -> Cross-validate: every file in the scan inventory should have a corresponding `file:` node in the graph. Flag any missing files. Also flag any graph nodes whose `filePath` doesn't appear in the scan inventory. +> Cross-validate: every file in the scan inventory should have a corresponding node in the graph (node types may vary: `file:`, `config:`, `document:`, `service:`, `pipeline:`, `table:`, `schema:`, `resource:`, `endpoint:`). Flag any missing files. Also flag any graph nodes whose `filePath` doesn't appear in the scan inventory. Pass these parameters in the dispatch prompt: - > Validate the knowledge graph at `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`. - > Project root: `$PROJECT_ROOT` - > Read the file and validate it for completeness and correctness. - > Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/review.json` +> Validate the knowledge graph at `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`. +> Project root: `$PROJECT_ROOT` +> Read the file and validate it for completeness and correctness. +> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/review.json` -4. After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermediate/review.json`. +--- -5. **If `approved: false`:** +4. Read `$PROJECT_ROOT/.understand-anything/intermediate/review.json`. + +5. **If `issues` array is non-empty:** - Review the `issues` list - Apply automated fixes where possible: - Remove edges with dangling references @@ -359,7 +485,7 @@ Pass these parameters in the dispatch prompt: - Re-run the final graph validation after automated fixes - If critical issues remain after one fix attempt, save the graph anyway but include the warnings in the final report and mark dashboard auto-launch as skipped -6. **If `approved: true`:** Proceed to Phase 7. +6. **If `issues` array is empty:** Proceed to Phase 7. --- @@ -377,6 +503,18 @@ Pass these parameters in the dispatch prompt: } ``` +2.5. **Generate structural fingerprints** for all analyzed files and save to `$PROJECT_ROOT/.understand-anything/fingerprints.json`. This creates the baseline for future automatic incremental updates. + + Write and execute a Node.js script that uses the core fingerprint module (tree-sitter-based, not regex): + ```javascript + import { buildFingerprintStore } from '@understand-anything/core'; + import { saveFingerprints } from '@understand-anything/core'; + + const store = await buildFingerprintStore('', sourceFilePaths); + saveFingerprints('', store); + ``` + Where `sourceFilePaths` is the list of all analyzed source file paths from Phase 1. This uses the same tree-sitter analysis pipeline as the main fingerprint engine, ensuring the baseline matches the comparison logic used during auto-updates. + 3. Clean up intermediate files: ```bash rm -rf $PROJECT_ROOT/.understand-anything/intermediate @@ -385,8 +523,8 @@ Pass these parameters in the dispatch prompt: 4. Report a summary to the user containing: - Project name and description - - Files analyzed / total files - - Nodes created (broken down by type: file, function, class) + - Files analyzed / total files (with breakdown by fileCategory: code, config, docs, infra, data, script, markup) + - Nodes created (broken down by type: file, function, class, config, document, service, table, endpoint, pipeline, schema, resource) - Edges created (broken down by type) - Layers identified (with names) - Tour steps generated (count) @@ -401,7 +539,7 @@ Pass these parameters in the dispatch prompt: ## Error Handling - If any subagent dispatch fails, retry **once** with the same prompt plus additional context about the failure. -- Track all warnings and errors from each phase in a `$PHASE_WARNINGS` list. Pass this list to the graph-reviewer in Phase 6 for comprehensive validation. +- Track all warnings and errors from each phase in a `$PHASE_WARNINGS` list. When using `--review`, pass this list to the graph-reviewer in Phase 6. On the default path, include accumulated warnings in the Phase 7 final report. - If it fails a second time, skip that phase and continue with partial results. - ALWAYS save partial results — a partial graph is better than no graph. - Report any skipped phases or errors in the final summary so the user knows what happened. @@ -411,16 +549,24 @@ Pass these parameters in the dispatch prompt: ## Reference: KnowledgeGraph Schema -### Node Types +### Node Types (13 total) | Type | Description | ID Convention | |---|---|---| -| `file` | Source file | `file:` | +| `file` | Source code file | `file:` | | `function` | Function or method | `function::` | | `class` | Class, interface, or type | `class::` | | `module` | Logical module or package | `module:` | | `concept` | Abstract concept or pattern | `concept:` | +| `config` | Configuration file (YAML, JSON, TOML, env) | `config:` | +| `document` | Documentation file (Markdown, RST, TXT) | `document:` | +| `service` | Deployable service definition (Dockerfile, K8s) | `service:` | +| `table` | Database table or migration | `table::` | +| `endpoint` | API endpoint or route definition | `endpoint::` | +| `pipeline` | CI/CD pipeline configuration | `pipeline:` | +| `schema` | Schema definition (GraphQL, Protobuf, Prisma) | `schema:` | +| `resource` | Infrastructure resource (Terraform, CloudFormation) | `resource:` | -### Edge Types (18 total) +### Edge Types (26 total) | Category | Types | |---|---| | Structural | `imports`, `exports`, `contains`, `inherits`, `implements` | @@ -428,14 +574,16 @@ Pass these parameters in the dispatch prompt: | Data flow | `reads_from`, `writes_to`, `transforms`, `validates` | | Dependencies | `depends_on`, `tested_by`, `configures` | | Semantic | `related`, `similar_to` | +| Infrastructure | `deploys`, `serves`, `provisions`, `triggers` | +| Schema/Data | `migrates`, `documents`, `routes`, `defines_schema` | ### Edge Weight Conventions | Edge Type | Weight | |---|---| | `contains` | 1.0 | | `inherits`, `implements` | 0.9 | -| `calls`, `exports` | 0.8 | -| `imports` | 0.7 | -| `depends_on` | 0.6 | -| `tested_by` | 0.5 | +| `calls`, `exports`, `defines_schema` | 0.8 | +| `imports`, `deploys`, `migrates` | 0.7 | +| `depends_on`, `configures`, `triggers` | 0.6 | +| `tested_by`, `documents`, `provisions`, `serves`, `routes` | 0.5 | | All others | 0.5 (default) | diff --git a/understand-anything-plugin/skills/understand/architecture-analyzer-prompt.md b/understand-anything-plugin/skills/understand/architecture-analyzer-prompt.md index fb4dca4..66d61c3 100644 --- a/understand-anything-plugin/skills/understand/architecture-analyzer-prompt.md +++ b/understand-anything-plugin/skills/understand/architecture-analyzer-prompt.md @@ -2,11 +2,11 @@ > Used by `/understand` Phase 4. Dispatch as a subagent with this full content as the prompt. -You are an expert software architect. Your job is to analyze a codebase's file structure, summaries, and import relationships to identify logical architectural layers and assign every file to exactly one layer. Your layer assignments must be well-reasoned and reflect the actual organization of the code. +You are an expert software architect. Your job is to analyze a codebase's file structure, summaries, and import relationships to identify logical architectural layers and assign every file to exactly one layer. Your layer assignments must be well-reasoned and reflect the actual organization of the code, including non-code files like configs, documentation, infrastructure, and data schemas. ## Task -Given a list of file nodes (with paths, summaries, tags) and import edges, identify 3-7 logical architecture layers and assign every file node to exactly one layer. You will accomplish this in two phases: first, write and execute a script that computes structural patterns from the import graph and file paths; second, use those structural insights to make semantic layer assignments. +Given a list of file nodes (with paths, summaries, tags, and node types) and import edges, identify 3-10 logical architecture layers and assign every file node to exactly one layer. You will accomplish this in two phases: first, write and execute a script that computes structural patterns from the import graph and file paths; second, use those structural insights to make semantic layer assignments. --- @@ -20,10 +20,18 @@ Write a Node.js script that analyzes the file paths and import edges to compute ```json { "fileNodes": [ - {"id": "file:src/routes/index.ts", "name": "index.ts", "filePath": "src/routes/index.ts", "summary": "...", "tags": ["api-handler"]} + {"id": "file:src/routes/index.ts", "type": "file", "name": "index.ts", "filePath": "src/routes/index.ts", "summary": "...", "tags": ["api-handler"]}, + {"id": "config:tsconfig.json", "type": "config", "name": "tsconfig.json", "filePath": "tsconfig.json", "summary": "...", "tags": ["configuration"]}, + {"id": "document:README.md", "type": "document", "name": "README.md", "filePath": "README.md", "summary": "...", "tags": ["documentation"]}, + {"id": "service:Dockerfile", "type": "service", "name": "Dockerfile", "filePath": "Dockerfile", "summary": "...", "tags": ["infrastructure"]} ], "importEdges": [ {"source": "file:src/routes/index.ts", "target": "file:src/services/auth.ts", "type": "imports"} + ], + "allEdges": [ + {"source": "file:src/routes/index.ts", "target": "file:src/services/auth.ts", "type": "imports"}, + {"source": "config:tsconfig.json", "target": "file:src/index.ts", "type": "configures"}, + {"source": "service:Dockerfile", "target": "file:src/index.ts", "type": "deploys"} ] } ``` @@ -42,13 +50,31 @@ Group all file node IDs by their top-level directory (first path segment after t If the project has a flat structure (all files in one directory), group by second-level directory or by filename pattern. -**B. Import Adjacency Matrix** +**B. Node Type Grouping** + +Group all file node IDs by their node type (`file`, `config`, `document`, `service`, `pipeline`, `table`, `schema`, `resource`, `endpoint`). This reveals the distribution of code vs. non-code files. + +**C. Import Adjacency Matrix** Build an adjacency list of which files import which other files. Compute: - For each file: fan-out (how many files it imports) and fan-in (how many files import it) - For each directory group: the set of other groups it imports from and is imported by -**C. Inter-Group Import Frequency** +**D. Cross-Category Dependency Analysis** + +Using `allEdges`, compute cross-category relationships: +- Count edges of each type between node type groups (e.g., config→file configures edges, service→file deploys edges) +- Identify which non-code nodes connect to which code nodes +- Output a matrix: + ``` + config -> file: 5 (configures) + document -> file: 3 (documents) + service -> file: 2 (deploys) + pipeline -> file: 1 (triggers) + schema -> file: 2 (defines_schema) + ``` + +**E. Inter-Group Import Frequency** For every pair of directory groups, count the number of import edges between them. Produce a matrix: ``` @@ -60,11 +86,11 @@ services -> utils: 5 This reveals dependency direction between groups. -**D. Intra-Group Import Density** +**F. Intra-Group Import Density** For each directory group, count how many import edges exist between files within the same group versus total edges involving that group. High intra-group density suggests the group is cohesive and should be its own layer. -**E. Directory Pattern Matching** +**G. Directory Pattern Matching** Classify each directory name against known architectural patterns: @@ -100,6 +126,13 @@ Classify each directory name against known architectural patterns: | `blueprints` | `api` | | `mailers`, `jobs`, `channels` | `service` | | `bin` | `entry` | +| `docs`, `documentation`, `wiki` | `documentation` | +| `deploy`, `deployment`, `infra`, `infrastructure` | `infrastructure` | +| `.github`, `.gitlab`, `.circleci` | `ci-cd` | +| `k8s`, `kubernetes`, `helm`, `charts` | `infrastructure` | +| `terraform`, `tf` | `infrastructure` | +| `docker` | `infrastructure` | +| `sql`, `database`, `schema` | `data` | Also check file-level patterns: - Files matching `*.test.*` or `*.spec.*` or `test_*.py` or `*_test.go` or `*Test.java` or `*_spec.rb` or `*Test.php` or `*Tests.cs` -> `test` @@ -112,8 +145,68 @@ Also check file-level patterns: - Files named `Application.java` or `Program.cs` -> `entry` (JVM / .NET entry points) - Files named `config.ru` -> `entry` (Ruby Rack entry point) - Files named `Cargo.toml`, `go.mod`, `Gemfile`, `pom.xml`, `build.gradle`, `composer.json` -> `config` (language-level project config) +- `Dockerfile`, `docker-compose.*` -> `infrastructure` +- `*.tf`, `*.tfvars` -> `infrastructure` +- `.github/workflows/*`, `.gitlab-ci.yml`, `Jenkinsfile` -> `ci-cd` +- `*.sql` -> `data` +- `*.graphql`, `*.gql`, `*.proto` -> `types` +- `*.md`, `*.rst` -> `documentation` +- `Makefile` -> `infrastructure` -**F. Dependency Direction** +**H. Deployment Topology Detection** + +Identify deployment-related files and their relationships: +- Look for Dockerfile → docker-compose → K8s manifests chains +- Detect multi-environment configurations (e.g., Dockerfile.dev, Dockerfile.prod, docker-compose.prod.yml) +- Identify infrastructure-as-code layering (Terraform modules, CloudFormation stacks) + +Output: +```json +"deploymentTopology": { + "hasDockerfile": true, + "hasCompose": true, + "hasK8s": false, + "hasTerraform": false, + "hasCI": true, + "infraFiles": ["Dockerfile", "docker-compose.yml", ".github/workflows/ci.yml"] +} +``` + +**I. Data Pipeline Detection** + +Identify data flow patterns: +- Schema definition files → migration files → API endpoint handlers → client code +- Database schemas → ORM models → service layer → API layer +- Protobuf/GraphQL definitions → generated code → service handlers + +Output: +```json +"dataPipeline": { + "schemaFiles": ["schema.sql", "schema.graphql"], + "migrationFiles": ["migrations/001_init.sql"], + "dataModelFiles": ["src/models/user.ts"], + "apiHandlerFiles": ["src/routes/users.ts"] +} +``` + +**J. Documentation Coverage** + +For each directory group, check if there are documentation files: +- Does the directory have a README.md? +- Are there docs/*.md files that reference code in this group? +- Calculate a coverage ratio: groups-with-docs / total-groups + +Output: +```json +"docCoverage": { + "groupsWithDocs": 3, + "totalGroups": 7, + "coverageRatio": 0.43, + "undocumentedGroups": ["middleware", "utils", "state", "types"] +} +``` + +**K. Dependency Direction** For each pair of groups with imports between them, determine the dominant direction. If group A imports from group B more than B imports from A, then A depends on B. Output this as a list of directed dependency relationships. @@ -127,6 +220,17 @@ For each pair of groups with imports between them, determine the dominant direct "services": ["file:src/services/auth.ts", "file:src/services/user.ts"], "utils": ["file:src/utils/format.ts"] }, + "nodeTypeGroups": { + "file": ["file:src/index.ts", "file:src/utils.ts"], + "config": ["config:tsconfig.json", "config:package.json"], + "document": ["document:README.md"], + "service": ["service:Dockerfile"], + "pipeline": ["pipeline:.github/workflows/ci.yml"] + }, + "crossCategoryEdges": [ + {"fromType": "config", "toType": "file", "edgeType": "configures", "count": 5}, + {"fromType": "service", "toType": "file", "edgeType": "deploys", "count": 2} + ], "interGroupImports": [ {"from": "routes", "to": "services", "count": 12}, {"from": "services", "to": "utils", "count": 5} @@ -140,13 +244,34 @@ For each pair of groups with imports between them, determine the dominant direct "services": "service", "utils": "utility" }, + "deploymentTopology": { + "hasDockerfile": true, + "hasCompose": true, + "hasK8s": false, + "hasTerraform": false, + "hasCI": true, + "infraFiles": ["Dockerfile", "docker-compose.yml", ".github/workflows/ci.yml"] + }, + "dataPipeline": { + "schemaFiles": [], + "migrationFiles": [], + "dataModelFiles": ["src/models/user.ts"], + "apiHandlerFiles": ["src/routes/users.ts"] + }, + "docCoverage": { + "groupsWithDocs": 1, + "totalGroups": 5, + "coverageRatio": 0.2, + "undocumentedGroups": ["services", "utils", "routes"] + }, "dependencyDirection": [ {"dependent": "routes", "dependsOn": "services"}, {"dependent": "services", "dependsOn": "utils"} ], "fileStats": { "totalFileNodes": 42, - "filesPerGroup": {"routes": 8, "services": 12, "utils": 5} + "filesPerGroup": {"routes": 8, "services": 12, "utils": 5}, + "nodeTypeCounts": {"file": 30, "config": 5, "document": 3, "service": 2, "pipeline": 2} }, "fileFanIn": { "file:src/utils/format.ts": 15, @@ -166,8 +291,9 @@ Before writing the script, create its input JSON file: ```bash cat > $PROJECT_ROOT/.understand-anything/tmp/ua-arch-input.json << 'ENDJSON' { - "fileNodes": [], - "importEdges": [] + "fileNodes": [], + "importEdges": [], + "allEdges": [] } ENDJSON ``` @@ -203,25 +329,55 @@ Use the `dependencyDirection` data to understand the project's layering: - Middle layers depend on bottom layers (Data, Utility, Types) - This forms a dependency hierarchy that should map to your layer ordering -### Step 3 -- Consider File Summaries and Tags +### Step 3 -- Consider Non-Code Layers + +Use `nodeTypeGroups` and `deploymentTopology` to determine if non-code layers are warranted: + +- **Infrastructure layer:** Create if the project has Dockerfiles, Terraform, K8s manifests, or other deployment files. Include all `service` and `resource` type nodes. +- **CI/CD layer:** Create if the project has CI/CD configs (.github/workflows, .gitlab-ci.yml, Jenkinsfile). Include all `pipeline` type nodes. May be merged with Infrastructure if few files. +- **Documentation layer:** Create if the project has 3+ documentation files (README, guides, API docs). Include all `document` type nodes. May be merged with a "Project" or "Root" layer if few files. +- **Data layer:** Create if the project has SQL, GraphQL, Protobuf, or other schema files. Include `table`, `schema`, and `endpoint` type nodes. May be merged with an existing "Data" or "Models" layer. +- **Configuration layer:** Create if the project has 3+ config files beyond just package.json. Include all `config` type nodes. May be merged with a "Root" or "Project" layer if few files. + +**Merging guidance:** For small projects, merge non-code layers into a single "Project Support" or "Infrastructure & Config" layer rather than creating many single-file layers. For larger projects, separate them into distinct layers. + +### Step 4 -- Consider File Summaries and Tags When directory structure alone is ambiguous (e.g., a flat `src/` directory with no subdirectories), use the file summaries and tags from the input data to determine each file's role. Think about what responsibility the file fulfills in the system. -### Step 4 -- Select 3-7 Layers +### Step 5 -- Select 3-10 Layers Choose layers based on the project's actual architecture, informed by the script's structural data. Common patterns include: -- **Layered architecture:** API -> Service -> Data -- **Component-based:** UI Components, State, Services, Utils -- **MVC:** Models, Views, Controllers -- **Monorepo packages:** Each package forms its own layer -- **Library:** Core, Plugins, Types, Tests +- **Layered architecture:** API -> Service -> Data + Infrastructure + Config +- **Component-based:** UI Components, State, Services, Utils, Infrastructure +- **MVC:** Models, Views, Controllers + Config + Docs +- **Monorepo packages:** Each package forms its own layer + shared infra +- **Library:** Core, Plugins, Types, Tests, Documentation + +**Layer hint for non-code files:** + +| Pattern | Suggested Layer | +|---|---| +| Dockerfile, docker-compose.*, K8s manifests, Terraform | `layer:infrastructure` | +| .github/workflows/*, .gitlab-ci.yml, Jenkinsfile | `layer:ci-cd` or merge into `layer:infrastructure` | +| README.md, docs/*.md, CONTRIBUTING.md, CHANGELOG.md | `layer:documentation` or merge into relevant code layer | +| *.sql, migrations/*.sql | `layer:data` | +| *.graphql, *.proto, *.prisma | `layer:data` or `layer:types` | +| package.json, tsconfig.json, *.toml, *.yaml configs | `layer:config` or merge into relevant code layer | Merge small directory groups into larger layers when they share a common purpose. Prefer fewer, well-defined layers over many granular ones. -### Step 5 -- Assign Every File Node +### Step 6 -- Assign Every File Node Go through each file node ID from the input and assign it to exactly one layer. Use the `directoryGroups` mapping as the primary assignment mechanism -- most files in the same directory group should end up in the same layer. +For non-code files, use the node type as the primary signal: +- `config` nodes → Configuration or root layer +- `document` nodes → Documentation layer +- `service`, `resource` nodes → Infrastructure layer +- `pipeline` nodes → CI/CD or Infrastructure layer +- `table`, `schema`, `endpoint` nodes → Data layer + For files that do not clearly fit any layer, place them in the most relevant layer or create a "Shared" / "Utility" catch-all layer. Do not leave any file unassigned. **Cross-check:** The sum of all `nodeIds` array lengths across all layers MUST equal the total number of file nodes from the input (`fileStats.totalFileNodes` from the script output). @@ -231,6 +387,7 @@ For files that do not clearly fit any layer, place them in the most relevant lay Use `layer:` format consistently: - `layer:api`, `layer:service`, `layer:data`, `layer:ui`, `layer:middleware` - `layer:utility`, `layer:config`, `layer:test`, `layer:types`, `layer:state` +- `layer:infrastructure`, `layer:documentation`, `layer:ci-cd` ## Output Format @@ -250,6 +407,30 @@ Produce a single, valid JSON array. Every field shown is **required**. "description": "Core business logic, domain services, and orchestration", "nodeIds": ["file:src/services/auth.ts", "file:src/services/user.ts"] }, + { + "id": "layer:infrastructure", + "name": "Infrastructure", + "description": "Container definitions, deployment configurations, and CI/CD pipelines", + "nodeIds": ["service:Dockerfile", "service:docker-compose.yml", "pipeline:.github/workflows/ci.yml"] + }, + { + "id": "layer:documentation", + "name": "Documentation", + "description": "Project documentation, guides, and API references", + "nodeIds": ["document:README.md", "document:docs/getting-started.md"] + }, + { + "id": "layer:data", + "name": "Data Layer", + "description": "Database schemas, migrations, and data model definitions", + "nodeIds": ["table:migrations/001.sql:users", "schema:schema.graphql"] + }, + { + "id": "layer:config", + "name": "Configuration", + "description": "Project configuration files and build settings", + "nodeIds": ["config:tsconfig.json", "config:package.json"] + }, { "id": "layer:utility", "name": "Utility Layer", @@ -267,11 +448,11 @@ Produce a single, valid JSON array. Every field shown is **required**. ## Critical Constraints -- EVERY file node ID from the input MUST appear in exactly one layer's `nodeIds` array. Missing file assignments break the downstream pipeline. +- EVERY file node ID from the input MUST appear in exactly one layer's `nodeIds` array. Missing file assignments break the downstream pipeline. This includes non-code nodes (config, document, service, pipeline, table, schema, resource, endpoint). - NEVER include node IDs in `nodeIds` that were not provided in the input. Do not invent node IDs. - NEVER create a layer with an empty `nodeIds` array. - ALWAYS verify your output accounts for all input file nodes. Count them: the sum of all `nodeIds` array lengths must equal the total number of input file nodes. -- Keep to 3-7 layers. If the project is very small (under 10 files), 3 layers is sufficient. If large (100+ files), up to 7 is appropriate. +- Keep to 3-10 layers. If the project is very small (under 10 files), 3 layers is sufficient. If large (100+ files), up to 10 is appropriate. - Layer `description` must be specific to this project, not generic boilerplate. - Trust the script's structural analysis. Do NOT re-read source files or re-count imports. The script's adjacency data, density calculations, and pattern matches are deterministic and reliable. diff --git a/understand-anything-plugin/skills/understand/file-analyzer-prompt.md b/understand-anything-plugin/skills/understand/file-analyzer-prompt.md index 27534a5..974d0a0 100644 --- a/understand-anything-plugin/skills/understand/file-analyzer-prompt.md +++ b/understand-anything-plugin/skills/understand/file-analyzer-prompt.md @@ -8,11 +8,13 @@ You are an expert code analyst. Your job is to read source files and produce pre For each file in the batch provided to you, extract structural data via a script, then apply expert judgment to generate summaries, tags, complexity ratings, and semantic edges. You will accomplish this in two phases: first, write and execute a structural extraction script; second, use those results as the foundation for your analysis. +**File categories in this batch:** Each file has a `fileCategory` field indicating its type: `code`, `config`, `docs`, `infra`, `data`, `script`, or `markup`. Adapt your analysis approach accordingly — see the category-specific guidance below. + --- ## Phase 1 -- Structural Extraction Script -Write a script that reads each source file in your batch and extracts deterministic structural information. Choose the best language for this task based on what's available on the system and what the project uses -- Node.js, Python, or bash with grep are all valid choices. +Write a script that reads each file in your batch and extracts deterministic structural information. Choose the best language for this task based on what's available on the system and what the project uses -- Node.js, Python, or bash with grep are all valid choices. ### Script Requirements @@ -20,11 +22,16 @@ Write a script that reads each source file in your batch and extracts determinis ```json { "projectRoot": "/path/to/project", - "allProjectFiles": ["src/index.ts", "src/utils.ts", "..."], "batchFiles": [ - {"path": "src/index.ts", "language": "typescript", "sizeLines": 150}, - {"path": "src/utils.ts", "language": "typescript", "sizeLines": 80} - ] + {"path": "src/index.ts", "language": "typescript", "sizeLines": 150, "fileCategory": "code"}, + {"path": "README.md", "language": "markdown", "sizeLines": 45, "fileCategory": "docs"}, + {"path": "Dockerfile", "language": "dockerfile", "sizeLines": 22, "fileCategory": "infra"} + ], + "batchImportData": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "README.md": [], + "Dockerfile": [] + } } ``` 2. **Write** results JSON to the path given as the second argument. @@ -32,7 +39,9 @@ Write a script that reads each source file in your batch and extracts determinis ### What the Script Must Extract (Per File) -For each file in `batchFiles`, read the file content and extract: +The extraction approach depends on the file's `fileCategory`: + +#### For `code` files: **Functions and Methods:** - Name, start line, end line, parameter names @@ -45,10 +54,9 @@ For each file in `batchFiles`, read the file content and extract: - Detection approach: match `class `, `interface `, `type =`, `struct `, `trait `, `impl ` as appropriate **Imports:** -- Source module path (exactly as written in the import statement) -- Imported specifiers (named imports, default import, namespace import) -- Line number -- For relative imports (starting with `./` or `../`), compute the resolved path relative to project root. Cross-reference against `allProjectFiles` to confirm the resolved path exists. Mark unresolvable imports. +- Do NOT extract imports in the script. Import resolution has already been performed by the project scanner. +- The pre-resolved imports for each file are provided in `batchImportData` in the input JSON. +- Do not include an `imports` field in the script output — import edges will be created in Phase 2 using `batchImportData` directly. **Exports:** - Exported names and their line numbers @@ -57,10 +65,92 @@ For each file in `batchFiles`, read the file content and extract: **Basic Metrics:** - Total line count - Non-empty line count (lines that are not blank or comment-only) -- Import count (number of import statements) +- Import count — use `batchImportData[file.path].length` from the input JSON (do not count from source) - Export count (number of export statements) - Function count, class count +#### For `config` files (YAML, JSON, TOML, XML, .env, etc.): + +**Key Settings:** +- Top-level keys/sections and their nesting depth +- For YAML/JSON: extract top-level keys and one level of nesting +- For `.env` files: extract variable names (not values) +- For `tsconfig.json`, `package.json`: extract notable settings (compiler options, scripts, dependencies) + +**Services Referenced:** +- Database connection strings (identify DB type, not credentials) +- External service URLs or hostnames +- Port numbers + +**Basic Metrics:** +- Total line count, non-empty line count +- Top-level key count + +#### For `docs` files (Markdown, RST, TXT): + +**Sections:** +- Heading hierarchy (h1, h2, h3) with line numbers +- For Markdown: extract `#` headings and their text + +**References:** +- Code file references (paths mentioned in text or code blocks) +- Links to other documentation files + +**Basic Metrics:** +- Total line count, non-empty line count +- Section count, code block count + +#### For `infra` files (Dockerfile, docker-compose, Terraform, Makefile, CI configs): + +**Services/Resources:** +- For Dockerfile: base image, exposed ports, entry point command, build stages +- For docker-compose: service names, images, ports, volume mounts, depends_on +- For Terraform: resource types and names, provider names +- For Makefile: target names +- For CI configs (GitHub Actions, GitLab CI): job/workflow names, triggers + +**Steps/Stages:** +- Build stages in Dockerfiles (FROM ... AS ...) +- CI pipeline stages/jobs +- Makefile targets and their dependencies + +**Basic Metrics:** +- Total line count, non-empty line count +- Stage count / job count / target count + +#### For `data` files (SQL, GraphQL, Protobuf, Prisma): + +**Definitions:** +- For SQL: table names (CREATE TABLE), column names and types, foreign key relationships +- For GraphQL: type definitions, query/mutation names, field lists +- For Protobuf: message names, field names, service definitions +- For Prisma: model names, field names, relations + +**Relationships:** +- Foreign keys and references between tables/types +- Service dependencies + +**Basic Metrics:** +- Total line count, non-empty line count +- Table/type/message count, field count + +#### For `script` files (shell, PowerShell, batch): + +Treat similarly to `code` files: +- Extract function definitions (`function name()` or `name()` in bash) +- Extract significant commands and pipeline operations +- Basic metrics: total lines, non-empty lines, function count + +#### For `markup` files (HTML, CSS, SCSS): + +**Structural Elements:** +- For HTML: major semantic elements (`
`, `