codegraph-engine 3.0.0__1-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.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +859 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- 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)
|