codegraph-engine 3.0.0__2-py3-none-any.whl

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 (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +868 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3404 @@
1
+ """Single-pass scope-aware source parsers for Python, JavaScript, and TypeScript.
2
+
3
+ Architectural Invariants:
4
+ 1. Exactly ONE scoped traversal per source file.
5
+ 2. Framework route analyzers consume AST node visit events during this primary traversal;
6
+ there is NEVER a second full-tree scan.
7
+ 3. Lexical scope context (classes, functions, closures, methods) is maintained continuously
8
+ on an explicit traversal stack without line-number or string-based heuristics.
9
+ 4. ParseResult is an immutable contract between parsing and indexing.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import hashlib
15
+ import json
16
+ import posixpath
17
+ import re
18
+ from dataclasses import asdict, dataclass
19
+
20
+ from codegraph.frameworks import (
21
+ AnalysisContext,
22
+ FrameworkAnalyzer,
23
+ RouteDetection,
24
+ RouterDefinition,
25
+ RouterMountDetection,
26
+ analyze_js_ts_line,
27
+ analyze_js_ts_mounts,
28
+ get_python_analyzers,
29
+ )
30
+ from codegraph.frameworks_nestjs import analyze_nestjs_file
31
+ from codegraph.frameworks_nextjs import analyze_nextjs_file
32
+ from codegraph.frameworks_react import analyze_react_file
33
+ from codegraph.semantic_decorators import analyze_symbol_decorators
34
+
35
+ from .models import (
36
+ BindingRef,
37
+ CallRef,
38
+ ImportRef,
39
+ InheritanceRef,
40
+ Symbol,
41
+ build_canonical_id,
42
+ normalize_module,
43
+ )
44
+
45
+ PARSER_VERSION = "3.0"
46
+
47
+ _PYTHON_BUILTINS = {
48
+ "abs",
49
+ "all",
50
+ "any",
51
+ "ascii",
52
+ "bin",
53
+ "bool",
54
+ "breakpoint",
55
+ "bytearray",
56
+ "bytes",
57
+ "callable",
58
+ "chr",
59
+ "classmethod",
60
+ "compile",
61
+ "complex",
62
+ "delattr",
63
+ "dict",
64
+ "dir",
65
+ "divmod",
66
+ "enumerate",
67
+ "eval",
68
+ "exec",
69
+ "filter",
70
+ "float",
71
+ "format",
72
+ "frozenset",
73
+ "getattr",
74
+ "globals",
75
+ "hasattr",
76
+ "hash",
77
+ "help",
78
+ "hex",
79
+ "id",
80
+ "input",
81
+ "int",
82
+ "isinstance",
83
+ "issubclass",
84
+ "iter",
85
+ "len",
86
+ "list",
87
+ "locals",
88
+ "map",
89
+ "max",
90
+ "memoryview",
91
+ "min",
92
+ "next",
93
+ "object",
94
+ "oct",
95
+ "open",
96
+ "ord",
97
+ "pow",
98
+ "print",
99
+ "property",
100
+ "range",
101
+ "repr",
102
+ "reversed",
103
+ "round",
104
+ "set",
105
+ "setattr",
106
+ "slice",
107
+ "sorted",
108
+ "staticmethod",
109
+ "str",
110
+ "sum",
111
+ "super",
112
+ "tuple",
113
+ "type",
114
+ "vars",
115
+ "zip",
116
+ "Exception",
117
+ "ValueError",
118
+ "TypeError",
119
+ "KeyError",
120
+ "IndexError",
121
+ "RuntimeError",
122
+ "AttributeError",
123
+ "NotImplementedError",
124
+ "OSError",
125
+ "FileNotFoundError",
126
+ }
127
+
128
+ _JS_KEYWORDS_AND_BUILTINS = {
129
+ "if",
130
+ "for",
131
+ "while",
132
+ "switch",
133
+ "catch",
134
+ "function",
135
+ "return",
136
+ "typeof",
137
+ "instanceof",
138
+ "new",
139
+ "delete",
140
+ "void",
141
+ "yield",
142
+ "await",
143
+ "super",
144
+ "this",
145
+ "import",
146
+ "require",
147
+ "console",
148
+ "Math",
149
+ "JSON",
150
+ "Object",
151
+ "Array",
152
+ "String",
153
+ "Number",
154
+ "Boolean",
155
+ "Promise",
156
+ "Set",
157
+ "Map",
158
+ "Error",
159
+ "TypeError",
160
+ "Date",
161
+ "RegExp",
162
+ "parseInt",
163
+ "parseFloat",
164
+ "setTimeout",
165
+ "clearTimeout",
166
+ "setInterval",
167
+ "clearInterval",
168
+ }
169
+
170
+ _PY_IMPORT = re.compile(
171
+ r"""^(?:from\s+([\w.]+)\s+import|import\s+([\w.,\s]+))""",
172
+ re.MULTILINE,
173
+ )
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class ParseResult:
178
+ path: str
179
+ language: str
180
+ parser_version: str = PARSER_VERSION
181
+ source_hash: str = ""
182
+ symbols: tuple[Symbol, ...] = ()
183
+ imports: tuple[ImportRef, ...] = ()
184
+ calls: tuple[CallRef, ...] = ()
185
+ inheritance: tuple[InheritanceRef, ...] = ()
186
+ routes: tuple[RouteDetection, ...] = ()
187
+ mounts: tuple[RouterMountDetection, ...] = ()
188
+ router_definitions: tuple[RouterDefinition, ...] = ()
189
+ bindings: tuple[BindingRef, ...] = ()
190
+ exports: tuple[str, ...] = ()
191
+ parse_failed: bool = False
192
+ parse_error: str | None = None
193
+
194
+ def as_dict(self) -> dict[str, object]:
195
+ return {
196
+ "path": self.path,
197
+ "language": self.language,
198
+ "parser_version": self.parser_version,
199
+ "source_hash": self.source_hash,
200
+ "symbols": [s.as_dict() for s in self.symbols],
201
+ "imports": [i.as_dict() for i in self.imports],
202
+ "calls": [c.qualified_callee or c.callee for c in self.calls],
203
+ "inheritance": [asdict(inh) for inh in self.inheritance],
204
+ "routes": [r.as_dict() for r in self.routes],
205
+ "mounts": [m.as_dict() for m in self.mounts],
206
+ "router_definitions": [d.as_dict() for d in self.router_definitions],
207
+ "bindings": [b.as_dict() for b in self.bindings],
208
+ "exports": list(self.exports),
209
+ "parse_failed": self.parse_failed,
210
+ "parse_error": self.parse_error,
211
+ }
212
+
213
+ def __getitem__(self, key: str) -> object:
214
+ return getattr(self, key)
215
+
216
+
217
+ def parse(content: str, language: str, file_path: str) -> ParseResult:
218
+ """Parse a source file in a single scoped pass."""
219
+ if language == "python":
220
+ return _parse_python(content, file_path)
221
+ if language == "luau" or file_path.lower().endswith((".lua", ".luau")):
222
+ from codegraph.roblox import parse_luau
223
+ return parse_luau(content, file_path)
224
+ if language == "html" or file_path.lower().endswith((".html", ".htm", ".jinja", ".jinja2", ".njk", ".ejs")):
225
+ from codegraph.indexing.templates import parse_html_template
226
+ return parse_html_template(content, file_path)
227
+ if language in ("sql", "prisma"):
228
+ source_hash = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
229
+ return ParseResult(
230
+ path=file_path,
231
+ language=language,
232
+ source_hash=source_hash,
233
+ parse_failed=False,
234
+ )
235
+ return _parse_js_ts(content, language, file_path)
236
+
237
+
238
+ def parse_symbols(
239
+ content: str, language: str, file_path: str
240
+ ) -> tuple[list[Symbol], list[str]]:
241
+ """Backward-compatible helper returning symbols and unique imported module names."""
242
+ result = parse(content, language, file_path)
243
+ seen: list[str] = []
244
+ for imp in result.imports:
245
+ if imp.module and imp.module not in seen:
246
+ seen.append(imp.module)
247
+ return list(result.symbols), seen
248
+
249
+
250
+ def _normalized_body_hash(lines: list[str], start_line: int, end_line: int) -> str:
251
+ """Compute a content hash of a symbol body independent of absolute line numbers."""
252
+ snippet = "\n".join(line.rstrip() for line in lines[max(0, start_line - 1) : end_line]).strip()
253
+ return hashlib.sha256(snippet.encode("utf-8", errors="replace")).hexdigest()[:16]
254
+
255
+
256
+ def _resolve_relative_py_module(file_path: str, level: int, module: str | None) -> str:
257
+ """Resolve a Python relative import using source file directory depth."""
258
+ if level <= 0:
259
+ return module or ""
260
+ norm_dir = posixpath.dirname(file_path.replace("\\", "/").lstrip("./"))
261
+ parts = [p for p in norm_dir.split("/") if p and p != "."]
262
+ up = level - 1
263
+ if up > 0 and up <= len(parts):
264
+ parts = parts[: len(parts) - up]
265
+ elif up > len(parts):
266
+ parts = []
267
+ base = ".".join(parts)
268
+ if base and module:
269
+ return f"{base}.{module}"
270
+ if base:
271
+ return base
272
+ if module:
273
+ return module
274
+ return "." * level
275
+
276
+
277
+ def _infer_function_return(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str | None:
278
+ """Conservatively infer return type from explicit annotation or unambiguous constructor/factory return."""
279
+ clean_ann: str | None = None
280
+ # 1. Explicit return annotation
281
+ if node.returns and hasattr(ast, "unparse"):
282
+ raw = ast.unparse(node.returns).strip()
283
+ if "|" in raw:
284
+ parts = [p.strip() for p in raw.split("|") if p.strip() not in ("None", "NoneType")]
285
+ if len(parts) != 1:
286
+ return None
287
+ raw = parts[0]
288
+ m_union = re.match(r"^(?:Union|typing\.Union)\[\s*(.+)\s*\]$", raw)
289
+ if m_union:
290
+ parts = [p.strip() for p in m_union.group(1).split(",") if p.strip() not in ("None", "NoneType")]
291
+ if len(parts) != 1:
292
+ return None
293
+ raw = parts[0]
294
+ m_opt = re.match(r"^(?:Optional|typing\.Optional)\[\s*([A-Za-z_][\w.]*)\s*\]$", raw)
295
+ if m_opt:
296
+ raw = m_opt.group(1)
297
+ raw = raw.strip("'\"")
298
+ clean = raw.split("[")[0].strip()
299
+ if clean and clean not in _PYTHON_BUILTINS and clean not in ("Any", "typing.Any", "object", "Callable", "typing.Callable", "Union", "typing.Union"):
300
+ clean_ann = clean
301
+
302
+ # 2. Inspect function body for constructor/factory returns and detect conflicting/dynamic branches
303
+ local_types: dict[str, str] = {}
304
+ for arg in list(node.args.args) + list(node.args.kwonlyargs):
305
+ if arg.annotation and hasattr(ast, "unparse"):
306
+ a_type = ast.unparse(arg.annotation).split("[")[0].strip().strip("'\"")
307
+ if a_type and a_type not in _PYTHON_BUILTINS:
308
+ local_types[arg.arg] = a_type
309
+
310
+ ctor_candidates: set[str] = set()
311
+
312
+ def _walk_body_stmts(stmts: list[ast.stmt], in_cond: bool) -> None:
313
+ for stmt in stmts:
314
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
315
+ continue
316
+ if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
317
+ var_name = stmt.targets[0].id
318
+ rhs_type = "<DYNAMIC>"
319
+ if isinstance(stmt.value, ast.Call):
320
+ if isinstance(stmt.value.func, ast.Name):
321
+ fn_id = stmt.value.func.id
322
+ if fn_id not in ("getattr", "globals", "locals", "__import__", "eval") and fn_id not in _PYTHON_BUILTINS:
323
+ rhs_type = fn_id
324
+ elif isinstance(stmt.value.func, ast.Attribute) and hasattr(ast, "unparse"):
325
+ rhs_type = ast.unparse(stmt.value.func).strip()
326
+ elif isinstance(stmt.value, ast.Name):
327
+ rhs_type = local_types.get(stmt.value.id, "<DYNAMIC>")
328
+ if in_cond or (var_name in local_types and local_types[var_name] != rhs_type):
329
+ local_types[var_name] = "<AMBIGUOUS>"
330
+ else:
331
+ local_types[var_name] = rhs_type
332
+ elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) and hasattr(ast, "unparse"):
333
+ var_name = stmt.target.id
334
+ ann_t = ast.unparse(stmt.annotation).split("[")[0].strip().strip("'\"")
335
+ if in_cond or (var_name in local_types and local_types[var_name] != ann_t):
336
+ local_types[var_name] = "<AMBIGUOUS>"
337
+ elif ann_t and ann_t not in _PYTHON_BUILTINS:
338
+ local_types[var_name] = ann_t
339
+ elif isinstance(stmt, ast.Return) and stmt.value is not None:
340
+ val = stmt.value
341
+ if isinstance(val, ast.Constant) and val.value is None:
342
+ continue
343
+ if isinstance(val, ast.Call):
344
+ if isinstance(val.func, ast.Name):
345
+ c_name = val.func.id
346
+ if c_name in ("getattr", "globals", "locals", "__import__", "eval") or c_name in _PYTHON_BUILTINS:
347
+ ctor_candidates.add("<DYNAMIC>")
348
+ elif c_name in local_types:
349
+ ctor_candidates.add(local_types[c_name])
350
+ else:
351
+ ctor_candidates.add(c_name)
352
+ elif isinstance(val.func, ast.Attribute) and hasattr(ast, "unparse"):
353
+ ctor_candidates.add(ast.unparse(val.func).strip())
354
+ else:
355
+ ctor_candidates.add("<DYNAMIC>")
356
+ elif isinstance(val, ast.Name):
357
+ mapped = local_types.get(val.id)
358
+ if mapped:
359
+ ctor_candidates.add(mapped)
360
+ else:
361
+ ctor_candidates.add("<DYNAMIC>")
362
+ else:
363
+ ctor_candidates.add("<DYNAMIC>")
364
+ elif isinstance(stmt, ast.If):
365
+ _walk_body_stmts(stmt.body, True)
366
+ _walk_body_stmts(stmt.orelse, True)
367
+ elif isinstance(stmt, (ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith)):
368
+ _walk_body_stmts(stmt.body, True)
369
+ _walk_body_stmts(getattr(stmt, "orelse", []), True)
370
+ elif isinstance(stmt, ast.Try):
371
+ _walk_body_stmts(stmt.body, True)
372
+ for h in stmt.handlers:
373
+ _walk_body_stmts(h.body, True)
374
+ _walk_body_stmts(stmt.orelse, True)
375
+ _walk_body_stmts(stmt.finalbody, True)
376
+
377
+ _walk_body_stmts(node.body, False)
378
+
379
+ # If the body has multiple distinct return constructors or dynamic dispatch, do not claim a single static return type
380
+ real_ctors = {c for c in ctor_candidates if c not in ("<DYNAMIC>", "<AMBIGUOUS>")}
381
+ if len(real_ctors) > 1 or "<AMBIGUOUS>" in ctor_candidates:
382
+ return None
383
+ if "<DYNAMIC>" in ctor_candidates and not clean_ann:
384
+ return None
385
+ if clean_ann:
386
+ return clean_ann
387
+ if len(real_ctors) == 1:
388
+ only_cand = next(iter(real_ctors))
389
+ if only_cand not in _PYTHON_BUILTINS:
390
+ return only_cand
391
+ return None
392
+
393
+
394
+ def _extract_python_file_declarations(tree: ast.AST) -> tuple[dict[str, str], set[str]]:
395
+ """Extract known return types and class names in a single Python AST."""
396
+ fn_returns: dict[str, str] = {}
397
+ known_classes: set[str] = set()
398
+ for node in getattr(tree, "body", []):
399
+ if isinstance(node, ast.ClassDef):
400
+ known_classes.add(node.name)
401
+ for item in node.body:
402
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
403
+ ret = _infer_function_return(item)
404
+ if ret:
405
+ fn_returns[f"{node.name}.{item.name}"] = ret
406
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
407
+ ret = _infer_function_return(node)
408
+ if ret:
409
+ fn_returns[node.name] = ret
410
+ return fn_returns, known_classes
411
+
412
+
413
+ class _PythonScopeVisitor(ast.NodeVisitor):
414
+ """Single-pass AST visitor maintaining scope context, bindings, and framework hooks."""
415
+
416
+ def __init__(
417
+ self,
418
+ content: str,
419
+ lines: list[str],
420
+ file_path: str,
421
+ analyzers: list[FrameworkAnalyzer],
422
+ fn_return_types: dict[str, str] | None = None,
423
+ known_classes: set[str] | None = None,
424
+ all_file_imports: list[ImportRef] | None = None,
425
+ ) -> None:
426
+ self.content = content
427
+ self.lines = lines
428
+ self.file_path = file_path
429
+ self.module = normalize_module(file_path, "python")
430
+ self.is_init = file_path.replace("\\", "/").endswith("__init__.py")
431
+ self.analyzers = analyzers
432
+ self.fn_return_types = fn_return_types or {}
433
+ self.known_classes = known_classes or set()
434
+ self.all_file_imports = all_file_imports or []
435
+
436
+ self.symbols: list[Symbol] = []
437
+ self.imports: list[ImportRef] = []
438
+ self.calls: list[CallRef] = []
439
+ self.inheritance: list[InheritanceRef] = []
440
+ self.routes: list[RouteDetection] = []
441
+ self.mounts: list[RouterMountDetection] = []
442
+ self.router_definitions: list[RouterDefinition] = []
443
+ self.bindings: list[BindingRef] = []
444
+ self.exports: list[str] = []
445
+ self.conditional_depth: int = 0
446
+ self.di_type_aliases: dict[str, str] = {}
447
+
448
+ # Scope stack: (name, kind, qualified_name, canonical_id)
449
+ self.scope_stack: list[tuple[str, str, str, str]] = []
450
+ # Local variable -> class/alias bindings per scope
451
+ self.local_bindings_stack: list[dict[str, str]] = [{}]
452
+ self.import_modules_set: set[str] = set()
453
+ self._cached_binding_resolver: tuple[int, int, object] | None = None
454
+
455
+ def _get_local_binding_resolver(self) -> object:
456
+ if (
457
+ self._cached_binding_resolver is not None
458
+ and self._cached_binding_resolver[0] == len(self.bindings)
459
+ and self._cached_binding_resolver[1] == len(self.symbols)
460
+ ):
461
+ return self._cached_binding_resolver[2]
462
+ from codegraph.binding_resolver import LocalBindingResolver
463
+
464
+ resolver = LocalBindingResolver(
465
+ bindings=self.bindings,
466
+ symbols=self.symbols,
467
+ imports=self.all_file_imports or self.imports,
468
+ known_files={self.file_path},
469
+ resolve_symbol_in_module=lambda m, s: None,
470
+ resolve_import_target=lambda imp: (None, imp.imported_module),
471
+ )
472
+ self._cached_binding_resolver = (len(self.bindings), len(self.symbols), resolver)
473
+ return resolver
474
+
475
+ @property
476
+ def current_scope_qname(self) -> str:
477
+ return self.scope_stack[-1][2] if self.scope_stack else ""
478
+
479
+ @property
480
+ def current_scope_canonical_id(self) -> str:
481
+ return self.scope_stack[-1][3] if self.scope_stack else self.module
482
+
483
+ @property
484
+ def current_scope_kind(self) -> str:
485
+ return self.scope_stack[-1][1] if self.scope_stack else "module"
486
+
487
+ @property
488
+ def current_enclosing_class(self) -> str | None:
489
+ for _name, kind, qname, _canon in reversed(self.scope_stack):
490
+ if kind == "class":
491
+ return qname
492
+ return None
493
+
494
+ def _make_context(self) -> AnalysisContext:
495
+ return AnalysisContext(
496
+ file_path=self.file_path,
497
+ module=self.module,
498
+ imports_modules=self.import_modules_set,
499
+ scope_qname=self.current_scope_qname,
500
+ scope_canonical_id=self.current_scope_canonical_id,
501
+ scope_kind=self.current_scope_kind,
502
+ )
503
+
504
+ def _lookup_local_binding(self, var_name: str) -> str | None:
505
+ for env in reversed(self.local_bindings_stack):
506
+ if var_name in env:
507
+ val = env[var_name]
508
+ if val in ("<AMBIGUOUS>", "<DYNAMIC>"):
509
+ return None
510
+ return val
511
+ return None
512
+
513
+ def _bind_in_local_scope(self, var_name: str, inferred_type: str) -> None:
514
+ if not self.local_bindings_stack:
515
+ return
516
+ env = self.local_bindings_stack[-1]
517
+ if self.conditional_depth > 0:
518
+ env[var_name] = "<AMBIGUOUS>"
519
+ elif var_name in env and env[var_name] != inferred_type:
520
+ env[var_name] = "<AMBIGUOUS>"
521
+ else:
522
+ env[var_name] = inferred_type
523
+
524
+ def _bind_in_class_scope(self, attr_name: str, inferred_type: str) -> None:
525
+ self._bind_in_local_scope(attr_name, inferred_type)
526
+ if len(self.local_bindings_stack) >= 2 and len(self.scope_stack) >= 2:
527
+ for idx in range(len(self.scope_stack) - 1, -1, -1):
528
+ if self.scope_stack[idx][1] == "class" and idx + 1 < len(self.local_bindings_stack):
529
+ cls_env = self.local_bindings_stack[idx + 1]
530
+ if self.conditional_depth > 0:
531
+ cls_env[attr_name] = "<AMBIGUOUS>"
532
+ elif attr_name in cls_env and cls_env[attr_name] != inferred_type:
533
+ cls_env[attr_name] = "<AMBIGUOUS>"
534
+ else:
535
+ cls_env[attr_name] = inferred_type
536
+ break
537
+
538
+ def _extract_decorators(
539
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef
540
+ ) -> list[str]:
541
+ if not node.decorator_list:
542
+ return []
543
+ dec_names: list[str] = []
544
+ for d in node.decorator_list:
545
+ if isinstance(d, ast.Name):
546
+ dec_names.append(d.id)
547
+ elif isinstance(d, ast.Attribute):
548
+ dec_names.append(ast.unparse(d) if hasattr(ast, "unparse") else d.attr)
549
+ elif isinstance(d, ast.Call):
550
+ if isinstance(d.func, ast.Name):
551
+ dec_names.append(d.func.id)
552
+ elif isinstance(d.func, ast.Attribute):
553
+ dec_names.append(ast.unparse(d.func) if hasattr(ast, "unparse") else d.func.attr)
554
+ return dec_names
555
+
556
+ def _visit_symbol_node(
557
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef
558
+ ) -> None:
559
+ parent_scope = self.current_scope_qname
560
+ parent_canon = self.scope_stack[-1][3] if self.scope_stack else None
561
+ parent_kind = self.scope_stack[-1][1] if self.scope_stack else None
562
+ decorators = self._extract_decorators(node)
563
+
564
+ if isinstance(node, ast.ClassDef):
565
+ kind = "class"
566
+ elif parent_kind == "class":
567
+ kind = "property" if "property" in decorators else "method"
568
+ else:
569
+ kind = "function"
570
+
571
+ qname = f"{parent_scope}.{node.name}" if parent_scope else node.name
572
+ canon_id = build_canonical_id(self.module, parent_scope, node.name)
573
+ end_line = node.end_lineno or node.lineno
574
+
575
+ sig = ""
576
+ ret_type: str | None = None
577
+ param_count: int | None = None
578
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
579
+ args = [a.arg for a in node.args.args]
580
+ param_count = len(args)
581
+ if qname in self.fn_return_types:
582
+ ret_type = self.fn_return_types[qname]
583
+ elif node.name in self.fn_return_types:
584
+ ret_type = self.fn_return_types[node.name]
585
+ else:
586
+ ret_type = _infer_function_return(node)
587
+ sig_ret = ast.unparse(node.returns) if (node.returns and hasattr(ast, "unparse")) else ret_type
588
+ sig = f"({', '.join(args)})" + (f" -> {sig_ret}" if sig_ret else "")
589
+ elif isinstance(node, ast.ClassDef):
590
+ bases_str = [ast.unparse(b) for b in node.bases] if hasattr(ast, "unparse") else []
591
+ sig = f"({', '.join(bases_str)})" if bases_str else ""
592
+ for base in node.bases:
593
+ base_name = ast.unparse(base) if hasattr(ast, "unparse") else getattr(base, "id", "")
594
+ base_clean = base_name.split("[")[0].strip()
595
+ if base_clean:
596
+ self.inheritance.append(
597
+ InheritanceRef(
598
+ source_symbol=qname,
599
+ base_name=base_clean,
600
+ relationship="EXTENDS",
601
+ source_file=self.file_path,
602
+ line=node.lineno,
603
+ source_canonical_id=canon_id,
604
+ )
605
+ )
606
+
607
+ doc = ast.get_docstring(node)
608
+ c_hash = _normalized_body_hash(self.lines, node.lineno, end_line)
609
+
610
+ # Avoid duplicate canonical symbol IDs for @name.setter within the same class
611
+ is_setter = any(d.endswith(".setter") for d in decorators)
612
+ existing_prop = None
613
+ if is_setter and parent_kind == "class":
614
+ for s in self.symbols:
615
+ if s.canonical_id == canon_id and s.kind == "property":
616
+ existing_prop = s
617
+ break
618
+
619
+ if existing_prop is not None:
620
+ # Merge setter signature/span into the existing property symbol
621
+ idx = self.symbols.index(existing_prop)
622
+ self.symbols[idx] = Symbol(
623
+ id=existing_prop.id,
624
+ canonical_id=existing_prop.canonical_id,
625
+ name=existing_prop.name,
626
+ qualified_name=existing_prop.qualified_name,
627
+ kind="property",
628
+ language=existing_prop.language,
629
+ module=existing_prop.module,
630
+ path=existing_prop.path,
631
+ file_path=existing_prop.file_path,
632
+ scope=existing_prop.scope,
633
+ signature=f"{existing_prop.signature} | setter: {sig}" if existing_prop.signature else sig,
634
+ start_line=existing_prop.start_line,
635
+ end_line=max(existing_prop.end_line, end_line),
636
+ content_hash=c_hash,
637
+ parent_symbol_id=existing_prop.parent_symbol_id,
638
+ decorators=list(dict.fromkeys(existing_prop.decorators + decorators)),
639
+ return_type=existing_prop.return_type,
640
+ parameter_count=existing_prop.parameter_count,
641
+ documentation=existing_prop.documentation or doc,
642
+ )
643
+ else:
644
+ self.symbols.append(
645
+ Symbol(
646
+ id=canon_id,
647
+ canonical_id=canon_id,
648
+ name=node.name,
649
+ qualified_name=qname,
650
+ kind=kind,
651
+ language="python",
652
+ module=self.module,
653
+ path=self.file_path,
654
+ file_path=self.file_path,
655
+ scope=parent_scope,
656
+ signature=sig,
657
+ start_line=node.lineno,
658
+ end_line=end_line,
659
+ content_hash=c_hash,
660
+ parent_symbol_id=parent_canon,
661
+ decorators=decorators,
662
+ return_type=ret_type,
663
+ parameter_count=param_count,
664
+ documentation=doc,
665
+ )
666
+ )
667
+
668
+ # Framework analyzer hook: check function/class for route decorators during this traversal
669
+ if node.decorator_list or isinstance(node, ast.ClassDef):
670
+ ctx = self._make_context()
671
+ for analyzer in self.analyzers:
672
+ detected = analyzer.analyze_python_node(node, ctx)
673
+ if detected:
674
+ self.routes.extend(detected)
675
+
676
+ # Semantic Decorators (Celery, Click, Typer, Django receiver, Registry)
677
+ if node.decorator_list:
678
+ local_resolver = self._get_local_binding_resolver()
679
+ dec_detections = analyze_symbol_decorators(
680
+ node=node,
681
+ file_path=self.file_path,
682
+ target_canonical_id=canon_id,
683
+ imports=self.all_file_imports or self.imports,
684
+ binding_resolver=local_resolver, # type: ignore[arg-type]
685
+ conditional_depth=self.conditional_depth,
686
+ )
687
+ else:
688
+ dec_detections = []
689
+ for det in dec_detections:
690
+ self.bindings.append(
691
+ BindingRef(
692
+ target_name=det.target_canonical_id,
693
+ file_path=self.file_path,
694
+ line=det.line,
695
+ column=getattr(node, "col_offset", None),
696
+ scope=self.current_scope_qname,
697
+ expr_kind="SEMANTIC_DECORATOR",
698
+ source_expr=det.decorator_expr,
699
+ is_conditional=det.is_conditional,
700
+ base_expr=det.framework,
701
+ attr_name=det.semantic_role,
702
+ subscript_key=json.dumps(det.metadata),
703
+ subscript_target=det.reason if det.is_dynamic_arg else None,
704
+ )
705
+ )
706
+
707
+ # Test fixture usage & fixture definition detection
708
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
709
+ is_test_ctx = node.name.startswith("test_") or "test" in self.file_path.lower()
710
+ if is_test_ctx:
711
+ for a in node.args.args:
712
+ if a.arg not in ("self", "cls"):
713
+ self.bindings.append(
714
+ BindingRef(
715
+ target_name=a.arg,
716
+ file_path=self.file_path,
717
+ line=node.lineno,
718
+ column=getattr(a, "col_offset", None),
719
+ scope=qname,
720
+ expr_kind="TEST_FIXTURE_USE",
721
+ source_expr=a.arg,
722
+ base_expr=node.name,
723
+ )
724
+ )
725
+ if any("fixture" in d.lower() for d in decorators):
726
+ self.bindings.append(
727
+ BindingRef(
728
+ target_name=node.name,
729
+ file_path=self.file_path,
730
+ line=node.lineno,
731
+ column=getattr(node, "col_offset", None),
732
+ scope=parent_scope,
733
+ expr_kind="PYTEST_FIXTURE_DEF",
734
+ source_expr=ret_type or node.name,
735
+ base_expr=node.name,
736
+ )
737
+ )
738
+
739
+ # Dependency Injection parameter analysis (FastAPI Depends & Constructor/Type-based injection)
740
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
741
+ self._extract_di_parameters(node, canon_id, parent_canon, decorators)
742
+
743
+ # Push scope and visit children in single pass
744
+ self.scope_stack.append((node.name, kind, qname, canon_id))
745
+ param_bindings: dict[str, str] = {}
746
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
747
+ for arg in list(node.args.args) + list(node.args.kwonlyargs):
748
+ if arg.annotation and hasattr(ast, "unparse"):
749
+ ann_type = ast.unparse(arg.annotation).split("[")[0].strip()
750
+ if ann_type and ann_type not in _PYTHON_BUILTINS:
751
+ param_bindings[arg.arg] = ann_type
752
+ self.local_bindings_stack.append(param_bindings)
753
+ for child in node.body:
754
+ self.visit(child)
755
+ self.local_bindings_stack.pop()
756
+ self.scope_stack.pop()
757
+
758
+ def _extract_di_parameters(
759
+ self,
760
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
761
+ canon_id: str,
762
+ parent_canon: str | None,
763
+ decorators: list[str],
764
+ ) -> None:
765
+ is_cond = self.conditional_depth > 0
766
+ has_inject_decorator = any("inject" in d.lower() for d in decorators)
767
+ is_init = (node.name == "__init__")
768
+
769
+ # Map positional arguments to defaults
770
+ pos_args = node.args.args
771
+ num_pos_defaults = len(node.args.defaults)
772
+ defaults_map: dict[str, ast.expr] = {}
773
+ if num_pos_defaults > 0:
774
+ for arg, def_val in zip(pos_args[-num_pos_defaults:], node.args.defaults, strict=False):
775
+ defaults_map[arg.arg] = def_val
776
+ for arg, kw_def in zip(node.args.kwonlyargs, node.args.kw_defaults, strict=False):
777
+ if kw_def is not None:
778
+ defaults_map[arg.arg] = kw_def
779
+
780
+ all_args = list(node.args.args) + list(node.args.kwonlyargs)
781
+ for arg in all_args:
782
+ if arg.arg in ("self", "cls"):
783
+ continue
784
+
785
+ dep_expr: str | None = None
786
+ is_dynamic_dep = False
787
+
788
+ # Check default value: param = Depends(get_db) or Depends(dependency=get_db)
789
+ def_node = defaults_map.get(arg.arg)
790
+ if isinstance(def_node, ast.Call):
791
+ func_nm = getattr(def_node.func, "id", None) or (
792
+ getattr(def_node.func, "attr", None) if isinstance(def_node.func, ast.Attribute) else ""
793
+ )
794
+ if func_nm == "Depends":
795
+ dep_arg: ast.expr | None = def_node.args[0] if def_node.args else None
796
+ if dep_arg is None:
797
+ dep_kw = next((kw for kw in def_node.keywords if kw.arg == "dependency"), None)
798
+ if dep_kw is not None:
799
+ dep_arg = dep_kw.value
800
+ if dep_arg is not None:
801
+ if isinstance(dep_arg, (ast.Name, ast.Attribute)) and hasattr(ast, "unparse"):
802
+ dep_expr = ast.unparse(dep_arg)
803
+ else:
804
+ dep_expr = "<DYNAMIC>"
805
+ is_dynamic_dep = True
806
+ elif arg.annotation and hasattr(ast, "unparse"):
807
+ dep_expr = ast.unparse(arg.annotation).split("[")[0].strip()
808
+
809
+ # Check annotation: param: Annotated[Session, Depends(get_db)]
810
+ if not dep_expr and isinstance(arg.annotation, ast.Subscript) and hasattr(ast, "unparse"):
811
+ ann_val = getattr(arg.annotation.value, "id", None) or (
812
+ getattr(arg.annotation.value, "attr", None) if isinstance(arg.annotation.value, ast.Attribute) else ""
813
+ )
814
+ if ann_val == "Annotated":
815
+ slice_node = arg.annotation.slice
816
+ elts = slice_node.elts if isinstance(slice_node, (ast.Tuple, ast.List)) else [slice_node]
817
+ for elt in elts[1:]:
818
+ if isinstance(elt, ast.Call):
819
+ f_nm = getattr(elt.func, "id", None) or (
820
+ getattr(elt.func, "attr", None) if isinstance(elt.func, ast.Attribute) else ""
821
+ )
822
+ if f_nm == "Depends":
823
+ elt_arg: ast.expr | None = elt.args[0] if elt.args else None
824
+ if elt_arg is None:
825
+ elt_kw = next((kw for kw in elt.keywords if kw.arg == "dependency"), None)
826
+ if elt_kw is not None:
827
+ elt_arg = elt_kw.value
828
+ if elt_arg is not None:
829
+ if isinstance(elt_arg, (ast.Name, ast.Attribute)):
830
+ dep_expr = ast.unparse(elt_arg)
831
+ else:
832
+ dep_expr = "<DYNAMIC>"
833
+ is_dynamic_dep = True
834
+ elif hasattr(ast, "unparse"):
835
+ dep_expr = ast.unparse(elts[0]).split("[")[0].strip()
836
+ break
837
+
838
+ # Check if annotation refers to an Annotated type alias: e.g. session: SessionDep, current_user: CurrentUser
839
+ if not dep_expr and arg.annotation and hasattr(ast, "unparse"):
840
+ ann_text = ast.unparse(arg.annotation).strip()
841
+ ann_short = ann_text.split("[")[0].strip().split(".")[-1]
842
+ if ann_short in self.di_type_aliases:
843
+ dep_expr = self.di_type_aliases[ann_short]
844
+ elif (
845
+ ann_short not in _PYTHON_BUILTINS
846
+ and not ann_short.startswith("_")
847
+ and ann_short not in (
848
+ "Any", "Optional", "Union", "List", "Dict", "Set", "Tuple",
849
+ "int", "str", "float", "bool", "bytes", "dict", "list", "set", "tuple",
850
+ )
851
+ ):
852
+ self.bindings.append(
853
+ BindingRef(
854
+ target_name=canon_id,
855
+ file_path=self.file_path,
856
+ line=node.lineno,
857
+ column=getattr(node, "col_offset", None),
858
+ scope=self.current_scope_qname,
859
+ expr_kind="PARAM_TYPE_DI",
860
+ source_expr=ann_text,
861
+ is_conditional=is_cond,
862
+ base_expr="fastapi",
863
+ attr_name="Depends",
864
+ subscript_key=json.dumps({"param": arg.arg, "alias": ann_short, "framework": "fastapi"}),
865
+ )
866
+ )
867
+
868
+ if dep_expr:
869
+ self.bindings.append(
870
+ BindingRef(
871
+ target_name=canon_id,
872
+ file_path=self.file_path,
873
+ line=node.lineno,
874
+ column=getattr(node, "col_offset", None),
875
+ scope=self.current_scope_qname,
876
+ expr_kind="DI_INJECTS",
877
+ source_expr=dep_expr,
878
+ is_conditional=is_cond,
879
+ base_expr="fastapi",
880
+ attr_name="Depends",
881
+ subscript_target="dynamic_provider" if is_dynamic_dep else None,
882
+ subscript_key=json.dumps({"param": arg.arg, "framework": "fastapi"}),
883
+ )
884
+ )
885
+
886
+ # Constructor or @inject parameter annotation
887
+ if (is_init or has_inject_decorator) and arg.annotation and hasattr(ast, "unparse"):
888
+ ann_str = ast.unparse(arg.annotation).split("[")[0].strip()
889
+ if ann_str and ann_str not in _PYTHON_BUILTINS and not ann_str.startswith("_"):
890
+ tgt_id = parent_canon if (is_init and parent_canon) else canon_id
891
+ self.bindings.append(
892
+ BindingRef(
893
+ target_name=tgt_id,
894
+ file_path=self.file_path,
895
+ line=node.lineno,
896
+ column=getattr(node, "col_offset", None),
897
+ scope=self.current_scope_qname,
898
+ expr_kind="CONSTRUCTOR_PARAM",
899
+ source_expr=ann_str,
900
+ is_conditional=is_cond,
901
+ base_expr="inject_decorator" if has_inject_decorator else "constructor",
902
+ attr_name=arg.arg,
903
+ subscript_key=json.dumps({"param": arg.arg, "type": ann_str}),
904
+ )
905
+ )
906
+
907
+ # Route decorator dependencies=[Depends(...)]
908
+ for dec in node.decorator_list:
909
+ if isinstance(dec, ast.Call):
910
+ for kw in dec.keywords:
911
+ if kw.arg == "dependencies" and isinstance(kw.value, (ast.List, ast.Tuple)):
912
+ for elt in kw.value.elts:
913
+ if isinstance(elt, ast.Call):
914
+ f_nm = getattr(elt.func, "id", None) or (
915
+ getattr(elt.func, "attr", None) if isinstance(elt.func, ast.Attribute) else ""
916
+ )
917
+ if f_nm == "Depends" and hasattr(ast, "unparse"):
918
+ d_arg: ast.expr | None = elt.args[0] if elt.args else None
919
+ if d_arg is None:
920
+ d_kw = next((k for k in elt.keywords if k.arg == "dependency"), None)
921
+ if d_kw is not None:
922
+ d_arg = d_kw.value
923
+ if d_arg is not None:
924
+ if isinstance(d_arg, (ast.Name, ast.Attribute)):
925
+ dep_str = ast.unparse(d_arg)
926
+ dyn_sub = None
927
+ else:
928
+ dep_str = "<DYNAMIC>"
929
+ dyn_sub = "dynamic_provider"
930
+ self.bindings.append(
931
+ BindingRef(
932
+ target_name=canon_id,
933
+ file_path=self.file_path,
934
+ line=node.lineno,
935
+ column=getattr(node, "col_offset", None),
936
+ scope=self.current_scope_qname,
937
+ expr_kind="DI_INJECTS",
938
+ source_expr=dep_str,
939
+ is_conditional=is_cond,
940
+ base_expr="fastapi",
941
+ attr_name="Depends",
942
+ subscript_target=dyn_sub,
943
+ subscript_key=json.dumps({"param": "dependencies", "framework": "fastapi"}),
944
+ )
945
+ )
946
+
947
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
948
+ self._visit_symbol_node(node)
949
+
950
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
951
+ self._visit_symbol_node(node)
952
+
953
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
954
+ self._visit_symbol_node(node)
955
+
956
+ def visit_Import(self, node: ast.Import) -> None:
957
+ for alias in node.names:
958
+ self.import_modules_set.add(alias.name)
959
+ self.imports.append(
960
+ ImportRef(
961
+ module=alias.name,
962
+ imported_module=alias.name,
963
+ imported_name=None,
964
+ alias=alias.asname,
965
+ local_name=alias.asname or alias.name.split(".")[0],
966
+ import_type="namespace" if alias.asname else "module",
967
+ line=node.lineno,
968
+ source_file=self.file_path,
969
+ source_module=self.module,
970
+ )
971
+ )
972
+
973
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
974
+ raw_mod = node.module or ""
975
+ resolved_mod = (
976
+ _resolve_relative_py_module(self.file_path, node.level, node.module)
977
+ if node.level > 0
978
+ else raw_mod
979
+ )
980
+ display_mod = raw_mod or resolved_mod
981
+ if resolved_mod:
982
+ self.import_modules_set.add(resolved_mod)
983
+ if raw_mod:
984
+ self.import_modules_set.add(raw_mod)
985
+
986
+ for alias in node.names:
987
+ full = f"{resolved_mod}.{alias.name}" if resolved_mod else alias.name
988
+ is_reexp = self.is_init
989
+ self.imports.append(
990
+ ImportRef(
991
+ module=display_mod,
992
+ imported_module=resolved_mod or display_mod,
993
+ name=alias.name,
994
+ imported_name=alias.name,
995
+ alias=alias.asname,
996
+ local_name=alias.asname or alias.name,
997
+ import_type="reexport" if is_reexp else ("alias" if alias.asname else "named"),
998
+ is_reexport=is_reexp,
999
+ exported_name=alias.asname or alias.name if is_reexp else None,
1000
+ line=node.lineno,
1001
+ source_file=self.file_path,
1002
+ source_module=self.module,
1003
+ full=full,
1004
+ )
1005
+ )
1006
+
1007
+ def visit_If(self, node: ast.If) -> None:
1008
+ self.visit(node.test)
1009
+ self.conditional_depth += 1
1010
+ for stmt in node.body:
1011
+ self.visit(stmt)
1012
+ for stmt in node.orelse:
1013
+ self.visit(stmt)
1014
+ self.conditional_depth -= 1
1015
+
1016
+ def visit_While(self, node: ast.While) -> None:
1017
+ self.visit(node.test)
1018
+ self.conditional_depth += 1
1019
+ for stmt in node.body:
1020
+ self.visit(stmt)
1021
+ for stmt in node.orelse:
1022
+ self.visit(stmt)
1023
+ self.conditional_depth -= 1
1024
+
1025
+ def visit_For(self, node: ast.For) -> None:
1026
+ self.visit(node.target)
1027
+ self.visit(node.iter)
1028
+ self.conditional_depth += 1
1029
+ for stmt in node.body:
1030
+ self.visit(stmt)
1031
+ for stmt in node.orelse:
1032
+ self.visit(stmt)
1033
+ self.conditional_depth -= 1
1034
+
1035
+ def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
1036
+ self.visit(node.target)
1037
+ self.visit(node.iter)
1038
+ self.conditional_depth += 1
1039
+ for stmt in node.body:
1040
+ self.visit(stmt)
1041
+ for stmt in node.orelse:
1042
+ self.visit(stmt)
1043
+ self.conditional_depth -= 1
1044
+
1045
+ def visit_Try(self, node: ast.Try) -> None:
1046
+ self.conditional_depth += 1
1047
+ for stmt in node.body:
1048
+ self.visit(stmt)
1049
+ for h in node.handlers:
1050
+ self.visit(h)
1051
+ for stmt in node.orelse:
1052
+ self.visit(stmt)
1053
+ for stmt in node.finalbody:
1054
+ self.visit(stmt)
1055
+ self.conditional_depth -= 1
1056
+
1057
+ def _extract_binding_from_assign(
1058
+ self,
1059
+ target_name: str,
1060
+ value_node: ast.AST,
1061
+ line: int,
1062
+ col: int | None,
1063
+ scope_override: str | None = None,
1064
+ ) -> None:
1065
+ if not target_name or target_name.startswith("__"):
1066
+ return
1067
+
1068
+ is_cond = self.conditional_depth > 0
1069
+ scope = scope_override if scope_override is not None else self.current_scope_qname
1070
+
1071
+ # 1. Identifier alias: b = a
1072
+ if isinstance(value_node, ast.Name):
1073
+ self.bindings.append(
1074
+ BindingRef(
1075
+ target_name=target_name,
1076
+ file_path=self.file_path,
1077
+ line=line,
1078
+ column=col,
1079
+ scope=scope,
1080
+ expr_kind="IDENTIFIER",
1081
+ source_expr=value_node.id,
1082
+ is_conditional=is_cond,
1083
+ base_expr=value_node.id,
1084
+ )
1085
+ )
1086
+ return
1087
+
1088
+ # 2. Attribute access: handler = controller.get_users
1089
+ if isinstance(value_node, ast.Attribute) and hasattr(ast, "unparse"):
1090
+ attr_name = value_node.attr
1091
+ base_expr = ast.unparse(value_node.value)
1092
+ self.bindings.append(
1093
+ BindingRef(
1094
+ target_name=target_name,
1095
+ file_path=self.file_path,
1096
+ line=line,
1097
+ column=col,
1098
+ scope=scope,
1099
+ expr_kind="ATTRIBUTE",
1100
+ source_expr=ast.unparse(value_node),
1101
+ is_conditional=is_cond,
1102
+ base_expr=base_expr,
1103
+ attr_name=attr_name,
1104
+ )
1105
+ )
1106
+ return
1107
+
1108
+ # 3. Dict literal: handlers = {"create": handle_create} or providers = {UserRepository: PostgresRepository}
1109
+ if isinstance(value_node, ast.Dict) and hasattr(ast, "unparse"):
1110
+ target_lower = target_name.lower()
1111
+ is_provider_dict = (
1112
+ target_lower in ("providers", "bindings", "container", "services", "dependencies")
1113
+ or target_lower.endswith(("_providers", "_bindings"))
1114
+ )
1115
+ dict_entries: list[tuple[str, str]] = []
1116
+ valid = True
1117
+ for k, v in zip(value_node.keys, value_node.values, strict=False):
1118
+ k_str: str | None = None
1119
+ v_str: str | None = None
1120
+ if k is not None:
1121
+ if isinstance(k, ast.Constant) and isinstance(k.value, str):
1122
+ k_str = k.value
1123
+ elif isinstance(k, (ast.Name, ast.Attribute)):
1124
+ k_str = ast.unparse(k)
1125
+ if isinstance(v, ast.Name):
1126
+ v_str = v.id
1127
+ elif isinstance(v, ast.Attribute):
1128
+ v_str = ast.unparse(v)
1129
+
1130
+ if k_str and v_str:
1131
+ dict_entries.append((k_str, v_str))
1132
+ if is_provider_dict:
1133
+ self.bindings.append(
1134
+ BindingRef(
1135
+ target_name=k_str,
1136
+ file_path=self.file_path,
1137
+ line=line,
1138
+ column=col,
1139
+ scope=scope,
1140
+ expr_kind="DI_PROVIDES",
1141
+ source_expr=v_str,
1142
+ is_conditional=is_cond,
1143
+ base_expr=target_name,
1144
+ )
1145
+ )
1146
+ else:
1147
+ valid = False
1148
+ if valid and dict_entries:
1149
+ self.bindings.append(
1150
+ BindingRef(
1151
+ target_name=target_name,
1152
+ file_path=self.file_path,
1153
+ line=line,
1154
+ column=col,
1155
+ scope=scope,
1156
+ expr_kind="DICT_LITERAL",
1157
+ source_expr=ast.unparse(value_node),
1158
+ is_conditional=is_cond,
1159
+ dict_entries=tuple(dict_entries),
1160
+ )
1161
+ )
1162
+ return
1163
+
1164
+ # 4. List / Tuple literal: handlers = [get_users, post_users]
1165
+ if isinstance(value_node, (ast.List, ast.Tuple)) and hasattr(ast, "unparse"):
1166
+ list_entries: list[str] = []
1167
+ valid = True
1168
+ for elt in value_node.elts:
1169
+ if isinstance(elt, ast.Name):
1170
+ list_entries.append(elt.id)
1171
+ elif isinstance(elt, ast.Attribute):
1172
+ list_entries.append(ast.unparse(elt))
1173
+ else:
1174
+ valid = False
1175
+ break
1176
+ if valid and list_entries:
1177
+ self.bindings.append(
1178
+ BindingRef(
1179
+ target_name=target_name,
1180
+ file_path=self.file_path,
1181
+ line=line,
1182
+ column=col,
1183
+ scope=scope,
1184
+ expr_kind="LIST_LITERAL",
1185
+ source_expr=ast.unparse(value_node),
1186
+ is_conditional=is_cond,
1187
+ list_entries=tuple(list_entries),
1188
+ )
1189
+ )
1190
+ return
1191
+
1192
+ # 5. Subscript access: h = handlers["create"] or h = handlers[0]
1193
+ if isinstance(value_node, ast.Subscript) and hasattr(ast, "unparse"):
1194
+ sub_target = ast.unparse(value_node.value)
1195
+ sub_key: str | None = None
1196
+ sub_idx: int | None = None
1197
+ if isinstance(value_node.slice, ast.Constant):
1198
+ if isinstance(value_node.slice.value, str):
1199
+ sub_key = value_node.slice.value
1200
+ elif isinstance(value_node.slice.value, int):
1201
+ sub_idx = value_node.slice.value
1202
+
1203
+ if sub_key is not None or sub_idx is not None:
1204
+ self.bindings.append(
1205
+ BindingRef(
1206
+ target_name=target_name,
1207
+ file_path=self.file_path,
1208
+ line=line,
1209
+ column=col,
1210
+ scope=scope,
1211
+ expr_kind="SUBSCRIPT",
1212
+ source_expr=ast.unparse(value_node),
1213
+ is_conditional=is_cond,
1214
+ subscript_target=sub_target,
1215
+ subscript_key=sub_key,
1216
+ subscript_index=sub_idx,
1217
+ )
1218
+ )
1219
+ return
1220
+
1221
+ # 6. Dynamic calls: getattr(mod, name), globals()[x], locals()[x]
1222
+ if isinstance(value_node, ast.Call) and hasattr(ast, "unparse"):
1223
+ func_name = ""
1224
+ if isinstance(value_node.func, ast.Name):
1225
+ func_name = value_node.func.id
1226
+ if func_name in ("getattr", "globals", "locals", "__import__"):
1227
+ self.bindings.append(
1228
+ BindingRef(
1229
+ target_name=target_name,
1230
+ file_path=self.file_path,
1231
+ line=line,
1232
+ column=col,
1233
+ scope=scope,
1234
+ expr_kind="DYNAMIC",
1235
+ source_expr=ast.unparse(value_node),
1236
+ is_conditional=is_cond,
1237
+ )
1238
+ )
1239
+ return
1240
+
1241
+ # 7. Environment variable & configuration assignment: db_url = os.getenv("DATABASE_URL") or os.environ["DATABASE_URL"]
1242
+ if isinstance(value_node, ast.Call) and hasattr(ast, "unparse"):
1243
+ if isinstance(value_node.func, ast.Attribute):
1244
+ b_name = ast.unparse(value_node.func.value)
1245
+ attr_nm = value_node.func.attr
1246
+ if (b_name == "os" and attr_nm == "getenv") or (b_name == "os.environ" and attr_nm in ("get", "getenv")):
1247
+ var_name = "<DYNAMIC>"
1248
+ is_dyn = True
1249
+ if value_node.args and isinstance(value_node.args[0], ast.Constant) and isinstance(value_node.args[0].value, str):
1250
+ var_name = value_node.args[0].value
1251
+ is_dyn = False
1252
+ self.bindings.append(
1253
+ BindingRef(
1254
+ target_name=var_name,
1255
+ file_path=self.file_path,
1256
+ line=line,
1257
+ column=col,
1258
+ scope=self.current_scope_canonical_id or self.current_scope_qname,
1259
+ expr_kind="ENV_CONFIG",
1260
+ source_expr=target_name,
1261
+ is_conditional=is_cond,
1262
+ base_expr=b_name,
1263
+ attr_name=attr_nm,
1264
+ subscript_target="runtime_configuration" if is_dyn else None,
1265
+ subscript_key=json.dumps({"env_var": var_name, "target_var": target_name}),
1266
+ )
1267
+ )
1268
+ return
1269
+ elif isinstance(value_node, ast.Subscript) and hasattr(ast, "unparse"):
1270
+ sub_target = ast.unparse(value_node.value)
1271
+ if sub_target == "os.environ":
1272
+ var_name = "<DYNAMIC>"
1273
+ is_dyn = True
1274
+ if isinstance(value_node.slice, ast.Constant) and isinstance(value_node.slice.value, str):
1275
+ var_name = value_node.slice.value
1276
+ is_dyn = False
1277
+ self.bindings.append(
1278
+ BindingRef(
1279
+ target_name=var_name,
1280
+ file_path=self.file_path,
1281
+ line=line,
1282
+ column=col,
1283
+ scope=self.current_scope_canonical_id or self.current_scope_qname,
1284
+ expr_kind="ENV_CONFIG",
1285
+ source_expr=target_name,
1286
+ is_conditional=is_cond,
1287
+ base_expr=sub_target,
1288
+ subscript_target="runtime_configuration" if is_dyn else None,
1289
+ subscript_key=json.dumps({"env_var": var_name, "target_var": target_name}),
1290
+ )
1291
+ )
1292
+ return
1293
+ elif sub_target.lower() in ("config", "settings", "configuration"):
1294
+ self.bindings.append(
1295
+ BindingRef(
1296
+ target_name="<DYNAMIC>",
1297
+ file_path=self.file_path,
1298
+ line=line,
1299
+ column=col,
1300
+ scope=self.current_scope_canonical_id or self.current_scope_qname,
1301
+ expr_kind="ENV_CONFIG",
1302
+ source_expr=target_name,
1303
+ is_conditional=is_cond,
1304
+ base_expr=sub_target,
1305
+ subscript_target="runtime_configuration",
1306
+ )
1307
+ )
1308
+ return
1309
+
1310
+ # 8. Router / App instantiation with dependencies=[Depends(...)]
1311
+ if isinstance(value_node, ast.Call) and hasattr(ast, "unparse"):
1312
+ for kw in value_node.keywords:
1313
+ if kw.arg == "dependencies" and isinstance(kw.value, (ast.List, ast.Tuple)):
1314
+ for elt in kw.value.elts:
1315
+ if isinstance(elt, ast.Call):
1316
+ f_nm = getattr(elt.func, "id", None) or (
1317
+ getattr(elt.func, "attr", None) if isinstance(elt.func, ast.Attribute) else ""
1318
+ )
1319
+ if f_nm == "Depends":
1320
+ d_arg: ast.expr | None = elt.args[0] if elt.args else None
1321
+ if d_arg is None:
1322
+ d_kw = next((k for k in elt.keywords if k.arg == "dependency"), None)
1323
+ if d_kw is not None:
1324
+ d_arg = d_kw.value
1325
+ if d_arg is not None:
1326
+ if isinstance(d_arg, (ast.Name, ast.Attribute)):
1327
+ dep_str = ast.unparse(d_arg)
1328
+ dyn_sub = None
1329
+ else:
1330
+ dep_str = "<DYNAMIC>"
1331
+ dyn_sub = "dynamic_provider"
1332
+ self.bindings.append(
1333
+ BindingRef(
1334
+ target_name=target_name,
1335
+ file_path=self.file_path,
1336
+ line=line,
1337
+ column=col,
1338
+ scope=scope,
1339
+ expr_kind="ROUTER_DI_INJECTS",
1340
+ source_expr=dep_str,
1341
+ is_conditional=is_cond,
1342
+ base_expr="fastapi",
1343
+ attr_name="Depends",
1344
+ subscript_target=dyn_sub,
1345
+ )
1346
+ )
1347
+
1348
+ # 9. Factory / Constructor / Function call assignment: service = get_inventory_service() or service = InventoryService()
1349
+ if isinstance(value_node, ast.Call) and hasattr(ast, "unparse"):
1350
+ if isinstance(value_node.func, ast.Name):
1351
+ callee_fn = value_node.func.id
1352
+ if callee_fn not in _PYTHON_BUILTINS:
1353
+ self.bindings.append(
1354
+ BindingRef(
1355
+ target_name=target_name,
1356
+ file_path=self.file_path,
1357
+ line=line,
1358
+ column=col,
1359
+ scope=scope,
1360
+ expr_kind="CALL_RETURN",
1361
+ source_expr=callee_fn,
1362
+ is_conditional=is_cond,
1363
+ base_expr=callee_fn,
1364
+ )
1365
+ )
1366
+ elif isinstance(value_node.func, ast.Attribute):
1367
+ callee_fn = ast.unparse(value_node.func).strip()
1368
+ base_str = ast.unparse(value_node.func.value).strip()
1369
+ self.bindings.append(
1370
+ BindingRef(
1371
+ target_name=target_name,
1372
+ file_path=self.file_path,
1373
+ line=line,
1374
+ column=col,
1375
+ scope=scope,
1376
+ expr_kind="CALL_RETURN",
1377
+ source_expr=callee_fn,
1378
+ is_conditional=is_cond,
1379
+ base_expr=base_str,
1380
+ attr_name=value_node.func.attr,
1381
+ )
1382
+ )
1383
+ elif isinstance(value_node.func, ast.Subscript):
1384
+ self.bindings.append(
1385
+ BindingRef(
1386
+ target_name=target_name,
1387
+ file_path=self.file_path,
1388
+ line=line,
1389
+ column=col,
1390
+ scope=scope,
1391
+ expr_kind="DYNAMIC",
1392
+ source_expr=ast.unparse(value_node),
1393
+ is_conditional=is_cond,
1394
+ )
1395
+ )
1396
+
1397
+ # 10. Modern FastAPI / typing Annotated DI alias
1398
+ # e.g., SessionDep = Annotated[Session, Depends(get_db)]
1399
+ # CurrentUser = Annotated[User, Depends(get_current_user)]
1400
+ # TokenDep = Annotated[str, Depends(reusable_oauth2)]
1401
+ if isinstance(value_node, ast.Subscript) and hasattr(ast, "unparse"):
1402
+ val_id = getattr(value_node.value, "id", None) or (
1403
+ getattr(value_node.value, "attr", None) if isinstance(value_node.value, ast.Attribute) else ""
1404
+ )
1405
+ val_unparse = ast.unparse(value_node.value) if hasattr(ast, "unparse") else ""
1406
+ if val_id in ("Annotated", "typing.Annotated", "typing_extensions.Annotated") or "Annotated" in val_unparse:
1407
+ slice_node = value_node.slice
1408
+ elts = slice_node.elts if isinstance(slice_node, (ast.Tuple, ast.List)) else [slice_node]
1409
+ for elt in elts[1:]:
1410
+ if isinstance(elt, ast.Call):
1411
+ f_nm = getattr(elt.func, "id", None) or (
1412
+ getattr(elt.func, "attr", None) if isinstance(elt.func, ast.Attribute) else ""
1413
+ )
1414
+ if f_nm == "Depends":
1415
+ elt_arg: ast.expr | None = elt.args[0] if elt.args else None
1416
+ if elt_arg is None:
1417
+ elt_kw = next((kw for kw in elt.keywords if kw.arg == "dependency"), None)
1418
+ if elt_kw is not None:
1419
+ elt_arg = elt_kw.value
1420
+ dep_alias_str: str | None = None
1421
+ if elt_arg is not None:
1422
+ if isinstance(elt_arg, (ast.Name, ast.Attribute)):
1423
+ dep_alias_str = ast.unparse(elt_arg)
1424
+ else:
1425
+ dep_alias_str = "<DYNAMIC>"
1426
+ elif hasattr(ast, "unparse"):
1427
+ dep_alias_str = ast.unparse(elts[0]).split("[")[0].strip()
1428
+ if dep_alias_str:
1429
+ self.di_type_aliases[target_name] = dep_alias_str
1430
+ self.bindings.append(
1431
+ BindingRef(
1432
+ target_name=target_name,
1433
+ file_path=self.file_path,
1434
+ line=line,
1435
+ column=col,
1436
+ scope=scope,
1437
+ expr_kind="DI_ALIAS_INJECTS",
1438
+ source_expr=dep_alias_str,
1439
+ is_conditional=is_cond,
1440
+ base_expr="fastapi",
1441
+ attr_name="Depends",
1442
+ subscript_key=json.dumps({"alias": target_name, "framework": "fastapi"}),
1443
+ )
1444
+ )
1445
+ break
1446
+
1447
+ def visit_Assign(self, node: ast.Assign) -> None:
1448
+ for target in node.targets:
1449
+ if isinstance(target, ast.Name):
1450
+ self._extract_binding_from_assign(
1451
+ target.id, node.value, node.lineno, getattr(node, "col_offset", None)
1452
+ )
1453
+ if target.id in ("template_name", "template") and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
1454
+ self.bindings.append(
1455
+ BindingRef(
1456
+ target_name=node.value.value,
1457
+ file_path=self.file_path,
1458
+ line=node.lineno,
1459
+ column=getattr(node, "col_offset", None),
1460
+ scope=self.current_scope_qname or "",
1461
+ expr_kind="RENDERS_TEMPLATE",
1462
+ base_expr=self.current_scope_canonical_id or "",
1463
+ source_expr=node.value.value,
1464
+ )
1465
+ )
1466
+ elif isinstance(node.value, ast.BinOp) and isinstance(node.value.op, ast.BitOr):
1467
+ # Unfold bitwise OR chain (e.g. prompt | llm | output_parser)
1468
+ pipe_steps: list[str] = []
1469
+ curr: ast.expr = node.value
1470
+ is_valid_pipe = True
1471
+ while isinstance(curr, ast.BinOp) and isinstance(curr.op, ast.BitOr):
1472
+ r_text = ast.unparse(curr.right).strip() if hasattr(ast, "unparse") else ""
1473
+ if not r_text or isinstance(curr.right, ast.Constant):
1474
+ is_valid_pipe = False
1475
+ break
1476
+ pipe_steps.append(r_text)
1477
+ curr = curr.left
1478
+ l_text = ast.unparse(curr).strip() if hasattr(ast, "unparse") else ""
1479
+ if is_valid_pipe and l_text and not isinstance(curr, ast.Constant):
1480
+ pipe_steps.append(l_text)
1481
+ pipe_steps.reverse()
1482
+ if len(pipe_steps) >= 2:
1483
+ self.bindings.append(
1484
+ BindingRef(
1485
+ target_name=target.id,
1486
+ file_path=self.file_path,
1487
+ line=node.lineno,
1488
+ column=getattr(node, "col_offset", None),
1489
+ scope=self.current_scope_qname or "",
1490
+ expr_kind="LCEL_PIPELINE",
1491
+ base_expr="|".join(pipe_steps),
1492
+ source_expr=json.dumps(pipe_steps),
1493
+ )
1494
+ )
1495
+ elif (
1496
+ isinstance(target, ast.Attribute)
1497
+ and isinstance(target.value, ast.Name)
1498
+ and target.value.id == "self"
1499
+ and self.current_enclosing_class
1500
+ ):
1501
+ self._extract_binding_from_assign(
1502
+ f"self.{target.attr}",
1503
+ node.value,
1504
+ node.lineno,
1505
+ getattr(node, "col_offset", None),
1506
+ scope_override=self.current_enclosing_class,
1507
+ )
1508
+ if isinstance(node.value, ast.Name):
1509
+ bound_param = self._lookup_local_binding(node.value.id)
1510
+ if bound_param:
1511
+ self._bind_in_class_scope(f"self.{target.attr}", bound_param)
1512
+ self.bindings.append(
1513
+ BindingRef(
1514
+ target_name=f"self.{target.attr}",
1515
+ file_path=self.file_path,
1516
+ line=node.lineno,
1517
+ column=getattr(node, "col_offset", None),
1518
+ scope=self.current_enclosing_class,
1519
+ expr_kind="TYPE_ANNOTATION",
1520
+ source_expr=bound_param,
1521
+ is_conditional=(self.conditional_depth > 0),
1522
+ base_expr=node.value.id,
1523
+ )
1524
+ )
1525
+ if isinstance(target, ast.Name) and target.id == "__all__":
1526
+ if isinstance(node.value, (ast.List, ast.Tuple, ast.Set)):
1527
+ for elt in node.value.elts:
1528
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
1529
+ self.exports.append(elt.value)
1530
+ elif isinstance(target, ast.Name) and isinstance(node.value, ast.Call):
1531
+ if isinstance(node.value.func, ast.Name):
1532
+ callee_name = node.value.func.id
1533
+ if callee_name not in _PYTHON_BUILTINS:
1534
+ if callee_name in self.fn_return_types:
1535
+ self._bind_in_local_scope(target.id, self.fn_return_types[callee_name])
1536
+ elif callee_name in self.known_classes or (callee_name[0].isupper() and "_" not in callee_name):
1537
+ self._bind_in_local_scope(target.id, callee_name)
1538
+ elif any(callee_name.endswith(sfx) for sfx in ("Service", "Client", "Repository", "Manager", "Store", "View")):
1539
+ self._bind_in_local_scope(target.id, callee_name)
1540
+ elif isinstance(node.value.func, ast.Attribute) and hasattr(ast, "unparse"):
1541
+ attr_expr = ast.unparse(node.value.func)
1542
+ if attr_expr in self.fn_return_types:
1543
+ self._bind_in_local_scope(target.id, self.fn_return_types[attr_expr])
1544
+ elif attr_expr.split(".")[-1][0].isupper():
1545
+ self._bind_in_local_scope(target.id, attr_expr.split(".")[-1])
1546
+ elif (
1547
+ isinstance(target, ast.Attribute)
1548
+ and isinstance(target.value, ast.Name)
1549
+ and target.value.id == "self"
1550
+ and isinstance(node.value, ast.Call)
1551
+ ):
1552
+ if isinstance(node.value.func, ast.Name):
1553
+ callee_name = node.value.func.id
1554
+ if callee_name in self.fn_return_types:
1555
+ self._bind_in_class_scope(f"self.{target.attr}", self.fn_return_types[callee_name])
1556
+ elif callee_name in self.known_classes or (callee_name[0].isupper() and "_" not in callee_name):
1557
+ self._bind_in_class_scope(f"self.{target.attr}", callee_name)
1558
+ elif isinstance(node.value.func, ast.Attribute) and hasattr(ast, "unparse"):
1559
+ attr_expr = ast.unparse(node.value.func)
1560
+ if attr_expr in self.fn_return_types:
1561
+ self._bind_in_class_scope(f"self.{target.attr}", self.fn_return_types[attr_expr])
1562
+ elif attr_expr.split(".")[-1][0].isupper():
1563
+ self._bind_in_class_scope(f"self.{target.attr}", attr_expr.split(".")[-1])
1564
+ elif isinstance(target, ast.Subscript) and hasattr(ast, "unparse"):
1565
+ sub_target = ast.unparse(target.value)
1566
+ lowered_target = sub_target.lower()
1567
+ is_provider_map = (
1568
+ lowered_target in ("providers", "bindings", "container", "services", "dependencies")
1569
+ or lowered_target.endswith(("_providers", "_bindings"))
1570
+ )
1571
+ if is_provider_map:
1572
+ iface_str = ast.unparse(target.slice) if isinstance(target.slice, (ast.Name, ast.Attribute)) else "<DYNAMIC>"
1573
+ impl_str = ast.unparse(node.value) if isinstance(node.value, (ast.Name, ast.Attribute)) else "<DYNAMIC>"
1574
+ self.bindings.append(
1575
+ BindingRef(
1576
+ target_name=iface_str,
1577
+ file_path=self.file_path,
1578
+ line=node.lineno,
1579
+ column=getattr(node, "col_offset", None),
1580
+ scope=self.current_scope_qname,
1581
+ expr_kind="DI_PROVIDES",
1582
+ source_expr=impl_str,
1583
+ is_conditional=(self.conditional_depth > 0),
1584
+ base_expr=sub_target,
1585
+ subscript_target="dynamic_provider" if (iface_str == "<DYNAMIC>" or impl_str == "<DYNAMIC>") else None,
1586
+ )
1587
+ )
1588
+
1589
+ sub_key: str | None = None
1590
+ is_dyn_key = False
1591
+ if isinstance(target.slice, ast.Constant):
1592
+ if isinstance(target.slice.value, str):
1593
+ sub_key = target.slice.value
1594
+ elif isinstance(target.slice.value, int):
1595
+ sub_key = str(target.slice.value)
1596
+ elif isinstance(target.slice, (ast.Name, ast.Attribute)):
1597
+ sub_key = ast.unparse(target.slice)
1598
+ else:
1599
+ is_dyn_key = True
1600
+ sub_key = "<DYNAMIC>"
1601
+
1602
+ val_expr = ast.unparse(node.value)
1603
+ is_dyn_val = not (isinstance(node.value, (ast.Name, ast.Attribute)))
1604
+
1605
+ lowered_target = sub_target.lower()
1606
+ is_event = (
1607
+ "event" in lowered_target
1608
+ or "listener" in lowered_target
1609
+ or "emitter" in lowered_target
1610
+ or (sub_key and "." in sub_key and not is_dyn_key)
1611
+ )
1612
+ expr_kind = "EVENT_ON" if is_event else "DICT_ASSIGN"
1613
+
1614
+ self.bindings.append(
1615
+ BindingRef(
1616
+ target_name=sub_target,
1617
+ file_path=self.file_path,
1618
+ line=node.lineno,
1619
+ column=getattr(node, "col_offset", None),
1620
+ scope=self.current_scope_qname,
1621
+ expr_kind=expr_kind,
1622
+ source_expr="<DYNAMIC>" if is_dyn_val else val_expr,
1623
+ is_conditional=(self.conditional_depth > 0),
1624
+ base_expr=sub_target,
1625
+ subscript_target=sub_target,
1626
+ subscript_key=sub_key,
1627
+ )
1628
+ )
1629
+
1630
+ ctx = self._make_context()
1631
+ for analyzer in self.analyzers:
1632
+ r_defs = analyzer.analyze_python_definitions(node, ctx)
1633
+ if r_defs:
1634
+ self.router_definitions.extend(r_defs)
1635
+ self.generic_visit(node)
1636
+
1637
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
1638
+ if isinstance(node.target, ast.Name) and node.value:
1639
+ self._extract_binding_from_assign(
1640
+ node.target.id, node.value, node.lineno, getattr(node, "col_offset", None)
1641
+ )
1642
+ elif (
1643
+ isinstance(node.target, ast.Attribute)
1644
+ and isinstance(node.target.value, ast.Name)
1645
+ and node.target.value.id == "self"
1646
+ and self.current_enclosing_class
1647
+ ):
1648
+ if node.value:
1649
+ self._extract_binding_from_assign(
1650
+ f"self.{node.target.attr}",
1651
+ node.value,
1652
+ node.lineno,
1653
+ getattr(node, "col_offset", None),
1654
+ scope_override=self.current_enclosing_class,
1655
+ )
1656
+ if hasattr(ast, "unparse"):
1657
+ ann = ast.unparse(node.annotation).split("[")[0].strip().strip("'\"")
1658
+ if ann and ann not in _PYTHON_BUILTINS:
1659
+ self._bind_in_class_scope(f"self.{node.target.attr}", ann)
1660
+ self.bindings.append(
1661
+ BindingRef(
1662
+ target_name=f"self.{node.target.attr}",
1663
+ file_path=self.file_path,
1664
+ line=node.lineno,
1665
+ column=getattr(node, "col_offset", None),
1666
+ scope=self.current_enclosing_class,
1667
+ expr_kind="TYPE_ANNOTATION",
1668
+ source_expr=ann,
1669
+ is_conditional=(self.conditional_depth > 0),
1670
+ )
1671
+ )
1672
+ if isinstance(node.target, ast.Name) and hasattr(ast, "unparse"):
1673
+ ann = ast.unparse(node.annotation).split("[")[0].strip().strip("'\"")
1674
+ if ann and ann not in _PYTHON_BUILTINS:
1675
+ self._bind_in_local_scope(node.target.id, ann)
1676
+ self.bindings.append(
1677
+ BindingRef(
1678
+ target_name=node.target.id,
1679
+ file_path=self.file_path,
1680
+ line=node.lineno,
1681
+ column=getattr(node, "col_offset", None),
1682
+ scope=self.current_scope_qname,
1683
+ expr_kind="TYPE_ANNOTATION",
1684
+ source_expr=ann,
1685
+ is_conditional=(self.conditional_depth > 0),
1686
+ )
1687
+ )
1688
+ ctx = self._make_context()
1689
+ for analyzer in self.analyzers:
1690
+ r_defs = analyzer.analyze_python_definitions(node, ctx)
1691
+ if r_defs:
1692
+ self.router_definitions.extend(r_defs)
1693
+ self.generic_visit(node)
1694
+
1695
+ def visit_Call(self, node: ast.Call) -> None:
1696
+ # Framework analyzer hook: check calls (e.g. Django path("login/", handler) or app.include_router(...))
1697
+ ctx = self._make_context()
1698
+ for analyzer in self.analyzers:
1699
+ detected = analyzer.analyze_python_node(node, ctx)
1700
+ if detected:
1701
+ self.routes.extend(detected)
1702
+ mounts = analyzer.analyze_python_mounts(node, ctx)
1703
+ if mounts:
1704
+ self.mounts.extend(mounts)
1705
+
1706
+ caller_qname = self.current_scope_qname or None
1707
+ caller_canon = self.current_scope_canonical_id
1708
+ end_ln = node.end_lineno or node.lineno
1709
+
1710
+ # Python View -> Template rendering (Flask render_template, Django render, FastAPI TemplateResponse)
1711
+ template_name: str | None = None
1712
+ if isinstance(node.func, ast.Name):
1713
+ if node.func.id == "render_template" and node.args:
1714
+ if isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
1715
+ template_name = node.args[0].value
1716
+ elif node.func.id == "render" and len(node.args) >= 2:
1717
+ if isinstance(node.args[1], ast.Constant) and isinstance(node.args[1].value, str):
1718
+ template_name = node.args[1].value
1719
+ elif isinstance(node.func, ast.Attribute):
1720
+ if node.func.attr == "TemplateResponse" and node.args:
1721
+ if isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
1722
+ template_name = node.args[0].value
1723
+ elif len(node.args) >= 2 and isinstance(node.args[1], ast.Constant) and isinstance(node.args[1].value, str):
1724
+ template_name = node.args[1].value
1725
+ for kw in node.keywords:
1726
+ if kw.arg in ("name", "template_name") and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
1727
+ template_name = kw.value.value
1728
+
1729
+ if template_name:
1730
+ self.bindings.append(
1731
+ BindingRef(
1732
+ target_name=template_name,
1733
+ file_path=self.file_path,
1734
+ line=node.lineno,
1735
+ column=getattr(node, "col_offset", None),
1736
+ scope=caller_qname or "",
1737
+ expr_kind="RENDERS_TEMPLATE",
1738
+ base_expr=caller_canon or "",
1739
+ source_expr=template_name,
1740
+ )
1741
+ )
1742
+
1743
+ if isinstance(node.func, ast.Name):
1744
+ callee_name = node.func.id
1745
+ if callee_name == "subscribe" and len(node.args) >= 2 and hasattr(ast, "unparse"):
1746
+ event_node = node.args[0]
1747
+ target_node = node.args[1]
1748
+ event_str = (
1749
+ event_node.value
1750
+ if isinstance(event_node, ast.Constant) and isinstance(event_node.value, str)
1751
+ else "<DYNAMIC>"
1752
+ )
1753
+ target_expr = (
1754
+ ast.unparse(target_node)
1755
+ if isinstance(target_node, (ast.Name, ast.Attribute))
1756
+ else "<DYNAMIC>"
1757
+ )
1758
+ self.bindings.append(
1759
+ BindingRef(
1760
+ target_name="subscribe",
1761
+ file_path=self.file_path,
1762
+ line=node.lineno,
1763
+ column=getattr(node, "col_offset", None),
1764
+ scope=self.current_scope_qname,
1765
+ expr_kind="SUBSCRIBE",
1766
+ source_expr=target_expr,
1767
+ is_conditional=(self.conditional_depth > 0),
1768
+ base_expr="subscribe",
1769
+ subscript_target="subscribe",
1770
+ subscript_key=event_str,
1771
+ )
1772
+ )
1773
+ if callee_name not in _PYTHON_BUILTINS:
1774
+ self.calls.append(
1775
+ CallRef(
1776
+ callee=callee_name,
1777
+ qualified_callee=callee_name,
1778
+ line=node.lineno,
1779
+ end_line=end_ln,
1780
+ source_file=self.file_path,
1781
+ confidence="LOW",
1782
+ caller_symbol=caller_qname,
1783
+ caller_canonical_id=caller_canon,
1784
+ receiver=None,
1785
+ )
1786
+ )
1787
+ elif isinstance(node.func, ast.Attribute):
1788
+ attr_name = node.func.attr
1789
+ if isinstance(node.func.value, ast.Call) and hasattr(ast, "unparse"):
1790
+ recv_clean = ast.unparse(node.func.value.func).strip()
1791
+ else:
1792
+ raw_recv = ast.unparse(node.func.value) if hasattr(ast, "unparse") else ""
1793
+ recv_clean = raw_recv[:-2].strip() if raw_recv.endswith("()") else raw_recv.strip()
1794
+
1795
+ enclosing_cls = self.current_enclosing_class
1796
+ if recv_clean in ("self", "cls") and enclosing_cls:
1797
+ resolved_recv = enclosing_cls
1798
+ elif recv_clean.startswith("self.") and self._lookup_local_binding(recv_clean):
1799
+ resolved_recv = self._lookup_local_binding(recv_clean) or recv_clean
1800
+ else:
1801
+ bound = self._lookup_local_binding(recv_clean)
1802
+ resolved_recv = bound if bound else recv_clean
1803
+
1804
+ # app.include_router(router, dependencies=[Depends(...)])
1805
+ if attr_name == "include_router" and node.args and hasattr(ast, "unparse"):
1806
+ router_arg = node.args[0]
1807
+ if isinstance(router_arg, (ast.Name, ast.Attribute)):
1808
+ r_name = ast.unparse(router_arg)
1809
+ for kw in node.keywords:
1810
+ if kw.arg == "dependencies" and isinstance(kw.value, (ast.List, ast.Tuple)):
1811
+ for elt in kw.value.elts:
1812
+ if isinstance(elt, ast.Call):
1813
+ f_nm = getattr(elt.func, "id", None) or (
1814
+ getattr(elt.func, "attr", None) if isinstance(elt.func, ast.Attribute) else ""
1815
+ )
1816
+ if f_nm == "Depends":
1817
+ d_arg: ast.expr | None = elt.args[0] if elt.args else None
1818
+ if d_arg is None:
1819
+ d_kw = next((k for k in elt.keywords if k.arg == "dependency"), None)
1820
+ if d_kw is not None:
1821
+ d_arg = d_kw.value
1822
+ if d_arg is not None:
1823
+ if isinstance(d_arg, (ast.Name, ast.Attribute)):
1824
+ dep_str = ast.unparse(d_arg)
1825
+ dyn_sub = None
1826
+ else:
1827
+ dep_str = "<DYNAMIC>"
1828
+ dyn_sub = "dynamic_provider"
1829
+ self.bindings.append(
1830
+ BindingRef(
1831
+ target_name=r_name,
1832
+ file_path=self.file_path,
1833
+ line=node.lineno,
1834
+ column=getattr(node, "col_offset", None),
1835
+ scope=self.current_scope_qname,
1836
+ expr_kind="ROUTER_DI_INJECTS",
1837
+ source_expr=dep_str,
1838
+ is_conditional=(self.conditional_depth > 0),
1839
+ base_expr=recv_clean,
1840
+ attr_name="Depends",
1841
+ subscript_target=dyn_sub,
1842
+ )
1843
+ )
1844
+
1845
+ # Known semantic registry pattern (.register())
1846
+ if attr_name == "register" and hasattr(ast, "unparse"):
1847
+ recv_lower = recv_clean.lower()
1848
+ is_known_registry = (
1849
+ recv_lower == "registry"
1850
+ or recv_lower.endswith("_registry")
1851
+ or recv_lower.endswith("registry")
1852
+ or recv_lower in (
1853
+ "command_registry",
1854
+ "handler_registry",
1855
+ "plugin_registry",
1856
+ "task_registry",
1857
+ "route_registry",
1858
+ )
1859
+ )
1860
+ if is_known_registry and len(node.args) >= 2:
1861
+ key_node = node.args[0]
1862
+ target_node = node.args[1]
1863
+ sub_key = (
1864
+ key_node.value
1865
+ if isinstance(key_node, ast.Constant) and isinstance(key_node.value, str)
1866
+ else "<DYNAMIC>"
1867
+ )
1868
+ target_expr = (
1869
+ ast.unparse(target_node)
1870
+ if isinstance(target_node, (ast.Name, ast.Attribute))
1871
+ else "<DYNAMIC>"
1872
+ )
1873
+ self.bindings.append(
1874
+ BindingRef(
1875
+ target_name=recv_clean,
1876
+ file_path=self.file_path,
1877
+ line=node.lineno,
1878
+ column=getattr(node, "col_offset", None),
1879
+ scope=self.current_scope_qname,
1880
+ expr_kind="CALL_REGISTER",
1881
+ source_expr=target_expr,
1882
+ is_conditional=(self.conditional_depth > 0),
1883
+ base_expr=recv_clean,
1884
+ subscript_target=recv_clean,
1885
+ subscript_key=sub_key,
1886
+ )
1887
+ )
1888
+ # Known event emitter pattern (.on())
1889
+ elif attr_name == "on" and hasattr(ast, "unparse"):
1890
+ recv_lower = recv_clean.lower()
1891
+ is_known_emitter = (
1892
+ recv_lower in ("event_bus", "bus", "emitter", "event_emitter", "events", "dispatcher")
1893
+ or recv_lower.endswith("_bus")
1894
+ or recv_lower.endswith("_emitter")
1895
+ or recv_lower.endswith("_dispatcher")
1896
+ or recv_lower.endswith("eventemitter")
1897
+ )
1898
+ if is_known_emitter and len(node.args) >= 2:
1899
+ event_node = node.args[0]
1900
+ target_node = node.args[1]
1901
+ event_str = (
1902
+ event_node.value
1903
+ if isinstance(event_node, ast.Constant) and isinstance(event_node.value, str)
1904
+ else "<DYNAMIC>"
1905
+ )
1906
+ target_expr = (
1907
+ ast.unparse(target_node)
1908
+ if isinstance(target_node, (ast.Name, ast.Attribute))
1909
+ else "<DYNAMIC>"
1910
+ )
1911
+ self.bindings.append(
1912
+ BindingRef(
1913
+ target_name=recv_clean,
1914
+ file_path=self.file_path,
1915
+ line=node.lineno,
1916
+ column=getattr(node, "col_offset", None),
1917
+ scope=self.current_scope_qname,
1918
+ expr_kind="EVENT_ON",
1919
+ source_expr=target_expr,
1920
+ is_conditional=(self.conditional_depth > 0),
1921
+ base_expr=recv_clean,
1922
+ subscript_target=recv_clean,
1923
+ subscript_key=event_str,
1924
+ )
1925
+ )
1926
+ # Known subscribe pattern (.subscribe())
1927
+ elif attr_name == "subscribe" and hasattr(ast, "unparse"):
1928
+ recv_lower = recv_clean.lower()
1929
+ is_known_sub = (
1930
+ recv_lower in ("event_bus", "bus", "emitter", "event_emitter", "events", "dispatcher", "pubsub")
1931
+ or recv_lower.endswith("_bus")
1932
+ or recv_lower.endswith("_emitter")
1933
+ )
1934
+ if is_known_sub and len(node.args) >= 2:
1935
+ event_node = node.args[0]
1936
+ target_node = node.args[1]
1937
+ event_str = (
1938
+ event_node.value
1939
+ if isinstance(event_node, ast.Constant) and isinstance(event_node.value, str)
1940
+ else "<DYNAMIC>"
1941
+ )
1942
+ target_expr = (
1943
+ ast.unparse(target_node)
1944
+ if isinstance(target_node, (ast.Name, ast.Attribute))
1945
+ else "<DYNAMIC>"
1946
+ )
1947
+ self.bindings.append(
1948
+ BindingRef(
1949
+ target_name=recv_clean,
1950
+ file_path=self.file_path,
1951
+ line=node.lineno,
1952
+ column=getattr(node, "col_offset", None),
1953
+ scope=self.current_scope_qname,
1954
+ expr_kind="SUBSCRIBE",
1955
+ source_expr=target_expr,
1956
+ is_conditional=(self.conditional_depth > 0),
1957
+ base_expr=recv_clean,
1958
+ subscript_target=recv_clean,
1959
+ subscript_key=event_str,
1960
+ )
1961
+ )
1962
+
1963
+ # Known DI container registration (.register(...), .bind(...), .provide(...), .singleton(...), .factory(...))
1964
+ recv_lower = recv_clean.lower()
1965
+ is_container = (
1966
+ recv_lower in ("container", "injector", "di", "ioc", "services", "providers", "provider_registry", "service_registry", "di_registry")
1967
+ or recv_lower.endswith(("_container", "_injector", "container", "injector"))
1968
+ )
1969
+ if is_container and attr_name in ("register", "bind", "provide", "singleton", "factory") and hasattr(ast, "unparse"):
1970
+ if len(node.args) >= 2:
1971
+ iface_node = node.args[0]
1972
+ impl_node = node.args[1]
1973
+ iface_str = ast.unparse(iface_node) if isinstance(iface_node, (ast.Name, ast.Attribute)) else "<DYNAMIC>"
1974
+ impl_str = ast.unparse(impl_node) if isinstance(impl_node, (ast.Name, ast.Attribute)) else "<DYNAMIC>"
1975
+ self.bindings.append(
1976
+ BindingRef(
1977
+ target_name=iface_str,
1978
+ file_path=self.file_path,
1979
+ line=node.lineno,
1980
+ column=getattr(node, "col_offset", None),
1981
+ scope=self.current_scope_qname,
1982
+ expr_kind="DI_PROVIDES",
1983
+ source_expr=impl_str,
1984
+ is_conditional=(self.conditional_depth > 0),
1985
+ base_expr=recv_clean,
1986
+ attr_name=attr_name,
1987
+ subscript_target="dynamic_provider" if (iface_str == "<DYNAMIC>" or impl_str == "<DYNAMIC>") else None,
1988
+ )
1989
+ )
1990
+ elif len(node.args) == 1:
1991
+ to_kw = next((kw for kw in node.keywords if kw.arg in ("to", "implementation", "provider", "cls", "factory")), None)
1992
+ if to_kw:
1993
+ iface_node = node.args[0]
1994
+ impl_node = to_kw.value
1995
+ iface_str = ast.unparse(iface_node) if isinstance(iface_node, (ast.Name, ast.Attribute)) else "<DYNAMIC>"
1996
+ impl_str = ast.unparse(impl_node) if isinstance(impl_node, (ast.Name, ast.Attribute)) else "<DYNAMIC>"
1997
+ self.bindings.append(
1998
+ BindingRef(
1999
+ target_name=iface_str,
2000
+ file_path=self.file_path,
2001
+ line=node.lineno,
2002
+ column=getattr(node, "col_offset", None),
2003
+ scope=self.current_scope_qname,
2004
+ expr_kind="DI_PROVIDES",
2005
+ source_expr=impl_str,
2006
+ is_conditional=(self.conditional_depth > 0),
2007
+ base_expr=recv_clean,
2008
+ attr_name=attr_name,
2009
+ subscript_target="dynamic_provider" if (iface_str == "<DYNAMIC>" or impl_str == "<DYNAMIC>") else None,
2010
+ )
2011
+ )
2012
+ elif attr_name in ("provide", "singleton", "factory"):
2013
+ impl_node = node.args[0]
2014
+ impl_str = ast.unparse(impl_node) if isinstance(impl_node, (ast.Name, ast.Attribute)) else "<DYNAMIC>"
2015
+ self.bindings.append(
2016
+ BindingRef(
2017
+ target_name=impl_str,
2018
+ file_path=self.file_path,
2019
+ line=node.lineno,
2020
+ column=getattr(node, "col_offset", None),
2021
+ scope=self.current_scope_qname,
2022
+ expr_kind="DI_PROVIDES",
2023
+ source_expr=impl_str,
2024
+ is_conditional=(self.conditional_depth > 0),
2025
+ base_expr=recv_clean,
2026
+ attr_name=attr_name,
2027
+ subscript_target="dynamic_provider" if impl_str == "<DYNAMIC>" else None,
2028
+ )
2029
+ )
2030
+
2031
+ # Fluent binding: container.bind(UserRepository).to(PostgresRepository) / .to_class(...) / .to_provider(...)
2032
+ if attr_name in ("to", "to_class", "to_provider", "to_singleton", "to_factory") and isinstance(node.func.value, ast.Call) and hasattr(ast, "unparse"):
2033
+ inner_c = node.func.value
2034
+ if isinstance(inner_c.func, ast.Attribute) and inner_c.func.attr == "bind" and inner_c.args and node.args:
2035
+ inner_recv = ast.unparse(inner_c.func.value).lower()
2036
+ if "container" in inner_recv or "injector" in inner_recv or "binder" in inner_recv:
2037
+ iface_str = ast.unparse(inner_c.args[0]) if isinstance(inner_c.args[0], (ast.Name, ast.Attribute)) else "<DYNAMIC>"
2038
+ impl_str = ast.unparse(node.args[0]) if isinstance(node.args[0], (ast.Name, ast.Attribute)) else "<DYNAMIC>"
2039
+ self.bindings.append(
2040
+ BindingRef(
2041
+ target_name=iface_str,
2042
+ file_path=self.file_path,
2043
+ line=node.lineno,
2044
+ column=getattr(node, "col_offset", None),
2045
+ scope=self.current_scope_qname,
2046
+ expr_kind="DI_PROVIDES",
2047
+ source_expr=impl_str,
2048
+ is_conditional=(self.conditional_depth > 0),
2049
+ base_expr=ast.unparse(inner_c.func.value),
2050
+ attr_name=f"bind.{attr_name}",
2051
+ subscript_target="dynamic_provider" if (iface_str == "<DYNAMIC>" or impl_str == "<DYNAMIC>") else None,
2052
+ )
2053
+ )
2054
+
2055
+ # Known DI container resolution (.resolve(...) or .get(...))
2056
+ if is_container and attr_name in ("resolve", "get") and node.args and hasattr(ast, "unparse"):
2057
+ iface_node = node.args[0]
2058
+ iface_str = ast.unparse(iface_node) if isinstance(iface_node, (ast.Name, ast.Attribute)) else "<DYNAMIC>"
2059
+ self.bindings.append(
2060
+ BindingRef(
2061
+ target_name=iface_str,
2062
+ file_path=self.file_path,
2063
+ line=node.lineno,
2064
+ column=getattr(node, "col_offset", None),
2065
+ scope=self.current_scope_qname,
2066
+ expr_kind="DI_RESOLVES",
2067
+ source_expr=iface_str,
2068
+ is_conditional=(self.conditional_depth > 0),
2069
+ base_expr=recv_clean,
2070
+ attr_name=attr_name,
2071
+ subscript_target="dynamic_provider" if iface_str == "<DYNAMIC>" else None,
2072
+ )
2073
+ )
2074
+
2075
+ # Environment variable reference (os.getenv(...) or os.environ.get(...))
2076
+ if (recv_clean == "os" and attr_name == "getenv") or (recv_clean == "os.environ" and attr_name in ("get", "getenv")):
2077
+ var_name = "<DYNAMIC>"
2078
+ is_dyn = True
2079
+ if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
2080
+ var_name = node.args[0].value
2081
+ is_dyn = False
2082
+ self.bindings.append(
2083
+ BindingRef(
2084
+ target_name=var_name,
2085
+ file_path=self.file_path,
2086
+ line=node.lineno,
2087
+ column=getattr(node, "col_offset", None),
2088
+ scope=self.current_scope_canonical_id or self.current_scope_qname,
2089
+ expr_kind="ENV_CONFIG",
2090
+ source_expr=var_name,
2091
+ is_conditional=(self.conditional_depth > 0),
2092
+ base_expr=recv_clean,
2093
+ attr_name=attr_name,
2094
+ subscript_target="runtime_configuration" if is_dyn else None,
2095
+ subscript_key=json.dumps({"env_var": var_name}),
2096
+ )
2097
+ )
2098
+
2099
+ # Known test client calls (client.get('/path'), test_client.post(...), self.client.get(...))
2100
+ is_client = (
2101
+ recv_clean.lower() in ("client", "test_client", "testclient", "api_client", "self.client", "self.test_client", "app.test_client()")
2102
+ or recv_clean.lower().endswith(("_client", ".client", "client"))
2103
+ )
2104
+ if is_client and attr_name.lower() in ("get", "post", "put", "delete", "patch", "head", "options", "request"):
2105
+ route_str = "<DYNAMIC>"
2106
+ if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
2107
+ route_str = node.args[0].value
2108
+ self.bindings.append(
2109
+ BindingRef(
2110
+ target_name=route_str,
2111
+ file_path=self.file_path,
2112
+ line=node.lineno,
2113
+ column=getattr(node, "col_offset", None),
2114
+ scope=self.current_scope_qname,
2115
+ expr_kind="TEST_ROUTE",
2116
+ source_expr=attr_name.upper(),
2117
+ is_conditional=(self.conditional_depth > 0),
2118
+ base_expr=recv_clean,
2119
+ attr_name=attr_name.upper(),
2120
+ subscript_target=recv_clean,
2121
+ subscript_key=route_str,
2122
+ )
2123
+ )
2124
+
2125
+ # Known browser test calls (page.goto('/route'))
2126
+ is_page = (
2127
+ recv_clean.lower() in ("page", "self.page", "browser_page")
2128
+ or recv_clean.lower().endswith(("_page", ".page", "page"))
2129
+ )
2130
+ if is_page and attr_name == "goto":
2131
+ url_str = "<DYNAMIC>"
2132
+ if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
2133
+ url_str = node.args[0].value
2134
+ self.bindings.append(
2135
+ BindingRef(
2136
+ target_name=url_str,
2137
+ file_path=self.file_path,
2138
+ line=node.lineno,
2139
+ column=getattr(node, "col_offset", None),
2140
+ scope=self.current_scope_qname,
2141
+ expr_kind="TEST_PAGE_GOTO",
2142
+ source_expr="GOTO",
2143
+ is_conditional=(self.conditional_depth > 0),
2144
+ base_expr=recv_clean,
2145
+ attr_name="goto",
2146
+ subscript_target=recv_clean,
2147
+ subscript_key=url_str,
2148
+ )
2149
+ )
2150
+
2151
+ # Known event emission calls (bus.emit('event', ...), dispatcher.dispatch(...))
2152
+ if attr_name in ("emit", "dispatch", "publish", "trigger", "send"):
2153
+ recv_lower = recv_clean.lower()
2154
+ is_emitter = (
2155
+ recv_lower in ("event_bus", "bus", "emitter", "event_emitter", "events", "dispatcher")
2156
+ or recv_lower.endswith(("_bus", "_emitter", "_dispatcher"))
2157
+ )
2158
+ if is_emitter and node.args:
2159
+ ev_str = "<DYNAMIC>"
2160
+ if isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
2161
+ ev_str = node.args[0].value
2162
+ self.bindings.append(
2163
+ BindingRef(
2164
+ target_name=recv_clean,
2165
+ file_path=self.file_path,
2166
+ line=node.lineno,
2167
+ column=getattr(node, "col_offset", None),
2168
+ scope=self.current_scope_qname,
2169
+ expr_kind="EVENT_DISPATCH",
2170
+ source_expr=ev_str,
2171
+ is_conditional=(self.conditional_depth > 0),
2172
+ base_expr=recv_clean,
2173
+ attr_name=attr_name,
2174
+ subscript_target=recv_clean,
2175
+ subscript_key=ev_str,
2176
+ )
2177
+ )
2178
+ # Celery / Background task / Ray remote task invocation (.delay(), .apply_async(), .s(), .si(), .signature(), .remote())
2179
+ if attr_name in ("delay", "apply_async", "s", "si", "signature", "remote"):
2180
+ task_callee = recv_clean.split(".")[-1]
2181
+ if task_callee and task_callee not in ("self", "cls"):
2182
+ self.calls.append(
2183
+ CallRef(
2184
+ callee=task_callee,
2185
+ qualified_callee=recv_clean,
2186
+ line=node.lineno,
2187
+ end_line=end_ln,
2188
+ source_file=self.file_path,
2189
+ confidence="HIGH",
2190
+ caller_symbol=caller_qname,
2191
+ caller_canonical_id=caller_canon,
2192
+ receiver=recv_clean if "." in recv_clean else None,
2193
+ )
2194
+ )
2195
+ dispatch_kind = "RAY_REMOTE" if attr_name == "remote" else "CELERY_DISPATCH"
2196
+ self.bindings.append(
2197
+ BindingRef(
2198
+ target_name=task_callee,
2199
+ file_path=self.file_path,
2200
+ line=node.lineno,
2201
+ column=getattr(node, "col_offset", None),
2202
+ scope=caller_qname or "",
2203
+ expr_kind=dispatch_kind,
2204
+ base_expr=attr_name,
2205
+ source_expr=recv_clean,
2206
+ )
2207
+ )
2208
+ elif attr_name == "send_task" and node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
2209
+ full_task = node.args[0].value
2210
+ task_short = full_task.split(".")[-1]
2211
+ self.calls.append(
2212
+ CallRef(
2213
+ callee=task_short,
2214
+ qualified_callee=full_task,
2215
+ line=node.lineno,
2216
+ end_line=end_ln,
2217
+ source_file=self.file_path,
2218
+ confidence="HIGH",
2219
+ caller_symbol=caller_qname,
2220
+ caller_canonical_id=caller_canon,
2221
+ receiver=full_task if "." in full_task else None,
2222
+ )
2223
+ )
2224
+ self.bindings.append(
2225
+ BindingRef(
2226
+ target_name=task_short,
2227
+ file_path=self.file_path,
2228
+ line=node.lineno,
2229
+ column=getattr(node, "col_offset", None),
2230
+ scope=caller_qname or "",
2231
+ expr_kind="CELERY_DISPATCH",
2232
+ base_expr="send_task",
2233
+ source_expr=full_task,
2234
+ )
2235
+ )
2236
+ elif attr_name == "connect" and node.args and hasattr(ast, "unparse"):
2237
+ receiver_node = node.args[0]
2238
+ receiver_name = ast.unparse(receiver_node).strip()
2239
+ sender_name = ""
2240
+ for kw in node.keywords:
2241
+ if kw.arg == "sender":
2242
+ if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
2243
+ sender_name = kw.value.value
2244
+ elif isinstance(kw.value, (ast.Name, ast.Attribute)):
2245
+ sender_name = ast.unparse(kw.value).strip()
2246
+ self.bindings.append(
2247
+ BindingRef(
2248
+ target_name=receiver_name,
2249
+ file_path=self.file_path,
2250
+ line=node.lineno,
2251
+ column=getattr(node, "col_offset", None),
2252
+ scope=self.current_scope_qname or "",
2253
+ expr_kind="DJANGO_SIGNAL_RECEIVER",
2254
+ base_expr=recv_clean,
2255
+ source_expr=sender_name,
2256
+ )
2257
+ )
2258
+ elif attr_name in ("send", "send_robust") and hasattr(ast, "unparse"):
2259
+ sender_name = ""
2260
+ for kw in node.keywords:
2261
+ if kw.arg == "sender":
2262
+ if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
2263
+ sender_name = kw.value.value
2264
+ elif isinstance(kw.value, (ast.Name, ast.Attribute)):
2265
+ sender_name = ast.unparse(kw.value).strip()
2266
+ self.bindings.append(
2267
+ BindingRef(
2268
+ target_name=recv_clean,
2269
+ file_path=self.file_path,
2270
+ line=node.lineno,
2271
+ column=getattr(node, "col_offset", None),
2272
+ scope=self.current_scope_qname or "",
2273
+ expr_kind="DJANGO_SIGNAL_SEND",
2274
+ base_expr=attr_name,
2275
+ source_expr=sender_name,
2276
+ )
2277
+ )
2278
+
2279
+ q_callee = f"{resolved_recv}.{attr_name}" if resolved_recv else attr_name
2280
+ self.calls.append(
2281
+ CallRef(
2282
+ callee=attr_name,
2283
+ qualified_callee=q_callee,
2284
+ line=node.lineno,
2285
+ end_line=end_ln,
2286
+ source_file=self.file_path,
2287
+ confidence="LOW",
2288
+ caller_symbol=caller_qname,
2289
+ caller_canonical_id=caller_canon,
2290
+ receiver=resolved_recv or None,
2291
+ )
2292
+ )
2293
+ elif isinstance(node.func, ast.Subscript) and hasattr(ast, "unparse"):
2294
+ sub_expr = ast.unparse(node.func)
2295
+ sub_recv = ast.unparse(node.func.value)
2296
+ self.calls.append(
2297
+ CallRef(
2298
+ callee=sub_expr,
2299
+ qualified_callee=sub_expr,
2300
+ line=node.lineno,
2301
+ end_line=end_ln,
2302
+ source_file=self.file_path,
2303
+ confidence="LOW",
2304
+ caller_symbol=caller_qname,
2305
+ caller_canonical_id=caller_canon,
2306
+ receiver=sub_recv,
2307
+ )
2308
+ )
2309
+
2310
+ # Agent tool registration (e.g. AgentExecutor(tools=[...]), create_openai_tools_agent(..., tools=[...]))
2311
+ for kw in node.keywords:
2312
+ if kw.arg in ("tools", "functions") and isinstance(kw.value, (ast.List, ast.Tuple)):
2313
+ func_label = ast.unparse(node.func).strip() if hasattr(ast, "unparse") else ""
2314
+ for elt in kw.value.elts:
2315
+ t_name = ast.unparse(elt).strip() if hasattr(ast, "unparse") else ""
2316
+ if t_name:
2317
+ self.bindings.append(
2318
+ BindingRef(
2319
+ target_name=t_name,
2320
+ file_path=self.file_path,
2321
+ line=node.lineno,
2322
+ column=getattr(node, "col_offset", None),
2323
+ scope=caller_qname or "",
2324
+ expr_kind="AGENT_TOOL_REGISTRATION",
2325
+ base_expr=func_label,
2326
+ source_expr=t_name,
2327
+ )
2328
+ )
2329
+
2330
+ self.generic_visit(node)
2331
+
2332
+
2333
+ def _extract_all_python_imports(
2334
+ tree: ast.AST, file_path: str, module: str, is_init: bool
2335
+ ) -> list[ImportRef]:
2336
+ imports: list[ImportRef] = []
2337
+ for node in getattr(tree, "body", []):
2338
+ if isinstance(node, ast.Import):
2339
+ for alias in node.names:
2340
+ imports.append(
2341
+ ImportRef(
2342
+ module=alias.name,
2343
+ imported_module=alias.name,
2344
+ imported_name=None,
2345
+ alias=alias.asname,
2346
+ local_name=alias.asname or alias.name.split(".")[0],
2347
+ import_type="namespace" if alias.asname else "module",
2348
+ line=node.lineno,
2349
+ source_file=file_path,
2350
+ source_module=module,
2351
+ )
2352
+ )
2353
+ elif isinstance(node, ast.ImportFrom):
2354
+ raw_mod = node.module or ""
2355
+ resolved_mod = (
2356
+ _resolve_relative_py_module(file_path, node.level, node.module)
2357
+ if node.level > 0
2358
+ else raw_mod
2359
+ )
2360
+ display_mod = raw_mod or resolved_mod
2361
+ for alias in node.names:
2362
+ full = f"{resolved_mod}.{alias.name}" if resolved_mod else alias.name
2363
+ is_reexp = is_init
2364
+ imports.append(
2365
+ ImportRef(
2366
+ module=display_mod,
2367
+ imported_module=resolved_mod or display_mod,
2368
+ name=alias.name,
2369
+ imported_name=alias.name,
2370
+ alias=alias.asname,
2371
+ local_name=alias.asname or alias.name,
2372
+ import_type="reexport" if is_reexp else ("alias" if alias.asname else "named"),
2373
+ is_reexport=is_reexp,
2374
+ exported_name=alias.asname or alias.name if is_reexp else None,
2375
+ line=node.lineno,
2376
+ source_file=file_path,
2377
+ source_module=module,
2378
+ full=full,
2379
+ )
2380
+ )
2381
+ return imports
2382
+
2383
+
2384
+ def _repair_python_syntax(content: str) -> str:
2385
+ """Repair common syntax patterns from newer or experimental Python versions (e.g. unparenthesized multiple except)."""
2386
+ lines = content.splitlines()
2387
+ repaired = False
2388
+ new_lines = []
2389
+ for line in lines:
2390
+ # Match 'except A, B:' or 'except A, B as e:' or 'except* A, B:' without enclosing parentheses
2391
+ m = re.match(
2392
+ r'^(\s*except\*?\s+)([A-Za-z0-9_.\s]+,\s*[A-Za-z0-9_.,\s]+?)(\s+as\s+[A-Za-z0-9_]+)?(\s*:.*)$',
2393
+ line,
2394
+ )
2395
+ if m:
2396
+ prefix, types, as_part, suffix = m.group(1), m.group(2).strip(), m.group(3) or "", m.group(4)
2397
+ if not types.startswith("("):
2398
+ line = f"{prefix}({types}){as_part}{suffix}"
2399
+ repaired = True
2400
+ new_lines.append(line)
2401
+ return "\n".join(new_lines) if repaired else content
2402
+
2403
+
2404
+ def _parse_python(content: str, file_path: str) -> ParseResult:
2405
+ source_hash = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
2406
+ tree: ast.Module | None = None
2407
+ try:
2408
+ tree = ast.parse(content)
2409
+ except SyntaxError as exc:
2410
+ repaired = _repair_python_syntax(content)
2411
+ if repaired != content:
2412
+ try:
2413
+ tree = ast.parse(repaired)
2414
+ except SyntaxError:
2415
+ tree = None
2416
+ if tree is None:
2417
+ return ParseResult(
2418
+ path=file_path,
2419
+ language="python",
2420
+ source_hash=source_hash,
2421
+ imports=tuple(_py_imports_fallback(content, file_path)),
2422
+ parse_failed=True,
2423
+ parse_error=f"SyntaxError at line {exc.lineno}: {exc.msg}",
2424
+ )
2425
+
2426
+ # First collect import module names cheaply from top-level imports to configure analyzers
2427
+ import_mods: set[str] = set()
2428
+ for node in tree.body:
2429
+ if isinstance(node, ast.Import):
2430
+ for alias in node.names:
2431
+ import_mods.add(alias.name)
2432
+ elif isinstance(node, ast.ImportFrom) and node.module:
2433
+ import_mods.add(node.module)
2434
+
2435
+ analyzers = get_python_analyzers(import_mods)
2436
+ lines = content.splitlines()
2437
+ fn_returns, known_classes = _extract_python_file_declarations(tree)
2438
+ module = normalize_module(file_path, "python")
2439
+ is_init = file_path.replace("\\", "/").endswith("__init__.py")
2440
+ all_imports = _extract_all_python_imports(tree, file_path, module, is_init)
2441
+
2442
+ # Single-pass scoped traversal
2443
+ visitor = _PythonScopeVisitor(
2444
+ content,
2445
+ lines,
2446
+ file_path,
2447
+ analyzers,
2448
+ fn_return_types=fn_returns,
2449
+ known_classes=known_classes,
2450
+ all_file_imports=all_imports,
2451
+ )
2452
+ visitor.visit(tree)
2453
+
2454
+ # Mark __all__ re-exports
2455
+ if visitor.exports:
2456
+ exported_set = set(visitor.exports)
2457
+ updated_imports: list[ImportRef] = []
2458
+ for imp in visitor.imports:
2459
+ if imp.local_name in exported_set and not imp.is_reexport:
2460
+ updated_imports.append(
2461
+ ImportRef(
2462
+ module=imp.module,
2463
+ imported_module=imp.imported_module,
2464
+ name=imp.name,
2465
+ imported_name=imp.imported_name,
2466
+ alias=imp.alias,
2467
+ local_name=imp.local_name,
2468
+ import_type="reexport",
2469
+ is_reexport=True,
2470
+ exported_name=imp.local_name,
2471
+ line=imp.line,
2472
+ source_file=imp.source_file,
2473
+ source_module=imp.source_module,
2474
+ full=imp.full,
2475
+ )
2476
+ )
2477
+ else:
2478
+ updated_imports.append(imp)
2479
+ visitor.imports = updated_imports
2480
+
2481
+ visitor.symbols.sort(key=lambda s: (s.start_line, s.canonical_id))
2482
+ return ParseResult(
2483
+ path=file_path,
2484
+ language="python",
2485
+ source_hash=source_hash,
2486
+ symbols=tuple(visitor.symbols),
2487
+ imports=tuple(visitor.imports),
2488
+ calls=tuple(visitor.calls),
2489
+ inheritance=tuple(visitor.inheritance),
2490
+ routes=tuple(visitor.routes),
2491
+ mounts=tuple(visitor.mounts),
2492
+ router_definitions=tuple(visitor.router_definitions),
2493
+ bindings=tuple(visitor.bindings),
2494
+ exports=tuple(visitor.exports),
2495
+ parse_failed=False,
2496
+ )
2497
+
2498
+
2499
+ def _py_imports_fallback(content: str, file_path: str) -> list[ImportRef]:
2500
+ imports: list[ImportRef] = []
2501
+ for m in _PY_IMPORT.finditer(content):
2502
+ module = (m.group(1) or "").strip() or (m.group(2) or "").strip().split(",")[0].strip()
2503
+ if module:
2504
+ imports.append(ImportRef(module=module, source_file=file_path))
2505
+ return imports
2506
+
2507
+
2508
+ # ---------------------------------------------------------------------------
2509
+ # JavaScript / TypeScript single-pass scoped analyzer
2510
+ # ---------------------------------------------------------------------------
2511
+
2512
+ _JS_CLASS_RE = re.compile(
2513
+ r"\b(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)"
2514
+ r"(?:\s+extends\s+([A-Za-z_$][\w$.]*))?"
2515
+ r"(?:\s+implements\s+([A-Za-z_$][\w$.,\s]*))?\s*\{"
2516
+ )
2517
+
2518
+ _TS_INTERFACE_RE = re.compile(
2519
+ r"\b(?:export\s+(?:default\s+)?)?interface\s+([A-Za-z_$][\w$]*)"
2520
+ r"(?:\s+extends\s+([A-Za-z_$][\w$.,\s]*))?"
2521
+ )
2522
+
2523
+ _TS_TYPE_RE = re.compile(
2524
+ r"\b(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*="
2525
+ )
2526
+
2527
+ _JS_FUNC_RE = re.compile(
2528
+ r"(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(([^)]*)\)"
2529
+ r"|(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::\s*[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>"
2530
+ )
2531
+
2532
+ _JS_METHOD_RE = re.compile(
2533
+ r"^\s{2,}(?:public\s+|private\s+|protected\s+)?(?:async\s+)?(?:static\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*(?::\s*[^{]+)?\{"
2534
+ )
2535
+
2536
+ _JS_IMPORT_NAMED = re.compile(
2537
+ r"""import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]"""
2538
+ )
2539
+ _JS_IMPORT_NAMESPACE = re.compile(
2540
+ r"""import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+)['"]"""
2541
+ )
2542
+ _JS_IMPORT_DEFAULT = re.compile(
2543
+ r"""import\s+([A-Za-z_$][\w$]*)\s*(?:,\s*\{[^}]*\})?\s+from\s+['"]([^'"]+)['"]"""
2544
+ )
2545
+ _JS_IMPORT_SIDE_EFFECT = re.compile(
2546
+ r"""import\s+['"]([^'"]+)['"]"""
2547
+ )
2548
+ _JS_REQUIRE_ASSIGN = re.compile(
2549
+ r"""(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)"""
2550
+ )
2551
+ _JS_REQUIRE_DESTRUCTURE = re.compile(
2552
+ r"""(?:const|let|var)\s+\{([^}]+)\}\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)"""
2553
+ )
2554
+ _JS_REEXPORT_NAMED = re.compile(
2555
+ r"""export\s+(?:type\s+)?\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]"""
2556
+ )
2557
+ _JS_REEXPORT_ALL = re.compile(
2558
+ r"""export\s+\*\s*(?:as\s+([A-Za-z_$][\w$]*)\s+)?from\s+['"]([^'"]+)['"]"""
2559
+ )
2560
+ _JS_NEW_ASSIGN = re.compile(
2561
+ r"""(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+([A-Za-z_$][\w$.]*)\s*\("""
2562
+ )
2563
+ _JS_CALL_SITE = re.compile(
2564
+ r"""(?<!function\s)(?<!new\s)\b(?:([A-Za-z_$][\w$]*)\.)?([A-Za-z_$][\w$]*)\s*\("""
2565
+ )
2566
+ _JS_CONST_ID = re.compile(
2567
+ r"^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\s*;?\s*$"
2568
+ )
2569
+ _JS_CONST_ATTR = re.compile(
2570
+ r"^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\s*;?\s*$"
2571
+ )
2572
+ _JS_CONST_DICT = re.compile(
2573
+ r"^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*\{\s*([^}]+)\s*\}\s*;?\s*$"
2574
+ )
2575
+ _JS_SUBSCRIPT_ASSIGN = re.compile(
2576
+ r"^\s*([A-Za-z_$][\w$]*)\[['\"]([^'\"]+)['\"]\]\s*=\s*([A-Za-z_$][\w$.]*)\s*;?\s*$"
2577
+ )
2578
+ _JS_REGISTRY_CALL = re.compile(
2579
+ r"^\s*([A-Za-z_$][\w$]*)\.register\(\s*['\"]([^'\"]+)['\"]\s*,\s*([A-Za-z_$][\w$.]+)\s*\)\s*;?\s*$"
2580
+ )
2581
+ _JS_EVENT_ON = re.compile(
2582
+ r"^\s*([A-Za-z_$][\w$]*)\.on\(\s*['\"]([^'\"]+)['\"]\s*,\s*([A-Za-z_$][\w$.]+)\s*\)\s*;?\s*$"
2583
+ )
2584
+ _JS_SUBSCRIPT_CALL = re.compile(
2585
+ r"^\s*([A-Za-z_$][\w$]*)\[['\"]([^'\"]+)['\"]\]\s*\("
2586
+ )
2587
+ _JS_DI_PROVIDE = re.compile(
2588
+ r"""(?:\{\s*provide:\s*([A-Za-z_$][\w$]*)\s*,\s*(?:useClass|useExisting|useValue|useFactory):\s*([A-Za-z_$][\w$.]*)\s*\})"""
2589
+ )
2590
+ _JS_CONTAINER_REG = re.compile(
2591
+ r"""^\s*([A-Za-z_$][\w$]*)\.register\(\s*([A-Za-z_$][\w$.]+)\s*,\s*([A-Za-z_$][\w$.]+)\s*\)\s*;?\s*$"""
2592
+ )
2593
+
2594
+
2595
+
2596
+ def _parse_js_ts(content: str, language: str, file_path: str) -> ParseResult:
2597
+ source_hash = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
2598
+ module = normalize_module(file_path, language)
2599
+ lines = content.splitlines()
2600
+
2601
+ symbols: list[Symbol] = []
2602
+ imports: list[ImportRef] = []
2603
+ calls: list[CallRef] = []
2604
+ inheritance: list[InheritanceRef] = []
2605
+ routes: list[RouteDetection] = []
2606
+ mounts: list[RouterMountDetection] = []
2607
+ exports: list[str] = []
2608
+
2609
+ def line_of(offset: int) -> int:
2610
+ return content.count("\n", 0, offset) + 1
2611
+
2612
+ # 1. Imports and Re-exports
2613
+ recorded_import_lines: set[tuple[str, str | None, str | None]] = set()
2614
+
2615
+ for m in _JS_REEXPORT_NAMED.finditer(content):
2616
+ specifiers = m.group(1)
2617
+ target_mod = m.group(2)
2618
+ ln = line_of(m.start())
2619
+ for raw_spec in specifiers.split(","):
2620
+ spec = raw_spec.strip()
2621
+ if not spec:
2622
+ continue
2623
+ if spec.startswith("type "):
2624
+ spec = spec[5:].strip()
2625
+ if " as " in spec:
2626
+ orig, aliased = [p.strip() for p in spec.split(" as ", 1)]
2627
+ else:
2628
+ orig, aliased = spec, spec
2629
+ exports.append(aliased)
2630
+ imports.append(
2631
+ ImportRef(
2632
+ module=target_mod,
2633
+ imported_module=target_mod,
2634
+ name=orig,
2635
+ imported_name=orig,
2636
+ alias=aliased if aliased != orig else None,
2637
+ local_name=aliased,
2638
+ import_type="reexport",
2639
+ is_reexport=True,
2640
+ exported_name=aliased,
2641
+ line=ln,
2642
+ source_file=file_path,
2643
+ source_module=module,
2644
+ )
2645
+ )
2646
+ recorded_import_lines.add((target_mod, orig, aliased))
2647
+
2648
+ for m in _JS_REEXPORT_ALL.finditer(content):
2649
+ ns_alias = m.group(1)
2650
+ target_mod = m.group(2)
2651
+ ln = line_of(m.start())
2652
+ imports.append(
2653
+ ImportRef(
2654
+ module=target_mod,
2655
+ imported_module=target_mod,
2656
+ name="*",
2657
+ imported_name="*",
2658
+ alias=ns_alias,
2659
+ local_name=ns_alias or "*",
2660
+ import_type="reexport",
2661
+ is_reexport=True,
2662
+ exported_name=ns_alias or "*",
2663
+ line=ln,
2664
+ source_file=file_path,
2665
+ source_module=module,
2666
+ )
2667
+ )
2668
+ recorded_import_lines.add((target_mod, "*", ns_alias))
2669
+
2670
+ for m in _JS_IMPORT_NAMED.finditer(content):
2671
+ specifiers = m.group(1)
2672
+ target_mod = m.group(2)
2673
+ ln = line_of(m.start())
2674
+ for raw_spec in specifiers.split(","):
2675
+ spec = raw_spec.strip()
2676
+ if not spec:
2677
+ continue
2678
+ if spec.startswith("type "):
2679
+ spec = spec[5:].strip()
2680
+ if " as " in spec:
2681
+ orig, aliased = [p.strip() for p in spec.split(" as ", 1)]
2682
+ else:
2683
+ orig, aliased = spec, None
2684
+ imports.append(
2685
+ ImportRef(
2686
+ module=target_mod,
2687
+ imported_module=target_mod,
2688
+ name=orig,
2689
+ imported_name=orig,
2690
+ alias=aliased,
2691
+ local_name=aliased or orig,
2692
+ import_type="alias" if aliased else "named",
2693
+ line=ln,
2694
+ source_file=file_path,
2695
+ source_module=module,
2696
+ )
2697
+ )
2698
+ recorded_import_lines.add((target_mod, orig, aliased))
2699
+
2700
+ for m in _JS_IMPORT_NAMESPACE.finditer(content):
2701
+ ns_alias = m.group(1)
2702
+ target_mod = m.group(2)
2703
+ ln = line_of(m.start())
2704
+ imports.append(
2705
+ ImportRef(
2706
+ module=target_mod,
2707
+ imported_module=target_mod,
2708
+ name="*",
2709
+ imported_name="*",
2710
+ alias=ns_alias,
2711
+ local_name=ns_alias,
2712
+ import_type="namespace",
2713
+ line=ln,
2714
+ source_file=file_path,
2715
+ source_module=module,
2716
+ )
2717
+ )
2718
+ recorded_import_lines.add((target_mod, "*", ns_alias))
2719
+
2720
+ for m in _JS_IMPORT_DEFAULT.finditer(content):
2721
+ def_name = m.group(1)
2722
+ if def_name == "type":
2723
+ continue
2724
+ target_mod = m.group(2)
2725
+ ln = line_of(m.start())
2726
+ imports.append(
2727
+ ImportRef(
2728
+ module=target_mod,
2729
+ imported_module=target_mod,
2730
+ name="default",
2731
+ imported_name="default",
2732
+ alias=def_name,
2733
+ local_name=def_name,
2734
+ import_type="default",
2735
+ line=ln,
2736
+ source_file=file_path,
2737
+ source_module=module,
2738
+ )
2739
+ )
2740
+ recorded_import_lines.add((target_mod, "default", def_name))
2741
+
2742
+ for m in _JS_IMPORT_SIDE_EFFECT.finditer(content):
2743
+ target_mod = m.group(1) or m.group(2)
2744
+ if target_mod and not any(r[0] == target_mod for r in recorded_import_lines):
2745
+ imports.append(
2746
+ ImportRef(
2747
+ module=target_mod,
2748
+ imported_module=target_mod,
2749
+ import_type="module",
2750
+ line=line_of(m.start()),
2751
+ source_file=file_path,
2752
+ source_module=module,
2753
+ )
2754
+ )
2755
+
2756
+ for m in _JS_REQUIRE_ASSIGN.finditer(content):
2757
+ local_name = m.group(1)
2758
+ target_mod = m.group(2)
2759
+ ln = line_of(m.start())
2760
+ imports.append(
2761
+ ImportRef(
2762
+ module=target_mod,
2763
+ imported_module=target_mod,
2764
+ name="default",
2765
+ imported_name="default",
2766
+ alias=local_name,
2767
+ local_name=local_name,
2768
+ import_type="commonjs",
2769
+ line=ln,
2770
+ source_file=file_path,
2771
+ source_module=module,
2772
+ )
2773
+ )
2774
+ recorded_import_lines.add((target_mod, "default", local_name))
2775
+
2776
+ for m in _JS_REQUIRE_DESTRUCTURE.finditer(content):
2777
+ specifiers = m.group(1)
2778
+ target_mod = m.group(2)
2779
+ ln = line_of(m.start())
2780
+ for raw_spec in specifiers.split(","):
2781
+ spec = raw_spec.strip()
2782
+ if not spec:
2783
+ continue
2784
+ if ":" in spec:
2785
+ orig, aliased = [p.strip() for p in spec.split(":", 1)]
2786
+ else:
2787
+ orig, aliased = spec, None
2788
+ imports.append(
2789
+ ImportRef(
2790
+ module=target_mod,
2791
+ imported_module=target_mod,
2792
+ name=orig,
2793
+ imported_name=orig,
2794
+ alias=aliased,
2795
+ local_name=aliased or orig,
2796
+ import_type="commonjs_destructure",
2797
+ line=ln,
2798
+ source_file=file_path,
2799
+ source_module=module,
2800
+ )
2801
+ )
2802
+ recorded_import_lines.add((target_mod, orig, aliased))
2803
+
2804
+ local_new_bindings: dict[str, str] = {}
2805
+ for m in _JS_NEW_ASSIGN.finditer(content):
2806
+ local_new_bindings[m.group(1)] = m.group(2)
2807
+
2808
+ import_mods_set = {imp.imported_module for imp in imports}
2809
+ bindings: list[BindingRef] = []
2810
+
2811
+ # 2. Single-pass scoped line scan
2812
+ active_class: tuple[str, str, int] | None = None
2813
+ active_func: tuple[str, str, int] | None = None
2814
+ seen_canonical: set[str] = set()
2815
+
2816
+ for idx, line in enumerate(lines):
2817
+ lineno = idx + 1
2818
+ if active_class and lineno > active_class[2]:
2819
+ active_class = None
2820
+ if active_func and lineno > active_func[2]:
2821
+ active_func = None
2822
+
2823
+ current_scope = (
2824
+ active_func[0]
2825
+ if active_func
2826
+ else (active_class[0] if active_class else "")
2827
+ )
2828
+
2829
+ m_id = _JS_CONST_ID.match(line)
2830
+ if m_id:
2831
+ v_name, s_name = m_id.group(1), m_id.group(2)
2832
+ if v_name not in _JS_KEYWORDS_AND_BUILTINS and s_name not in _JS_KEYWORDS_AND_BUILTINS:
2833
+ bindings.append(
2834
+ BindingRef(
2835
+ target_name=v_name,
2836
+ file_path=file_path,
2837
+ line=lineno,
2838
+ scope=current_scope,
2839
+ expr_kind="IDENTIFIER",
2840
+ source_expr=s_name,
2841
+ base_expr=s_name,
2842
+ )
2843
+ )
2844
+ else:
2845
+ m_attr = _JS_CONST_ATTR.match(line)
2846
+ if m_attr:
2847
+ v_name, b_name, a_name = m_attr.group(1), m_attr.group(2), m_attr.group(3)
2848
+ if v_name not in _JS_KEYWORDS_AND_BUILTINS:
2849
+ bindings.append(
2850
+ BindingRef(
2851
+ target_name=v_name,
2852
+ file_path=file_path,
2853
+ line=lineno,
2854
+ scope=current_scope,
2855
+ expr_kind="ATTRIBUTE",
2856
+ source_expr=f"{b_name}.{a_name}",
2857
+ base_expr=b_name,
2858
+ attr_name=a_name,
2859
+ )
2860
+ )
2861
+ else:
2862
+ m_dict = _JS_CONST_DICT.match(line)
2863
+ m_dict_start = re.match(r"^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*\{\s*$", line)
2864
+ if m_dict:
2865
+ d_name, d_body = m_dict.group(1), m_dict.group(2)
2866
+ entries: list[tuple[str, str]] = []
2867
+ for raw_entry in d_body.split(","):
2868
+ if ":" in raw_entry:
2869
+ ek, ev = raw_entry.split(":", 1)
2870
+ clean_k = ek.strip().strip("'\"")
2871
+ clean_v = ev.strip()
2872
+ if clean_k and clean_v:
2873
+ entries.append((clean_k, clean_v))
2874
+ if entries:
2875
+ bindings.append(
2876
+ BindingRef(
2877
+ target_name=d_name,
2878
+ file_path=file_path,
2879
+ line=lineno,
2880
+ scope=current_scope,
2881
+ expr_kind="DICT_LITERAL",
2882
+ source_expr=line.strip(),
2883
+ dict_entries=tuple(entries),
2884
+ )
2885
+ )
2886
+ elif m_dict_start:
2887
+ d_name = m_dict_start.group(1)
2888
+ body_parts: list[str] = []
2889
+ for j in range(idx + 1, min(idx + 50, len(lines))):
2890
+ sub_ln = lines[j].strip()
2891
+ if sub_ln.startswith("}"):
2892
+ break
2893
+ body_parts.append(sub_ln)
2894
+ d_body = " ".join(body_parts)
2895
+ entries = []
2896
+ for raw_entry in d_body.split(","):
2897
+ if ":" in raw_entry:
2898
+ ek, ev = raw_entry.split(":", 1)
2899
+ clean_k = ek.strip().strip("'\"")
2900
+ clean_v = ev.strip()
2901
+ if clean_k and clean_v:
2902
+ entries.append((clean_k, clean_v))
2903
+ if entries:
2904
+ bindings.append(
2905
+ BindingRef(
2906
+ target_name=d_name,
2907
+ file_path=file_path,
2908
+ line=lineno,
2909
+ scope=current_scope,
2910
+ expr_kind="DICT_LITERAL",
2911
+ source_expr=d_name,
2912
+ dict_entries=tuple(entries),
2913
+ )
2914
+ )
2915
+ else:
2916
+ m_sub = _JS_SUBSCRIPT_ASSIGN.match(line)
2917
+ if m_sub:
2918
+ s_target, s_key, s_src = m_sub.group(1), m_sub.group(2), m_sub.group(3)
2919
+ bindings.append(
2920
+ BindingRef(
2921
+ target_name=s_target,
2922
+ file_path=file_path,
2923
+ line=lineno,
2924
+ scope=current_scope,
2925
+ expr_kind="DICT_ASSIGN",
2926
+ source_expr=s_src,
2927
+ base_expr=s_target,
2928
+ subscript_target=s_target,
2929
+ subscript_key=s_key,
2930
+ )
2931
+ )
2932
+ else:
2933
+ m_reg = _JS_REGISTRY_CALL.match(line)
2934
+ if m_reg:
2935
+ r_recv, r_key, r_src = m_reg.group(1), m_reg.group(2), m_reg.group(3)
2936
+ r_lower = r_recv.lower()
2937
+ if r_lower == "registry" or r_lower.endswith("_registry") or r_lower.endswith("registry"):
2938
+ bindings.append(
2939
+ BindingRef(
2940
+ target_name=r_recv,
2941
+ file_path=file_path,
2942
+ line=lineno,
2943
+ scope=current_scope,
2944
+ expr_kind="CALL_REGISTER",
2945
+ source_expr=r_src,
2946
+ base_expr=r_recv,
2947
+ subscript_target=r_recv,
2948
+ subscript_key=r_key,
2949
+ )
2950
+ )
2951
+ else:
2952
+ m_evt = _JS_EVENT_ON.match(line)
2953
+ if m_evt:
2954
+ e_recv, e_event, e_src = m_evt.group(1), m_evt.group(2), m_evt.group(3)
2955
+ e_lower = e_recv.lower()
2956
+ if (
2957
+ e_lower in ("eventemitter", "emitter", "bus", "event_bus")
2958
+ or e_lower.endswith("emitter")
2959
+ or e_lower.endswith("bus")
2960
+ ):
2961
+ bindings.append(
2962
+ BindingRef(
2963
+ target_name=e_recv,
2964
+ file_path=file_path,
2965
+ line=lineno,
2966
+ scope=current_scope,
2967
+ expr_kind="EVENT_ON",
2968
+ source_expr=e_src,
2969
+ base_expr=e_recv,
2970
+ subscript_target=e_recv,
2971
+ subscript_key=e_event,
2972
+ )
2973
+ )
2974
+
2975
+ m_di = _JS_DI_PROVIDE.search(line)
2976
+ if m_di:
2977
+ bindings.append(
2978
+ BindingRef(
2979
+ target_name=m_di.group(1),
2980
+ file_path=file_path,
2981
+ line=lineno,
2982
+ scope=current_scope,
2983
+ expr_kind="DI_PROVIDES",
2984
+ source_expr=m_di.group(2),
2985
+ base_expr="providers",
2986
+ )
2987
+ )
2988
+ m_creg = _JS_CONTAINER_REG.match(line)
2989
+ if m_creg:
2990
+ r_recv, r_iface, r_impl = m_creg.group(1), m_creg.group(2), m_creg.group(3)
2991
+ if "container" in r_recv.lower() or "injector" in r_recv.lower():
2992
+ bindings.append(
2993
+ BindingRef(
2994
+ target_name=r_iface,
2995
+ file_path=file_path,
2996
+ line=lineno,
2997
+ scope=current_scope,
2998
+ expr_kind="DI_PROVIDES",
2999
+ source_expr=r_impl,
3000
+ base_expr=r_recv,
3001
+ )
3002
+ )
3003
+
3004
+ # JS/TS Test client (request(app).get('/api/login'), supertest, etc.)
3005
+ m_js_req = re.search(r"""(?:request(?:\([^)]*\))?|client|agent)\.(get|post|put|delete|patch)\s*\(\s*(['"][^'"]+['"])""", line)
3006
+ if m_js_req:
3007
+ http_m = m_js_req.group(1).upper()
3008
+ route_p = m_js_req.group(2).strip("'\"")
3009
+ bindings.append(
3010
+ BindingRef(
3011
+ target_name=route_p,
3012
+ file_path=file_path,
3013
+ line=lineno,
3014
+ scope=current_scope,
3015
+ expr_kind="TEST_ROUTE",
3016
+ source_expr=http_m,
3017
+ base_expr="request",
3018
+ attr_name=http_m,
3019
+ subscript_target="request",
3020
+ subscript_key=route_p,
3021
+ )
3022
+ )
3023
+
3024
+ # Playwright browser navigation (page.goto('/route'))
3025
+ m_js_goto = re.search(r"""page\.goto\s*\(\s*(['"][^'"]+['"]|`[^`]+`|[^,\)]+)""", line)
3026
+ if m_js_goto:
3027
+ raw_url = m_js_goto.group(1).strip()
3028
+ if raw_url.startswith(("'", '"', "`")):
3029
+ url_p = raw_url.strip("'\"`")
3030
+ else:
3031
+ url_p = "<DYNAMIC>"
3032
+ bindings.append(
3033
+ BindingRef(
3034
+ target_name=url_p,
3035
+ file_path=file_path,
3036
+ line=lineno,
3037
+ scope=current_scope,
3038
+ expr_kind="TEST_PAGE_GOTO",
3039
+ source_expr="GOTO",
3040
+ base_expr="page",
3041
+ attr_name="goto",
3042
+ subscript_target="page",
3043
+ subscript_key=url_p,
3044
+ )
3045
+ )
3046
+
3047
+ # Route detection hook per line (Express and Next.js)
3048
+ detected_routes = analyze_js_ts_line(line, lineno, file_path, module, import_mods_set)
3049
+ if detected_routes:
3050
+ routes.extend(detected_routes)
3051
+ detected_mounts = analyze_js_ts_mounts(line, lineno, file_path, module)
3052
+ if detected_mounts:
3053
+ mounts.extend(detected_mounts)
3054
+
3055
+ # Class declaration
3056
+ cls_match = _JS_CLASS_RE.search(line)
3057
+ if cls_match:
3058
+ cls_name = cls_match.group(1)
3059
+ extends_name = cls_match.group(2)
3060
+ implements_raw = cls_match.group(3)
3061
+ end_ln = _estimate_block_end(lines, idx)
3062
+ canon_id = build_canonical_id(module, "", cls_name)
3063
+ if canon_id not in seen_canonical:
3064
+ seen_canonical.add(canon_id)
3065
+ symbols.append(
3066
+ Symbol(
3067
+ id=canon_id,
3068
+ canonical_id=canon_id,
3069
+ name=cls_name,
3070
+ qualified_name=cls_name,
3071
+ kind="class",
3072
+ language=language,
3073
+ module=module,
3074
+ path=file_path,
3075
+ file_path=file_path,
3076
+ scope="",
3077
+ start_line=lineno,
3078
+ end_line=end_ln,
3079
+ content_hash=_normalized_body_hash(lines, lineno, end_ln),
3080
+ )
3081
+ )
3082
+ if extends_name:
3083
+ inheritance.append(
3084
+ InheritanceRef(
3085
+ source_symbol=cls_name,
3086
+ base_name=extends_name.strip(),
3087
+ relationship="EXTENDS",
3088
+ source_file=file_path,
3089
+ line=lineno,
3090
+ source_canonical_id=canon_id,
3091
+ )
3092
+ )
3093
+ if implements_raw:
3094
+ for iface in implements_raw.split(","):
3095
+ iface_clean = iface.split("<")[0].strip()
3096
+ if iface_clean:
3097
+ inheritance.append(
3098
+ InheritanceRef(
3099
+ source_symbol=cls_name,
3100
+ base_name=iface_clean,
3101
+ relationship="IMPLEMENTS",
3102
+ source_file=file_path,
3103
+ line=lineno,
3104
+ source_canonical_id=canon_id,
3105
+ )
3106
+ )
3107
+ active_class = (cls_name, canon_id, end_ln)
3108
+
3109
+ # TypeScript interface / type
3110
+ if language == "typescript":
3111
+ iface_match = _TS_INTERFACE_RE.search(line)
3112
+ if iface_match:
3113
+ iface_name = iface_match.group(1)
3114
+ extends_raw = iface_match.group(2)
3115
+ end_ln = _estimate_block_end(lines, idx)
3116
+ canon_id = build_canonical_id(module, "", iface_name)
3117
+ if canon_id not in seen_canonical:
3118
+ seen_canonical.add(canon_id)
3119
+ symbols.append(
3120
+ Symbol(
3121
+ id=canon_id,
3122
+ canonical_id=canon_id,
3123
+ name=iface_name,
3124
+ qualified_name=iface_name,
3125
+ kind="interface",
3126
+ language=language,
3127
+ module=module,
3128
+ path=file_path,
3129
+ file_path=file_path,
3130
+ scope="",
3131
+ start_line=lineno,
3132
+ end_line=end_ln,
3133
+ content_hash=_normalized_body_hash(lines, lineno, end_ln),
3134
+ )
3135
+ )
3136
+ if extends_raw:
3137
+ for base_if in extends_raw.split(","):
3138
+ base_clean = base_if.split("<")[0].strip()
3139
+ if base_clean:
3140
+ inheritance.append(
3141
+ InheritanceRef(
3142
+ source_symbol=iface_name,
3143
+ base_name=base_clean,
3144
+ relationship="EXTENDS",
3145
+ source_file=file_path,
3146
+ line=lineno,
3147
+ source_canonical_id=canon_id,
3148
+ )
3149
+ )
3150
+
3151
+ type_match = _TS_TYPE_RE.search(line)
3152
+ if type_match:
3153
+ t_name = type_match.group(1)
3154
+ canon_id = build_canonical_id(module, "", t_name)
3155
+ if canon_id not in seen_canonical:
3156
+ seen_canonical.add(canon_id)
3157
+ symbols.append(
3158
+ Symbol(
3159
+ id=canon_id,
3160
+ canonical_id=canon_id,
3161
+ name=t_name,
3162
+ qualified_name=t_name,
3163
+ kind="type",
3164
+ language=language,
3165
+ module=module,
3166
+ path=file_path,
3167
+ file_path=file_path,
3168
+ scope="",
3169
+ start_line=lineno,
3170
+ end_line=lineno,
3171
+ content_hash=_normalized_body_hash(lines, lineno, lineno),
3172
+ )
3173
+ )
3174
+
3175
+ # Method inside active class
3176
+ if active_class:
3177
+ m_match = _JS_METHOD_RE.match(line)
3178
+ if m_match:
3179
+ m_name = m_match.group(1)
3180
+ if m_name and m_name not in _JS_KEYWORDS_AND_BUILTINS:
3181
+ end_ln = _estimate_block_end(lines, idx)
3182
+ cls_name, cls_canon, _ = active_class
3183
+ qname = f"{cls_name}.{m_name}"
3184
+ canon_id = build_canonical_id(module, cls_name, m_name)
3185
+ if canon_id not in seen_canonical:
3186
+ seen_canonical.add(canon_id)
3187
+ symbols.append(
3188
+ Symbol(
3189
+ id=canon_id,
3190
+ canonical_id=canon_id,
3191
+ name=m_name,
3192
+ qualified_name=qname,
3193
+ kind="method",
3194
+ language=language,
3195
+ module=module,
3196
+ path=file_path,
3197
+ file_path=file_path,
3198
+ scope=cls_name,
3199
+ start_line=lineno,
3200
+ end_line=end_ln,
3201
+ content_hash=_normalized_body_hash(lines, lineno, end_ln),
3202
+ parent_symbol_id=cls_canon,
3203
+ )
3204
+ )
3205
+ active_func = (qname, canon_id, end_ln)
3206
+
3207
+ # Top-level or nested function / arrow function
3208
+ for fn_match in _JS_FUNC_RE.finditer(line):
3209
+ fn_name = fn_match.group(1) or fn_match.group(3)
3210
+ if fn_name and fn_name not in _JS_KEYWORDS_AND_BUILTINS:
3211
+ end_ln = _estimate_block_end(lines, idx)
3212
+ scope_str = active_class[0] if active_class else ""
3213
+ qname = f"{scope_str}.{fn_name}" if scope_str else fn_name
3214
+ canon_id = build_canonical_id(module, scope_str, fn_name)
3215
+ parent_id = active_class[1] if active_class else None
3216
+ kind = "method" if active_class else "function"
3217
+ if canon_id not in seen_canonical:
3218
+ seen_canonical.add(canon_id)
3219
+ symbols.append(
3220
+ Symbol(
3221
+ id=canon_id,
3222
+ canonical_id=canon_id,
3223
+ name=fn_name,
3224
+ qualified_name=qname,
3225
+ kind=kind,
3226
+ language=language,
3227
+ module=module,
3228
+ path=file_path,
3229
+ file_path=file_path,
3230
+ scope=scope_str,
3231
+ start_line=lineno,
3232
+ end_line=end_ln,
3233
+ content_hash=_normalized_body_hash(lines, lineno, end_ln),
3234
+ parent_symbol_id=parent_id,
3235
+ )
3236
+ )
3237
+ active_func = (qname, canon_id, end_ln)
3238
+
3239
+ # Jest / Vitest test("...", () => ...) or it("...", () => ...)
3240
+ m_test = re.search(r"""\b(?:test|it)\s*\(\s*(['"][^'"]+['"]|`[^`]+`)""", line)
3241
+ if m_test:
3242
+ raw_title = m_test.group(1).strip("'\"`")
3243
+ clean_title = re.sub(r"[^a-zA-Z0-9_]", "_", raw_title).strip("_")
3244
+ t_name = f"test_{clean_title}"[:60] if clean_title else "test_case"
3245
+ end_ln = _estimate_block_end(lines, idx)
3246
+ scope_str = active_class[0] if active_class else ""
3247
+ qname = f"{scope_str}.{t_name}" if scope_str else t_name
3248
+ canon_id = build_canonical_id(module, scope_str, t_name)
3249
+ if canon_id not in seen_canonical:
3250
+ seen_canonical.add(canon_id)
3251
+ symbols.append(
3252
+ Symbol(
3253
+ id=canon_id,
3254
+ canonical_id=canon_id,
3255
+ name=t_name,
3256
+ qualified_name=qname,
3257
+ kind="test",
3258
+ language=language,
3259
+ module=module,
3260
+ path=file_path,
3261
+ file_path=file_path,
3262
+ scope=scope_str,
3263
+ start_line=lineno,
3264
+ end_line=end_ln,
3265
+ content_hash=_normalized_body_hash(lines, lineno, end_ln),
3266
+ )
3267
+ )
3268
+ active_func = (qname, canon_id, end_ln)
3269
+
3270
+ # Call sites
3271
+ stripped = line.strip()
3272
+ if not stripped.startswith(("import ", "export {", "export *", "interface ", "type ", "//")):
3273
+ caller_qname = (
3274
+ active_func[0]
3275
+ if active_func
3276
+ else (active_class[0] if active_class else None)
3277
+ )
3278
+ caller_canon = (
3279
+ active_func[1]
3280
+ if active_func
3281
+ else (active_class[1] if active_class else module)
3282
+ )
3283
+ for cm in _JS_CALL_SITE.finditer(line):
3284
+ recv = cm.group(1)
3285
+ callee_nm = cm.group(2)
3286
+ if callee_nm in _JS_KEYWORDS_AND_BUILTINS:
3287
+ continue
3288
+ if recv in _JS_KEYWORDS_AND_BUILTINS and recv != "this":
3289
+ continue
3290
+ resolved_recv = recv
3291
+ if recv == "this" and active_class:
3292
+ resolved_recv = active_class[0]
3293
+ elif recv and recv in local_new_bindings:
3294
+ resolved_recv = local_new_bindings[recv]
3295
+ q_callee = f"{resolved_recv}.{callee_nm}" if resolved_recv else callee_nm
3296
+ calls.append(
3297
+ CallRef(
3298
+ callee=callee_nm,
3299
+ qualified_callee=q_callee,
3300
+ line=lineno,
3301
+ end_line=lineno,
3302
+ source_file=file_path,
3303
+ confidence="LOW",
3304
+ caller_symbol=caller_qname,
3305
+ caller_canonical_id=caller_canon,
3306
+ receiver=resolved_recv,
3307
+ )
3308
+ )
3309
+
3310
+ m_sub_call = _JS_SUBSCRIPT_CALL.search(line)
3311
+ if m_sub_call:
3312
+ sub_recv = m_sub_call.group(1)
3313
+ sub_key = m_sub_call.group(2)
3314
+ sub_callee = f"{sub_recv}['{sub_key}']"
3315
+ calls.append(
3316
+ CallRef(
3317
+ callee=sub_callee,
3318
+ qualified_callee=sub_callee,
3319
+ line=lineno,
3320
+ end_line=lineno,
3321
+ source_file=file_path,
3322
+ confidence="LOW",
3323
+ caller_symbol=caller_qname,
3324
+ caller_canonical_id=caller_canon,
3325
+ receiver=sub_recv,
3326
+ )
3327
+ )
3328
+
3329
+ # Framework file-level enhancements (NestJS and Next.js)
3330
+ if "@" in content:
3331
+ nest_res = analyze_nestjs_file(content, file_path, module)
3332
+ if nest_res.routes:
3333
+ routes.extend(nest_res.routes)
3334
+ if nest_res.mounts:
3335
+ mounts.extend(nest_res.mounts)
3336
+ if nest_res.bindings:
3337
+ bindings.extend(nest_res.bindings)
3338
+
3339
+ next_res = analyze_nextjs_file(content, file_path, module)
3340
+ if next_res.routes:
3341
+ seen_ep_ids = {r.endpoint_id for r in routes}
3342
+ for nr in next_res.routes:
3343
+ if nr.endpoint_id not in seen_ep_ids:
3344
+ routes.append(nr)
3345
+ seen_ep_ids.add(nr.endpoint_id)
3346
+ if next_res.bindings:
3347
+ bindings.extend(next_res.bindings)
3348
+
3349
+ # React and JSX component architecture (Pillar 4)
3350
+ react_res = analyze_react_file(content, file_path, module)
3351
+ sym_indices = {s.canonical_id: i for i, s in enumerate(symbols)}
3352
+ for sym in react_res.components:
3353
+ if sym.canonical_id in sym_indices:
3354
+ symbols[sym_indices[sym.canonical_id]] = sym
3355
+ else:
3356
+ seen_canonical.add(sym.canonical_id)
3357
+ symbols.append(sym)
3358
+ for h_sym in react_res.hooks:
3359
+ if h_sym.canonical_id in sym_indices:
3360
+ symbols[sym_indices[h_sym.canonical_id]] = h_sym
3361
+ else:
3362
+ seen_canonical.add(h_sym.canonical_id)
3363
+ symbols.append(h_sym)
3364
+ if react_res.bindings:
3365
+ bindings.extend(react_res.bindings)
3366
+
3367
+ if language == "typescript" and ("@Service" in content or "@Controller" in content or "Dependency<" in content):
3368
+ from codegraph.roblox import extract_flamework_bindings
3369
+ fw_bindings = extract_flamework_bindings(content, file_path, module)
3370
+ if fw_bindings:
3371
+ bindings.extend(fw_bindings)
3372
+
3373
+ symbols.sort(key=lambda s: (s.start_line, s.canonical_id))
3374
+ return ParseResult(
3375
+ path=file_path,
3376
+ language=language,
3377
+ source_hash=source_hash,
3378
+ symbols=tuple(symbols),
3379
+ imports=tuple(imports),
3380
+ calls=tuple(calls),
3381
+ inheritance=tuple(inheritance),
3382
+ routes=tuple(routes),
3383
+ mounts=tuple(mounts),
3384
+ bindings=tuple(bindings),
3385
+ exports=tuple(exports),
3386
+ parse_failed=False,
3387
+ )
3388
+
3389
+
3390
+ def _estimate_block_end(lines: list[str], start_idx: int) -> int:
3391
+ """Walk forward counting braces to estimate block end line."""
3392
+ depth = 0
3393
+ opened = False
3394
+ for i, line in enumerate(lines[start_idx:], start=start_idx):
3395
+ opens = line.count("{")
3396
+ closes = line.count("}")
3397
+ if opens > 0:
3398
+ opened = True
3399
+ depth += opens - closes
3400
+ if opened and depth <= 0:
3401
+ return i + 1
3402
+ if not opened and i == start_idx and ";" in line:
3403
+ return i + 1
3404
+ return len(lines)