mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
Merge pull request #42 from Lum1104/feat/dashboard-robustness
feat: dashboard robustness — permissive graph loading with user-friendly warnings
This commit is contained in:
@@ -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": "1.2.1",
|
||||
"source": "./understand-anything-plugin"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"author": {
|
||||
"name": "Lum1104"
|
||||
},
|
||||
@@ -15,4 +15,4 @@
|
||||
"onboarding",
|
||||
"dashboard"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "1.2.1",
|
||||
"author": {
|
||||
"name": "Lum1104"
|
||||
},
|
||||
|
||||
@@ -7,3 +7,4 @@ dist
|
||||
.env.*
|
||||
coverage/
|
||||
*.log
|
||||
.claude/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@understand-anything/skill",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -16,4 +16,4 @@
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,389 @@ 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<string, string> = {
|
||||
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<string, string> = {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -33,7 +33,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;
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("persistence", () => {
|
||||
edges: [
|
||||
{
|
||||
source: "node-1",
|
||||
target: "node-2",
|
||||
target: "node-1",
|
||||
type: "imports",
|
||||
direction: "forward",
|
||||
weight: 0.8,
|
||||
|
||||
@@ -38,6 +38,232 @@ export const EDGE_TYPE_ALIASES: Record<string, string> = {
|
||||
subscribe: "subscribes",
|
||||
};
|
||||
|
||||
// Aliases for complexity values LLMs commonly generate
|
||||
export const COMPLEXITY_ALIASES: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
to: "forward",
|
||||
outbound: "forward",
|
||||
from: "backward",
|
||||
inbound: "backward",
|
||||
both: "bidirectional",
|
||||
mutual: "bidirectional",
|
||||
};
|
||||
|
||||
export function sanitizeGraph(data: Record<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>[]).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<string, unknown>[]).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<string, unknown>[]).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<string, unknown>): {
|
||||
data: Record<string, unknown>;
|
||||
issues: GraphIssue[];
|
||||
} {
|
||||
const issues: GraphIssue[] = [];
|
||||
const result = { ...data };
|
||||
|
||||
if (Array.isArray(data.nodes)) {
|
||||
result.nodes = (data.nodes as Record<string, unknown>[]).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<string, unknown>[]).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"]),
|
||||
@@ -92,10 +318,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<typeof KnowledgeGraphSchema>;
|
||||
/** @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 +387,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<string, unknown>;
|
||||
|
||||
return { success: false, errors };
|
||||
// Tier 1: Sanitize
|
||||
const sanitized = sanitizeGraph(raw);
|
||||
|
||||
// Existing: Normalize type aliases
|
||||
const normalized = normalizeGraph(sanitized) as Record<string, unknown>;
|
||||
|
||||
// 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<typeof GraphNodeSchema>[] = [];
|
||||
if (Array.isArray(fixed.nodes)) {
|
||||
for (let i = 0; i < fixed.nodes.length; i++) {
|
||||
const node = fixed.nodes[i] as Record<string, unknown>;
|
||||
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<typeof GraphEdgeSchema>[] = [];
|
||||
if (Array.isArray(fixed.edges)) {
|
||||
for (let i = 0; i < fixed.edges.length; i++) {
|
||||
const edge = fixed.edges[i] as Record<string, unknown>;
|
||||
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<typeof LayerSchema>[] = [];
|
||||
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<typeof TourStepSchema>[] = [];
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, useMemo } 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";
|
||||
@@ -11,6 +12,7 @@ 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 { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import type { KeyboardShortcut } from "./hooks/useKeyboardShortcuts";
|
||||
|
||||
@@ -24,6 +26,7 @@ function App() {
|
||||
const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer);
|
||||
const setDiffOverlay = useDashboardStore((s) => s.setDiffOverlay);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [graphIssues, setGraphIssues] = useState<GraphIssue[]>([]);
|
||||
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
|
||||
|
||||
// Define keyboard shortcuts
|
||||
@@ -123,10 +126,20 @@ function App() {
|
||||
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) => {
|
||||
@@ -210,6 +223,11 @@ function App() {
|
||||
{/* Search */}
|
||||
<SearchBar />
|
||||
|
||||
{/* Validation warning banner */}
|
||||
{graphIssues.length > 0 && !loadError && (
|
||||
<WarningBanner issues={graphIssues} />
|
||||
)}
|
||||
|
||||
{/* Error banner */}
|
||||
{loadError && (
|
||||
<div className="px-5 py-3 bg-red-900/30 border-b border-red-700 text-red-200 text-sm">
|
||||
|
||||
@@ -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<string, number> = { "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 (
|
||||
<div className="bg-amber-900/20 border-b border-amber-700 text-amber-200 text-sm">
|
||||
{/* Collapsed summary row */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className="w-full flex items-center gap-2 px-5 py-3 text-left hover:bg-amber-900/10 transition-colors"
|
||||
>
|
||||
{/* Chevron icon */}
|
||||
<svg
|
||||
className={`w-4 h-4 shrink-0 text-amber-400 transition-transform duration-200 ${
|
||||
expanded ? "rotate-90" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Warning icon */}
|
||||
<svg
|
||||
className="w-4 h-4 shrink-0 text-amber-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className="flex-1">{summary}</span>
|
||||
|
||||
<span className="text-amber-400/60 text-xs shrink-0">
|
||||
{expanded ? "click to collapse" : "click to expand"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded detail panel */}
|
||||
{expanded && (
|
||||
<div className="px-5 pb-4">
|
||||
{/* Issue list */}
|
||||
<div className="space-y-1 mb-3">
|
||||
{/* Auto-corrected issues */}
|
||||
{autoCorrected.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-amber-400 mb-1">
|
||||
Auto-corrected ({autoCorrected.length})
|
||||
</h4>
|
||||
{autoCorrected.map((issue, i) => (
|
||||
<div key={`ac-${i}`} className="flex items-start gap-2 py-0.5 pl-2 text-amber-200/80">
|
||||
<span className="text-amber-400 shrink-0 mt-0.5">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-xs">{issue.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropped issues */}
|
||||
{dropped.length > 0 && (
|
||||
<div className={autoCorrected.length > 0 ? "mt-2" : ""}>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-400 mb-1">
|
||||
Dropped ({dropped.length})
|
||||
</h4>
|
||||
{dropped.map((issue, i) => (
|
||||
<div key={`dr-${i}`} className="flex items-start gap-2 py-0.5 pl-2 text-orange-300/80">
|
||||
<span className="text-orange-400 shrink-0 mt-0.5">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-xs">{issue.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with copy button and actionable message */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-amber-700/50">
|
||||
<p className="text-xs text-amber-200/60">
|
||||
Copy these issues and ask your agent to fix them in knowledge-graph.json
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium bg-amber-800/40 text-amber-200 hover:bg-amber-800/60 transition-colors shrink-0 ml-4"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Copy Issues
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user