diff --git a/package-lock.json b/package-lock.json index a30b81b..eec335c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -191,7 +191,6 @@ "integrity": "sha512-ly1wETVGRo30cx61O7fetESN+ElL9c9K+bD/AVgnT1ar4c6v+/Yqjrhdtu6Fm4D0s4NZP081Isf6tunH1wUXHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", @@ -1450,7 +1449,6 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1888,7 +1886,6 @@ "integrity": "sha512-0ZzY9mjqV7gop/AH8pIBiAS8giXP7WcSiUfoFYIzYAK9QC5c37E4SIVtJVBMwlURc0/uNt2o4RcNRvdHa4CJ5w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/abtesting": "1.18.0", "@algolia/client-abtesting": "5.52.0", @@ -2235,7 +2232,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2389,7 +2385,6 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -3658,7 +3653,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4228,7 +4222,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4375,7 +4368,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -4395,7 +4387,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4540,7 +4531,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5182,7 +5172,6 @@ "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/compiler-sfc": "3.5.33", diff --git a/src/core/analyze.ts b/src/core/analyze.ts index c262ffa..08dec14 100644 --- a/src/core/analyze.ts +++ b/src/core/analyze.ts @@ -1,11 +1,14 @@ import type { GraphBuilderAnalysis, GraphBuilderConfidence, + GraphBuilderEdge, GraphBuilderGraph, GraphBuilderNode } from "./types.js"; import { buildAdjacency } from "./query.js"; +const BETWEENNESS_NODE_LIMIT = 500; + export function analyzeGraph(graph: GraphBuilderGraph, topN = 10): GraphBuilderAnalysis { const adjacency = buildAdjacency(graph); const communities = assignCommunities(graph, adjacency); @@ -51,13 +54,180 @@ export function analyzeGraph(graph: GraphBuilderGraph, topN = 10): GraphBuilderA reason: edge.confidence === "INFERRED" ? "Cross-source inferred reference" : "Cross-source structural connection" })); + const pageRank = computePageRank(graph); + const betweenness = graph.nodes.length <= BETWEENNESS_NODE_LIMIT ? computeBetweenness(graph, adjacency) : undefined; + const bridgeNodeIds = detectBridgeNodes(graph, adjacency); + const bridgeNodes = bridgeNodeIds + .map((id) => { + const node = nodeMap.get(id); + return node ? { id, label: node.label, degree: degrees.get(id) ?? 0 } : null; + }) + .filter((entry): entry is { id: string; label: string; degree: number } => entry !== null) + .sort((left, right) => right.degree - left.degree); + return { godNodes, confidenceBreakdown, isolatedNodes, surprisingConnections, - communities + communities, + pageRank, + betweenness, + bridgeNodes + }; +} + +function computePageRank(graph: GraphBuilderGraph, iterations = 30, damping = 0.85): Record { + const nodeIds = graph.nodes.map((node) => node.id); + const N = nodeIds.length; + if (N === 0) { + return {}; + } + + const pr: Record = {}; + const outDegree: Record = {}; + const inLinks: Record = {}; + + for (const id of nodeIds) { + pr[id] = 1 / N; + outDegree[id] = 0; + inLinks[id] = []; + } + + for (const edge of graph.edges) { + if (pr[edge.source] !== undefined && pr[edge.target] !== undefined) { + outDegree[edge.source] = (outDegree[edge.source] ?? 0) + 1; + inLinks[edge.target] = [...(inLinks[edge.target] ?? []), edge.source]; + } + } + + for (let i = 0; i < iterations; i++) { + const next: Record = {}; + for (const id of nodeIds) { + let rank = (1 - damping) / N; + for (const inId of (inLinks[id] ?? [])) { + const deg = outDegree[inId] ?? 1; + rank += damping * (pr[inId] ?? 0) / Math.max(deg, 1); + } + next[id] = rank; + } + for (const id of nodeIds) { + pr[id] = next[id] ?? (1 / N); + } + } + + return pr; +} + +function computeBetweenness(graph: GraphBuilderGraph, adjacency: Map): Record { + const nodeIds = graph.nodes.map((node) => node.id); + const bc: Record = {}; + for (const id of nodeIds) { + bc[id] = 0; + } + + for (const s of nodeIds) { + const stack: string[] = []; + const pred: Record = {}; + const sigma: Record = {}; + const dist: Record = {}; + + for (const id of nodeIds) { + pred[id] = []; + sigma[id] = 0; + dist[id] = -1; + } + sigma[s] = 1; + dist[s] = 0; + + const queue: string[] = [s]; + while (queue.length > 0) { + const v = queue.shift()!; + stack.push(v); + for (const edge of (adjacency.get(v) ?? [])) { + const w = edge.source === v ? edge.target : edge.source; + if (dist[w] < 0) { + queue.push(w); + dist[w] = dist[v]! + 1; + } + if (dist[w] === dist[v]! + 1) { + sigma[w] += sigma[v]!; + pred[w]!.push(v); + } + } + } + + const delta: Record = {}; + for (const id of nodeIds) { + delta[id] = 0; + } + while (stack.length > 0) { + const w = stack.pop()!; + for (const v of (pred[w] ?? [])) { + delta[v] += (sigma[v]! / Math.max(sigma[w]!, 1)) * (1 + delta[w]!); + } + if (w !== s) { + bc[w] += delta[w]!; + } + } + } + + const n = nodeIds.length; + if (n > 2) { + const factor = 2 / ((n - 1) * (n - 2)); + for (const id of nodeIds) { + bc[id] *= factor; + } + } + + return bc; +} + +function detectBridgeNodes(graph: GraphBuilderGraph, adjacency: Map): string[] { + const nodeIds = graph.nodes.map((node) => node.id); + const nodeSet = new Set(nodeIds); + const visited = new Set(); + const disc: Record = {}; + const low: Record = {}; + const parent: Record = {}; + const ap = new Set(); + let timer = 0; + + const dfs = (u: string): void => { + visited.add(u); + disc[u] = low[u] = timer++; + let childCount = 0; + + for (const edge of (adjacency.get(u) ?? [])) { + const v = edge.source === u ? edge.target : edge.source; + if (!nodeSet.has(v)) { + continue; + } + if (!visited.has(v)) { + childCount++; + parent[v] = u; + dfs(v); + low[u] = Math.min(low[u]!, low[v]!); + if (parent[u] === null && childCount > 1) { + ap.add(u); + } + if (parent[u] !== null && low[v]! >= disc[u]!) { + ap.add(u); + } + } else if (v !== parent[u]) { + low[u] = Math.min(low[u]!, disc[v]!); + } + } }; + + for (const id of nodeIds) { + if (!visited.has(id)) { + parent[id] = null; + dfs(id); + } + } + + return [...ap]; } function assignCommunities(graph: GraphBuilderGraph, adjacency: Map): GraphBuilderAnalysis["communities"] { diff --git a/src/core/artifacts.ts b/src/core/artifacts.ts index eb78c76..2e12151 100644 --- a/src/core/artifacts.ts +++ b/src/core/artifacts.ts @@ -22,7 +22,7 @@ export function buildArtifacts( nextArtifacts.wiki = generateWiki(graph, analysis); } if (artifacts.includes("html")) { - nextArtifacts.html = generateHtml(graph); + nextArtifacts.html = generateHtml(graph, analysis); } if (artifacts.includes("timeline")) { nextArtifacts.timeline = generateTimeline(graph); @@ -201,104 +201,251 @@ export function generateWiki(graph: GraphBuilderGraph, analysis: GraphBuilderAna return wiki; } -export function generateHtml(graph: GraphBuilderGraph): string { +export function generateHtml(graph: GraphBuilderGraph, analysis?: GraphBuilderAnalysis): string { const nodes = graph.nodes.map((node) => ({ id: node.id, label: node.label, - group: node.community ?? node.type, - title: `${node.label}\n${node.type}${node.metadata?.path ? `\n${String(node.metadata.path)}` : ""}` + type: node.type, + community: node.community, + sourceItemId: node.sourceItemId, + mergedFrom: node.mergedFrom })); - const edges = graph.edges.map((edge, index) => ({ - id: `${edge.source}-${edge.target}-${index}`, - from: edge.source, - to: edge.target, - label: edge.relation, - title: `${edge.relation} [${edge.confidence}]`, - arrows: "to" + + const edges = graph.edges.map((edge) => ({ + source: edge.source, + target: edge.target, + relation: edge.relation, + confidence: edge.confidence, + confidenceScore: edge.confidenceScore ?? 0.5 })); + const pageRankData = analysis?.pageRank ?? {}; + const betweennessData = analysis?.betweenness ?? {}; + + const safeJson = (value: unknown) => JSON.stringify(value).replace(/<\/script>/gi, "<\\/script>"); + return ` - - - - Graph Builder - - - - -
- -
+ + + + Graph Builder + - + window.addEventListener("resize",()=>{ W=wrap.clientWidth; H=wrap.clientHeight; sim.force("center",d3.forceCenter(W/2,H/2)).alpha(0.1).restart(); }); +})(); +<\/script> + `; } diff --git a/src/core/build.ts b/src/core/build.ts index be806a7..9f984a1 100644 --- a/src/core/build.ts +++ b/src/core/build.ts @@ -86,6 +86,81 @@ export function buildGraph(extractions: GraphBuilderExtraction[]): GraphBuilderG }); } +const MERGE_ELIGIBLE_TYPES = new Set(["function", "class", "interface", "type", "enum", "method", "symbol"]); + +export function resolveEntities(graph: GraphBuilderGraph): GraphBuilderGraph { + const groups = new Map(); + const utility = new Set(["tag", "resource", "package", "document", "code_file"]); + + for (const node of graph.nodes) { + if (utility.has(node.type) || !MERGE_ELIGIBLE_TYPES.has(node.type)) { + continue; + } + const key = `${node.normalizedLabel ?? normalizeLabel(node.label)}:${node.type}`; + groups.set(key, [...(groups.get(key) ?? []), node]); + } + + const mergeMap = new Map(); + const processedIds = new Set(); + const mergedNodes: GraphBuilderNode[] = []; + + for (const [, members] of groups) { + const multiSource = new Set(members.map((m) => m.sourceItemId).filter(Boolean)).size > 1; + if (!multiSource || members.length < 2) { + for (const m of members) { + if (!processedIds.has(m.id)) { + mergedNodes.push(m); + processedIds.add(m.id); + } + } + continue; + } + + const canonical = members[0]!; + const mergedFrom = members.map((m) => m.id); + mergedNodes.push({ ...canonical, mergedFrom }); + for (const m of members) { + mergeMap.set(m.id, canonical.id); + processedIds.add(m.id); + } + } + + for (const node of graph.nodes) { + if (!processedIds.has(node.id)) { + mergedNodes.push(node); + processedIds.add(node.id); + } + } + + const remapId = (id: string) => mergeMap.get(id) ?? id; + const edgeKeys = new Set(); + const remappedEdges: GraphBuilderEdge[] = []; + + for (const edge of graph.edges) { + const source = remapId(edge.source); + const target = remapId(edge.target); + if (source === target) { + continue; + } + const key = `${source}:${target}:${edge.relation}:${edge.sourceItemId ?? ""}`; + if (!edgeKeys.has(key)) { + remappedEdges.push({ ...edge, source, target }); + edgeKeys.add(key); + } + } + + return { + ...graph, + nodes: mergedNodes, + edges: remappedEdges, + stats: { + ...graph.stats, + nodeCount: mergedNodes.length, + edgeCount: remappedEdges.length + } + }; +} + export function validateGraph(graph: GraphBuilderGraph): string[] { const errors: string[] = []; const nodeIds = new Set(graph.nodes.map((node) => node.id)); diff --git a/src/core/graph-builder.ts b/src/core/graph-builder.ts index 332e4ba..a82d1b7 100644 --- a/src/core/graph-builder.ts +++ b/src/core/graph-builder.ts @@ -1,6 +1,6 @@ import { analyzeGraph } from "./analyze.js"; import { buildArtifacts } from "./artifacts.js"; -import { buildGraph, validateGraph } from "./build.js"; +import { buildGraph, resolveEntities, validateGraph } from "./build.js"; import { collectChangedTextItems, collectTextItems } from "./provider.js"; import { createGraphBuilderResult, loadGraphBuilderResult } from "./result.js"; import { mergeSemanticFragment } from "./semantic.js"; @@ -53,13 +53,20 @@ export function createGraphBuilder(config: GraphBuilderConfig = {}) { timings.collect = Date.now() - startedAt; const extractStartedAt = Date.now(); - const extractions = await Promise.all(items.map((item) => extractGraphBuilderItem(item, options))); + const concurrency = options.concurrency ?? 0; + const extractions = concurrency > 0 + ? await extractWithConcurrency(items, options, concurrency) + : await Promise.all(items.map((item) => extractGraphBuilderItem(item, options))); timings.extract = Date.now() - extractStartedAt; const buildStartedAt = Date.now(); let graph = buildGraph(extractions); timings.build = Date.now() - buildStartedAt; + if (options.entityResolution) { + graph = resolveEntities(graph); + } + const warnings: string[] = []; const modelUsage = []; @@ -113,4 +120,23 @@ export function createGraphBuilder(config: GraphBuilderConfig = {}) { updateGraph, loadGraph: loadGraphBuilderResult }; +} + +async function extractWithConcurrency( + items: GraphBuilderTextItem[], + options: GraphBuilderOptions, + concurrency: number +): Promise { + const results: GraphBuilderExtraction[] = new Array(items.length); + let index = 0; + + async function worker(): Promise { + while (index < items.length) { + const i = index++; + results[i] = await extractGraphBuilderItem(items[i]!, options); + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); + return results; } \ No newline at end of file diff --git a/src/core/query.ts b/src/core/query.ts index 78276d2..6ec577a 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -74,30 +74,45 @@ export function shortestPath( return null; } + if (source.id === target.id) { + return { nodes: [source], edges: [], totalWeight: 0 }; + } + const adjacency = buildAdjacency(graph); - const queue: string[] = [source.id]; - const visited = new Set([source.id]); + + const edgeWeight = (edge: GraphBuilderEdge) => 1 / Math.max(edge.confidenceScore ?? 0.5, 0.01); + + const dist = new Map(graph.nodes.map((n) => [n.id, Infinity])); const parentNode = new Map(); const parentEdge = new Map(); + dist.set(source.id, 0); + + const pq: Array<{ id: string; cost: number }> = [{ id: source.id, cost: 0 }]; + + while (pq.length > 0) { + pq.sort((left, right) => left.cost - right.cost); + const { id: current, cost } = pq.shift()!; - while (queue.length > 0) { - const current = queue.shift()!; if (current === target.id) { break; } - for (const edge of adjacency.get(current) ?? []) { + if (cost > (dist.get(current) ?? Infinity)) { + continue; + } + + for (const edge of (adjacency.get(current) ?? [])) { const neighbor = edge.source === current ? edge.target : edge.source; - if (visited.has(neighbor)) { - continue; + const newCost = cost + edgeWeight(edge); + if (newCost < (dist.get(neighbor) ?? Infinity)) { + dist.set(neighbor, newCost); + parentNode.set(neighbor, current); + parentEdge.set(neighbor, edge); + pq.push({ id: neighbor, cost: newCost }); } - visited.add(neighbor); - parentNode.set(neighbor, current); - parentEdge.set(neighbor, edge); - queue.push(neighbor); } } - if (!visited.has(target.id)) { + if (!parentNode.has(target.id)) { return null; } @@ -120,7 +135,8 @@ export function shortestPath( return { nodes: nodeIds.reverse().map((id) => nodeMap.get(id)).filter((value): value is GraphBuilderNode => Boolean(value)), - edges: edges.reverse() + edges: edges.reverse(), + totalWeight: dist.get(target.id) ?? 0 }; } @@ -147,26 +163,39 @@ export function getCommunityNodes(graph: GraphBuilderGraph, id: number): GraphBu return graph.nodes.filter((node) => node.community === id); } +const TYPE_PRIORITY: Record = { + document: 4, code_file: 4, + class: 3, function: 3, interface: 3, enum: 3, + method: 2, property: 2, type: 2, + heading: 2, memory_fact: 2, + symbol: 1, schema_object: 1, schema_field: 1, + resource: -2, tag: -2, package: -2 +}; + function scoreNodes(graph: GraphBuilderGraph, query: string): Array<{ node: GraphBuilderNode; score: number }> { const terms = normalizeLabel(query).split(/\s+/).filter(Boolean); return graph.nodes .map((node) => { const label = node.normalizedLabel ?? normalizeLabel(node.label); const path = typeof node.metadata?.path === "string" ? normalizeLabel(node.metadata.path) : ""; + const typePriority = TYPE_PRIORITY[node.type] ?? 0; const score = terms.reduce((accumulator, term) => { let next = accumulator; if (label === term) { next += 100; - } - if (label.includes(term)) { + } else if (label.startsWith(term)) { + next += 50; + } else if (label.includes(term)) { next += 10; } - if (path.includes(term)) { + if (path.startsWith(term)) { + next += 8; + } else if (path.includes(term)) { next += 5; } return next; }, 0); - return { node, score }; + return { node, score: score > 0 ? score + typePriority : 0 }; }) .filter((entry) => entry.score > 0) .sort((left, right) => right.score - left.score); diff --git a/src/core/types.ts b/src/core/types.ts index 57b9314..01f6b02 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -71,6 +71,7 @@ export interface GraphBuilderNode { storageRef?: StorageRef; community?: number; normalizedLabel?: string; + mergedFrom?: string[]; metadata?: Record; } @@ -186,6 +187,9 @@ export interface GraphBuilderAnalysis { reason: string; }>; communities: GraphBuilderCommunity[]; + pageRank?: Record; + betweenness?: Record; + bridgeNodes?: Array<{ id: string; label: string; degree: number }>; } export interface GraphBuilderArtifacts { @@ -242,6 +246,7 @@ export interface GraphBuilderQueryResult { export interface GraphBuilderPathResult { nodes: GraphBuilderNode[]; edges: GraphBuilderEdge[]; + totalWeight?: number; } export interface GraphBuilderNeighbor { @@ -297,6 +302,8 @@ export interface GraphBuilderOptions { providerOptions?: unknown; extractor?: GraphBuilderExtractor | ((item: GraphBuilderTextItem) => GraphBuilderExtraction | Promise); semantic?: GraphBuilderSemanticOptions; + concurrency?: number; + entityResolution?: boolean; } export type GraphBuilderInput = diff --git a/src/index.ts b/src/index.ts index 5292908..4e5d70b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { buildGraph, validateGraph, withInferredReferences } from "./core/build.js"; +import { buildGraph, resolveEntities, validateGraph, withInferredReferences } from "./core/build.js"; import { updateGraph } from "./core/incremental.js"; import { createArrayProvider } from "./core/provider.js"; import { getNeighbors, getNode, queryGraph, shortestPath } from "./core/query.js"; @@ -68,6 +68,7 @@ export { loadGraphBuilderResult, mergeSemanticFragment, queryGraph, + resolveEntities, serializeGraph, shortestPath, updateGraph, diff --git a/tests/graph-builder.test.ts b/tests/graph-builder.test.ts index f3843c5..7be7f1c 100644 --- a/tests/graph-builder.test.ts +++ b/tests/graph-builder.test.ts @@ -349,4 +349,88 @@ export function start() { schedule(); } expect(files.some((file) => file.endsWith("graph.graphml"))).toBe(true); await expect(readFile(join(dir, "GRAPH_REPORT.md"), "utf8")).resolves.toContain("Graph Builder Report"); }); + + it("computes pageRank, betweenness centrality and bridge nodes", async () => { + const result = await graphBuilder.fromTexts([ + { + id: "docs/a.md", + title: "A", + path: "docs/a.md", + text: "# A\n\nSee [B](b.md) and [C](c.md)." + }, + { + id: "docs/b.md", + title: "B", + path: "docs/b.md", + text: "# B\n\nSee [C](c.md)." + }, + { + id: "docs/c.md", + title: "C", + path: "docs/c.md", + text: "# C\n\nFinal document." + } + ]); + + expect(result.analysis.pageRank).toBeDefined(); + expect(Object.keys(result.analysis.pageRank!).length).toBeGreaterThan(0); + expect(result.analysis.betweenness).toBeDefined(); + expect(result.analysis.bridgeNodes).toBeDefined(); + }); + + it("merges cross-document entities with entityResolution", async () => { + const result = await graphBuilder.fromTexts([ + { + id: "src/auth.ts", + title: "Auth", + path: "src/auth.ts", + text: "export class TokenService { validate() {} }\nexport function authenticate() {}" + }, + { + id: "src/gateway.ts", + title: "Gateway", + path: "src/gateway.ts", + text: "export class TokenService { refresh() {} }\nexport function route() {}" + } + ], { entityResolution: true }); + + const tokenNodes = result.graph.nodes.filter((n) => n.label === "TokenService"); + expect(tokenNodes.length).toBe(1); + expect(tokenNodes[0]?.mergedFrom?.length).toBe(2); + }); + + it("respects concurrency limit during extraction", async () => { + const items = Array.from({ length: 8 }, (_, i) => ({ + id: `docs/doc-${i}.md`, + title: `Doc ${i}`, + path: `docs/doc-${i}.md`, + text: `# Doc ${i}\n\nContent of document ${i}.` + })); + + const result = await graphBuilder.fromTexts(items, { concurrency: 3 }); + expect(result.graph.stats.sourceCount).toBe(8); + }); + + it("weighted shortest path prefers high-confidence edges", async () => { + const result = await graphBuilder.fromTexts([ + { id: "docs/x.md", title: "X", path: "docs/x.md", text: "# X\n\nSee [Y](y.md)." }, + { id: "docs/y.md", title: "Y", path: "docs/y.md", text: "# Y\n\nSee [Z](z.md)." }, + { id: "docs/z.md", title: "Z", path: "docs/z.md", text: "# Z\n\nEnd." } + ]); + + const path = result.query.path("X", "Z"); + expect(path).not.toBeNull(); + expect(path!.nodes.length).toBeGreaterThan(1); + expect(path!.totalWeight).toBeGreaterThan(0); + }); + + it("generates D3.js interactive HTML", async () => { + const result = await graphBuilder.fromTexts([ + { id: "docs/viz.md", title: "Viz", path: "docs/viz.md", text: "# Viz\n\nVisualization test." } + ], { artifacts: ["html"] }); + + expect(result.artifacts.html).toContain("d3js.org"); + expect(result.artifacts.html).toContain("forceSimulation"); + expect(result.artifacts.html).toContain("Graph Builder"); + }); }); \ No newline at end of file