import { z } from "zod"; // Edge types (38 values across 9 categories) export const EdgeTypeSchema = z.enum([ "imports", "exports", "contains", "inherits", "implements", // Structural "calls", "subscribes", "publishes", "middleware", // Behavioral "reads_from", "writes_to", "transforms", "validates", // Data flow "depends_on", "tested_by", "configures", // Dependencies "related", "similar_to", // Semantic "deploys", "serves", "provisions", "triggers", // Infrastructure "migrates", "documents", "routes", "defines_schema", // Schema/Data "contains_flow", "flow_step", "cross_domain", // Domain "cites", "contradicts", "builds_on", "exemplifies", "categorized_under", "authored_by", // Knowledge "instance_of", "variant_of", "uses_token", // Design ]); // Aliases that LLMs commonly generate instead of canonical node types export const NODE_TYPE_ALIASES: Record = { func: "function", fn: "function", method: "function", interface: "class", struct: "class", mod: "module", pkg: "module", package: "module", // Non-code aliases container: "service", deployment: "service", pod: "service", doc: "document", readme: "document", docs: "document", job: "pipeline", ci: "pipeline", route: "endpoint", api: "endpoint", query: "endpoint", mutation: "endpoint", setting: "config", env: "config", configuration: "config", infra: "resource", infrastructure: "resource", terraform: "resource", migration: "table", database: "table", db: "table", view: "table", proto: "schema", protobuf: "schema", definition: "schema", typedef: "schema", // Domain aliases — "process" intentionally excluded (ambiguous with OS/Node.js process) business_domain: "domain", business_flow: "flow", business_process: "flow", task: "step", business_step: "step", // Knowledge aliases note: "article", wiki_page: "article", person: "entity", actor: "entity", organization: "entity", tag: "topic", category: "topic", theme: "topic", assertion: "claim", decision: "claim", thesis: "claim", reference: "source", raw: "source", paper: "source", }; // Design aliases (Figma node types) — applied only when the graph's kind is // "design". Terms like "page" and "style" mean something else in other // kinds, so these must not leak into them (see NON_DESIGN_NODE_TYPE_ALIASES). export const DESIGN_NODE_TYPE_ALIASES: Record = { frame: "screen", artboard: "screen", canvas: "page", main_component: "component", component_set: "componentSet", variant_set: "componentSet", // sanitizeGraph lowercases every node type, and "componentSet" is the only // camelCase canonical NodeType — so it arrives here as "componentset" and // must be mapped back, otherwise it fails the enum check and gets dropped. componentset: "componentSet", design_token: "token", style: "token", }; // Applied to every non-design kind: `page` is a first-class *design* node // type, but knowledge/codebase graphs relied on it normalizing to "article" // (see 2fc85e6) — the sanitizer can't tell a wiki page from a Figma page, // so the graph's `kind` decides which table wins. export const NON_DESIGN_NODE_TYPE_ALIASES: Record = { page: "article", }; // Aliases that LLMs commonly generate instead of canonical edge types export const EDGE_TYPE_ALIASES: Record = { extends: "inherits", invokes: "calls", invoke: "calls", uses: "depends_on", requires: "depends_on", relates_to: "related", related_to: "related", similar: "similar_to", import: "imports", export: "exports", contain: "contains", publish: "publishes", subscribe: "subscribes", // Non-code aliases describes: "documents", documented_by: "documents", creates: "provisions", exposes: "serves", listens: "serves", deploys_to: "deploys", migrates_to: "migrates", routes_to: "routes", triggers_on: "triggers", fires: "triggers", defines: "defines_schema", // Domain aliases has_flow: "contains_flow", next_step: "flow_step", interacts_with: "cross_domain", // Knowledge aliases references: "cites", cites_source: "cites", conflicts_with: "contradicts", disagrees_with: "contradicts", refines: "builds_on", elaborates: "builds_on", illustrates: "exemplifies", example_of: "exemplifies", belongs_to: "categorized_under", tagged_with: "categorized_under", written_by: "authored_by", created_by: "authored_by", // Note: "implemented_by" is intentionally NOT aliased to "implements" — // it inverts edge direction (see commit fd0df15). The LLM should use // "implements" with correct source/target instead. }; // Design edge aliases — applied only when the graph's kind is "design". export const DESIGN_EDGE_TYPE_ALIASES: Record = { instantiates: "instance_of", variant: "variant_of", styled_by: "uses_token", applies_token: "uses_token", }; // Applied to every non-design kind: `instance_of` is a first-class design // edge, but knowledge graphs relied on it normalizing to "exemplifies" // ("X is an instance of Y" — see 2fc85e6). export const NON_DESIGN_EDGE_TYPE_ALIASES: Record = { instance_of: "exemplifies", }; // Aliases for complexity values LLMs commonly generate export const COMPLEXITY_ALIASES: Record = { low: "simple", easy: "simple", medium: "moderate", intermediate: "moderate", high: "complex", hard: "complex", difficult: "complex", }; // Aliases for direction values LLMs commonly generate export const DIRECTION_ALIASES: Record = { to: "forward", outbound: "forward", from: "backward", inbound: "backward", both: "bidirectional", mutual: "bidirectional", }; export function sanitizeGraph(data: Record): Record { const result = { ...data }; // Null → empty array for top-level collections if (data.tour === null || data.tour === undefined) result.tour = []; if (data.layers === null || data.layers === undefined) result.layers = []; // Sanitize nodes if (Array.isArray(data.nodes)) { result.nodes = (data.nodes as Record[]).map((node) => { if (typeof node !== "object" || node === null) return node; const n = { ...node }; // Null → undefined for optional fields if (n.filePath === null) delete n.filePath; if (n.lineRange === null) delete n.lineRange; if (n.languageNotes === null) delete n.languageNotes; // Lowercase enum-like strings if (typeof n.type === "string") n.type = n.type.toLowerCase(); if (typeof n.complexity === "string") n.complexity = n.complexity.toLowerCase(); return n; }); } // Sanitize edges if (Array.isArray(data.edges)) { result.edges = (data.edges as Record[]).map((edge) => { if (typeof edge !== "object" || edge === null) return edge; const e = { ...edge }; if (e.description === null) delete e.description; if (typeof e.type === "string") e.type = e.type.toLowerCase(); if (typeof e.direction === "string") e.direction = e.direction.toLowerCase(); return e; }); } // Sanitize tour steps if (Array.isArray(result.tour)) { result.tour = (result.tour as Record[]).map((step) => { if (typeof step !== "object" || step === null) return step; const s = { ...step }; if (s.languageLesson === null) delete s.languageLesson; return s; }); } return result; } export function autoFixGraph(data: Record): { data: Record; issues: GraphIssue[]; } { const issues: GraphIssue[] = []; const result = { ...data }; if (Array.isArray(data.nodes)) { result.nodes = (data.nodes as Record[]).map((node, i) => { if (typeof node !== "object" || node === null) return node; const n = { ...node }; const name = (n.name as string) || (n.id as string) || `index ${i}`; // Missing or empty type if (!n.type || typeof n.type !== "string") { n.type = "file"; issues.push({ level: "auto-corrected", category: "missing-field", message: `nodes[${i}] ("${name}"): missing "type" — defaulted to "file"`, path: `nodes[${i}].type`, }); } // Missing or empty complexity if (!n.complexity || n.complexity === "") { n.complexity = "moderate"; issues.push({ level: "auto-corrected", category: "missing-field", message: `nodes[${i}] ("${name}"): missing "complexity" — defaulted to "moderate"`, path: `nodes[${i}].complexity`, }); } else if (typeof n.complexity === "string" && n.complexity in COMPLEXITY_ALIASES) { const original = n.complexity; n.complexity = COMPLEXITY_ALIASES[n.complexity]; issues.push({ level: "auto-corrected", category: "alias", message: `nodes[${i}] ("${name}"): complexity "${original}" — mapped to "${n.complexity}"`, path: `nodes[${i}].complexity`, }); } // Missing tags if (!Array.isArray(n.tags)) { n.tags = []; issues.push({ level: "auto-corrected", category: "missing-field", message: `nodes[${i}] ("${name}"): missing "tags" — defaulted to []`, path: `nodes[${i}].tags`, }); } // Missing summary if (!n.summary || typeof n.summary !== "string") { n.summary = (n.name as string) || "No summary"; issues.push({ level: "auto-corrected", category: "missing-field", message: `nodes[${i}] ("${name}"): missing "summary" — defaulted to name`, path: `nodes[${i}].summary`, }); } return n; }); } if (Array.isArray(data.edges)) { result.edges = (data.edges as Record[]).map((edge, i) => { if (typeof edge !== "object" || edge === null) return edge; const e = { ...edge }; // Missing type if (!e.type || typeof e.type !== "string") { e.type = "depends_on"; issues.push({ level: "auto-corrected", category: "missing-field", message: `edges[${i}]: missing "type" — defaulted to "depends_on"`, path: `edges[${i}].type`, }); } // Missing direction if (!e.direction || typeof e.direction !== "string") { e.direction = "forward"; issues.push({ level: "auto-corrected", category: "missing-field", message: `edges[${i}]: missing "direction" — defaulted to "forward"`, path: `edges[${i}].direction`, }); } else if (e.direction in DIRECTION_ALIASES) { const original = e.direction; e.direction = DIRECTION_ALIASES[e.direction as string]; issues.push({ level: "auto-corrected", category: "alias", message: `edges[${i}]: direction "${original}" — mapped to "${e.direction}"`, path: `edges[${i}].direction`, }); } // Missing weight if (e.weight === undefined || e.weight === null) { e.weight = 0.5; issues.push({ level: "auto-corrected", category: "missing-field", message: `edges[${i}]: missing "weight" — defaulted to 0.5`, path: `edges[${i}].weight`, }); } else if (typeof e.weight === "string") { const parsed = parseFloat(e.weight as string); if (!isNaN(parsed)) { const original = e.weight; e.weight = parsed; issues.push({ level: "auto-corrected", category: "type-coercion", message: `edges[${i}]: weight was string "${original}" — coerced to number`, path: `edges[${i}].weight`, }); } else { const original = e.weight; e.weight = 0.5; issues.push({ level: "auto-corrected", category: "type-coercion", message: `edges[${i}]: weight "${original}" is not a valid number — defaulted to 0.5`, path: `edges[${i}].weight`, }); } } // Clamp weight to [0, 1] if (typeof e.weight === "number" && (e.weight < 0 || e.weight > 1)) { const original = e.weight; e.weight = Math.max(0, Math.min(1, e.weight)); issues.push({ level: "auto-corrected", category: "out-of-range", message: `edges[${i}]: weight ${original} clamped to ${e.weight}`, path: `edges[${i}].weight`, }); } return e; }); } return { data: result, issues }; } const DomainMetaSchema = z.object({ entities: z.array(z.string()).optional(), businessRules: z.array(z.string()).optional(), crossDomainInteractions: z.array(z.string()).optional(), entryPoint: z.string().optional(), entryType: z.enum(["http", "cli", "event", "cron", "manual"]).optional(), }).passthrough(); const KnowledgeMetaSchema = z.object({ wikilinks: z.array(z.string()).optional(), backlinks: z.array(z.string()).optional(), category: z.string().optional(), content: z.string().optional(), }).passthrough(); const FigmaMetaSchema = z.object({ fileKey: z.string().optional(), nodeId: z.string().optional(), figmaType: z.string().optional(), thumbnailUrl: z.string().optional(), dimensions: z.object({ width: z.number(), height: z.number() }).optional(), tokenKind: z.enum(["color", "type", "spacing", "effect", "grid"]).optional(), tokenValue: z.string().optional(), prototypeTargets: z.array(z.string()).optional(), componentKey: z.string().optional(), }).passthrough(); export const GraphNodeSchema = z.object({ id: z.string(), type: z.enum([ "file", "function", "class", "module", "concept", "config", "document", "service", "table", "endpoint", "pipeline", "schema", "resource", "domain", "flow", "step", "article", "entity", "topic", "claim", "source", "page", "screen", "component", "componentSet", "instance", "token", ]), name: z.string(), filePath: z.string().optional(), lineRange: z.tuple([z.number(), z.number()]).optional(), summary: z.string(), tags: z.array(z.string()), complexity: z.enum(["simple", "moderate", "complex"]), languageNotes: z.string().optional(), domainMeta: DomainMetaSchema.optional(), knowledgeMeta: KnowledgeMetaSchema.optional(), figmaMeta: FigmaMetaSchema.optional(), }).passthrough(); export const GraphEdgeSchema = z.object({ source: z.string(), target: z.string(), type: EdgeTypeSchema, direction: z.enum(["forward", "backward", "bidirectional"]), description: z.string().optional(), weight: z.number().min(0).max(1), }); export const LayerSchema = z.object({ id: z.string(), name: z.string(), description: z.string(), nodeIds: z.array(z.string()), }); export const TourStepSchema = z.object({ order: z.number(), title: z.string(), description: z.string(), nodeIds: z.array(z.string()), languageLesson: z.string().optional(), }); export const ProjectMetaSchema = z.object({ name: z.string(), languages: z.array(z.string()), frameworks: z.array(z.string()), description: z.string(), analyzedAt: z.string(), gitCommitHash: z.string(), }); export const KnowledgeGraphSchema = z.object({ version: z.string(), kind: z.enum(["codebase", "knowledge", "design"]).optional(), project: ProjectMetaSchema, nodes: z.array(GraphNodeSchema), edges: z.array(GraphEdgeSchema), layers: z.array(LayerSchema), tour: z.array(TourStepSchema), }); export interface GraphIssue { level: "auto-corrected" | "dropped" | "fatal"; category: string; message: string; path?: string; } export interface ValidationResult { success: boolean; data?: z.infer; /** @deprecated Use issues/fatal instead */ errors?: string[]; issues: GraphIssue[]; fatal?: string; } function buildInvalidCollectionIssue(name: string): GraphIssue { return { level: "fatal", category: "invalid-collection", message: `"${name}" must be an array when present`, path: name, }; } function buildErrors(issues: GraphIssue[], fatal?: string): string[] | undefined { const messages = issues.map((issue) => issue.message); if (fatal && !messages.includes(fatal)) messages.unshift(fatal); return messages.length > 0 ? messages : undefined; } export function normalizeGraph(data: unknown): unknown { if (typeof data !== "object" || data === null) return data; const d = data as Record; const result = { ...d }; // "page" and "instance_of" are canonical design types but alias *sources* // in every other kind, so the active alias tables depend on the graph's kind. const isDesign = typeof d.kind === "string" && d.kind.toLowerCase() === "design"; const nodeAliases: Record = isDesign ? { ...NODE_TYPE_ALIASES, ...DESIGN_NODE_TYPE_ALIASES } : { ...NODE_TYPE_ALIASES, ...NON_DESIGN_NODE_TYPE_ALIASES }; const edgeAliases: Record = isDesign ? { ...EDGE_TYPE_ALIASES, ...DESIGN_EDGE_TYPE_ALIASES } : { ...EDGE_TYPE_ALIASES, ...NON_DESIGN_EDGE_TYPE_ALIASES }; if (Array.isArray(d.nodes)) { result.nodes = (d.nodes as Array>).map((node) => { if ( typeof node === "object" && node !== null && typeof node.type === "string" && node.type in nodeAliases ) { return { ...node, type: nodeAliases[node.type] }; } return node; }); } if (Array.isArray(d.edges)) { result.edges = (d.edges as Array>).map((edge) => { if ( typeof edge === "object" && edge !== null && typeof edge.type === "string" && edge.type in edgeAliases ) { return { ...edge, type: edgeAliases[edge.type] }; } return edge; }); } return result; } export function validateGraph(data: unknown): ValidationResult { // 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 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); // Tier 4: Fatal — malformed top-level collections const requiredCollections = ["nodes", "edges", "layers", "tour"] as const; for (const collection of requiredCollections) { if (collection in fixed && fixed[collection] !== undefined && !Array.isArray(fixed[collection])) { const issue = buildInvalidCollectionIssue(collection); issues.push(issue); return { success: false, errors: buildErrors(issues, issue.message), issues, fatal: issue.message, }; } } // Tier 4: Fatal — missing project metadata const projectResult = ProjectMetaSchema.safeParse(fixed.project); if (!projectResult.success) { return { success: false, errors: buildErrors(issues, "Missing or invalid project metadata"), issues, fatal: "Missing or invalid project metadata", }; } // Tier 3: Validate nodes individually, drop broken const validNodes: z.infer[] = []; if (Array.isArray(fixed.nodes)) { for (let i = 0; i < fixed.nodes.length; i++) { const node = fixed.nodes[i] as Record; const result = GraphNodeSchema.safeParse(node); if (result.success) { validNodes.push(result.data); } else { const name = node?.name || node?.id || `index ${i}`; issues.push({ level: "dropped", category: "invalid-node", message: `nodes[${i}] ("${name}"): ${result.error.issues[0]?.message ?? "validation failed"} — removed`, path: `nodes[${i}]`, }); } } } // Tier 4: Fatal — no valid nodes if (validNodes.length === 0) { return { success: false, errors: buildErrors(issues, "No valid nodes found in knowledge graph"), issues, fatal: "No valid nodes found in knowledge graph", }; } // Tier 3: Validate edges + referential integrity const nodeIds = new Set(validNodes.map((n) => n.id)); const validEdges: z.infer[] = []; if (Array.isArray(fixed.edges)) { for (let i = 0; i < fixed.edges.length; i++) { const edge = fixed.edges[i] as Record; const result = GraphEdgeSchema.safeParse(edge); if (!result.success) { issues.push({ level: "dropped", category: "invalid-edge", message: `edges[${i}]: ${result.error.issues[0]?.message ?? "validation failed"} — removed`, path: `edges[${i}]`, }); continue; } if (!nodeIds.has(result.data.source)) { issues.push({ level: "dropped", category: "invalid-reference", message: `edges[${i}]: source "${result.data.source}" does not exist in nodes — removed`, path: `edges[${i}].source`, }); continue; } if (!nodeIds.has(result.data.target)) { issues.push({ level: "dropped", category: "invalid-reference", message: `edges[${i}]: target "${result.data.target}" does not exist in nodes — removed`, path: `edges[${i}].target`, }); continue; } validEdges.push(result.data); } } // Validate layers (drop broken, filter dangling nodeIds) const validLayers: z.infer[] = []; if (Array.isArray(fixed.layers)) { for (let i = 0; i < (fixed.layers as unknown[]).length; i++) { const result = LayerSchema.safeParse((fixed.layers as unknown[])[i]); if (result.success) { validLayers.push({ ...result.data, nodeIds: result.data.nodeIds.filter((id) => nodeIds.has(id)), }); } else { issues.push({ level: "dropped", category: "invalid-layer", message: `layers[${i}]: ${result.error.issues[0]?.message ?? "validation failed"} — removed`, path: `layers[${i}]`, }); } } } // Validate tour steps (drop broken, filter dangling nodeIds) const validTour: z.infer[] = []; if (Array.isArray(fixed.tour)) { for (let i = 0; i < (fixed.tour as unknown[]).length; i++) { const result = TourStepSchema.safeParse((fixed.tour as unknown[])[i]); if (result.success) { validTour.push({ ...result.data, nodeIds: result.data.nodeIds.filter((id) => nodeIds.has(id)), }); } else { issues.push({ level: "dropped", category: "invalid-tour-step", message: `tour[${i}]: ${result.error.issues[0]?.message ?? "validation failed"} — removed`, path: `tour[${i}]`, }); } } } const graph = { version: typeof fixed.version === "string" ? fixed.version : "1.0.0", project: projectResult.data, nodes: validNodes, edges: validEdges, layers: validLayers, tour: validTour, }; return { success: true, data: graph, issues, errors: buildErrors(issues) }; }