@monoes/monograph 1.5.1 → 1.5.3

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 (61) hide show
  1. package/.monodesign/hook.cache.json +1 -0
  2. package/__tests__/integration/build-atomicity.test.ts +61 -0
  3. package/dist/src/analysis/cycles.d.ts.map +1 -1
  4. package/dist/src/analysis/cycles.js +5 -3
  5. package/dist/src/analysis/cycles.js.map +1 -1
  6. package/dist/src/analysis/trace.d.ts.map +1 -1
  7. package/dist/src/analysis/trace.js +1 -19
  8. package/dist/src/analysis/trace.js.map +1 -1
  9. package/dist/src/analysis/worker-pool.d.ts.map +1 -1
  10. package/dist/src/analysis/worker-pool.js +9 -7
  11. package/dist/src/analysis/worker-pool.js.map +1 -1
  12. package/dist/src/cli/skill-gen.d.ts.map +1 -1
  13. package/dist/src/cli/skill-gen.js +16 -25
  14. package/dist/src/cli/skill-gen.js.map +1 -1
  15. package/dist/src/export/html.d.ts.map +1 -1
  16. package/dist/src/export/html.js +3 -2
  17. package/dist/src/export/html.js.map +1 -1
  18. package/dist/src/graph/regex-search.d.ts.map +1 -1
  19. package/dist/src/graph/regex-search.js +9 -3
  20. package/dist/src/graph/regex-search.js.map +1 -1
  21. package/dist/src/pipeline/orchestrator.js +22 -0
  22. package/dist/src/pipeline/orchestrator.js.map +1 -1
  23. package/dist/src/pipeline/phases/call-site-extractors.d.ts +17 -0
  24. package/dist/src/pipeline/phases/call-site-extractors.d.ts.map +1 -0
  25. package/dist/src/pipeline/phases/call-site-extractors.js +132 -0
  26. package/dist/src/pipeline/phases/call-site-extractors.js.map +1 -0
  27. package/dist/src/pipeline/phases/module-resolution.d.ts +10 -0
  28. package/dist/src/pipeline/phases/module-resolution.d.ts.map +1 -0
  29. package/dist/src/pipeline/phases/module-resolution.js +301 -0
  30. package/dist/src/pipeline/phases/module-resolution.js.map +1 -0
  31. package/dist/src/pipeline/phases/orm.js +1 -2
  32. package/dist/src/pipeline/phases/orm.js.map +1 -1
  33. package/dist/src/pipeline/phases/scope-resolution.d.ts +2 -25
  34. package/dist/src/pipeline/phases/scope-resolution.d.ts.map +1 -1
  35. package/dist/src/pipeline/phases/scope-resolution.js +18 -580
  36. package/dist/src/pipeline/phases/scope-resolution.js.map +1 -1
  37. package/dist/src/pipeline/phases/wildcard-phase.d.ts.map +1 -1
  38. package/dist/src/pipeline/phases/wildcard-phase.js +6 -1
  39. package/dist/src/pipeline/phases/wildcard-phase.js.map +1 -1
  40. package/dist/src/search/hybrid-query.js +1 -1
  41. package/dist/src/search/hybrid-query.js.map +1 -1
  42. package/dist/src/web/api.d.ts.map +1 -1
  43. package/dist/src/web/api.js +17 -14
  44. package/dist/src/web/api.js.map +1 -1
  45. package/dist/tsconfig.tsbuildinfo +1 -1
  46. package/package.json +1 -1
  47. package/src/__tests__/pipeline/phases/wildcard-phase.test.ts +39 -4
  48. package/src/analysis/cycles.ts +7 -3
  49. package/src/analysis/trace.ts +1 -21
  50. package/src/analysis/worker-pool.ts +8 -7
  51. package/src/cli/skill-gen.ts +6 -14
  52. package/src/export/html.ts +3 -2
  53. package/src/graph/regex-search.ts +9 -5
  54. package/src/pipeline/orchestrator.ts +18 -0
  55. package/src/pipeline/phases/call-site-extractors.ts +169 -0
  56. package/src/pipeline/phases/module-resolution.ts +294 -0
  57. package/src/pipeline/phases/orm.ts +1 -2
  58. package/src/pipeline/phases/scope-resolution.ts +25 -618
  59. package/src/pipeline/phases/wildcard-phase.ts +5 -1
  60. package/src/search/hybrid-query.ts +1 -1
  61. package/src/web/api.ts +17 -13
@@ -1,9 +1,16 @@
1
- import { readFileSync, existsSync } from 'fs';
2
- import { join, extname, dirname, resolve as resolvePath } from 'path';
1
+ import { readFileSync } from 'fs';
2
+ import { join, extname } from 'path';
3
3
  import type { PipelinePhase, PipelineContext } from '../types.js';
4
4
  import type { MonographNode } from '../../types.js';
5
5
  import { makeId } from '../../types.js';
6
6
  import type { ParseOutput } from './parse.js';
7
+ import type { CallSite } from './call-site-extractors.js';
8
+ import { TS_JS_EXTS, CJS_MJS_EXTS, isSupportedExt, extractCallSites, extractConstructorAssignments } from './call-site-extractors.js';
9
+ import { buildWorkspacePackageMap, resolveModuleSpecifier, extractImportNames, buildAllImportMapsFromSource, REEXPORT_RE } from './module-resolution.js';
10
+
11
+ // Re-export for external consumers
12
+ export { clearWorkspacePackageMapCache, buildWorkspacePackageMap, resolveModuleSpecifier } from './module-resolution.js';
13
+ export { extractGoCallSites, extractJavaCallSites, extractRustCallSites } from './call-site-extractors.js';
7
14
 
8
15
  // ── Output ────────────────────────────────────────────────────────────────────
9
16
 
@@ -16,581 +23,8 @@ export interface ScopeResolutionOutput {
16
23
  importsReconstructed: number;
17
24
  }
18
25
 
19
- // ── Call site ─────────────────────────────────────────────────────────────────
20
-
21
- interface CallSite {
22
- callerFileNodeId: string;
23
- callerFilePath: string;
24
- calleeRaw: string;
25
- form: 'method' | 'direct' | 'dynamic';
26
- receiverName?: string;
27
- methodName?: string;
28
- }
29
-
30
- // ── JS/TS keywords to skip for direct calls ───────────────────────────────────
31
-
32
- const JS_KEYWORDS = new Set([
33
- 'if', 'for', 'while', 'switch', 'return', 'function', 'class', 'new', 'typeof',
34
- 'await', 'catch', 'throw', 'delete', 'void', 'instanceof', 'in', 'of',
35
- 'import', 'export', 'yield', 'async', 'super', 'this', 'const', 'let', 'var',
36
- 'try', 'finally', 'break', 'continue', 'debugger', 'with', 'do',
37
- ]);
38
-
39
- const PY_KEYWORDS = new Set([
40
- 'if', 'for', 'while', 'with', 'return', 'def', 'class', 'import', 'from',
41
- 'raise', 'yield', 'lambda', 'await', 'async', 'del', 'pass', 'assert',
42
- 'except', 'elif', 'else', 'not', 'and', 'or', 'in', 'is', 'print',
43
- ]);
44
-
45
- const GO_KEYWORDS = new Set([
46
- 'if', 'for', 'range', 'return', 'func', 'type', 'var', 'const', 'import', 'package',
47
- 'go', 'defer', 'select', 'case', 'default', 'break', 'continue', 'goto', 'fallthrough',
48
- 'chan', 'map', 'struct', 'interface', 'make', 'new', 'len', 'cap', 'append', 'delete',
49
- 'panic', 'recover', 'close', 'switch', 'else',
50
- ]);
51
-
52
- const JAVA_KEYWORDS = new Set([
53
- 'if', 'for', 'while', 'do', 'switch', 'case', 'return', 'class', 'interface', 'enum',
54
- 'new', 'extends', 'implements', 'import', 'package', 'throw', 'throws', 'catch', 'try',
55
- 'finally', 'static', 'final', 'abstract', 'public', 'private', 'protected', 'void',
56
- 'break', 'continue', 'default', 'else', 'instanceof', 'this', 'super',
57
- ]);
58
-
59
- const RUST_KEYWORDS = new Set([
60
- 'if', 'let', 'for', 'while', 'loop', 'match', 'return', 'fn', 'struct', 'enum', 'trait',
61
- 'impl', 'use', 'mod', 'pub', 'super', 'self', 'type', 'where', 'in', 'as', 'mut',
62
- 'ref', 'move', 'async', 'await', 'dyn', 'extern', 'crate', 'static', 'const', 'unsafe',
63
- 'break', 'continue', 'else',
64
- ]);
65
-
66
- // ── Supported extensions ──────────────────────────────────────────────────────
67
-
68
- const TS_JS_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx']);
69
- const PY_EXTS = new Set(['.py']);
70
- const GO_EXTS = new Set(['.go']);
71
- const JAVA_EXTS = new Set(['.java']);
72
- const RUST_EXTS = new Set(['.rs']);
73
-
74
- const CJS_MJS_EXTS = new Set(['.cjs', '.mjs']);
75
-
76
- function isSupportedExt(ext: string): boolean {
77
- return TS_JS_EXTS.has(ext) || CJS_MJS_EXTS.has(ext) || PY_EXTS.has(ext) || GO_EXTS.has(ext) || JAVA_EXTS.has(ext) || RUST_EXTS.has(ext);
78
- }
79
-
80
- // ── Language-specific extractors ──────────────────────────────────────────────
81
-
82
- export function extractGoCallSites(source: string, filePath: string, fileNodeId: string): CallSite[] {
83
- const sites: CallSite[] = [];
84
- const methodPattern = /(\w+)\.(\w+)\s*\(/g;
85
- let m: RegExpExecArray | null;
86
- while ((m = methodPattern.exec(source)) !== null) {
87
- sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: `${m[1]}.${m[2]}`, form: 'method', receiverName: m[1], methodName: m[2] });
88
- }
89
- const directPattern = /(?<![.[\w])([A-Za-z_][\w]*)\s*\(/g;
90
- while ((m = directPattern.exec(source)) !== null) {
91
- const name = m[1]!;
92
- if (GO_KEYWORDS.has(name)) continue;
93
- sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: name, form: 'direct' });
94
- }
95
- return sites;
96
- }
97
-
98
- export function extractJavaCallSites(source: string, filePath: string, fileNodeId: string): CallSite[] {
99
- const sites: CallSite[] = [];
100
- const methodPattern = /(\w+)\.(\w+)\s*\(/g;
101
- let m: RegExpExecArray | null;
102
- while ((m = methodPattern.exec(source)) !== null) {
103
- sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: `${m[1]}.${m[2]}`, form: 'method', receiverName: m[1], methodName: m[2] });
104
- }
105
- const directPattern = /(?<![.[\w])([A-Za-z_$][\w$]*)\s*\(/g;
106
- while ((m = directPattern.exec(source)) !== null) {
107
- const name = m[1]!;
108
- if (JAVA_KEYWORDS.has(name)) continue;
109
- sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: name, form: 'direct' });
110
- }
111
- return sites;
112
- }
113
-
114
- export function extractRustCallSites(source: string, filePath: string, fileNodeId: string): CallSite[] {
115
- const sites: CallSite[] = [];
116
- const methodPattern = /(\w+)\.(\w+)\s*\(/g;
117
- let m: RegExpExecArray | null;
118
- while ((m = methodPattern.exec(source)) !== null) {
119
- sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: `${m[1]}.${m[2]}`, form: 'method', receiverName: m[1], methodName: m[2] });
120
- }
121
- const directPattern = /(?<![.[\w])([A-Za-z_][\w]*)\s*\(/g;
122
- while ((m = directPattern.exec(source)) !== null) {
123
- const name = m[1]!;
124
- if (RUST_KEYWORDS.has(name)) continue;
125
- sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: name, form: 'direct' });
126
- }
127
- return sites;
128
- }
129
-
130
- // ── Stage 0.5: Track constructor assignments for type-aware method resolution ─
131
-
132
- /**
133
- * Scan source for `const/let/var x = new ClassName(...)` patterns.
134
- * Returns a map: localVarName → ClassName (e.g. "svc" → "MyService").
135
- */
136
- function extractConstructorAssignments(source: string): Map<string, string> {
137
- const result = new Map<string, string>();
138
- const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*new\s+([A-Z][\w$]*)\s*(?:<[^>]*>\s*)?\(/g;
139
- let m: RegExpExecArray | null;
140
- while ((m = pattern.exec(source)) !== null) {
141
- result.set(m[1], m[2]);
142
- }
143
- return result;
144
- }
145
-
146
- // ── Stage 1 + 2: Extract and classify call sites ──────────────────────────────
147
-
148
- function extractCallSites(
149
- source: string,
150
- filePath: string,
151
- fileNodeId: string,
152
- ext: string,
153
- ): CallSite[] {
154
- const sites: CallSite[] = [];
155
-
156
- if (TS_JS_EXTS.has(ext) || CJS_MJS_EXTS.has(ext)) {
157
- // Method calls: word.word( or word.word<T>(
158
- const methodPattern = /(\w+)\.(\w+)\s*(?:<[^>]*>\s*)?\(/g;
159
- let m: RegExpExecArray | null;
160
- while ((m = methodPattern.exec(source)) !== null) {
161
- sites.push({
162
- callerFileNodeId: fileNodeId,
163
- callerFilePath: filePath,
164
- calleeRaw: `${m[1]}.${m[2]}`,
165
- form: 'method',
166
- receiverName: m[1],
167
- methodName: m[2],
168
- });
169
- }
170
-
171
- // Chained method calls: ).method( or ].method( — receiver unknown, resolve by method name
172
- const chainedPattern = /[)\]]\s*\.(\w+)\s*(?:<[^>]*>\s*)?\(/g;
173
- while ((m = chainedPattern.exec(source)) !== null) {
174
- const name = m[1];
175
- if (JS_KEYWORDS.has(name)) continue;
176
- sites.push({
177
- callerFileNodeId: fileNodeId,
178
- callerFilePath: filePath,
179
- calleeRaw: `?.${name}`,
180
- form: 'method',
181
- methodName: name,
182
- });
183
- }
184
-
185
- // Direct calls: word( or word<T>( — not preceded by . or word char
186
- const directPattern = /(?<![.[\w])([A-Za-z_$][\w$]*)\s*(?:<[^>]*>\s*)?\(/g;
187
- while ((m = directPattern.exec(source)) !== null) {
188
- const name = m[1];
189
- if (JS_KEYWORDS.has(name)) continue;
190
- sites.push({
191
- callerFileNodeId: fileNodeId,
192
- callerFilePath: filePath,
193
- calleeRaw: name,
194
- form: 'direct',
195
- methodName: name,
196
- });
197
- }
198
-
199
- // Constructor calls: new ClassName( or new ClassName<T>(
200
- const newPattern = /\bnew\s+([A-Z][\w$]*)\s*(?:<[^>]*>\s*)?\(/g;
201
- while ((m = newPattern.exec(source)) !== null) {
202
- sites.push({
203
- callerFileNodeId: fileNodeId,
204
- callerFilePath: filePath,
205
- calleeRaw: `new ${m[1]}`,
206
- form: 'direct',
207
- methodName: m[1],
208
- });
209
- }
210
-
211
- // Dynamic calls: obj[key]( → mark as dynamic
212
- const dynamicPattern = /\w+\s*\[[\w'"` ]+\]\s*\(/g;
213
- while ((m = dynamicPattern.exec(source)) !== null) {
214
- sites.push({
215
- callerFileNodeId: fileNodeId,
216
- callerFilePath: filePath,
217
- calleeRaw: m[0],
218
- form: 'dynamic',
219
- });
220
- }
221
- } else if (PY_EXTS.has(ext)) {
222
- // Method calls: self.method( or obj.method(
223
- const methodPattern = /(\w+)\.(\w+)\s*\(/g;
224
- let m: RegExpExecArray | null;
225
- while ((m = methodPattern.exec(source)) !== null) {
226
- sites.push({
227
- callerFileNodeId: fileNodeId,
228
- callerFilePath: filePath,
229
- calleeRaw: `${m[1]}.${m[2]}`,
230
- form: 'method',
231
- receiverName: m[1],
232
- methodName: m[2],
233
- });
234
- }
235
-
236
- // Direct calls: word( not preceded by . or word char
237
- const directPattern = /(?<![.[\w])([A-Za-z_][\w]*)\s*\(/g;
238
- while ((m = directPattern.exec(source)) !== null) {
239
- const name = m[1];
240
- if (PY_KEYWORDS.has(name)) continue;
241
- sites.push({
242
- callerFileNodeId: fileNodeId,
243
- callerFilePath: filePath,
244
- calleeRaw: name,
245
- form: 'direct',
246
- methodName: name,
247
- });
248
- }
249
- } else if (GO_EXTS.has(ext)) {
250
- return extractGoCallSites(source, filePath, fileNodeId);
251
- } else if (JAVA_EXTS.has(ext)) {
252
- return extractJavaCallSites(source, filePath, fileNodeId);
253
- } else if (RUST_EXTS.has(ext)) {
254
- return extractRustCallSites(source, filePath, fileNodeId);
255
- }
256
-
257
- return sites;
258
- }
259
-
260
- // ── Stage 3: Build import maps by parsing source imports ─────────────────────
261
-
262
- const IMPORT_RE = /import\s+(?:type\s+)?(?:\{([^}]+)\}|(\w+)|\*\s+as\s+(\w+))(?:\s*,\s*(?:\{([^}]+)\}|(\w+)|\*\s+as\s+(\w+)))?\s+from\s+['"]([^'"]+)['"]/g;
263
-
264
- // CJS require: const x = require('y') or const { a, b } = require('y')
265
- const REQUIRE_RE = /(?:const|let|var)\s+(?:\{([^}]+)\}|(\w+))\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
266
-
267
- // Python: from x import y, z OR import x
268
- const PY_FROM_IMPORT_RE = /from\s+([\w.]+)\s+import\s+(.+)/g;
269
- const PY_IMPORT_RE = /^import\s+([\w.]+)(?:\s+as\s+(\w+))?/gm;
270
-
271
- // Go: import "path" or import ( "path" )
272
- const GO_IMPORT_RE = /import\s+(?:"([^"]+)"|(?:\w+\s+)?"([^"]+)"|\(\s*([\s\S]*?)\s*\))/g;
273
-
274
- // Java: import com.foo.Bar;
275
- const JAVA_IMPORT_RE = /import\s+(?:static\s+)?([\w.]+)\s*;/g;
276
-
277
- // Rust: use crate::module::Item; or use super::foo;
278
- const RUST_USE_RE = /use\s+((?:crate|super|self)(?:::\w+)+)(?:::\{([^}]+)\})?;/g;
279
-
280
- // TS/JS re-exports: export { foo, bar } from './module'
281
- const REEXPORT_RE = /export\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g;
282
-
283
- const RESOLVE_EXTS = ['.ts', '.tsx', '.js', '.jsx'];
284
- const PY_RESOLVE_EXTS = ['.py'];
285
- const GO_RESOLVE_EXTS = ['.go'];
286
- const JAVA_RESOLVE_EXTS = ['.java'];
287
- const RUST_RESOLVE_EXTS = ['.rs'];
288
-
289
- const workspacePackageMapCache = new Map<string, Map<string, string>>();
290
-
291
- /** Invalidate the cached workspace package map for a repo — call at the start
292
- * of each build so long-lived processes (watch mode) pick up package.json
293
- * additions/removals instead of serving a stale map from a prior build. */
294
- export function clearWorkspacePackageMapCache(repoPath: string): void {
295
- workspacePackageMapCache.delete(repoPath);
296
- }
297
-
298
- /**
299
- * Build package-name → directory map from workspace package.json files.
300
- * Scans packages/ for package.json and maps npm name to its relative src path.
301
- * Cached per repoPath — multiple pipeline phases (cross-file, scope-resolution,
302
- * the latter's own re-export loop) call this per-file/per-edge within the same
303
- * build, and the workspace's package.json set doesn't change mid-build.
304
- */
305
- export function buildWorkspacePackageMap(repoPath: string): Map<string, string> {
306
- const cached = workspacePackageMapCache.get(repoPath);
307
- if (cached) return cached;
308
- const result = new Map<string, string>();
309
- const packagesDir = join(repoPath, 'packages');
310
- try {
311
- const scanDirs = (base: string, depth: number) => {
312
- if (depth > 2) return;
313
- let entries: string[];
314
- try { entries = require('fs').readdirSync(base); } catch { return; }
315
- for (const e of entries) {
316
- const full = join(base, e);
317
- const pkgJson = join(full, 'package.json');
318
- try {
319
- const pkg = JSON.parse(readFileSync(pkgJson, 'utf-8'));
320
- if (pkg.name) {
321
- const relDir = full.slice(repoPath.length + 1); // e.g. packages/@monomind/cli
322
- result.set(pkg.name, relDir);
323
- }
324
- } catch {
325
- // No package.json — recurse for @scoped dirs
326
- if (e.startsWith('@')) scanDirs(full, depth + 1);
327
- }
328
- }
329
- };
330
- scanDirs(packagesDir, 0);
331
- } catch { /* no packages dir */ }
332
- workspacePackageMapCache.set(repoPath, result);
333
- return result;
334
- }
335
-
336
- export function resolveModuleSpecifier(
337
- importerPath: string,
338
- specifier: string,
339
- repoPath: string,
340
- knownFiles: Set<string>,
341
- workspaceMap: Map<string, string>,
342
- ): string | null {
343
- if (specifier.startsWith('.')) {
344
- const dir = dirname(importerPath);
345
- const raw = resolvePath('/', dir, specifier).slice(1);
346
- // Strip .js/.jsx — TS source uses .js extensions but files are .ts
347
- const base = raw.replace(/\.(js|jsx)$/, '');
348
-
349
- for (const candidate of [
350
- raw,
351
- base,
352
- ...RESOLVE_EXTS.map(e => base + e),
353
- ...RESOLVE_EXTS.map(e => base + '/index' + e),
354
- ]) {
355
- if (knownFiles.has(candidate)) return candidate;
356
- }
357
-
358
- for (const ext of RESOLVE_EXTS) {
359
- if (existsSync(join(repoPath, base + ext))) return base + ext;
360
- }
361
- return null;
362
- }
363
-
364
- // Workspace package specifier: @monoes/hooks → packages/@monomind/hooks/src/index.ts
365
- // Also handles subpath: @monoes/hooks/src/foo → packages/@monomind/hooks/src/foo.ts
366
- for (const [pkgName, pkgDir] of workspaceMap) {
367
- if (specifier === pkgName) {
368
- // Bare import: resolve to package entry point (src/index.ts)
369
- for (const entry of RESOLVE_EXTS.map(e => pkgDir + '/src/index' + e)) {
370
- if (knownFiles.has(entry)) return entry;
371
- }
372
- return null;
373
- }
374
- if (specifier.startsWith(pkgName + '/')) {
375
- const subpath = specifier.slice(pkgName.length + 1);
376
- const base = pkgDir + '/' + subpath;
377
- for (const candidate of [
378
- base,
379
- ...RESOLVE_EXTS.map(e => base + e),
380
- ...RESOLVE_EXTS.map(e => base + '/index' + e),
381
- ]) {
382
- if (knownFiles.has(candidate)) return candidate;
383
- }
384
- return null;
385
- }
386
- }
26
+ // ── Function index ───────────────────────────────────────────────────────────
387
27
 
388
- return null;
389
- }
390
-
391
- function resolvePythonModule(importerPath: string, modulePath: string, knownFiles: Set<string>): string | null {
392
- // Try relative to importer's directory, then absolute from repo root
393
- const dir = dirname(importerPath);
394
- for (const base of [dir + '/' + modulePath, modulePath]) {
395
- for (const candidate of [base + '.py', base + '/__init__.py']) {
396
- if (knownFiles.has(candidate)) return candidate;
397
- }
398
- }
399
- return null;
400
- }
401
-
402
- function resolveGoPackage(importerPath: string, goPath: string, knownFiles: Set<string>): string | null {
403
- for (const f of knownFiles) {
404
- if (f.startsWith(goPath + '/') && f.endsWith('.go')) return f;
405
- }
406
- return null;
407
- }
408
-
409
- function resolveJavaImport(qualifiedName: string, knownFiles: Set<string>): string | null {
410
- // com.foo.Bar → com/foo/Bar.java or src/main/java/com/foo/Bar.java
411
- const pathPart = qualifiedName.replace(/\./g, '/');
412
- for (const candidate of [pathPart + '.java', 'src/main/java/' + pathPart + '.java', 'src/' + pathPart + '.java']) {
413
- if (knownFiles.has(candidate)) return candidate;
414
- }
415
- // Wildcard: com.foo.* → find any .java file in com/foo/
416
- if (pathPart.endsWith('/*')) {
417
- const dir = pathPart.slice(0, -2);
418
- for (const f of knownFiles) {
419
- if (f.endsWith('.java') && (f.startsWith(dir + '/') || f.includes('/' + dir + '/'))) return f;
420
- }
421
- }
422
- return null;
423
- }
424
-
425
- function resolveRustUse(usePath: string, importerPath: string, knownFiles: Set<string>): string | null {
426
- // crate::module::item → src/module.rs or src/module/mod.rs
427
- const parts = usePath.split('::');
428
- if (parts[0] === 'crate') parts[0] = 'src';
429
- else if (parts[0] === 'super') {
430
- const dir = dirname(importerPath);
431
- parts[0] = dirname(dir);
432
- } else if (parts[0] === 'self') {
433
- parts[0] = dirname(importerPath);
434
- }
435
- // Last segment is the item name — try with and without it
436
- for (let len = parts.length; len >= 2; len--) {
437
- const base = parts.slice(0, len).join('/');
438
- for (const candidate of [base + '.rs', base + '/mod.rs']) {
439
- if (knownFiles.has(candidate)) return candidate;
440
- }
441
- }
442
- return null;
443
- }
444
-
445
- function extractImportNames(clause: string): string[] {
446
- return clause
447
- .split(',')
448
- .map(s => s.trim().split(/\s+as\s+/).pop()!.trim())
449
- .filter(Boolean);
450
- }
451
-
452
- /**
453
- * Parse each file's import statements to build importedName → resolvedFilePath maps.
454
- * Replaces the broken DB-edge approach (IMPORTS edges target Variable nodes, not Files).
455
- */
456
- function buildAllImportMapsFromSource(
457
- repoPath: string,
458
- fileNodesByPath: Map<string, MonographNode>,
459
- fileContents?: Map<string, string>,
460
- ): Map<string, Map<string, string>> {
461
- const result = new Map<string, Map<string, string>>();
462
- const knownFiles = new Set(fileNodesByPath.keys());
463
- const workspaceMap = buildWorkspacePackageMap(repoPath);
464
-
465
- for (const [filePath, fileNode] of fileNodesByPath) {
466
- const ext = extname(filePath).toLowerCase();
467
- const importMap = new Map<string, string>();
468
-
469
- let source: string | undefined = fileContents?.get(filePath);
470
- if (!source) {
471
- try {
472
- source = readFileSync(join(repoPath, filePath), 'utf-8');
473
- } catch {
474
- continue;
475
- }
476
- }
477
-
478
- if (TS_JS_EXTS.has(ext) || ext === '.cjs' || ext === '.mjs') {
479
- // ESM imports
480
- IMPORT_RE.lastIndex = 0;
481
- let m: RegExpExecArray | null;
482
- while ((m = IMPORT_RE.exec(source)) !== null) {
483
- const specifier = m[7];
484
- const resolved = resolveModuleSpecifier(filePath, specifier, repoPath, knownFiles, workspaceMap);
485
- if (!resolved) continue;
486
-
487
- if (m[1]) for (const n of extractImportNames(m[1])) importMap.set(n, resolved);
488
- if (m[4]) for (const n of extractImportNames(m[4])) importMap.set(n, resolved);
489
- if (m[2]) importMap.set(m[2], resolved);
490
- if (m[5]) importMap.set(m[5], resolved);
491
- if (m[3]) importMap.set(m[3], resolved);
492
- if (m[6]) importMap.set(m[6], resolved);
493
- const baseName = resolved.split('/').pop()!.replace(/\.\w+$/, '');
494
- importMap.set(baseName, resolved);
495
- }
496
-
497
- // CJS require()
498
- REQUIRE_RE.lastIndex = 0;
499
- while ((m = REQUIRE_RE.exec(source)) !== null) {
500
- const specifier = m[3];
501
- const resolved = resolveModuleSpecifier(filePath, specifier, repoPath, knownFiles, workspaceMap);
502
- if (!resolved) continue;
503
- if (m[1]) for (const n of extractImportNames(m[1])) importMap.set(n, resolved);
504
- if (m[2]) importMap.set(m[2], resolved);
505
- const baseName = resolved.split('/').pop()!.replace(/\.\w+$/, '');
506
- importMap.set(baseName, resolved);
507
- }
508
- } else if (PY_EXTS.has(ext)) {
509
- // Python: from module import name
510
- PY_FROM_IMPORT_RE.lastIndex = 0;
511
- let m: RegExpExecArray | null;
512
- while ((m = PY_FROM_IMPORT_RE.exec(source)) !== null) {
513
- const modulePath = m[1].replace(/\./g, '/');
514
- const resolved = resolvePythonModule(filePath, modulePath, knownFiles);
515
- if (!resolved) continue;
516
- for (const name of m[2].split(',').map(s => s.trim().split(/\s+as\s+/).pop()!.trim()).filter(Boolean)) {
517
- importMap.set(name, resolved);
518
- }
519
- const baseName = resolved.split('/').pop()!.replace(/\.\w+$/, '');
520
- importMap.set(baseName, resolved);
521
- }
522
- // Python: import module
523
- PY_IMPORT_RE.lastIndex = 0;
524
- while ((m = PY_IMPORT_RE.exec(source)) !== null) {
525
- const modulePath = m[1].replace(/\./g, '/');
526
- const resolved = resolvePythonModule(filePath, modulePath, knownFiles);
527
- if (!resolved) continue;
528
- const alias = m[2] ?? m[1].split('.').pop()!;
529
- importMap.set(alias, resolved);
530
- }
531
- } else if (GO_EXTS.has(ext)) {
532
- GO_IMPORT_RE.lastIndex = 0;
533
- let m: RegExpExecArray | null;
534
- while ((m = GO_IMPORT_RE.exec(source)) !== null) {
535
- const paths = m[3]
536
- ? m[3].match(/"([^"]+)"/g)?.map(s => s.slice(1, -1)) ?? []
537
- : [m[1] ?? m[2]].filter(Boolean);
538
- for (const goPath of paths) {
539
- const resolved = resolveGoPackage(filePath, goPath, knownFiles);
540
- if (!resolved) continue;
541
- const pkgName = goPath.split('/').pop()!;
542
- importMap.set(pkgName, resolved);
543
- }
544
- }
545
- } else if (JAVA_EXTS.has(ext)) {
546
- JAVA_IMPORT_RE.lastIndex = 0;
547
- let m: RegExpExecArray | null;
548
- while ((m = JAVA_IMPORT_RE.exec(source)) !== null) {
549
- const resolved = resolveJavaImport(m[1], knownFiles);
550
- if (!resolved) continue;
551
- const className = m[1].split('.').pop()!;
552
- importMap.set(className, resolved);
553
- }
554
- } else if (RUST_EXTS.has(ext)) {
555
- RUST_USE_RE.lastIndex = 0;
556
- let m: RegExpExecArray | null;
557
- while ((m = RUST_USE_RE.exec(source)) !== null) {
558
- if (m[2]) {
559
- // use crate::module::{Item1, Item2}
560
- for (const name of m[2].split(',').map(s => s.trim()).filter(Boolean)) {
561
- const resolved = resolveRustUse(m[1] + '::' + name, filePath, knownFiles);
562
- if (resolved) importMap.set(name.split('::').pop()!, resolved);
563
- }
564
- } else {
565
- const resolved = resolveRustUse(m[1], filePath, knownFiles);
566
- if (!resolved) continue;
567
- const itemName = m[1].split('::').pop()!;
568
- importMap.set(itemName, resolved);
569
- }
570
- }
571
- }
572
-
573
- if (importMap.size > 0) {
574
- result.set(fileNode.id, importMap);
575
- }
576
- }
577
-
578
- return result;
579
- }
580
-
581
- function getImportMap(
582
- allImportMaps: Map<string, Map<string, string>>,
583
- fileNodeId: string,
584
- ): Map<string, string> {
585
- return allImportMaps.get(fileNodeId) ?? new Map();
586
- }
587
-
588
- // ── Stage 4 helpers: preloaded function/method index ──────────────────────────
589
-
590
- /**
591
- * Load all callable nodes once (Function, Method, Constructor, Class).
592
- * Class is included so `new ClassName()` can resolve to Class nodes.
593
- */
594
28
  function buildFunctionIndex(ctx: PipelineContext): {
595
29
  byFilePath: Map<string, Map<string, string[]>>;
596
30
  nameCounts: Map<string, number>;
@@ -605,7 +39,6 @@ function buildFunctionIndex(ctx: PipelineContext): {
605
39
  .all() as { id: string; name: string; file_path: string }[];
606
40
 
607
41
  for (const row of rows) {
608
- // byFilePath index
609
42
  let fileMap = byFilePath.get(row.file_path);
610
43
  if (!fileMap) {
611
44
  fileMap = new Map();
@@ -617,23 +50,19 @@ function buildFunctionIndex(ctx: PipelineContext): {
617
50
  fileMap.set(row.name, ids);
618
51
  }
619
52
  ids.push(row.id);
620
-
621
- // global count for ambiguity detection
622
53
  nameCounts.set(row.name, (nameCounts.get(row.name) ?? 0) + 1);
623
54
  }
624
55
 
625
56
  return { byFilePath, nameCounts };
626
57
  }
627
58
 
628
- // ── Stage 4 + 5: Resolve target node (index-based, no per-call DB query) ──────
59
+ // ── Target resolution ────────────────────────────────────────────────────────
629
60
 
630
61
  function pickBestId(ids: string[], site: CallSite): string | null {
631
62
  if (ids.length === 1) return ids[0];
632
63
  if (ids.length === 0) return null;
633
- // Disambiguate: prefer Function for direct calls, Method for method calls
634
64
  const suffix = site.form === 'method' ? '_method' : '_function';
635
65
  const match = ids.find(id => id.endsWith(suffix));
636
- // For `new ClassName()` prefer _class
637
66
  if (!match && site.calleeRaw.startsWith('new ')) {
638
67
  const classMatch = ids.find(id => id.endsWith('_class'));
639
68
  if (classMatch) return classMatch;
@@ -698,7 +127,7 @@ function resolveTarget(
698
127
  return null;
699
128
  }
700
129
 
701
- // ── Stage 6: Emit edge ────────────────────────────────────────────────────────
130
+ // ── Edge emission ────────────────────────────────────────────────────────────
702
131
 
703
132
  interface PreparedEdgeStmts {
704
133
  selectExisting: import('better-sqlite3').Statement;
@@ -750,6 +179,13 @@ function emitEdge(
750
179
 
751
180
  // ── Phase definition ──────────────────────────────────────────────────────────
752
181
 
182
+ function getImportMap(
183
+ allImportMaps: Map<string, Map<string, string>>,
184
+ fileNodeId: string,
185
+ ): Map<string, string> {
186
+ return allImportMaps.get(fileNodeId) ?? new Map();
187
+ }
188
+
753
189
  export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
754
190
  name: 'scope-resolution',
755
191
  deps: ['parse', 'cross-file'],
@@ -757,15 +193,12 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
757
193
  if (ctx.allFilesCached) {
758
194
  return { resolvedEdges: 0, skippedDynamic: 0, ambiguous: 0, reexportEdges: 0, orphanImportsRemoved: 0, importsReconstructed: 0 };
759
195
  }
760
- const { symbolNodes } = deps.get('parse') as ParseOutput;
196
+ const { symbolNodes, fileContents } = deps.get('parse') as ParseOutput;
761
197
 
762
198
  let resolvedEdges = 0;
763
199
  let skippedDynamic = 0;
764
200
  let ambiguous = 0;
765
201
 
766
- // ── Batch preload: one query per table instead of one per file/call-site ──
767
-
768
- // Build a map from file_path → file node id using the parse output
769
202
  const fileNodesByPath = new Map<string, MonographNode>();
770
203
  for (const node of symbolNodes) {
771
204
  if (node.label === 'File' && node.filePath) {
@@ -773,7 +206,6 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
773
206
  }
774
207
  }
775
208
 
776
- // Also look up File nodes from the DB (includes nodes created by structure phase)
777
209
  if (ctx.db) {
778
210
  const dbFileNodes = ctx.db
779
211
  .prepare(`SELECT id, file_path FROM nodes WHERE label = 'File'`)
@@ -792,11 +224,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
792
224
  }
793
225
  }
794
226
 
795
- // Parse source imports → per-file import maps (replaces broken DB-edge approach)
796
- const { fileContents } = deps.get('parse') as ParseOutput;
797
227
  const allImportMaps = buildAllImportMapsFromSource(ctx.repoPath, fileNodesByPath, fileContents);
798
-
799
- // Preload all Function/Method/Constructor nodes into indices (one DB query total)
800
228
  const { byFilePath: fnIndex, nameCounts } = buildFunctionIndex(ctx);
801
229
 
802
230
  const edgeStmts = ctx.db ? prepareEdgeStmts(ctx.db) : null;
@@ -805,7 +233,6 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
805
233
  const ext = extname(filePath).toLowerCase();
806
234
  if (!isSupportedExt(ext)) continue;
807
235
 
808
- // Read file source (prefer cached from parse phase)
809
236
  let source: string | undefined = fileContents.get(filePath);
810
237
  if (!source) {
811
238
  try {
@@ -815,16 +242,9 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
815
242
  }
816
243
  }
817
244
 
818
- // Stage 1+2: Extract call sites
819
245
  const callSites = extractCallSites(source, filePath, fileNode.id, ext);
820
-
821
- // Stage 3: Get pre-built import map for this file (O(1))
822
246
  const importMap = getImportMap(allImportMaps, fileNode.id);
823
-
824
- // Track constructor assignments for type-aware method resolution
825
247
  const ctorMap = (TS_JS_EXTS.has(ext) || CJS_MJS_EXTS.has(ext)) ? extractConstructorAssignments(source) : undefined;
826
-
827
- // Precompute once per file instead of per call site
828
248
  const importedFiles = [...new Set(importMap.values())];
829
249
 
830
250
  for (const site of callSites) {
@@ -833,19 +253,13 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
833
253
  continue;
834
254
  }
835
255
 
836
- // Stage 4+5: Resolve target using preloaded indices (no DB query)
837
256
  const resolved = resolveTarget(site, filePath, importMap, fnIndex, ctorMap, importedFiles);
257
+ if (!resolved) continue;
838
258
 
839
- if (!resolved) {
840
- continue;
841
- }
842
-
843
- // Ambiguity check using preloaded name counts (no DB query)
844
259
  if (site.methodName && (nameCounts.get(site.methodName) ?? 0) > 1) {
845
260
  ambiguous++;
846
261
  }
847
262
 
848
- // Stage 6: Emit edge (source is the file node of the caller)
849
263
  const result = edgeStmts ? emitEdge(edgeStmts, site.callerFileNodeId, resolved.targetId) : 'skipped';
850
264
  if (result === 'inserted' || result === 'upgraded') {
851
265
  resolvedEdges++;
@@ -855,14 +269,14 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
855
269
  });
856
270
  resolveAllCalls?.();
857
271
 
858
- // Scan for re-exports: export { foo, bar } from './module'
859
- // Creates REFERENCES edges so re-exported symbols don't appear as dead code.
860
272
  let reexportEdges = 0;
861
273
  if (ctx.db) {
862
274
  const insertRef = ctx.db.prepare(`
863
275
  INSERT OR IGNORE INTO edges (id, source_id, target_id, relation, confidence, confidence_score)
864
276
  VALUES (?, ?, ?, 'REFERENCES', 'EXTRACTED', 0.85)
865
277
  `);
278
+ const reexportKnownFiles = new Set(fileNodesByPath.keys());
279
+ const reexportWorkspaceMap = buildWorkspacePackageMap(ctx.repoPath);
866
280
  const insertReexports = ctx.db.transaction(() => {
867
281
  for (const [filePath, fileNode] of fileNodesByPath) {
868
282
  const ext = extname(filePath).toLowerCase();
@@ -871,20 +285,15 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
871
285
  let source: string | undefined = fileContents.get(filePath);
872
286
  if (!source) { try { source = readFileSync(join(ctx.repoPath, filePath), 'utf-8'); } catch { continue; } }
873
287
 
874
- const importMap = getImportMap(allImportMaps, fileNode.id);
875
288
  REEXPORT_RE.lastIndex = 0;
876
289
  let m: RegExpExecArray | null;
877
290
  while ((m = REEXPORT_RE.exec(source)) !== null) {
878
291
  const specifier = m[2];
879
292
  const names = extractImportNames(m[1]);
880
- // Resolve the source module
881
- const knownFiles = new Set(fileNodesByPath.keys());
882
- const workspaceMap = buildWorkspacePackageMap(ctx.repoPath);
883
- const resolvedFile = resolveModuleSpecifier(filePath, specifier, ctx.repoPath, knownFiles, workspaceMap);
293
+ const resolvedFile = resolveModuleSpecifier(filePath, specifier, ctx.repoPath, reexportKnownFiles, reexportWorkspaceMap);
884
294
  if (!resolvedFile) continue;
885
295
 
886
296
  for (const name of names) {
887
- // Find the function/class node in the source file
888
297
  const ids = fnIndex.get(resolvedFile)?.get(name);
889
298
  if (!ids || ids.length === 0) continue;
890
299
  const targetId = ids.length === 1 ? ids[0] : (ids.find(id => id.endsWith('_function')) ?? ids[0]);
@@ -900,7 +309,6 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
900
309
  insertReexports();
901
310
  }
902
311
 
903
- // Clean up orphan IMPORTS edges that target Variable nodes instead of Files.
904
312
  let orphanImportsRemoved = 0;
905
313
  if (ctx.db) {
906
314
  const result = ctx.db
@@ -915,7 +323,6 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
915
323
  orphanImportsRemoved = result.changes;
916
324
  }
917
325
 
918
- // Reconstruct proper File→File IMPORTS edges from source-parsed import maps.
919
326
  let importsReconstructed = 0;
920
327
  if (ctx.db) {
921
328
  const insertImport = ctx.db.prepare(`
@@ -935,7 +342,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
935
342
  try {
936
343
  insertImport.run(edgeId, fileNodeId, targetFileId);
937
344
  importsReconstructed++;
938
- } catch { /* duplicate — already exists */ }
345
+ } catch { /* duplicate */ }
939
346
  }
940
347
  }
941
348
  });