@monoes/monograph 1.5.0 → 1.5.2

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 (67) hide show
  1. package/.monodesign/hook.cache.json +1 -0
  2. package/__tests__/storage/stores.test.ts +66 -1
  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/phases/call-site-extractors.d.ts +17 -0
  22. package/dist/src/pipeline/phases/call-site-extractors.d.ts.map +1 -0
  23. package/dist/src/pipeline/phases/call-site-extractors.js +132 -0
  24. package/dist/src/pipeline/phases/call-site-extractors.js.map +1 -0
  25. package/dist/src/pipeline/phases/module-resolution.d.ts +10 -0
  26. package/dist/src/pipeline/phases/module-resolution.d.ts.map +1 -0
  27. package/dist/src/pipeline/phases/module-resolution.js +301 -0
  28. package/dist/src/pipeline/phases/module-resolution.js.map +1 -0
  29. package/dist/src/pipeline/phases/orm.js +1 -2
  30. package/dist/src/pipeline/phases/orm.js.map +1 -1
  31. package/dist/src/pipeline/phases/scope-resolution.d.ts +2 -25
  32. package/dist/src/pipeline/phases/scope-resolution.d.ts.map +1 -1
  33. package/dist/src/pipeline/phases/scope-resolution.js +18 -580
  34. package/dist/src/pipeline/phases/scope-resolution.js.map +1 -1
  35. package/dist/src/pipeline/phases/wildcard-phase.d.ts.map +1 -1
  36. package/dist/src/pipeline/phases/wildcard-phase.js +6 -1
  37. package/dist/src/pipeline/phases/wildcard-phase.js.map +1 -1
  38. package/dist/src/search/hybrid-query.js +1 -1
  39. package/dist/src/search/hybrid-query.js.map +1 -1
  40. package/dist/src/storage/fts-store.d.ts.map +1 -1
  41. package/dist/src/storage/fts-store.js +7 -4
  42. package/dist/src/storage/fts-store.js.map +1 -1
  43. package/dist/src/watch/watcher.d.ts +2 -0
  44. package/dist/src/watch/watcher.d.ts.map +1 -1
  45. package/dist/src/watch/watcher.js +23 -1
  46. package/dist/src/watch/watcher.js.map +1 -1
  47. package/dist/src/web/api.d.ts.map +1 -1
  48. package/dist/src/web/api.js +17 -14
  49. package/dist/src/web/api.js.map +1 -1
  50. package/dist/tsconfig.tsbuildinfo +1 -1
  51. package/package.json +1 -1
  52. package/src/__tests__/pipeline/phases/wildcard-phase.test.ts +39 -4
  53. package/src/analysis/cycles.ts +7 -3
  54. package/src/analysis/trace.ts +1 -21
  55. package/src/analysis/worker-pool.ts +8 -7
  56. package/src/cli/skill-gen.ts +6 -14
  57. package/src/export/html.ts +3 -2
  58. package/src/graph/regex-search.ts +9 -5
  59. package/src/pipeline/phases/call-site-extractors.ts +169 -0
  60. package/src/pipeline/phases/module-resolution.ts +294 -0
  61. package/src/pipeline/phases/orm.ts +1 -2
  62. package/src/pipeline/phases/scope-resolution.ts +25 -618
  63. package/src/pipeline/phases/wildcard-phase.ts +5 -1
  64. package/src/search/hybrid-query.ts +1 -1
  65. package/src/storage/fts-store.ts +7 -3
  66. package/src/watch/watcher.ts +23 -1
  67. package/src/web/api.ts +17 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monograph",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "type": "module",
5
5
  "description": "Native TypeScript code intelligence engine for monomind",
6
6
  "main": "dist/src/index.js",
@@ -2,14 +2,22 @@ import { describe, it, expect } from 'vitest';
2
2
  import Database from 'better-sqlite3';
3
3
  import { wildcardSynthesisPhase } from '../../../pipeline/phases/wildcard-phase.js';
4
4
  import type { PipelineContext } from '../../../pipeline/types.js';
5
+ import { CREATE_NODES, CREATE_EDGES } from '../../../storage/schema.js';
5
6
 
7
+ // Uses the REAL production schema (including the edges FK constraint on
8
+ // source_id/target_id) with foreign_keys actually enabled — the original
9
+ // hand-rolled test schema had neither, so it silently passed inserts that
10
+ // would violate the real edges table's FK constraint in production (issue #40).
6
11
  function makeDb(): Database.Database {
7
12
  const db = new Database(':memory:');
13
+ db.pragma('foreign_keys = ON');
14
+ db.exec(CREATE_NODES);
15
+ db.exec(CREATE_EDGES);
8
16
  db.exec(`
9
- CREATE TABLE nodes (id TEXT PRIMARY KEY, label TEXT, name TEXT, norm_label TEXT, file_path TEXT, start_line INTEGER, end_line INTEGER, community_id INTEGER, is_exported INTEGER DEFAULT 0, language TEXT, properties TEXT);
10
- CREATE TABLE edges (id TEXT PRIMARY KEY, source_id TEXT, target_id TEXT, relation TEXT, confidence TEXT, confidence_score REAL);
11
- INSERT INTO nodes VALUES ('n1', 'Function', 'greet', 'greet', '/mod.ts', 1, 5, null, 1, null, null);
12
- INSERT INTO nodes VALUES ('n2', 'File', 'main.ts', 'main.ts', '/main.ts', null, null, null, 0, null, null);
17
+ INSERT INTO nodes (id, label, name, norm_label, file_path, start_line, end_line, is_exported)
18
+ VALUES ('n1', 'Function', 'greet', 'greet', '/mod.ts', 1, 5, 1);
19
+ INSERT INTO nodes (id, label, name, norm_label, file_path, is_exported)
20
+ VALUES ('n2', 'File', 'main.ts', 'main.ts', '/main.ts', 0);
13
21
  `);
14
22
  return db;
15
23
  }
@@ -61,4 +69,31 @@ describe('wildcardSynthesisPhase', () => {
61
69
  const deps = new Map<string, any>([['parse', parseOutput], ['cross-file', crossFileOutput]]);
62
70
  await expect(wildcardSynthesisPhase.execute(makeCtx(db), deps)).resolves.not.toThrow();
63
71
  });
72
+
73
+ it('skips a file with no fileNodeIndex entry instead of fabricating a source_id and violating the edges FK constraint (issue #40)', async () => {
74
+ // Regression: a file with no parsed symbols (e.g. blank, or a filetype
75
+ // the parser emits no node for) has no entry in fileNodeIndex. The old
76
+ // code fell back to a fabricated `file:${filePath}` id that was never a
77
+ // real row in `nodes`, so the edge insert below violated the edges
78
+ // table's FOREIGN KEY constraint on source_id and crashed the whole
79
+ // monograph_build. /empty.ts here is exactly that case: it's present in
80
+ // fileContents (parsed) but has NO corresponding node in symbolNodes.
81
+ const db = makeDb();
82
+ const source = `import * as mod from '/mod.ts';\nmod.greet("hello");`;
83
+ const parseOutput = {
84
+ allEdges: [],
85
+ symbolNodes: [
86
+ { id: 'n1', name: 'greet', label: 'Function', normLabel: 'greet', filePath: '/mod.ts', isExported: true },
87
+ ],
88
+ parseErrors: [],
89
+ fileContents: new Map([['/empty.ts', source]]),
90
+ };
91
+ const crossFileOutput = { resolvedEdges: [] };
92
+ const deps = new Map<string, any>([['parse', parseOutput], ['cross-file', crossFileOutput]]);
93
+
94
+ await expect(wildcardSynthesisPhase.execute(makeCtx(db), deps)).resolves.not.toThrow();
95
+
96
+ const edgeCount = (db.prepare('SELECT COUNT(*) as c FROM edges').get() as { c: number }).c;
97
+ expect(edgeCount).toBe(0); // nothing could legitimately attach to a file with no real node
98
+ });
64
99
  });
@@ -203,14 +203,18 @@ export function detectCycles(db: MonographDb): CycleDetectionResult {
203
203
  const nodeIds = nodeRows.map(n => n.id);
204
204
  const sccs = tarjanSCC(adjacency, nodeIds);
205
205
 
206
- // Only SCCs with size > 1 are actual cycles
207
- const cycleSCCs = sccs.filter(scc => scc.length > 1);
206
+ // SCCs with size > 1 are multi-node cycles; size-1 SCCs are cycles only if self-referencing
207
+ const cycleSCCs = sccs.filter(scc =>
208
+ scc.length > 1 || (scc.length === 1 && adjacency.get(scc[0])?.includes(scc[0])),
209
+ );
208
210
 
209
211
  const cycles: DependencyCycle[] = [];
210
212
  const filesInCyclesSet = new Set<string>();
211
213
 
212
214
  for (const scc of cycleSCCs) {
213
- const elementaryCycles = findCyclesInSCC(scc, adjacency, 20);
215
+ const elementaryCycles = scc.length === 1
216
+ ? [[scc[0], scc[0]]]
217
+ : findCyclesInSCC(scc, adjacency, 20);
214
218
  for (const cyclePath of elementaryCycles) {
215
219
  // cyclePath is [...nodes, startNode] — last element = first
216
220
  const cycleNodes = cyclePath.slice(0, -1);
@@ -226,27 +226,7 @@ export function traceDependency(
226
226
 
227
227
  const directSet = new Set(directImporters);
228
228
 
229
- // BFS to find transitive importers
230
- // Build a file→files-it-imports map for internal files
231
- const fileImports = new Map<string, Set<string>>();
232
- for (const edge of importEdges) {
233
- if (!fileImports.has(edge.sourceFile)) {
234
- fileImports.set(edge.sourceFile, new Set());
235
- }
236
- }
237
-
238
- // Build a reverse map: file → files that import it
239
- const importedBy = new Map<string, Set<string>>();
240
- for (const edge of importEdges) {
241
- if (!importedBy.has(edge.sourceFile)) {
242
- importedBy.set(edge.sourceFile, new Set());
243
- }
244
- // We track which files import this package, then who imports those files
245
- }
246
-
247
- // For transitive: BFS from direct importers outward through reverse import graph
248
- // We need: who imports the direct importers (via internal imports)
249
- // Build reverse internal-import map from edges where targetPackage looks like a file path
229
+ // BFS to find transitive importers via reverse import graph
250
230
  const reverseImportMap = new Map<string, Set<string>>();
251
231
  for (const edge of importEdges) {
252
232
  if (!reverseImportMap.has(edge.targetPackage)) {
@@ -44,22 +44,23 @@ export class WorkerPool {
44
44
  workerData: task.input,
45
45
  resourceLimits: { stackSizeMb: this.stackSizeMb },
46
46
  });
47
- const cleanup = () => {
47
+ let settled = false;
48
+ const settle = (fn: () => void) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ fn();
48
52
  this._active--;
49
53
  this._drain();
50
54
  };
51
55
  worker.on('message', (v: unknown) => {
52
- task.resolve(v);
53
- cleanup();
56
+ settle(() => task.resolve(v));
54
57
  });
55
58
  worker.on('error', (err: unknown) => {
56
- task.reject(err);
57
- cleanup();
59
+ settle(() => task.reject(err));
58
60
  });
59
61
  worker.on('exit', (code: number) => {
60
62
  if (code !== 0) {
61
- task.reject(new Error(`Worker exited with code ${code}`));
62
- cleanup();
63
+ settle(() => task.reject(new Error(`Worker exited with code ${code}`)));
63
64
  }
64
65
  });
65
66
  }
@@ -59,12 +59,8 @@ export async function generateSkillFiles(
59
59
 
60
60
  const db = openDb(dbPath);
61
61
 
62
- let communities: CommunityRow[];
63
62
  try {
64
- communities = queryCommunities(db);
65
- } finally {
66
- closeDb(db);
67
- }
63
+ const communities = queryCommunities(db);
68
64
 
69
65
  if (communities.length === 0) {
70
66
  return { filesWritten: [], communityCount: 0 };
@@ -75,15 +71,8 @@ export async function generateSkillFiles(
75
71
  const filesWritten: string[] = [];
76
72
 
77
73
  for (const community of communities) {
78
- const db2 = openDb(dbPath);
79
- let members: MemberRow[];
80
- let crossConnections: EdgeRow[];
81
- try {
82
- members = queryMembers(db2, community.community_id);
83
- crossConnections = queryCrossConnections(db2, community.community_id);
84
- } finally {
85
- closeDb(db2);
86
- }
74
+ const members = queryMembers(db, community.community_id);
75
+ const crossConnections = queryCrossConnections(db, community.community_id);
87
76
 
88
77
  const content = renderSkillMarkdown(community, members, crossConnections, repoPath);
89
78
 
@@ -94,6 +83,9 @@ export async function generateSkillFiles(
94
83
  }
95
84
 
96
85
  return { filesWritten, communityCount: communities.length };
86
+ } finally {
87
+ closeDb(db);
88
+ }
97
89
  }
98
90
 
99
91
  // ============================================================================
@@ -74,6 +74,7 @@ export function toHtml(nodes: MonographNode[], edges: MonographEdge[]): string {
74
74
  return {
75
75
  id: n.id,
76
76
  label: shortLabel,
77
+ _fullName: n.name,
77
78
  title: buildTooltip(n, deg),
78
79
  group: n.communityId ?? 0,
79
80
  color: {
@@ -398,7 +399,7 @@ function showInfoCard(nodeId) {
398
399
  if (!node) return;
399
400
  const neighbors = network.getConnectedNodes(nodeId);
400
401
  document.getElementById('ic-type').textContent = node._nodeType || '';
401
- document.getElementById('ic-name').textContent = node.label;
402
+ document.getElementById('ic-name').textContent = node._fullName || node.label;
402
403
  const fp = node._filePath ? escHtml(node._filePath) : '';
403
404
  const fpShort = node._filePath ? escHtml(node._filePath.split('/').pop()) : '';
404
405
  document.getElementById('ic-meta').innerHTML =
@@ -420,7 +421,7 @@ function onSearch(q) {
420
421
  searchActive = !!q;
421
422
  const lq = q.toLowerCase();
422
423
  if (!q) { resetHighlight(); return; }
423
- const matchIds = new Set(ALL_NODES.filter(n => n.label.toLowerCase().includes(lq) || (n._filePath && n._filePath.toLowerCase().includes(lq))).map(n => n.id));
424
+ const matchIds = new Set(ALL_NODES.filter(n => (n._fullName || n.label).toLowerCase().includes(lq) || (n._filePath && n._filePath.toLowerCase().includes(lq))).map(n => n.id));
424
425
  nodesDS.update(ALL_NODES.map(n => ({ id: n.id, opacity: matchIds.has(n.id) ? 1 : 0.06 })));
425
426
  edgesDS.update(ALL_EDGES.map(e => ({ id: e.id, opacity: matchIds.has(e.from) && matchIds.has(e.to) ? 0.7 : 0.03 })));
426
427
  // Focus on first match
@@ -25,11 +25,13 @@ export function regexSearchNodes(
25
25
  ): RegexNodeMatch[] {
26
26
  const re = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
27
27
 
28
+ // Scan all rows and apply regex filter in JS, then cap at limit.
29
+ // LIMIT in SQL would silently miss matches beyond the first N rows.
28
30
  const rows = db.prepare(
29
31
  `SELECT id, label, name, norm_label, file_path, start_line, end_line,
30
32
  community_id, is_exported, language, properties
31
- FROM nodes LIMIT ?`,
32
- ).all(limit) as {
33
+ FROM nodes`,
34
+ ).all() as {
33
35
  id: string;
34
36
  label: string;
35
37
  name: string;
@@ -69,9 +71,10 @@ export function regexSearchNodes(
69
71
 
70
72
  if (value && re.test(value)) {
71
73
  results.push({ node, field });
72
- break; // report once per node even if multiple fields match
74
+ break;
73
75
  }
74
76
  }
77
+ if (results.length >= limit) break;
75
78
  }
76
79
 
77
80
  return results;
@@ -103,8 +106,8 @@ export function regexSearchEdges(
103
106
 
104
107
  const rows = db.prepare(
105
108
  `SELECT id, source_id, target_id, relation, confidence, confidence_score, reason
106
- FROM edges LIMIT ?`,
107
- ).all(limit) as {
109
+ FROM edges`,
110
+ ).all() as {
108
111
  id: string;
109
112
  source_id: string;
110
113
  target_id: string;
@@ -138,6 +141,7 @@ export function regexSearchEdges(
138
141
  break;
139
142
  }
140
143
  }
144
+ if (results.length >= limit) break;
141
145
  }
142
146
 
143
147
  return results;
@@ -0,0 +1,169 @@
1
+ // ── Call site extraction by language ──────────────────────────────────────────
2
+
3
+ export interface CallSite {
4
+ callerFileNodeId: string;
5
+ callerFilePath: string;
6
+ calleeRaw: string;
7
+ form: 'method' | 'direct' | 'dynamic';
8
+ receiverName?: string;
9
+ methodName?: string;
10
+ }
11
+
12
+ // ── Language keyword sets ────────────────────────────────────────────────────
13
+
14
+ const JS_KEYWORDS = new Set([
15
+ 'if', 'for', 'while', 'switch', 'return', 'function', 'class', 'new', 'typeof',
16
+ 'await', 'catch', 'throw', 'delete', 'void', 'instanceof', 'in', 'of',
17
+ 'import', 'export', 'yield', 'async', 'super', 'this', 'const', 'let', 'var',
18
+ 'try', 'finally', 'break', 'continue', 'debugger', 'with', 'do',
19
+ ]);
20
+
21
+ const PY_KEYWORDS = new Set([
22
+ 'if', 'for', 'while', 'with', 'return', 'def', 'class', 'import', 'from',
23
+ 'raise', 'yield', 'lambda', 'await', 'async', 'del', 'pass', 'assert',
24
+ 'except', 'elif', 'else', 'not', 'and', 'or', 'in', 'is', 'print',
25
+ ]);
26
+
27
+ const GO_KEYWORDS = new Set([
28
+ 'if', 'for', 'range', 'return', 'func', 'type', 'var', 'const', 'import', 'package',
29
+ 'go', 'defer', 'select', 'case', 'default', 'break', 'continue', 'goto', 'fallthrough',
30
+ 'chan', 'map', 'struct', 'interface', 'make', 'new', 'len', 'cap', 'append', 'delete',
31
+ 'panic', 'recover', 'close', 'switch', 'else',
32
+ ]);
33
+
34
+ const JAVA_KEYWORDS = new Set([
35
+ 'if', 'for', 'while', 'do', 'switch', 'case', 'return', 'class', 'interface', 'enum',
36
+ 'new', 'extends', 'implements', 'import', 'package', 'throw', 'throws', 'catch', 'try',
37
+ 'finally', 'static', 'final', 'abstract', 'public', 'private', 'protected', 'void',
38
+ 'break', 'continue', 'default', 'else', 'instanceof', 'this', 'super',
39
+ ]);
40
+
41
+ const RUST_KEYWORDS = new Set([
42
+ 'if', 'let', 'for', 'while', 'loop', 'match', 'return', 'fn', 'struct', 'enum', 'trait',
43
+ 'impl', 'use', 'mod', 'pub', 'super', 'self', 'type', 'where', 'in', 'as', 'mut',
44
+ 'ref', 'move', 'async', 'await', 'dyn', 'extern', 'crate', 'static', 'const', 'unsafe',
45
+ 'break', 'continue', 'else',
46
+ ]);
47
+
48
+ // ── Extension sets ───────────────────────────────────────────────────────────
49
+
50
+ export const TS_JS_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx']);
51
+ export const CJS_MJS_EXTS = new Set(['.cjs', '.mjs']);
52
+ const PY_EXTS = new Set(['.py']);
53
+ const GO_EXTS = new Set(['.go']);
54
+ const JAVA_EXTS = new Set(['.java']);
55
+ const RUST_EXTS = new Set(['.rs']);
56
+
57
+ export function isSupportedExt(ext: string): boolean {
58
+ 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);
59
+ }
60
+
61
+ // ── Generic extractor for languages with method.call and direct() patterns ──
62
+
63
+ function extractSimpleCallSites(
64
+ source: string,
65
+ filePath: string,
66
+ fileNodeId: string,
67
+ keywords: Set<string>,
68
+ directCharClass = '[A-Za-z_][\\w]*',
69
+ ): CallSite[] {
70
+ const sites: CallSite[] = [];
71
+ const methodPattern = /(\w+)\.(\w+)\s*\(/g;
72
+ let m: RegExpExecArray | null;
73
+ while ((m = methodPattern.exec(source)) !== null) {
74
+ sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: `${m[1]}.${m[2]}`, form: 'method', receiverName: m[1], methodName: m[2] });
75
+ }
76
+ const directPattern = new RegExp(`(?<![.[\\w])(${directCharClass})\\s*\\(`, 'g');
77
+ while ((m = directPattern.exec(source)) !== null) {
78
+ const name = m[1]!;
79
+ if (keywords.has(name)) continue;
80
+ sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: name, form: 'direct' });
81
+ }
82
+ return sites;
83
+ }
84
+
85
+ export function extractGoCallSites(source: string, filePath: string, fileNodeId: string): CallSite[] {
86
+ return extractSimpleCallSites(source, filePath, fileNodeId, GO_KEYWORDS);
87
+ }
88
+
89
+ export function extractJavaCallSites(source: string, filePath: string, fileNodeId: string): CallSite[] {
90
+ return extractSimpleCallSites(source, filePath, fileNodeId, JAVA_KEYWORDS, '[A-Za-z_$][\\w$]*');
91
+ }
92
+
93
+ export function extractRustCallSites(source: string, filePath: string, fileNodeId: string): CallSite[] {
94
+ return extractSimpleCallSites(source, filePath, fileNodeId, RUST_KEYWORDS);
95
+ }
96
+
97
+ // ── Main extractor: dispatches by file extension ─────────────────────────────
98
+
99
+ export function extractCallSites(
100
+ source: string,
101
+ filePath: string,
102
+ fileNodeId: string,
103
+ ext: string,
104
+ ): CallSite[] {
105
+ if (GO_EXTS.has(ext)) return extractGoCallSites(source, filePath, fileNodeId);
106
+ if (JAVA_EXTS.has(ext)) return extractJavaCallSites(source, filePath, fileNodeId);
107
+ if (RUST_EXTS.has(ext)) return extractRustCallSites(source, filePath, fileNodeId);
108
+
109
+ const sites: CallSite[] = [];
110
+
111
+ if (TS_JS_EXTS.has(ext) || CJS_MJS_EXTS.has(ext)) {
112
+ const methodPattern = /(\w+)\.(\w+)\s*(?:<[^>]*>\s*)?\(/g;
113
+ let m: RegExpExecArray | null;
114
+ while ((m = methodPattern.exec(source)) !== null) {
115
+ sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: `${m[1]}.${m[2]}`, form: 'method', receiverName: m[1], methodName: m[2] });
116
+ }
117
+
118
+ const chainedPattern = /[)\]]\s*\.(\w+)\s*(?:<[^>]*>\s*)?\(/g;
119
+ while ((m = chainedPattern.exec(source)) !== null) {
120
+ const name = m[1];
121
+ if (JS_KEYWORDS.has(name)) continue;
122
+ sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: `?.${name}`, form: 'method', methodName: name });
123
+ }
124
+
125
+ const directPattern = /(?<![.[\w])([A-Za-z_$][\w$]*)\s*(?:<[^>]*>\s*)?\(/g;
126
+ while ((m = directPattern.exec(source)) !== null) {
127
+ const name = m[1];
128
+ if (JS_KEYWORDS.has(name)) continue;
129
+ sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: name, form: 'direct', methodName: name });
130
+ }
131
+
132
+ const newPattern = /\bnew\s+([A-Z][\w$]*)\s*(?:<[^>]*>\s*)?\(/g;
133
+ while ((m = newPattern.exec(source)) !== null) {
134
+ sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: `new ${m[1]}`, form: 'direct', methodName: m[1] });
135
+ }
136
+
137
+ const dynamicPattern = /\w+\s*\[[\w'"` ]+\]\s*\(/g;
138
+ while ((m = dynamicPattern.exec(source)) !== null) {
139
+ sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: m[0], form: 'dynamic' });
140
+ }
141
+ } else if (PY_EXTS.has(ext)) {
142
+ const methodPattern = /(\w+)\.(\w+)\s*\(/g;
143
+ let m: RegExpExecArray | null;
144
+ while ((m = methodPattern.exec(source)) !== null) {
145
+ sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: `${m[1]}.${m[2]}`, form: 'method', receiverName: m[1], methodName: m[2] });
146
+ }
147
+
148
+ const directPattern = /(?<![.[\w])([A-Za-z_][\w]*)\s*\(/g;
149
+ while ((m = directPattern.exec(source)) !== null) {
150
+ const name = m[1];
151
+ if (PY_KEYWORDS.has(name)) continue;
152
+ sites.push({ callerFileNodeId: fileNodeId, callerFilePath: filePath, calleeRaw: name, form: 'direct', methodName: name });
153
+ }
154
+ }
155
+
156
+ return sites;
157
+ }
158
+
159
+ // ── Constructor assignment tracking ──────────────────────────────────────────
160
+
161
+ export function extractConstructorAssignments(source: string): Map<string, string> {
162
+ const result = new Map<string, string>();
163
+ const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*new\s+([A-Z][\w$]*)\s*(?:<[^>]*>\s*)?\(/g;
164
+ let m: RegExpExecArray | null;
165
+ while ((m = pattern.exec(source)) !== null) {
166
+ result.set(m[1], m[2]);
167
+ }
168
+ return result;
169
+ }