diff --git a/.gitignore b/.gitignore index fafe0f1..9375422 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist .env.* coverage/ *.log +.claude/ 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)