@monoes/graph 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/src/analyze.d.ts +23 -0
  2. package/dist/src/analyze.d.ts.map +1 -0
  3. package/dist/src/analyze.js +105 -0
  4. package/dist/src/analyze.js.map +1 -0
  5. package/dist/src/build.d.ts +8 -0
  6. package/dist/src/build.d.ts.map +1 -0
  7. package/dist/src/build.js +59 -0
  8. package/dist/src/build.js.map +1 -0
  9. package/dist/src/cache.d.ts +10 -0
  10. package/dist/src/cache.d.ts.map +1 -0
  11. package/dist/src/cache.js +34 -0
  12. package/dist/src/cache.js.map +1 -0
  13. package/dist/src/cluster.d.ts +8 -0
  14. package/dist/src/cluster.d.ts.map +1 -0
  15. package/dist/src/cluster.js +50 -0
  16. package/dist/src/cluster.js.map +1 -0
  17. package/dist/src/detect.d.ts +8 -0
  18. package/dist/src/detect.d.ts.map +1 -0
  19. package/dist/src/detect.js +108 -0
  20. package/dist/src/detect.js.map +1 -0
  21. package/dist/src/export.d.ts +21 -0
  22. package/dist/src/export.d.ts.map +1 -0
  23. package/dist/src/export.js +68 -0
  24. package/dist/src/export.js.map +1 -0
  25. package/dist/src/extract/index.d.ts +20 -0
  26. package/dist/src/extract/index.d.ts.map +1 -0
  27. package/dist/src/extract/index.js +158 -0
  28. package/dist/src/extract/index.js.map +1 -0
  29. package/dist/src/extract/languages/go.d.ts +3 -0
  30. package/dist/src/extract/languages/go.d.ts.map +1 -0
  31. package/dist/src/extract/languages/go.js +181 -0
  32. package/dist/src/extract/languages/go.js.map +1 -0
  33. package/dist/src/extract/languages/python.d.ts +3 -0
  34. package/dist/src/extract/languages/python.d.ts.map +1 -0
  35. package/dist/src/extract/languages/python.js +230 -0
  36. package/dist/src/extract/languages/python.js.map +1 -0
  37. package/dist/src/extract/languages/rust.d.ts +3 -0
  38. package/dist/src/extract/languages/rust.d.ts.map +1 -0
  39. package/dist/src/extract/languages/rust.js +195 -0
  40. package/dist/src/extract/languages/rust.js.map +1 -0
  41. package/dist/src/extract/languages/typescript.d.ts +3 -0
  42. package/dist/src/extract/languages/typescript.d.ts.map +1 -0
  43. package/dist/src/extract/languages/typescript.js +295 -0
  44. package/dist/src/extract/languages/typescript.js.map +1 -0
  45. package/dist/src/extract/tree-sitter-runner.d.ts +48 -0
  46. package/dist/src/extract/tree-sitter-runner.d.ts.map +1 -0
  47. package/dist/src/extract/tree-sitter-runner.js +128 -0
  48. package/dist/src/extract/tree-sitter-runner.js.map +1 -0
  49. package/dist/src/extract/types.d.ts +7 -0
  50. package/dist/src/extract/types.d.ts.map +1 -0
  51. package/dist/src/extract/types.js +2 -0
  52. package/dist/src/extract/types.js.map +1 -0
  53. package/dist/src/index.d.ts +11 -0
  54. package/dist/src/index.d.ts.map +1 -0
  55. package/dist/src/index.js +9 -0
  56. package/dist/src/index.js.map +1 -0
  57. package/dist/src/pipeline.d.ts +16 -0
  58. package/dist/src/pipeline.d.ts.map +1 -0
  59. package/dist/src/pipeline.js +143 -0
  60. package/dist/src/pipeline.js.map +1 -0
  61. package/dist/src/types.d.ts +99 -0
  62. package/dist/src/types.d.ts.map +1 -0
  63. package/dist/src/types.js +2 -0
  64. package/dist/src/types.js.map +1 -0
  65. package/dist/tsconfig.tsbuildinfo +1 -0
  66. package/package.json +44 -0
  67. package/src/analyze.ts +122 -0
  68. package/src/build.ts +62 -0
  69. package/src/cache.ts +38 -0
  70. package/src/cluster.ts +54 -0
  71. package/src/detect.ts +123 -0
  72. package/src/export.ts +78 -0
  73. package/src/extract/index.ts +190 -0
  74. package/src/extract/languages/go.ts +206 -0
  75. package/src/extract/languages/python.ts +270 -0
  76. package/src/extract/languages/rust.ts +230 -0
  77. package/src/extract/languages/typescript.ts +344 -0
  78. package/src/extract/tree-sitter-runner.ts +165 -0
  79. package/src/extract/types.ts +7 -0
  80. package/src/index.ts +10 -0
  81. package/src/pipeline.ts +166 -0
  82. package/src/types.ts +116 -0
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@monoes/graph",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Knowledge graph engine for monobrain — AST extraction, graph construction, community detection, and persistent codebase understanding",
6
+ "main": "./dist/src/index.js",
7
+ "types": "./dist/src/index.d.ts",
8
+ "exports": {
9
+ ".": { "types": "./dist/src/index.d.ts", "import": "./dist/src/index.js" }
10
+ },
11
+ "files": ["dist", "src"],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "test": "vitest run",
15
+ "clean": "rm -rf dist"
16
+ },
17
+ "dependencies": {
18
+ "graphology": "^0.25.4",
19
+ "graphology-communities-louvain": "^2.0.1",
20
+ "graphology-shortest-path": "^2.0.2",
21
+ "graphology-traversal": "^0.3.1",
22
+ "graphology-metrics": "^2.4.0",
23
+ "chokidar": "^3.6.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^20.0.0",
27
+ "typescript": "^5.3.0",
28
+ "vitest": "^4.1.4"
29
+ },
30
+ "optionalDependencies": {
31
+ "node-tree-sitter": "^0.22.4",
32
+ "tree-sitter-typescript": "^0.23.2",
33
+ "tree-sitter-javascript": "^0.23.1",
34
+ "tree-sitter-python": "^0.23.6",
35
+ "tree-sitter-go": "^0.23.4",
36
+ "tree-sitter-rust": "^0.23.2",
37
+ "tree-sitter-java": "^0.23.5",
38
+ "tree-sitter-c": "^0.23.4",
39
+ "tree-sitter-cpp": "^0.23.4",
40
+ "tree-sitter-ruby": "^0.23.1",
41
+ "tree-sitter-c-sharp": "^0.23.1"
42
+ },
43
+ "publishConfig": { "access": "public" }
44
+ }
package/src/analyze.ts ADDED
@@ -0,0 +1,122 @@
1
+ import type Graph from 'graphology';
2
+ import type { GodNode, SurpriseEdge, GraphAnalysis, GraphStats, Confidence } from './types.js';
3
+
4
+ /**
5
+ * Find the most connected nodes (god nodes) — core abstractions of the codebase.
6
+ * Sorted by total degree (in + out), descending.
7
+ */
8
+ export function godNodes(graph: Graph, topN = 15): GodNode[] {
9
+ const nodes: GodNode[] = [];
10
+
11
+ graph.forEachNode((id, attrs) => {
12
+ nodes.push({
13
+ id,
14
+ label: (attrs.label as string) || id,
15
+ degree: graph.degree(id),
16
+ community: attrs.community as number | undefined,
17
+ sourceFile: (attrs.sourceFile as string) || '',
18
+ neighbors: graph
19
+ .neighbors(id)
20
+ .slice(0, 8)
21
+ .map((n) => (graph.getNodeAttribute(n, 'label') as string) || n),
22
+ });
23
+ });
24
+
25
+ return nodes.sort((a, b) => b.degree - a.degree).slice(0, topN);
26
+ }
27
+
28
+ /**
29
+ * Find surprising cross-community connections.
30
+ * An edge is surprising when its endpoints belong to different communities.
31
+ * Scored by the product of their degrees — high degree on both sides = high surprise.
32
+ */
33
+ export function surprisingConnections(graph: Graph, topN = 20): SurpriseEdge[] {
34
+ const surprises: SurpriseEdge[] = [];
35
+
36
+ graph.forEachEdge((_, attrs, source, target) => {
37
+ const cu = graph.getNodeAttribute(source, 'community') as number | undefined;
38
+ const cv = graph.getNodeAttribute(target, 'community') as number | undefined;
39
+
40
+ if (cu !== undefined && cv !== undefined && cu !== cv) {
41
+ surprises.push({
42
+ from: (graph.getNodeAttribute(source, 'label') as string) || source,
43
+ fromCommunity: cu,
44
+ fromFile: (graph.getNodeAttribute(source, 'sourceFile') as string) || '',
45
+ to: (graph.getNodeAttribute(target, 'label') as string) || target,
46
+ toCommunity: cv,
47
+ toFile: (graph.getNodeAttribute(target, 'sourceFile') as string) || '',
48
+ relation: (attrs.relation as string) || '',
49
+ confidence: (attrs.confidence as Confidence) ?? 'AMBIGUOUS',
50
+ score: graph.degree(source) * graph.degree(target),
51
+ });
52
+ }
53
+ });
54
+
55
+ return surprises.sort((a, b) => b.score - a.score).slice(0, topN);
56
+ }
57
+
58
+ /**
59
+ * Compute high-level graph statistics.
60
+ */
61
+ export function graphStats(graph: Graph, graphPath?: string): GraphStats {
62
+ const confidence: Record<string, number> = {};
63
+ const relations: Record<string, number> = {};
64
+ const fileTypes: Record<string, number> = {};
65
+ const commSet = new Set<number>();
66
+
67
+ graph.forEachEdge((_, attrs) => {
68
+ const c = (attrs.confidence as string) || 'UNKNOWN';
69
+ confidence[c] = (confidence[c] ?? 0) + 1;
70
+
71
+ const r = (attrs.relation as string) || 'unknown';
72
+ relations[r] = (relations[r] ?? 0) + 1;
73
+ });
74
+
75
+ graph.forEachNode((_, attrs) => {
76
+ const ft = (attrs.fileType as string) || 'unknown';
77
+ fileTypes[ft] = (fileTypes[ft] ?? 0) + 1;
78
+
79
+ const c = attrs.community as number | undefined;
80
+ if (c !== undefined) commSet.add(c);
81
+ });
82
+
83
+ const topRelations = Object.fromEntries(
84
+ Object.entries(relations)
85
+ .sort(([, a], [, b]) => b - a)
86
+ .slice(0, 10),
87
+ );
88
+
89
+ return {
90
+ nodes: graph.order,
91
+ edges: graph.size,
92
+ communities: commSet.size,
93
+ confidence: confidence as Record<Confidence, number>,
94
+ fileTypes,
95
+ topRelations,
96
+ isDirected: graph.type === 'directed',
97
+ graphPath,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Build a complete GraphAnalysis object from an annotated graph.
103
+ * Assumes community detection has already been run (nodes have `community` attribute).
104
+ */
105
+ export function buildAnalysis(graph: Graph, graphPath?: string): GraphAnalysis {
106
+ // Reconstruct communities map from node attributes
107
+ const communities: Record<number, string[]> = {};
108
+ graph.forEachNode((id, attrs) => {
109
+ const c = attrs.community as number | undefined;
110
+ if (c !== undefined) {
111
+ if (!communities[c]) communities[c] = [];
112
+ communities[c].push(id);
113
+ }
114
+ });
115
+
116
+ return {
117
+ godNodes: godNodes(graph),
118
+ surprises: surprisingConnections(graph),
119
+ communities,
120
+ stats: graphStats(graph, graphPath),
121
+ };
122
+ }
package/src/build.ts ADDED
@@ -0,0 +1,62 @@
1
+ import Graph from 'graphology';
2
+ import type { ExtractionResult } from './types.js';
3
+
4
+ /**
5
+ * Build a graphology Graph from extracted nodes and edges.
6
+ * Deduplicates nodes by id, merges parallel edges with higher weight.
7
+ */
8
+ export function buildGraph(extraction: ExtractionResult): Graph {
9
+ const graph = new Graph({ type: 'directed', multi: false });
10
+
11
+ // Add all nodes — merge attributes if already present (dedup by id)
12
+ for (const node of extraction.nodes) {
13
+ if (!graph.hasNode(node.id)) {
14
+ graph.addNode(node.id, { ...node });
15
+ } else {
16
+ graph.mergeNodeAttributes(node.id, { ...node });
17
+ }
18
+ }
19
+
20
+ // Add edges — skip self-loops, auto-stub missing endpoints
21
+ for (const edge of extraction.edges) {
22
+ if (edge.source === edge.target) continue;
23
+
24
+ // Create stub nodes for referenced endpoints not in the extraction
25
+ if (!graph.hasNode(edge.source)) {
26
+ graph.addNode(edge.source, {
27
+ id: edge.source,
28
+ label: edge.source,
29
+ fileType: 'unknown',
30
+ sourceFile: '',
31
+ });
32
+ }
33
+ if (!graph.hasNode(edge.target)) {
34
+ graph.addNode(edge.target, {
35
+ id: edge.target,
36
+ label: edge.target,
37
+ fileType: 'unknown',
38
+ sourceFile: '',
39
+ });
40
+ }
41
+
42
+ try {
43
+ graph.addEdge(edge.source, edge.target, {
44
+ relation: edge.relation,
45
+ confidence: edge.confidence,
46
+ confidenceScore: edge.confidenceScore,
47
+ weight: edge.weight ?? 1,
48
+ sourceFile: edge.sourceFile,
49
+ sourceLocation: edge.sourceLocation,
50
+ });
51
+ } catch {
52
+ // Edge already exists — bump its weight
53
+ const existing = graph.edge(edge.source, edge.target);
54
+ if (existing) {
55
+ const prev = (graph.getEdgeAttribute(existing, 'weight') as number) ?? 1;
56
+ graph.setEdgeAttribute(existing, 'weight', prev + 1);
57
+ }
58
+ }
59
+ }
60
+
61
+ return graph;
62
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { createHash } from 'crypto';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import type { ExtractionResult } from './types.js';
5
+
6
+ export class FileCache {
7
+ private cacheDir: string;
8
+
9
+ constructor(outputDir: string) {
10
+ this.cacheDir = join(outputDir, 'cache');
11
+ mkdirSync(this.cacheDir, { recursive: true });
12
+ }
13
+
14
+ key(filePath: string, content: string): string {
15
+ return createHash('sha256')
16
+ .update(filePath + content)
17
+ .digest('hex');
18
+ }
19
+
20
+ get(key: string): ExtractionResult | null {
21
+ const p = join(this.cacheDir, `${key}.json`);
22
+ if (!existsSync(p)) return null;
23
+ try {
24
+ return JSON.parse(readFileSync(p, 'utf-8')) as ExtractionResult;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ set(key: string, result: ExtractionResult): void {
31
+ const p = join(this.cacheDir, `${key}.json`);
32
+ writeFileSync(p, JSON.stringify(result), 'utf-8');
33
+ }
34
+
35
+ has(key: string): boolean {
36
+ return existsSync(join(this.cacheDir, `${key}.json`));
37
+ }
38
+ }
package/src/cluster.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type Graph from 'graphology';
2
+
3
+ /**
4
+ * Run Louvain community detection on the graph.
5
+ * Assigns the `community` attribute to each node in-place.
6
+ * Returns a map from communityId → list of nodeIds.
7
+ */
8
+ export async function detectCommunities(graph: Graph): Promise<Record<number, string[]>> {
9
+ try {
10
+ const { default: louvain } = await import('graphology-communities-louvain');
11
+ const assignment = louvain(graph) as Record<string, number>;
12
+
13
+ // Write community id back onto each node
14
+ for (const [nodeId, communityId] of Object.entries(assignment)) {
15
+ graph.setNodeAttribute(nodeId, 'community', communityId);
16
+ }
17
+
18
+ // Build communityId → members map
19
+ const communities: Record<number, string[]> = {};
20
+ for (const [nodeId, communityId] of Object.entries(assignment)) {
21
+ if (!communities[communityId]) communities[communityId] = [];
22
+ communities[communityId].push(nodeId);
23
+ }
24
+ return communities;
25
+ } catch {
26
+ // Louvain unavailable — fall back to directory-based clustering
27
+ return fallbackCluster(graph);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Fallback: group nodes by the directory portion of their sourceFile attribute.
33
+ * Deterministic and zero-dependency.
34
+ */
35
+ function fallbackCluster(graph: Graph): Record<number, string[]> {
36
+ const dirMap = new Map<string, number>();
37
+ let nextId = 0;
38
+ const communities: Record<number, string[]> = {};
39
+
40
+ graph.forEachNode((id, attrs) => {
41
+ const file = (attrs.sourceFile as string) || '';
42
+ const parts = file.split('/');
43
+ const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : 'root';
44
+
45
+ if (!dirMap.has(dir)) dirMap.set(dir, nextId++);
46
+ const cid = dirMap.get(dir)!;
47
+
48
+ graph.setNodeAttribute(id, 'community', cid);
49
+ if (!communities[cid]) communities[cid] = [];
50
+ communities[cid].push(id);
51
+ });
52
+
53
+ return communities;
54
+ }
package/src/detect.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { readdirSync, statSync } from 'fs';
2
+ import { join, extname, relative } from 'path';
3
+ import type { BuildOptions, ClassifiedFile, FileType } from './types.js';
4
+
5
+ const DEFAULT_MAX_FILE_SIZE = 500 * 1024; // 500KB
6
+
7
+ const EXCLUDED_DIRS = new Set([
8
+ 'node_modules',
9
+ '.git',
10
+ 'dist',
11
+ 'build',
12
+ '.monobrain',
13
+ '__pycache__',
14
+ '.pytest_cache',
15
+ 'target',
16
+ '.cache',
17
+ ]);
18
+
19
+ // Maps file extension to [fileType, language]
20
+ const EXTENSION_MAP: Record<string, [FileType, string]> = {
21
+ '.ts': ['code', 'typescript'],
22
+ '.tsx': ['code', 'typescript'],
23
+ '.js': ['code', 'javascript'],
24
+ '.jsx': ['code', 'javascript'],
25
+ '.py': ['code', 'python'],
26
+ '.go': ['code', 'go'],
27
+ '.rs': ['code', 'rust'],
28
+ '.java': ['code', 'java'],
29
+ '.c': ['code', 'c'],
30
+ '.cpp': ['code', 'cpp'],
31
+ '.h': ['code', 'c'],
32
+ '.cs': ['code', 'csharp'],
33
+ '.rb': ['code', 'ruby'],
34
+ '.php': ['code', 'php'],
35
+ '.swift': ['code', 'swift'],
36
+ '.kt': ['code', 'kotlin'],
37
+ '.scala': ['code', 'scala'],
38
+ '.md': ['document', 'markdown'],
39
+ '.txt': ['document', 'text'],
40
+ '.rst': ['document', 'rst'],
41
+ };
42
+
43
+ /**
44
+ * Recursively collects and classifies all files under rootPath, applying
45
+ * exclusion rules for directories, file size limits, and optional language
46
+ * filtering from BuildOptions.
47
+ */
48
+ export function collectFiles(
49
+ rootPath: string,
50
+ options: BuildOptions = {},
51
+ ): ClassifiedFile[] {
52
+ const maxFileSizeBytes = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE;
53
+ const codeOnly = options.codeOnly ?? false;
54
+ const languageFilter = options.languages ? new Set(options.languages) : null;
55
+ const excludePatterns = options.excludePatterns ?? [];
56
+
57
+ const results: ClassifiedFile[] = [];
58
+
59
+ function walkDir(dirPath: string): void {
60
+ let entries: string[];
61
+ try {
62
+ entries = readdirSync(dirPath);
63
+ } catch {
64
+ // Skip unreadable directories
65
+ return;
66
+ }
67
+
68
+ for (const entry of entries) {
69
+ const fullPath = join(dirPath, entry);
70
+
71
+ // Check against custom exclude patterns (matched against relative path)
72
+ const relPath = relative(rootPath, fullPath);
73
+ if (excludePatterns.some((pat) => relPath.includes(pat))) {
74
+ continue;
75
+ }
76
+
77
+ let stat;
78
+ try {
79
+ stat = statSync(fullPath);
80
+ } catch {
81
+ continue;
82
+ }
83
+
84
+ if (stat.isDirectory()) {
85
+ // Skip excluded directory names
86
+ if (EXCLUDED_DIRS.has(entry)) continue;
87
+ walkDir(fullPath);
88
+ continue;
89
+ }
90
+
91
+ if (!stat.isFile()) continue;
92
+
93
+ // Enforce file size limit
94
+ if (stat.size > maxFileSizeBytes) continue;
95
+
96
+ const ext = extname(entry).toLowerCase();
97
+ const mapping = EXTENSION_MAP[ext];
98
+
99
+ if (!mapping) {
100
+ // Unknown extension — skip rather than emit 'unknown' noise
101
+ continue;
102
+ }
103
+
104
+ const [fileType, language] = mapping;
105
+
106
+ // Apply codeOnly filter
107
+ if (codeOnly && fileType !== 'code') continue;
108
+
109
+ // Apply language filter
110
+ if (languageFilter && !languageFilter.has(language)) continue;
111
+
112
+ results.push({
113
+ path: fullPath,
114
+ fileType,
115
+ language,
116
+ sizeBytes: stat.size,
117
+ });
118
+ }
119
+ }
120
+
121
+ walkDir(rootPath);
122
+ return results;
123
+ }
package/src/export.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import Graph from 'graphology';
4
+ import type { SerializedGraph } from './types.js';
5
+
6
+ /**
7
+ * Serialize a graphology graph to disk as a JSON file.
8
+ * Creates the output directory if it does not exist.
9
+ * Returns the absolute path to the written file.
10
+ */
11
+ export function saveGraph(graph: Graph, outputDir: string, projectPath: string): string {
12
+ mkdirSync(outputDir, { recursive: true });
13
+ const graphPath = join(outputDir, 'graph.json');
14
+
15
+ const nodes: Array<{ id: string } & Record<string, unknown>> = [];
16
+ graph.forEachNode((id, attrs) => nodes.push({ id, ...attrs }));
17
+
18
+ const links: Array<{ source: string; target: string } & Record<string, unknown>> = [];
19
+ graph.forEachEdge((_, attrs, source, target) => links.push({ source, target, ...attrs }));
20
+
21
+ const serialized: SerializedGraph = {
22
+ version: '1.0.0',
23
+ builtAt: new Date().toISOString(),
24
+ projectPath,
25
+ nodes,
26
+ links,
27
+ directed: graph.type === 'directed',
28
+ multigraph: graph.multi,
29
+ };
30
+
31
+ writeFileSync(graphPath, JSON.stringify(serialized, null, 2), 'utf-8');
32
+ return graphPath;
33
+ }
34
+
35
+ /**
36
+ * Deserialize a graph from a previously saved JSON file.
37
+ * Silently skips edges whose endpoints are missing from the node list.
38
+ */
39
+ export function loadGraph(graphPath: string): Graph {
40
+ const raw = readFileSync(graphPath, 'utf-8');
41
+ const data = JSON.parse(raw) as SerializedGraph;
42
+
43
+ const graph = new Graph({
44
+ type: data.directed ? 'directed' : 'undirected',
45
+ multi: false,
46
+ });
47
+
48
+ for (const node of data.nodes) {
49
+ const { id, ...attrs } = node;
50
+ graph.addNode(id, attrs);
51
+ }
52
+
53
+ for (const link of data.links) {
54
+ const { source, target, ...attrs } = link;
55
+ if (!graph.hasNode(source) || !graph.hasNode(target)) continue;
56
+ try {
57
+ graph.addEdge(source, target, attrs);
58
+ } catch {
59
+ // Duplicate edge — ignore
60
+ }
61
+ }
62
+
63
+ return graph;
64
+ }
65
+
66
+ /**
67
+ * Return true when a graph.json already exists in the given output directory.
68
+ */
69
+ export function graphExists(outputDir: string): boolean {
70
+ return existsSync(join(outputDir, 'graph.json'));
71
+ }
72
+
73
+ /**
74
+ * Return the canonical path to graph.json inside an output directory.
75
+ */
76
+ export function getGraphPath(outputDir: string): string {
77
+ return join(outputDir, 'graph.json');
78
+ }