graphite-code 0.3.0__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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/extract/ast.py
ADDED
|
@@ -0,0 +1,1964 @@
|
|
|
1
|
+
"""Deterministic structural extraction via tree-sitter."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import unicodedata
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Collection, Final
|
|
11
|
+
|
|
12
|
+
from ..cache import Cache
|
|
13
|
+
from ..config import Config
|
|
14
|
+
from ..ingest import FileEntry
|
|
15
|
+
from ..resolve import SourceIndex, should_keep_call_target
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ExtractionResult:
|
|
20
|
+
nodes: list[dict[str, Any]] = field(default_factory=list)
|
|
21
|
+
edges: list[dict[str, Any]] = field(default_factory=list)
|
|
22
|
+
error: str | None = None
|
|
23
|
+
errors: list[dict[str, Any]] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_LANGUAGE_BUILTIN_GLOBALS: frozenset[str] = frozenset({
|
|
27
|
+
"String", "Number", "Boolean", "Object", "Array", "Symbol", "BigInt",
|
|
28
|
+
"Date", "RegExp", "Error", "TypeError", "RangeError", "SyntaxError",
|
|
29
|
+
"ReferenceError", "EvalError", "URIError",
|
|
30
|
+
"Promise", "Map", "Set", "WeakMap", "WeakSet", "JSON", "Math",
|
|
31
|
+
"Reflect", "Proxy", "Intl",
|
|
32
|
+
"parseInt", "parseFloat", "isNaN", "isFinite",
|
|
33
|
+
"encodeURIComponent", "decodeURIComponent", "encodeURI", "decodeURI",
|
|
34
|
+
"URL", "URLSearchParams", "FormData", "Blob", "File",
|
|
35
|
+
"Headers", "Request", "Response", "AbortController", "AbortSignal",
|
|
36
|
+
"TextEncoder", "TextDecoder", "console",
|
|
37
|
+
"str", "int", "float", "bool", "list", "dict", "set", "tuple", "bytes",
|
|
38
|
+
"len", "range", "enumerate", "zip", "map", "filter", "sum", "min", "max",
|
|
39
|
+
"print", "open", "isinstance", "type", "super", "sorted", "reversed",
|
|
40
|
+
"any", "all", "abs", "round", "next", "iter", "hash", "id", "repr",
|
|
41
|
+
"callable", "getattr", "setattr", "hasattr", "delattr", "vars", "dir",
|
|
42
|
+
# Go builtins
|
|
43
|
+
"append", "cap", "make", "copy", "delete", "panic", "recover", "close",
|
|
44
|
+
"new", "println", "clear",
|
|
45
|
+
# Rust prelude constructors/functions (macros like println! never reach
|
|
46
|
+
# call extraction; these are the plain-call noise sources)
|
|
47
|
+
"Some", "None", "Ok", "Err", "Box", "drop", "Default",
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Names that are never defined in-repo: test-framework injections, runtime
|
|
52
|
+
# globals, and language builtins missing from _LANGUAGE_BUILTIN_GLOBALS.
|
|
53
|
+
# These are TAGGED EXTERNAL_CALL and excluded from the health ratio by
|
|
54
|
+
# health.py -- NOT dropped -- so the excluded evidence stays visible and
|
|
55
|
+
# countable in graph.json. Nothing moves between this set and the drop-list
|
|
56
|
+
# above; see spec §4.3.
|
|
57
|
+
#
|
|
58
|
+
# Deliberately absent: generic words a repo plausibly defines itself
|
|
59
|
+
# (`context`, `run`, `setup`, `main`). A false external costs more than a
|
|
60
|
+
# missed one, because it would mask real code. Also absent: `process`,
|
|
61
|
+
# `console`, `window`, `document` -- already in resolve.py's _BUILTIN_OBJECTS,
|
|
62
|
+
# so their member calls are dropped before reaching this classifier.
|
|
63
|
+
_EXTERNAL_GLOBALS: frozenset[str] = frozenset({
|
|
64
|
+
# test-framework injected globals (vitest / jest / mocha)
|
|
65
|
+
"expect", "it", "describe", "test", "vi", "jest",
|
|
66
|
+
"beforeEach", "afterEach", "beforeAll", "afterAll",
|
|
67
|
+
"suite", "xit", "xdescribe", "fit", "fdescribe",
|
|
68
|
+
# JS / Web runtime globals absent from the drop-list
|
|
69
|
+
"setTimeout", "setInterval", "clearTimeout", "clearInterval",
|
|
70
|
+
"fetch", "queueMicrotask", "structuredClone", "atob", "btoa",
|
|
71
|
+
"crypto", "performance", "Buffer", "require",
|
|
72
|
+
# Python builtins absent from the drop-list. This was a partial list of
|
|
73
|
+
# Python's builtin exception hierarchy (found incomplete via operation
|
|
74
|
+
# -firewall dogfooding, 2026-07-31: SystemExit and FileNotFoundError were
|
|
75
|
+
# missing, so `raise FileNotFoundError(...)` classified LOCAL_CALL against
|
|
76
|
+
# a synthesized unknown target instead of EXTERNAL_CALL like its sibling
|
|
77
|
+
# ValueError) -- now the full builtin exception/warning hierarchy, not
|
|
78
|
+
# just the names one dogfooding pass happened to exercise.
|
|
79
|
+
"ValueError", "OSError", "AssertionError", "KeyError", "IndexError",
|
|
80
|
+
"RuntimeError", "NotImplementedError", "StopIteration",
|
|
81
|
+
"frozenset", "bytearray", "complex", "object", "Exception",
|
|
82
|
+
"BaseException", "property", "staticmethod", "classmethod",
|
|
83
|
+
"slice", "divmod", "format",
|
|
84
|
+
"SystemExit", "GeneratorExit", "KeyboardInterrupt",
|
|
85
|
+
"ArithmeticError", "FloatingPointError", "OverflowError", "ZeroDivisionError",
|
|
86
|
+
"AttributeError", "BufferError", "EOFError",
|
|
87
|
+
"ImportError", "ModuleNotFoundError", "LookupError", "MemoryError",
|
|
88
|
+
"NameError", "UnboundLocalError", "RecursionError", "SystemError",
|
|
89
|
+
"UnicodeError", "UnicodeDecodeError", "UnicodeEncodeError", "UnicodeTranslateError",
|
|
90
|
+
"IndentationError", "TabError",
|
|
91
|
+
"BlockingIOError", "ChildProcessError", "ConnectionError", "BrokenPipeError",
|
|
92
|
+
"ConnectionAbortedError", "ConnectionRefusedError", "ConnectionResetError",
|
|
93
|
+
"FileExistsError", "FileNotFoundError", "InterruptedError", "IsADirectoryError",
|
|
94
|
+
"NotADirectoryError", "PermissionError", "ProcessLookupError", "TimeoutError",
|
|
95
|
+
"Warning", "DeprecationWarning", "PendingDeprecationWarning", "RuntimeWarning",
|
|
96
|
+
"UserWarning", "FutureWarning", "ImportWarning", "UnicodeWarning",
|
|
97
|
+
"BytesWarning", "ResourceWarning",
|
|
98
|
+
"BaseExceptionGroup", "ExceptionGroup",
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _call_confidence(
|
|
103
|
+
called: str,
|
|
104
|
+
external_names: Collection[str] = (),
|
|
105
|
+
in_repo_names: Collection[str] = (),
|
|
106
|
+
*,
|
|
107
|
+
attributable: bool = True,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""`LOCAL_CALL`, or `EXTERNAL_CALL` when the call provably leaves the repo.
|
|
110
|
+
|
|
111
|
+
``called`` may be dotted (``z.object``); the ROOT carries the binding, so
|
|
112
|
+
that is what is tested. ``external_names`` holds local names bound by
|
|
113
|
+
imports that did not resolve in-repo (Tasks 3 and 4). ``in_repo_names``
|
|
114
|
+
holds local names bound by imports that DID resolve in-repo -- for
|
|
115
|
+
TypeScript/JavaScript this is every binding form (default, namespace,
|
|
116
|
+
named), not just the named imports resolution already tracks. An in-repo
|
|
117
|
+
binding wins over both ``_EXTERNAL_GLOBALS`` and ``external_names``: the
|
|
118
|
+
source index proved the name local, so a name collision with a global
|
|
119
|
+
(``crypto`` the module vs. `crypto` the Web Crypto global) cannot make it
|
|
120
|
+
external.
|
|
121
|
+
|
|
122
|
+
This precedence check is threaded for BOTH TypeScript and Python (#14
|
|
123
|
+
mechanism B). Python's bare-identifier and unresolved-member paths pass the
|
|
124
|
+
keys of `symbol_map` from `_collect_python_import_maps`, so a name the
|
|
125
|
+
source index proved local -- `helpers.py` defining `def format(...)`, then
|
|
126
|
+
`from helpers import format` and a call to `format(...)` -- stays
|
|
127
|
+
`LOCAL_CALL` rather than colliding with `_EXTERNAL_GLOBALS`.
|
|
128
|
+
|
|
129
|
+
``attributable`` is False when ``called`` is a bare method name recovered
|
|
130
|
+
from a receiver the extractor could not name -- a regex or string literal,
|
|
131
|
+
a call result, a subscript (``/re/.test(s)``, ``"{}".format(x)``,
|
|
132
|
+
``f().g()``). Such a name says nothing about where the call goes, so
|
|
133
|
+
classifying it against the globals list produced a **false external**
|
|
134
|
+
(#14 mechanism A): an edge excused from the health denominator despite
|
|
135
|
+
never being shown to leave the repo. A false external inflates health,
|
|
136
|
+
which is the more dangerous direction than a missed one. Unattributable
|
|
137
|
+
calls are therefore never classified external.
|
|
138
|
+
"""
|
|
139
|
+
if not attributable:
|
|
140
|
+
return "LOCAL_CALL"
|
|
141
|
+
root = called.split(".", 1)[0]
|
|
142
|
+
if root in in_repo_names:
|
|
143
|
+
return "LOCAL_CALL"
|
|
144
|
+
if root in _EXTERNAL_GLOBALS or root in external_names:
|
|
145
|
+
return "EXTERNAL_CALL"
|
|
146
|
+
return "LOCAL_CALL"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# Languages whose extraction consults the SourceIndex, and whose cached result
|
|
150
|
+
# therefore depends on the repo's file set rather than on file content alone.
|
|
151
|
+
_RESOLVER_LANGUAGES: Final = frozenset({"python", "javascript", "typescript", "tsx", "jsx"})
|
|
152
|
+
|
|
153
|
+
_MAX_ID_LEN = 120
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _make_id(*parts: str) -> str:
|
|
157
|
+
combined = "_".join(p.strip("_.") for p in parts if p)
|
|
158
|
+
combined = unicodedata.normalize("NFKC", combined)
|
|
159
|
+
cleaned = re.sub(r"[^\w]+", "_", combined, flags=re.UNICODE)
|
|
160
|
+
cleaned = re.sub(r"_+", "_", cleaned)
|
|
161
|
+
cleaned = cleaned.strip("_").casefold()
|
|
162
|
+
if len(cleaned) > _MAX_ID_LEN:
|
|
163
|
+
cleaned = cleaned[:_MAX_ID_LEN]
|
|
164
|
+
return cleaned
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _file_node_id(rel_path: str) -> str:
|
|
168
|
+
"""Stable node id for a file: a slug of the FULL repo-relative path.
|
|
169
|
+
|
|
170
|
+
Using only the parent dir + stem (the pre-v4 scheme) silently merged any
|
|
171
|
+
two files sharing that tail — a monorepo with `apps/worker/src/db/queries.ts`
|
|
172
|
+
and `apps/workers/booking/src/db/queries.ts` got ONE `db_queries` node and
|
|
173
|
+
every symbol in both files collided. Full-path ids keep every file (and
|
|
174
|
+
therefore every symbol id derived from the file id) distinct.
|
|
175
|
+
"""
|
|
176
|
+
path = Path(rel_path)
|
|
177
|
+
parts = [*path.parts[:-1], path.stem]
|
|
178
|
+
return _make_id(".".join(p for p in parts if p not in (".", "")))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _node(
|
|
182
|
+
id_: str,
|
|
183
|
+
kind: str,
|
|
184
|
+
name: str,
|
|
185
|
+
rel_path: str,
|
|
186
|
+
line: int | None = None,
|
|
187
|
+
extra: dict[str, Any] | None = None,
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
n: dict[str, Any] = {
|
|
190
|
+
"id": id_,
|
|
191
|
+
"kind": kind,
|
|
192
|
+
"name": name,
|
|
193
|
+
"source_file": rel_path,
|
|
194
|
+
}
|
|
195
|
+
if line is not None:
|
|
196
|
+
n["source_location"] = f"L{line}"
|
|
197
|
+
if extra:
|
|
198
|
+
n.update(extra)
|
|
199
|
+
return n
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _edge(
|
|
203
|
+
source: str,
|
|
204
|
+
target: str,
|
|
205
|
+
relation: str,
|
|
206
|
+
rel_path: str,
|
|
207
|
+
line: int | None = None,
|
|
208
|
+
context: str | None = None,
|
|
209
|
+
confidence: str = "EXTRACTED",
|
|
210
|
+
) -> dict[str, Any]:
|
|
211
|
+
e: dict[str, Any] = {
|
|
212
|
+
"source": source,
|
|
213
|
+
"target": target,
|
|
214
|
+
"relation": relation,
|
|
215
|
+
"source_file": rel_path,
|
|
216
|
+
"confidence": confidence,
|
|
217
|
+
"weight": 1.0,
|
|
218
|
+
}
|
|
219
|
+
if line is not None:
|
|
220
|
+
e["source_location"] = f"L{line}"
|
|
221
|
+
if context:
|
|
222
|
+
e["context"] = context
|
|
223
|
+
return e
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _TreeSitterLoader:
|
|
227
|
+
"""Lazy parser cache per language."""
|
|
228
|
+
|
|
229
|
+
def __init__(self) -> None:
|
|
230
|
+
self._parsers: dict[str, Any] = {}
|
|
231
|
+
|
|
232
|
+
def parser(self, language: str) -> Any | None:
|
|
233
|
+
if language in self._parsers:
|
|
234
|
+
return self._parsers[language]
|
|
235
|
+
mapping = {
|
|
236
|
+
"javascript": ("tree_sitter_javascript", "language"),
|
|
237
|
+
"typescript": ("tree_sitter_typescript", "language_typescript"),
|
|
238
|
+
"tsx": ("tree_sitter_typescript", "language_tsx"),
|
|
239
|
+
"jsx": ("tree_sitter_javascript", "language"),
|
|
240
|
+
"python": ("tree_sitter_python", "language"),
|
|
241
|
+
"go": ("tree_sitter_go", "language"),
|
|
242
|
+
"rust": ("tree_sitter_rust", "language"),
|
|
243
|
+
}
|
|
244
|
+
item = mapping.get(language)
|
|
245
|
+
if not item:
|
|
246
|
+
return None
|
|
247
|
+
pkg, attr = item
|
|
248
|
+
try:
|
|
249
|
+
mod = importlib.import_module(pkg)
|
|
250
|
+
lang_fn = getattr(mod, attr)
|
|
251
|
+
lang_mod = importlib.import_module("tree_sitter")
|
|
252
|
+
parser = lang_mod.Parser(lang_mod.Language(lang_fn()))
|
|
253
|
+
self._parsers[language] = parser
|
|
254
|
+
return parser
|
|
255
|
+
except Exception as e:
|
|
256
|
+
if sys.stderr:
|
|
257
|
+
print(f"[graphite] parser load failed for {language}: {e}", file=sys.stderr)
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
_LOADER = _TreeSitterLoader()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class _Scope:
|
|
265
|
+
"""A call-attribution scope (the nearest enclosing function/method/arrow).
|
|
266
|
+
|
|
267
|
+
``pending`` holds a node dict that is emitted lazily the first time the scope
|
|
268
|
+
is used as the source of an edge. Named functions are materialized eagerly
|
|
269
|
+
(``pending is None``); anonymous arrows/functions are only materialized if
|
|
270
|
+
they actually emit a call, so trivial callbacks never bloat the graph.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
__slots__ = ("id", "pending")
|
|
274
|
+
|
|
275
|
+
def __init__(self, node_id: str, pending: dict[str, Any] | None = None) -> None:
|
|
276
|
+
self.id = node_id
|
|
277
|
+
self.pending = pending
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _extract_ts_js(file_id: str, rel_path: str, source: bytes, tree: Any, source_index: SourceIndex | None = None) -> ExtractionResult:
|
|
281
|
+
result = ExtractionResult()
|
|
282
|
+
root = tree.root_node
|
|
283
|
+
|
|
284
|
+
def _line(node: Any) -> int:
|
|
285
|
+
return (node.start_point[0] + 1) if node.start_point else 1
|
|
286
|
+
|
|
287
|
+
def _name(node: Any) -> str | None:
|
|
288
|
+
# identifiers, property_identifier, type_identifier, etc.
|
|
289
|
+
for child in node.children:
|
|
290
|
+
if child.type.endswith("identifier"):
|
|
291
|
+
text = child.text.decode("utf-8", errors="ignore") if child.text else None
|
|
292
|
+
return _short_name(text)
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
# Add file node.
|
|
296
|
+
result.nodes.append(_node(file_id, "file", Path(rel_path).name, rel_path))
|
|
297
|
+
|
|
298
|
+
# Pre-pass: map locally-bound imported names to the definition node id in
|
|
299
|
+
# the file that defines them, so cross-file calls resolve to the real target
|
|
300
|
+
# instead of a same-file phantom.
|
|
301
|
+
bindings = _collect_ts_import_symbols(root, rel_path, source_index)
|
|
302
|
+
|
|
303
|
+
def _materialize(scope: _Scope) -> None:
|
|
304
|
+
if scope.pending is not None:
|
|
305
|
+
result.nodes.append(scope.pending)
|
|
306
|
+
scope.pending = None
|
|
307
|
+
|
|
308
|
+
file_scope = _Scope(file_id) # file node already emitted above
|
|
309
|
+
|
|
310
|
+
def _anon_scope(node: Any) -> _Scope:
|
|
311
|
+
syn = _safe_label(_synthetic_fn_name(node, source))
|
|
312
|
+
line = _line(node)
|
|
313
|
+
col = (node.start_point[1] + 1) if node.start_point else 1
|
|
314
|
+
mid = _make_id(file_id, syn, f"l{line}", f"c{col}")
|
|
315
|
+
pending = _node(mid, "function", syn, rel_path, line, extra={"anonymous": True})
|
|
316
|
+
return _Scope(mid, pending=pending)
|
|
317
|
+
|
|
318
|
+
# Destructured hook results: `const [v, setV] = useState(...)` binds a
|
|
319
|
+
# callable that no declaration names, so calls to it could never resolve
|
|
320
|
+
# (#20). Collected during the walk, materialized after it -- see the
|
|
321
|
+
# post-pass below for why the decision has to be deferred.
|
|
322
|
+
hook_bindings: dict[str, tuple[str | None, int]] = {}
|
|
323
|
+
|
|
324
|
+
def _record_hook_destructuring(node: Any, parent_id: str | None) -> None:
|
|
325
|
+
name = node.child_by_field_name("name")
|
|
326
|
+
value = node.child_by_field_name("value")
|
|
327
|
+
if name is None or value is None:
|
|
328
|
+
return
|
|
329
|
+
if name.type not in ("array_pattern", "object_pattern"):
|
|
330
|
+
return
|
|
331
|
+
if not _is_hook_call(value):
|
|
332
|
+
return
|
|
333
|
+
for ident, text in _pattern_identifiers(name):
|
|
334
|
+
if text and text not in hook_bindings:
|
|
335
|
+
hook_bindings[text] = (parent_id, _line(ident))
|
|
336
|
+
|
|
337
|
+
# Walk for declarations and calls. ``parent_id`` is the nearest named
|
|
338
|
+
# container (for ``contains`` edges); ``scope`` is the nearest function-like
|
|
339
|
+
# scope (for ``calls`` attribution).
|
|
340
|
+
def walk(node: Any, parent_id: str | None, scope: _Scope) -> None:
|
|
341
|
+
t = node.type
|
|
342
|
+
if t in ("function_declaration", "generator_function_declaration", "function", "generator_function", "method_definition"):
|
|
343
|
+
# An anonymous function expression assigned to a name is callable by
|
|
344
|
+
# that name, so it takes the named path too (#16).
|
|
345
|
+
name = _name(node) or _declarator_binding_name(node)
|
|
346
|
+
if name:
|
|
347
|
+
mid = _make_id(file_id, name)
|
|
348
|
+
# Tag class methods so the global method-dispatch post-pass can
|
|
349
|
+
# resolve `recv.method()` member calls to this definition (_merge).
|
|
350
|
+
extra = {"is_method": True} if t == "method_definition" else None
|
|
351
|
+
result.nodes.append(_node(mid, "function", name, rel_path, _line(node), extra=extra))
|
|
352
|
+
if parent_id:
|
|
353
|
+
result.edges.append(_edge(parent_id, mid, "contains", rel_path, _line(node)))
|
|
354
|
+
walk_children(node, mid, _Scope(mid))
|
|
355
|
+
else:
|
|
356
|
+
walk_children(node, parent_id, _anon_scope(node))
|
|
357
|
+
elif t in ("arrow_function", "function_expression", "generator_function_expression"):
|
|
358
|
+
# Arrows carry no name of their own; _name would misread a bare
|
|
359
|
+
# single parameter as the name, so they are anonymous by default.
|
|
360
|
+
# A variable-declarator binding is the one exception: it makes the
|
|
361
|
+
# function callable by name, so it must get the same id shape a
|
|
362
|
+
# `function f()` declaration produces or nothing can bind to it.
|
|
363
|
+
bound = _declarator_binding_name(node)
|
|
364
|
+
# A class field holding an arrow is callable too, but only through
|
|
365
|
+
# `this.handle()` / `obj.handle()`, so it needs the `is_method` tag
|
|
366
|
+
# the dispatch post-pass indexes on -- a bare name is not enough (#19).
|
|
367
|
+
field = _class_field_binding_name(node) if not bound else None
|
|
368
|
+
name = bound or field
|
|
369
|
+
if name:
|
|
370
|
+
mid = _make_id(file_id, name)
|
|
371
|
+
extra = {"is_method": True} if field else None
|
|
372
|
+
result.nodes.append(_node(mid, "function", name, rel_path, _line(node), extra=extra))
|
|
373
|
+
if parent_id:
|
|
374
|
+
result.edges.append(_edge(parent_id, mid, "contains", rel_path, _line(node)))
|
|
375
|
+
walk_children(node, mid, _Scope(mid))
|
|
376
|
+
elif t == "arrow_function":
|
|
377
|
+
walk_children(node, parent_id, _anon_scope(node))
|
|
378
|
+
else:
|
|
379
|
+
# Unbound function expressions keep their previous handling:
|
|
380
|
+
# calls inside them stay attributed to the enclosing scope.
|
|
381
|
+
walk_children(node, parent_id, scope)
|
|
382
|
+
elif t == "class_declaration":
|
|
383
|
+
name = _name(node)
|
|
384
|
+
if name:
|
|
385
|
+
cid = _make_id(file_id, name)
|
|
386
|
+
result.nodes.append(_node(cid, "class", name, rel_path, _line(node)))
|
|
387
|
+
if parent_id:
|
|
388
|
+
result.edges.append(_edge(parent_id, cid, "contains", rel_path, _line(node)))
|
|
389
|
+
walk_children(node, cid, scope)
|
|
390
|
+
else:
|
|
391
|
+
walk_children(node, parent_id, scope)
|
|
392
|
+
elif t == "import_statement":
|
|
393
|
+
# import ... from 'module' or import 'module'
|
|
394
|
+
source_lit = None
|
|
395
|
+
for child in node.children:
|
|
396
|
+
if child.type == "string":
|
|
397
|
+
source_lit = child.text.decode("utf-8", errors="ignore").strip("'\"")
|
|
398
|
+
elif child.type == "import_clause":
|
|
399
|
+
pass
|
|
400
|
+
if source_lit:
|
|
401
|
+
resolved = _resolve_import(rel_path, source_lit, source_index)
|
|
402
|
+
if resolved:
|
|
403
|
+
result.edges.append(
|
|
404
|
+
_edge(
|
|
405
|
+
file_id,
|
|
406
|
+
_file_node_id(resolved.rel_path),
|
|
407
|
+
"imports",
|
|
408
|
+
rel_path,
|
|
409
|
+
_line(node),
|
|
410
|
+
confidence=resolved.confidence,
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
else:
|
|
414
|
+
result.edges.append(_edge(file_id, _make_id(source_lit), "imports", rel_path, _line(node), confidence="EXTERNAL_IMPORT"))
|
|
415
|
+
walk_children(node, parent_id, scope)
|
|
416
|
+
elif t in ("call_expression", "new_expression"):
|
|
417
|
+
# A `require('<literal>')` is a module load wearing a call's syntax.
|
|
418
|
+
# Emitted here, beside the call handling, because that is where the
|
|
419
|
+
# node actually appears -- the `import_statement` arm above can
|
|
420
|
+
# never see it. Detection is shared with the binding collector via
|
|
421
|
+
# `_require_source_literal` so the two cannot diverge.
|
|
422
|
+
require_lit = _require_source_literal(node) if t == "call_expression" else None
|
|
423
|
+
if require_lit:
|
|
424
|
+
required = _resolve_import(rel_path, require_lit, source_index)
|
|
425
|
+
if required:
|
|
426
|
+
result.edges.append(
|
|
427
|
+
_edge(
|
|
428
|
+
file_id,
|
|
429
|
+
_file_node_id(required.rel_path),
|
|
430
|
+
"imports",
|
|
431
|
+
rel_path,
|
|
432
|
+
_line(node),
|
|
433
|
+
confidence=required.confidence,
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
else:
|
|
437
|
+
result.edges.append(
|
|
438
|
+
_edge(
|
|
439
|
+
file_id,
|
|
440
|
+
_make_id(require_lit),
|
|
441
|
+
"imports",
|
|
442
|
+
rel_path,
|
|
443
|
+
_line(node),
|
|
444
|
+
confidence="EXTERNAL_IMPORT",
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
func = node.child_by_field_name("function")
|
|
448
|
+
if func is None and t == "new_expression":
|
|
449
|
+
# tree-sitter names this field `constructor` on a new_expression,
|
|
450
|
+
# not `function`, so the lookup above always returned None and
|
|
451
|
+
# this whole arm was dead code for construction (#15).
|
|
452
|
+
func = node.child_by_field_name("constructor")
|
|
453
|
+
if func:
|
|
454
|
+
called = _call_target_name(func, source)
|
|
455
|
+
if called and called not in _LANGUAGE_BUILTIN_GLOBALS and should_keep_call_target(called):
|
|
456
|
+
target_id = _resolve_call(
|
|
457
|
+
file_id, called, bindings.resolved, bindings.namespaces
|
|
458
|
+
)
|
|
459
|
+
_materialize(scope)
|
|
460
|
+
# A member call whose receiver could not be named leaves
|
|
461
|
+
# `called` as a bare method name, which says nothing about
|
|
462
|
+
# where the call goes -- do not classify it (#14).
|
|
463
|
+
attributable = True
|
|
464
|
+
namespace_resolved = False
|
|
465
|
+
if func.type == "member_expression":
|
|
466
|
+
obj = func.child_by_field_name("object")
|
|
467
|
+
obj_name = _simple_object_name(obj) if obj is not None else None
|
|
468
|
+
attributable = bool(obj_name)
|
|
469
|
+
namespace_resolved = bool(
|
|
470
|
+
obj_name and obj_name in bindings.namespaces
|
|
471
|
+
)
|
|
472
|
+
edge = _edge(
|
|
473
|
+
scope.id, target_id, "calls", rel_path, _line(node),
|
|
474
|
+
confidence=_call_confidence(
|
|
475
|
+
called, bindings.external, bindings.in_repo, attributable=attributable
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
# Method dispatch: for `recv.method(...)` the callee is a
|
|
479
|
+
# member_expression and target_id above is only a file-scoped
|
|
480
|
+
# phantom. Stash the bare method name so the global post-pass
|
|
481
|
+
# can re-point this edge to the real class method definition
|
|
482
|
+
# (see _resolve_method_dispatch). `new x.Foo()` is construction,
|
|
483
|
+
# not dispatch, so restrict to call_expression.
|
|
484
|
+
# Skipped when the receiver was a whole-module binding: the
|
|
485
|
+
# post-pass exists for edges whose target is "only a
|
|
486
|
+
# file-scoped phantom", and re-points on method NAME alone.
|
|
487
|
+
# A namespace-resolved edge already points at the real
|
|
488
|
+
# definition, so leaving `_member` set would let any
|
|
489
|
+
# same-named class method elsewhere steal it back.
|
|
490
|
+
if (
|
|
491
|
+
t == "call_expression"
|
|
492
|
+
and func.type == "member_expression"
|
|
493
|
+
and not namespace_resolved
|
|
494
|
+
):
|
|
495
|
+
prop = func.child_by_field_name("property")
|
|
496
|
+
method = prop.text.decode("utf-8", errors="ignore") if prop is not None and prop.text else None
|
|
497
|
+
if method:
|
|
498
|
+
edge["_member"] = method
|
|
499
|
+
result.edges.append(edge)
|
|
500
|
+
walk_children(node, parent_id, scope)
|
|
501
|
+
elif t == "variable_declarator":
|
|
502
|
+
_record_hook_destructuring(node, parent_id)
|
|
503
|
+
walk_children(node, parent_id, scope)
|
|
504
|
+
else:
|
|
505
|
+
walk_children(node, parent_id, scope)
|
|
506
|
+
|
|
507
|
+
def walk_children(node: Any, parent_id: str | None, scope: _Scope) -> None:
|
|
508
|
+
for child in node.children:
|
|
509
|
+
walk(child, parent_id, scope)
|
|
510
|
+
|
|
511
|
+
walk(root, file_id, file_scope)
|
|
512
|
+
# Materialize a destructured hook binding ONLY if this file actually calls
|
|
513
|
+
# it. Deferring the decision is what keeps the node provably callable: the
|
|
514
|
+
# non-callable half of `[value, setValue]` never gets a `function` node, and
|
|
515
|
+
# destructured locals that nobody invokes do not inflate the node count.
|
|
516
|
+
# A name that resolved to an import is skipped too -- `_resolve_call` would
|
|
517
|
+
# have pointed the call at the exporting file, so `mid` is absent from
|
|
518
|
+
# `called` and the real cross-file target keeps the edge (#20).
|
|
519
|
+
if hook_bindings:
|
|
520
|
+
existing = {n["id"] for n in result.nodes}
|
|
521
|
+
called = {e["target"] for e in result.edges if e["relation"] == "calls"}
|
|
522
|
+
for bound_name, (parent_id, line) in hook_bindings.items():
|
|
523
|
+
mid = _make_id(file_id, bound_name)
|
|
524
|
+
if mid in existing or mid not in called:
|
|
525
|
+
continue
|
|
526
|
+
result.nodes.append(_node(mid, "function", bound_name, rel_path, line))
|
|
527
|
+
if parent_id:
|
|
528
|
+
result.edges.append(_edge(parent_id, mid, "contains", rel_path, line))
|
|
529
|
+
existing.add(mid)
|
|
530
|
+
if source_index is not None:
|
|
531
|
+
for edge in source_index.supplemental_ts_edges(rel_path):
|
|
532
|
+
result.edges.append(
|
|
533
|
+
_edge(
|
|
534
|
+
file_id,
|
|
535
|
+
_file_node_id(edge.target),
|
|
536
|
+
edge.relation,
|
|
537
|
+
rel_path,
|
|
538
|
+
edge.line,
|
|
539
|
+
context=edge.specifier,
|
|
540
|
+
confidence=edge.confidence,
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
return result
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@dataclass(frozen=True)
|
|
547
|
+
class _ImportBindings:
|
|
548
|
+
"""What a file's import statements bind.
|
|
549
|
+
|
|
550
|
+
``resolved`` maps a local name to the definition node id in the in-repo
|
|
551
|
+
file that exports it (used for call resolution). ``external`` holds local
|
|
552
|
+
names bound by imports that did NOT resolve in-repo, in every binding form
|
|
553
|
+
-- those calls leave the repo and are tagged EXTERNAL_CALL. ``in_repo``
|
|
554
|
+
holds local names bound by imports that DID resolve in-repo, in every
|
|
555
|
+
binding form -- an in-repo binding must win over an `_EXTERNAL_GLOBALS`
|
|
556
|
+
name collision (`crypto` the local module vs. `crypto` the Web Crypto
|
|
557
|
+
global), mirroring the precedence Python's import maps already give
|
|
558
|
+
resolved names for free (they simply never enter `external_names`).
|
|
559
|
+
"""
|
|
560
|
+
resolved: dict[str, str]
|
|
561
|
+
external: frozenset[str]
|
|
562
|
+
in_repo: frozenset[str] = frozenset()
|
|
563
|
+
#: Local name -> the FILE node id it stands for, for a whole-module binding:
|
|
564
|
+
#: `import * as ns from './x'` and `const m = require('./x')`. A call
|
|
565
|
+
#: `ns.f()` resolves to `<that file>_f`, which is exactly what Python's
|
|
566
|
+
#: `alias_map` has always done for `import x` + `x.attr()`. Separate from
|
|
567
|
+
#: `resolved` because that maps a name to a DEFINITION, and these names
|
|
568
|
+
#: stand for a whole file rather than any one export.
|
|
569
|
+
namespaces: dict[str, str] = field(default_factory=dict)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _collect_ts_import_symbols(root: Any, rel_path: str, source_index: SourceIndex | None) -> _ImportBindings:
|
|
573
|
+
"""What this file's imports bind: resolved definitions and external names.
|
|
574
|
+
|
|
575
|
+
Only *named* imports are mapped for resolution: ``import { foo }`` /
|
|
576
|
+
``import { foo as bar }``. The value is ``<defining-file-id>_<original-export-name>``
|
|
577
|
+
so a call to the local name links to the real definition node in the file
|
|
578
|
+
that exports it. Default and namespace imports are skipped for resolution
|
|
579
|
+
(they can't be tied to a single named definition), so those calls fall
|
|
580
|
+
back to same-file resolution. Every binding form (default, namespace,
|
|
581
|
+
named) is collected for externality when its import does not resolve
|
|
582
|
+
in-repo, and for `in_repo` (classification precedence, not resolution)
|
|
583
|
+
when it does.
|
|
584
|
+
"""
|
|
585
|
+
symbols: dict[str, str] = {}
|
|
586
|
+
external: set[str] = set()
|
|
587
|
+
in_repo: set[str] = set()
|
|
588
|
+
namespaces: dict[str, str] = {}
|
|
589
|
+
#: Local names bound by a `require()` declarator, so the shadowing guard
|
|
590
|
+
#: below applies to exactly what CommonJS support introduced.
|
|
591
|
+
cjs_locals: set[str] = set()
|
|
592
|
+
if source_index is None:
|
|
593
|
+
return _ImportBindings(symbols, frozenset(), frozenset(), {})
|
|
594
|
+
|
|
595
|
+
def _handle_import_statement(node: Any) -> None:
|
|
596
|
+
source_lit = None
|
|
597
|
+
clause = None
|
|
598
|
+
for child in node.children:
|
|
599
|
+
if child.type == "string":
|
|
600
|
+
source_lit = child.text.decode("utf-8", errors="ignore").strip("'\"")
|
|
601
|
+
elif child.type == "import_clause":
|
|
602
|
+
clause = child
|
|
603
|
+
if not source_lit or clause is None:
|
|
604
|
+
return
|
|
605
|
+
resolved = _resolve_import(rel_path, source_lit, source_index)
|
|
606
|
+
if resolved is None:
|
|
607
|
+
# Unresolved module: every name it binds leaves the repo.
|
|
608
|
+
external.update(_iter_bound_local_names(clause))
|
|
609
|
+
return
|
|
610
|
+
# Resolved module: every name it binds is proven in-repo, regardless
|
|
611
|
+
# of binding form -- must win over an _EXTERNAL_GLOBALS collision.
|
|
612
|
+
in_repo.update(_iter_bound_local_names(clause))
|
|
613
|
+
target_file_id = _file_node_id(resolved.rel_path)
|
|
614
|
+
for local, original in _iter_named_imports(clause):
|
|
615
|
+
symbols[local] = _make_id(target_file_id, original)
|
|
616
|
+
for child in clause.children:
|
|
617
|
+
if child.type != "namespace_import": # import * as ns from './x'
|
|
618
|
+
continue
|
|
619
|
+
for sub in child.children:
|
|
620
|
+
if sub.type.endswith("identifier") and sub.text:
|
|
621
|
+
namespaces[sub.text.decode("utf-8", errors="ignore")] = target_file_id
|
|
622
|
+
|
|
623
|
+
def _handle_require_declarator(node: Any) -> None:
|
|
624
|
+
"""`const m = require('./x')` and `const { f } = require('./x')`.
|
|
625
|
+
|
|
626
|
+
A require is a call expression, so none of this is reachable from the
|
|
627
|
+
`import_statement` walk above -- which is why CommonJS bound nothing at
|
|
628
|
+
all before #49.
|
|
629
|
+
"""
|
|
630
|
+
value = node.child_by_field_name("value")
|
|
631
|
+
name_node = node.child_by_field_name("name")
|
|
632
|
+
if value is None or name_node is None:
|
|
633
|
+
return
|
|
634
|
+
source_lit = _require_source_literal(value)
|
|
635
|
+
if not source_lit:
|
|
636
|
+
return
|
|
637
|
+
if name_node.type.endswith("identifier"):
|
|
638
|
+
if not name_node.text:
|
|
639
|
+
return
|
|
640
|
+
local = name_node.text.decode("utf-8", errors="ignore")
|
|
641
|
+
bound, pairs = [local], []
|
|
642
|
+
elif name_node.type == "object_pattern":
|
|
643
|
+
pairs = list(_iter_object_pattern_names(name_node))
|
|
644
|
+
bound = [local for local, _original in pairs]
|
|
645
|
+
else:
|
|
646
|
+
return
|
|
647
|
+
if not bound:
|
|
648
|
+
return
|
|
649
|
+
cjs_locals.update(bound)
|
|
650
|
+
resolved = _resolve_import(rel_path, source_lit, source_index)
|
|
651
|
+
if resolved is None:
|
|
652
|
+
external.update(bound)
|
|
653
|
+
return
|
|
654
|
+
in_repo.update(bound)
|
|
655
|
+
target_file_id = _file_node_id(resolved.rel_path)
|
|
656
|
+
if pairs:
|
|
657
|
+
for local, original in pairs:
|
|
658
|
+
symbols[local] = _make_id(target_file_id, original)
|
|
659
|
+
else:
|
|
660
|
+
namespaces[bound[0]] = target_file_id
|
|
661
|
+
|
|
662
|
+
def visit(node: Any) -> None:
|
|
663
|
+
# Recursive, unlike the old top-level-only scan: a `require` inside a
|
|
664
|
+
# function body binds a name the same way one at module scope does.
|
|
665
|
+
if node.type == "import_statement":
|
|
666
|
+
_handle_import_statement(node)
|
|
667
|
+
elif node.type == "variable_declarator":
|
|
668
|
+
_handle_require_declarator(node)
|
|
669
|
+
for child in node.children:
|
|
670
|
+
visit(child)
|
|
671
|
+
|
|
672
|
+
visit(root)
|
|
673
|
+
# Applied only to what CommonJS introduced. ESM binding forms are
|
|
674
|
+
# statement-level and were never re-derived from a declarator, so filtering
|
|
675
|
+
# them here would change long-standing behaviour for a hazard this change
|
|
676
|
+
# did not create.
|
|
677
|
+
rebound = _rebound_local_names(root) & cjs_locals
|
|
678
|
+
for name in rebound:
|
|
679
|
+
namespaces.pop(name, None)
|
|
680
|
+
symbols.pop(name, None)
|
|
681
|
+
return _ImportBindings(symbols, frozenset(external), frozenset(in_repo), namespaces)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _iter_named_imports(clause: Any):
|
|
685
|
+
"""Yield (local_name, original_export_name) for each named import in a clause."""
|
|
686
|
+
for child in clause.children:
|
|
687
|
+
if child.type != "named_imports":
|
|
688
|
+
continue
|
|
689
|
+
for spec in child.children:
|
|
690
|
+
if spec.type != "import_specifier":
|
|
691
|
+
continue
|
|
692
|
+
name_node = spec.child_by_field_name("name")
|
|
693
|
+
if name_node is None or not name_node.text:
|
|
694
|
+
continue
|
|
695
|
+
original = name_node.text.decode("utf-8", errors="ignore")
|
|
696
|
+
alias_node = spec.child_by_field_name("alias")
|
|
697
|
+
local = (
|
|
698
|
+
alias_node.text.decode("utf-8", errors="ignore")
|
|
699
|
+
if alias_node is not None and alias_node.text
|
|
700
|
+
else original
|
|
701
|
+
)
|
|
702
|
+
yield local, original
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _iter_bound_local_names(clause: Any):
|
|
706
|
+
"""Yield every local name a TS import clause binds.
|
|
707
|
+
|
|
708
|
+
`_iter_named_imports` is deliberately narrower: resolution needs the
|
|
709
|
+
original export name, so it handles only `named_imports`. Externality needs
|
|
710
|
+
only the local name, so all three binding forms count here -- otherwise
|
|
711
|
+
`import axios from 'axios'` and `import * as lib from 'lib'` stay invisible.
|
|
712
|
+
"""
|
|
713
|
+
for child in clause.children:
|
|
714
|
+
if child.type == "identifier": # import axios from 'axios'
|
|
715
|
+
if child.text:
|
|
716
|
+
yield child.text.decode("utf-8", errors="ignore")
|
|
717
|
+
elif child.type == "namespace_import": # import * as lib from 'lib'
|
|
718
|
+
for sub in child.children:
|
|
719
|
+
if sub.type == "identifier" and sub.text:
|
|
720
|
+
yield sub.text.decode("utf-8", errors="ignore")
|
|
721
|
+
elif child.type == "named_imports": # import { a, b as c } from 'lib'
|
|
722
|
+
for spec in child.children:
|
|
723
|
+
if spec.type != "import_specifier":
|
|
724
|
+
continue
|
|
725
|
+
alias_node = spec.child_by_field_name("alias")
|
|
726
|
+
name_node = spec.child_by_field_name("name")
|
|
727
|
+
chosen = alias_node if alias_node is not None else name_node
|
|
728
|
+
if chosen is not None and chosen.text:
|
|
729
|
+
yield chosen.text.decode("utf-8", errors="ignore")
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _require_source_literal(node: Any) -> str | None:
|
|
733
|
+
"""The module string of `require('<literal>')`, else None.
|
|
734
|
+
|
|
735
|
+
ONE definition, called from both the binding collector and the walk that
|
|
736
|
+
emits the import edge. Two independent detections of the same syntax drift
|
|
737
|
+
the moment one of them learns about `require.resolve` or a template literal
|
|
738
|
+
and the other does not.
|
|
739
|
+
|
|
740
|
+
Literal-only on purpose: `require(someExpr)` is a genuine dynamic import
|
|
741
|
+
with no statically knowable target, and stays unmodelled -- and declared.
|
|
742
|
+
"""
|
|
743
|
+
if node.type != "call_expression":
|
|
744
|
+
return None
|
|
745
|
+
func = node.child_by_field_name("function")
|
|
746
|
+
if func is None or not func.type.endswith("identifier") or not func.text:
|
|
747
|
+
return None
|
|
748
|
+
if func.text.decode("utf-8", errors="ignore") != "require":
|
|
749
|
+
return None
|
|
750
|
+
arguments = node.child_by_field_name("arguments")
|
|
751
|
+
if arguments is None:
|
|
752
|
+
return None
|
|
753
|
+
literals = [child for child in arguments.children if child.type == "string"]
|
|
754
|
+
# Exactly one string argument. `require(a, b)` is not a module load, and a
|
|
755
|
+
# template literal parses as `template_string`, so it never lands here.
|
|
756
|
+
if len(literals) != 1 or not literals[0].text:
|
|
757
|
+
return None
|
|
758
|
+
return literals[0].text.decode("utf-8", errors="ignore").strip("'\"") or None
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _rebound_local_names(root: Any) -> frozenset[str]:
|
|
762
|
+
"""Names this file binds more than once, anywhere, at any depth.
|
|
763
|
+
|
|
764
|
+
The CommonJS binding maps are FILE-level while calls are walked per scope,
|
|
765
|
+
so an inner `const m = ...`, a parameter named `m`, or a second destructure
|
|
766
|
+
of the same name is indistinguishable from the module binding at resolution
|
|
767
|
+
time -- and would make `m.real()` claim the module's definition, putting a
|
|
768
|
+
caller in `callers real` that does not exist.
|
|
769
|
+
|
|
770
|
+
Deliberately blunt: count every binding occurrence and distrust any name
|
|
771
|
+
that appears twice, rather than modelling JavaScript scope. That FAILS
|
|
772
|
+
CLOSED, giving up an edge instead of inventing one, which is the right
|
|
773
|
+
direction for a graph whose empty answers are graded honestly. Real
|
|
774
|
+
scope tracking would bind more, and is a larger change than #49.
|
|
775
|
+
"""
|
|
776
|
+
counts: dict[str, int] = {}
|
|
777
|
+
|
|
778
|
+
def _count(name_node: Any) -> None:
|
|
779
|
+
if name_node is None:
|
|
780
|
+
return
|
|
781
|
+
if name_node.type.endswith("identifier"):
|
|
782
|
+
if name_node.text:
|
|
783
|
+
name = name_node.text.decode("utf-8", errors="ignore")
|
|
784
|
+
counts[name] = counts.get(name, 0) + 1
|
|
785
|
+
elif name_node.type in ("object_pattern", "array_pattern"):
|
|
786
|
+
for _node, name in _pattern_identifiers(name_node):
|
|
787
|
+
counts[name] = counts.get(name, 0) + 1
|
|
788
|
+
|
|
789
|
+
def visit(node: Any) -> None:
|
|
790
|
+
if node.type == "variable_declarator":
|
|
791
|
+
_count(node.child_by_field_name("name"))
|
|
792
|
+
elif node.type in ("formal_parameters", "arrow_function"):
|
|
793
|
+
for child in node.children:
|
|
794
|
+
if child.type in ("required_parameter", "optional_parameter"):
|
|
795
|
+
_count(child.child_by_field_name("pattern"))
|
|
796
|
+
elif child.type.endswith("identifier") or child.type in (
|
|
797
|
+
"object_pattern",
|
|
798
|
+
"array_pattern",
|
|
799
|
+
):
|
|
800
|
+
_count(child)
|
|
801
|
+
for child in node.children:
|
|
802
|
+
visit(child)
|
|
803
|
+
|
|
804
|
+
visit(root)
|
|
805
|
+
return frozenset(name for name, count in counts.items() if count > 1)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _iter_object_pattern_names(pattern: Any):
|
|
809
|
+
"""Yield (local_name, original_export_name) for `const { a, b: c } = ...`."""
|
|
810
|
+
for child in pattern.children:
|
|
811
|
+
if child.type == "shorthand_property_identifier_pattern":
|
|
812
|
+
if child.text:
|
|
813
|
+
name = child.text.decode("utf-8", errors="ignore")
|
|
814
|
+
yield name, name
|
|
815
|
+
elif child.type == "pair_pattern":
|
|
816
|
+
key = child.child_by_field_name("key")
|
|
817
|
+
value = child.child_by_field_name("value")
|
|
818
|
+
if key is None or value is None or not key.text or not value.text:
|
|
819
|
+
continue
|
|
820
|
+
if not value.type.endswith("identifier"):
|
|
821
|
+
# Nested destructuring binds no single callable name.
|
|
822
|
+
continue
|
|
823
|
+
yield (
|
|
824
|
+
value.text.decode("utf-8", errors="ignore"),
|
|
825
|
+
key.text.decode("utf-8", errors="ignore"),
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _declarator_binding_name(node: Any) -> str | None:
|
|
830
|
+
"""The plain identifier a function-valued variable declarator binds to.
|
|
831
|
+
|
|
832
|
+
``const f = () => ...`` and ``const f = function () {}`` make ``f`` callable
|
|
833
|
+
by that name, so the definition must carry the same id shape a
|
|
834
|
+
``function f()`` declaration produces -- otherwise no call to ``f()`` can
|
|
835
|
+
ever bind, even inside the defining file (#16).
|
|
836
|
+
|
|
837
|
+
Returns None for anything that binds no such callable name: destructuring
|
|
838
|
+
patterns, object-literal property values, class fields, and callbacks.
|
|
839
|
+
"""
|
|
840
|
+
parent = node.parent
|
|
841
|
+
if parent is None or parent.type != "variable_declarator":
|
|
842
|
+
return None
|
|
843
|
+
value = parent.child_by_field_name("value")
|
|
844
|
+
# Compare by node id, not identity: the tree-sitter binding returns a fresh
|
|
845
|
+
# Node object per call, so `value is node` is always False.
|
|
846
|
+
if value is None or value.id != node.id:
|
|
847
|
+
return None
|
|
848
|
+
name = parent.child_by_field_name("name")
|
|
849
|
+
if name is None or name.type != "identifier" or not name.text:
|
|
850
|
+
return None
|
|
851
|
+
return name.text.decode("utf-8", errors="ignore") or None
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _class_field_binding_name(node: Any) -> str | None:
|
|
855
|
+
"""The field name a function-valued class field binds to.
|
|
856
|
+
|
|
857
|
+
``handle = () => 1`` inside a class parses as ``public_field_definition``
|
|
858
|
+
(``field_definition`` in plain JS), not ``method_definition``, so it never
|
|
859
|
+
reached the named path and produced no call target at all (#19). This is the
|
|
860
|
+
case #16 explicitly scoped out.
|
|
861
|
+
|
|
862
|
+
Such a field is invoked as ``this.handle()`` / ``obj.handle()`` -- through
|
|
863
|
+
method dispatch rather than by bare name -- so the caller must also tag the
|
|
864
|
+
resulting node ``is_method``, or the dispatch post-pass will not index it and
|
|
865
|
+
the call edge stays dropped.
|
|
866
|
+
|
|
867
|
+
Returns None for anything that binds no such callable name: object-literal
|
|
868
|
+
property values and callbacks keep the deliberate anonymity of #16.
|
|
869
|
+
"""
|
|
870
|
+
parent = node.parent
|
|
871
|
+
if parent is None or parent.type not in ("public_field_definition", "field_definition"):
|
|
872
|
+
return None
|
|
873
|
+
value = parent.child_by_field_name("value")
|
|
874
|
+
# Compare by node id, not identity: the tree-sitter binding returns a fresh
|
|
875
|
+
# Node object per call, so `value is node` is always False.
|
|
876
|
+
if value is None or value.id != node.id:
|
|
877
|
+
return None
|
|
878
|
+
name = parent.child_by_field_name("name")
|
|
879
|
+
if name is None or name.type not in ("property_identifier", "identifier") or not name.text:
|
|
880
|
+
return None
|
|
881
|
+
return name.text.decode("utf-8", errors="ignore") or None
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
# React enforces that hooks are named `use<Capital>` -- it is a real language
|
|
885
|
+
# convention (eslint-plugin-react-hooks keys on exactly this), not a guess.
|
|
886
|
+
# Restricting destructure-binding to hook calls is deliberate: `const { readFile }
|
|
887
|
+
# = require('fs')` destructures an EXTERNAL callable, and registering that as an
|
|
888
|
+
# in-repo definition would be a bound-to-wrong-target error, which #7 established
|
|
889
|
+
# is invisible to health. A missed binding is honestly counted; a false one is not.
|
|
890
|
+
_HOOK_CALL_NAME = re.compile(r"^use[A-Z]\w*$")
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _is_hook_call(value: Any) -> bool:
|
|
894
|
+
"""True for `useThing(...)` / `React.useThing(...)`, including `useThing<T>(...)`.
|
|
895
|
+
|
|
896
|
+
The TS generic spelling matters: `useState<string>('')` is the dominant form
|
|
897
|
+
in typed React code, and keying on the plain `useState(` shape would miss
|
|
898
|
+
every TypeScript repo (#20).
|
|
899
|
+
"""
|
|
900
|
+
if value is None or value.type != "call_expression":
|
|
901
|
+
return False
|
|
902
|
+
func = value.child_by_field_name("function")
|
|
903
|
+
if func is None or not func.text:
|
|
904
|
+
return False
|
|
905
|
+
name = func.text.decode("utf-8", errors="ignore").strip().rsplit(".", 1)[-1]
|
|
906
|
+
return bool(_HOOK_CALL_NAME.match(name))
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _pattern_identifiers(pattern: Any) -> list[tuple[Any, str]]:
|
|
910
|
+
"""Binding identifiers introduced by a destructuring pattern.
|
|
911
|
+
|
|
912
|
+
Covers `[a, b]`, `{ a }`, `{ a: b }`, defaults (`[a = 1]`) and nesting. Only
|
|
913
|
+
the *binding* side is collected -- an object pattern's key is a
|
|
914
|
+
`property_identifier`, so `{ a: b }` yields `b` and never `a`.
|
|
915
|
+
"""
|
|
916
|
+
out: list[tuple[Any, str]] = []
|
|
917
|
+
|
|
918
|
+
def visit(node: Any) -> None:
|
|
919
|
+
for child in node.children:
|
|
920
|
+
ct = child.type
|
|
921
|
+
if ct in ("identifier", "shorthand_property_identifier_pattern"):
|
|
922
|
+
if child.text:
|
|
923
|
+
out.append((child, child.text.decode("utf-8", errors="ignore")))
|
|
924
|
+
elif ct == "pair_pattern":
|
|
925
|
+
value = child.child_by_field_name("value")
|
|
926
|
+
if value is None:
|
|
927
|
+
continue
|
|
928
|
+
if value.type == "identifier" and value.text:
|
|
929
|
+
out.append((value, value.text.decode("utf-8", errors="ignore")))
|
|
930
|
+
elif value.type in ("array_pattern", "object_pattern"):
|
|
931
|
+
visit(value)
|
|
932
|
+
elif ct == "assignment_pattern":
|
|
933
|
+
# `[a = fallback()]` -- take the binding, never the default expr.
|
|
934
|
+
left = child.child_by_field_name("left")
|
|
935
|
+
if left is not None and left.type == "identifier" and left.text:
|
|
936
|
+
out.append((left, left.text.decode("utf-8", errors="ignore")))
|
|
937
|
+
elif ct in ("array_pattern", "object_pattern", "rest_pattern"):
|
|
938
|
+
visit(child)
|
|
939
|
+
|
|
940
|
+
visit(pattern)
|
|
941
|
+
return out
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _synthetic_fn_name(node: Any, source: bytes) -> str | None:
|
|
945
|
+
"""Derive a stable, human-ish name for an anonymous function from its context.
|
|
946
|
+
|
|
947
|
+
Handles the common shapes: object property value (``run: () => ...``),
|
|
948
|
+
variable binding (``const f = () => ...``), assignment, class field, and
|
|
949
|
+
callback arguments (``app.post('/x', () => ...)`` -> ``app.post /x``).
|
|
950
|
+
Returns None when no context is available (caller falls back to ``anon``).
|
|
951
|
+
"""
|
|
952
|
+
parent = node.parent
|
|
953
|
+
if parent is None:
|
|
954
|
+
return None
|
|
955
|
+
pt = parent.type
|
|
956
|
+
if pt == "pair":
|
|
957
|
+
key = parent.child_by_field_name("key")
|
|
958
|
+
if key is not None and key.text:
|
|
959
|
+
return _short_name(key.text.decode("utf-8", errors="ignore"))
|
|
960
|
+
elif pt == "variable_declarator":
|
|
961
|
+
nm = parent.child_by_field_name("name")
|
|
962
|
+
if nm is not None and nm.text:
|
|
963
|
+
return _short_name(nm.text.decode("utf-8", errors="ignore"))
|
|
964
|
+
elif pt == "assignment_expression":
|
|
965
|
+
left = parent.child_by_field_name("left")
|
|
966
|
+
if left is not None:
|
|
967
|
+
nm = _simple_object_name(left)
|
|
968
|
+
if nm:
|
|
969
|
+
return _short_name(nm)
|
|
970
|
+
if left.text:
|
|
971
|
+
return _short_name(left.text.decode("utf-8", errors="ignore"))
|
|
972
|
+
elif pt in ("public_field_definition", "field_definition", "property_signature"):
|
|
973
|
+
nm = parent.child_by_field_name("name")
|
|
974
|
+
if nm is not None and nm.text:
|
|
975
|
+
return _short_name(nm.text.decode("utf-8", errors="ignore"))
|
|
976
|
+
elif pt == "arguments":
|
|
977
|
+
gp = parent.parent
|
|
978
|
+
if gp is not None and gp.type in ("call_expression", "new_expression"):
|
|
979
|
+
callee = gp.child_by_field_name("function")
|
|
980
|
+
callee_name = _call_target_name(callee, source) if callee is not None else None
|
|
981
|
+
str_arg = None
|
|
982
|
+
for arg in parent.children:
|
|
983
|
+
if arg.type == "string":
|
|
984
|
+
str_arg = arg.text.decode("utf-8", errors="ignore").strip("'\"`")
|
|
985
|
+
break
|
|
986
|
+
if callee_name and str_arg:
|
|
987
|
+
return _short_name(f"{callee_name} {str_arg}")
|
|
988
|
+
if callee_name:
|
|
989
|
+
return _short_name(callee_name)
|
|
990
|
+
return None
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
_MAX_NAME_LEN = 80
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _short_name(text: str | None) -> str | None:
|
|
997
|
+
if not text:
|
|
998
|
+
return None
|
|
999
|
+
text = text.strip()
|
|
1000
|
+
if len(text) > _MAX_NAME_LEN:
|
|
1001
|
+
# For chained/long names keep the final segment.
|
|
1002
|
+
return text.split(".")[-1][: _MAX_NAME_LEN]
|
|
1003
|
+
return text
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _safe_label(text: str | None) -> str:
|
|
1007
|
+
"""Normalize a synthetic label into a compact, console-safe display name.
|
|
1008
|
+
|
|
1009
|
+
Synthetic names are derived from arbitrary source strings (route paths, test
|
|
1010
|
+
descriptions, i18n literals) which may contain newlines, emoji, or other
|
|
1011
|
+
non-ASCII characters. Collapse whitespace and drop anything outside printable
|
|
1012
|
+
ASCII so labels stay short and render on any terminal/encoding.
|
|
1013
|
+
"""
|
|
1014
|
+
if not text:
|
|
1015
|
+
return "anon"
|
|
1016
|
+
# Replace control/non-ASCII with spaces, then collapse, so a stripped
|
|
1017
|
+
# character between words doesn't leave a double space.
|
|
1018
|
+
text = "".join(ch if 32 <= ord(ch) < 127 else " " for ch in text)
|
|
1019
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
1020
|
+
return text[:_MAX_NAME_LEN] if text else "anon"
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _simple_object_name(node: Any) -> str | None:
|
|
1024
|
+
"""Return a short name for the object of a member_expression, or None if too complex."""
|
|
1025
|
+
if node.type.endswith("identifier"):
|
|
1026
|
+
return node.text.decode("utf-8", errors="ignore") if node.text else None
|
|
1027
|
+
if node.type == "member_expression":
|
|
1028
|
+
obj = node.child_by_field_name("object")
|
|
1029
|
+
prop = node.child_by_field_name("property")
|
|
1030
|
+
if obj and prop:
|
|
1031
|
+
obj_name = _simple_object_name(obj)
|
|
1032
|
+
prop_name = prop.text.decode("utf-8", errors="ignore") if prop.text else None
|
|
1033
|
+
if obj_name and prop_name:
|
|
1034
|
+
combined = f"{obj_name}.{prop_name}"
|
|
1035
|
+
return _short_name(combined)
|
|
1036
|
+
return None
|
|
1037
|
+
# Complex literals (array, object, parenthesized, call) — don't stringify.
|
|
1038
|
+
return None
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _call_target_name(node: Any, source: bytes) -> str | None:
|
|
1042
|
+
"""Best-effort name for a call target."""
|
|
1043
|
+
if node.type.endswith("identifier"):
|
|
1044
|
+
return _short_name(node.text.decode("utf-8", errors="ignore") if node.text else None)
|
|
1045
|
+
if node.type == "member_expression":
|
|
1046
|
+
obj = node.child_by_field_name("object")
|
|
1047
|
+
prop = node.child_by_field_name("property")
|
|
1048
|
+
prop_name = prop.text.decode("utf-8", errors="ignore") if prop and prop.text else None
|
|
1049
|
+
obj_name = _simple_object_name(obj) if obj else None
|
|
1050
|
+
if prop_name:
|
|
1051
|
+
return _short_name(f"{obj_name}.{prop_name}" if obj_name else prop_name)
|
|
1052
|
+
return _short_name(prop_name)
|
|
1053
|
+
# Fallback: first identifier child.
|
|
1054
|
+
for child in node.children:
|
|
1055
|
+
if child.type.endswith("identifier"):
|
|
1056
|
+
return _short_name(child.text.decode("utf-8", errors="ignore") if child.text else None)
|
|
1057
|
+
return None
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _python_import_modules(node: Any) -> list[tuple[str, int]]:
|
|
1061
|
+
"""(module_dotted, relative_dots) per module imported by this statement.
|
|
1062
|
+
|
|
1063
|
+
import_statement: one entry per dotted_name / aliased_import child.
|
|
1064
|
+
import_from_statement: exactly one entry from the module_name field —
|
|
1065
|
+
imported NAMES are deliberately ignored (they are symbols, not modules).
|
|
1066
|
+
"""
|
|
1067
|
+
def _text(n: Any) -> str:
|
|
1068
|
+
return n.text.decode("utf-8", errors="ignore") if n is not None and n.text else ""
|
|
1069
|
+
|
|
1070
|
+
out: list[tuple[str, int]] = []
|
|
1071
|
+
if node.type == "import_statement":
|
|
1072
|
+
for child in node.children:
|
|
1073
|
+
if child.type == "dotted_name":
|
|
1074
|
+
if _text(child):
|
|
1075
|
+
out.append((_text(child), 0))
|
|
1076
|
+
elif child.type == "aliased_import":
|
|
1077
|
+
name = child.child_by_field_name("name")
|
|
1078
|
+
if _text(name):
|
|
1079
|
+
out.append((_text(name), 0))
|
|
1080
|
+
elif node.type == "import_from_statement":
|
|
1081
|
+
module = node.child_by_field_name("module_name")
|
|
1082
|
+
if module is None:
|
|
1083
|
+
return out
|
|
1084
|
+
if module.type == "relative_import":
|
|
1085
|
+
dots = 0
|
|
1086
|
+
dotted = ""
|
|
1087
|
+
for child in module.children:
|
|
1088
|
+
if child.type == "import_prefix":
|
|
1089
|
+
dots = len(_text(child))
|
|
1090
|
+
elif child.type == "dotted_name":
|
|
1091
|
+
dotted = _text(child)
|
|
1092
|
+
if dots:
|
|
1093
|
+
out.append((dotted, dots))
|
|
1094
|
+
elif module.type == "dotted_name" and _text(module):
|
|
1095
|
+
out.append((_text(module), 0))
|
|
1096
|
+
return out
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _python_from_import_submodules(
|
|
1100
|
+
node: Any, rel_path: str, source_index: SourceIndex | None
|
|
1101
|
+
) -> list[str]:
|
|
1102
|
+
"""Resolved submodule paths for `from P import a, b` when a/b are modules.
|
|
1103
|
+
|
|
1104
|
+
Mirrors _collect_python_import_maps' module-first probe (the
|
|
1105
|
+
`as_module = source_index.resolve_python_module(...)` check tried
|
|
1106
|
+
before the symbol-map fallback, in its import_from_statement branch)
|
|
1107
|
+
at the import-EDGE layer: the emission site only ever saw the base
|
|
1108
|
+
module, which is how `from aramid import pipeline` bound to the package
|
|
1109
|
+
__init__ and hid test files from impact (issue #7).
|
|
1110
|
+
"""
|
|
1111
|
+
if node.type != "import_from_statement" or source_index is None:
|
|
1112
|
+
return []
|
|
1113
|
+
modules = _python_import_modules(node)
|
|
1114
|
+
if not modules:
|
|
1115
|
+
return []
|
|
1116
|
+
base_module, dots = modules[0]
|
|
1117
|
+
module_field = node.child_by_field_name("module_name")
|
|
1118
|
+
|
|
1119
|
+
def _text(n: Any) -> str:
|
|
1120
|
+
return n.text.decode("utf-8", errors="ignore") if n is not None and n.text else ""
|
|
1121
|
+
|
|
1122
|
+
out: list[Any] = []
|
|
1123
|
+
for child in node.children:
|
|
1124
|
+
if module_field is not None and child.id == module_field.id:
|
|
1125
|
+
# Identity-skip the module_name's own dotted_name (paren-safe;
|
|
1126
|
+
# see _collect_python_import_maps for the sibling-token trap).
|
|
1127
|
+
continue
|
|
1128
|
+
original = None
|
|
1129
|
+
if child.type == "dotted_name":
|
|
1130
|
+
original = _text(child)
|
|
1131
|
+
elif child.type == "aliased_import":
|
|
1132
|
+
original = _text(child.child_by_field_name("name"))
|
|
1133
|
+
if not original or "." in original:
|
|
1134
|
+
continue
|
|
1135
|
+
sub = f"{base_module}.{original}" if base_module else original
|
|
1136
|
+
resolved = source_index.resolve_python_module(rel_path, sub, dots)
|
|
1137
|
+
if resolved:
|
|
1138
|
+
out.append(resolved)
|
|
1139
|
+
return out
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _collect_python_import_maps(
|
|
1143
|
+
root: Any, rel_path: str, source_index: SourceIndex | None
|
|
1144
|
+
) -> tuple[dict[str, str], dict[str, str], frozenset[str]]:
|
|
1145
|
+
"""(symbol_map, alias_map, external_names).
|
|
1146
|
+
|
|
1147
|
+
symbol_map: local -> definition node id. alias_map: local -> module file id.
|
|
1148
|
+
external_names: local names bound by imports that did NOT resolve in-repo --
|
|
1149
|
+
calls through them leave the repo (EXTERNAL_CALL).
|
|
1150
|
+
|
|
1151
|
+
Walked at ALL depths (Python allows function-local imports). For
|
|
1152
|
+
`from P import name`, `P.name` is tried as a MODULE first (alias), then
|
|
1153
|
+
as a symbol defined in P's file. Unresolvable modules enter neither map,
|
|
1154
|
+
but DO enter external_names.
|
|
1155
|
+
Last binding wins, matching Python shadowing.
|
|
1156
|
+
"""
|
|
1157
|
+
symbol_map: dict[str, str] = {}
|
|
1158
|
+
alias_map: dict[str, str] = {}
|
|
1159
|
+
external: set[str] = set()
|
|
1160
|
+
if source_index is None:
|
|
1161
|
+
return symbol_map, alias_map, frozenset()
|
|
1162
|
+
|
|
1163
|
+
def _text(n: Any) -> str:
|
|
1164
|
+
return n.text.decode("utf-8", errors="ignore") if n is not None and n.text else ""
|
|
1165
|
+
|
|
1166
|
+
def visit(node: Any) -> None:
|
|
1167
|
+
if node.type == "import_statement":
|
|
1168
|
+
for child in node.children:
|
|
1169
|
+
if child.type == "dotted_name":
|
|
1170
|
+
module = _text(child)
|
|
1171
|
+
if module and "." not in module:
|
|
1172
|
+
resolved = source_index.resolve_python_module(rel_path, module)
|
|
1173
|
+
if resolved:
|
|
1174
|
+
alias_map[module] = _file_node_id(resolved)
|
|
1175
|
+
else:
|
|
1176
|
+
external.add(module)
|
|
1177
|
+
elif module:
|
|
1178
|
+
# `import pkg.sub` binds only the root name `pkg`.
|
|
1179
|
+
# Mark it external ONLY if that root does not
|
|
1180
|
+
# resolve in-repo -- a local `pkg` would otherwise
|
|
1181
|
+
# have its calls excluded from the ratio as a false
|
|
1182
|
+
# external (spec §4.2: a false external costs more
|
|
1183
|
+
# than a missed one).
|
|
1184
|
+
root = module.split(".", 1)[0]
|
|
1185
|
+
if source_index.resolve_python_module(rel_path, root) is None:
|
|
1186
|
+
external.add(root)
|
|
1187
|
+
elif child.type == "aliased_import":
|
|
1188
|
+
module = _text(child.child_by_field_name("name"))
|
|
1189
|
+
local = _text(child.child_by_field_name("alias"))
|
|
1190
|
+
if module and local:
|
|
1191
|
+
resolved = source_index.resolve_python_module(rel_path, module)
|
|
1192
|
+
if resolved:
|
|
1193
|
+
alias_map[local] = _file_node_id(resolved)
|
|
1194
|
+
else:
|
|
1195
|
+
external.add(local)
|
|
1196
|
+
elif node.type == "import_from_statement":
|
|
1197
|
+
modules = _python_import_modules(node)
|
|
1198
|
+
if modules:
|
|
1199
|
+
base_module, dots = modules[0]
|
|
1200
|
+
module_field = node.child_by_field_name("module_name")
|
|
1201
|
+
for child in node.children:
|
|
1202
|
+
if module_field is not None and child.id == module_field.id:
|
|
1203
|
+
# The module_name field's own dotted_name (e.g. `pkg`
|
|
1204
|
+
# in `from pkg import a`) is ALSO a plain child of
|
|
1205
|
+
# this statement. Skip it by identity rather than by
|
|
1206
|
+
# sibling-token sniffing: `prev_sibling in ("import",
|
|
1207
|
+
# ",")` fails for the first name inside parens
|
|
1208
|
+
# (`from x import (a, b)` — `a`'s prev_sibling is
|
|
1209
|
+
# `(`), silently dropping black-style multi-imports.
|
|
1210
|
+
continue
|
|
1211
|
+
local = original = None
|
|
1212
|
+
if child.type == "dotted_name":
|
|
1213
|
+
original = local = _text(child)
|
|
1214
|
+
elif child.type == "aliased_import":
|
|
1215
|
+
original = _text(child.child_by_field_name("name"))
|
|
1216
|
+
local = _text(child.child_by_field_name("alias"))
|
|
1217
|
+
if not original or not local or "." in original:
|
|
1218
|
+
continue
|
|
1219
|
+
sub = f"{base_module}.{original}" if base_module else original
|
|
1220
|
+
as_module = source_index.resolve_python_module(rel_path, sub, dots)
|
|
1221
|
+
if as_module:
|
|
1222
|
+
alias_map[local] = _file_node_id(as_module)
|
|
1223
|
+
continue
|
|
1224
|
+
parent = source_index.resolve_python_module(rel_path, base_module, dots)
|
|
1225
|
+
if parent:
|
|
1226
|
+
symbol_map[local] = _make_id(_file_node_id(parent), original)
|
|
1227
|
+
else:
|
|
1228
|
+
external.add(local)
|
|
1229
|
+
for child in node.children:
|
|
1230
|
+
visit(child)
|
|
1231
|
+
|
|
1232
|
+
visit(root)
|
|
1233
|
+
return symbol_map, alias_map, frozenset(external)
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _python_call_target(func: Any) -> tuple[str | None, str | None, str | None]:
|
|
1237
|
+
"""(bare_name, object_name, attribute_name) for a Python call's function node."""
|
|
1238
|
+
def _text(n: Any) -> str | None:
|
|
1239
|
+
return n.text.decode("utf-8", errors="ignore") if n is not None and n.text else None
|
|
1240
|
+
|
|
1241
|
+
if func.type == "identifier":
|
|
1242
|
+
return _text(func), None, None
|
|
1243
|
+
if func.type == "attribute":
|
|
1244
|
+
obj = func.child_by_field_name("object")
|
|
1245
|
+
attr = _text(func.child_by_field_name("attribute"))
|
|
1246
|
+
obj_name = _text(obj) if obj is not None and obj.type == "identifier" else None
|
|
1247
|
+
return None, obj_name, attr
|
|
1248
|
+
return None, None, None
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def _python_attribute_root(node: Any) -> str | None:
|
|
1252
|
+
"""Leftmost identifier of a (possibly nested) attribute chain.
|
|
1253
|
+
|
|
1254
|
+
`os.path.join` parses as `attribute(attribute(identifier(os), path), join)`
|
|
1255
|
+
-- `_python_call_target` only looks at the immediate object, so for a
|
|
1256
|
+
depth->=2 chain `obj_name` comes back None and the import-bound root
|
|
1257
|
+
(`os`) is invisible to `_call_confidence`. Walked here purely to recover
|
|
1258
|
+
that root for classification; the `dotted` string used for targeting,
|
|
1259
|
+
dispatch (`_member`), and `should_keep_call_target` noise-filtering is
|
|
1260
|
+
untouched by this -- changing that shape trips the noise filter on leaf
|
|
1261
|
+
names like `join` for reasons unrelated to externality.
|
|
1262
|
+
"""
|
|
1263
|
+
while node is not None and node.type == "attribute":
|
|
1264
|
+
node = node.child_by_field_name("object")
|
|
1265
|
+
if node is not None and node.type == "identifier" and node.text:
|
|
1266
|
+
return node.text.decode("utf-8", errors="ignore")
|
|
1267
|
+
return None
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def _extract_python(file_id: str, rel_path: str, _source: bytes, tree: Any, source_index: SourceIndex | None = None) -> ExtractionResult:
|
|
1271
|
+
result = ExtractionResult()
|
|
1272
|
+
root = tree.root_node
|
|
1273
|
+
|
|
1274
|
+
def _line(node: Any) -> int:
|
|
1275
|
+
return (node.start_point[0] + 1) if node.start_point else 1
|
|
1276
|
+
|
|
1277
|
+
result.nodes.append(_node(file_id, "file", Path(rel_path).name, rel_path))
|
|
1278
|
+
|
|
1279
|
+
symbol_map, alias_map, external_names = _collect_python_import_maps(root, rel_path, source_index)
|
|
1280
|
+
|
|
1281
|
+
class_ids: set[str] = set()
|
|
1282
|
+
|
|
1283
|
+
# ``parent_id`` is the nearest named container (for ``contains`` edges);
|
|
1284
|
+
# ``scope_id`` is the nearest enclosing function (for ``calls`` attribution).
|
|
1285
|
+
def walk(node: Any, parent_id: str | None, scope_id: str) -> None:
|
|
1286
|
+
if node.type == "function_definition":
|
|
1287
|
+
name_node = node.child_by_field_name("name")
|
|
1288
|
+
name = _short_name(name_node.text.decode("utf-8", errors="ignore")) if name_node and name_node.text else None
|
|
1289
|
+
if name:
|
|
1290
|
+
mid = _make_id(file_id, name)
|
|
1291
|
+
extra = {"is_method": True} if parent_id in class_ids else None
|
|
1292
|
+
result.nodes.append(_node(mid, "function", name, rel_path, _line(node), extra))
|
|
1293
|
+
if parent_id:
|
|
1294
|
+
result.edges.append(_edge(parent_id, mid, "contains", rel_path, _line(node)))
|
|
1295
|
+
walk_children(node, mid, mid)
|
|
1296
|
+
else:
|
|
1297
|
+
walk_children(node, parent_id, scope_id)
|
|
1298
|
+
elif node.type == "class_definition":
|
|
1299
|
+
name_node = node.child_by_field_name("name")
|
|
1300
|
+
name = _short_name(name_node.text.decode("utf-8", errors="ignore")) if name_node and name_node.text else None
|
|
1301
|
+
if name:
|
|
1302
|
+
cid = _make_id(file_id, name)
|
|
1303
|
+
class_ids.add(cid)
|
|
1304
|
+
result.nodes.append(_node(cid, "class", name, rel_path, _line(node)))
|
|
1305
|
+
if parent_id:
|
|
1306
|
+
result.edges.append(_edge(parent_id, cid, "contains", rel_path, _line(node)))
|
|
1307
|
+
# Inheritance
|
|
1308
|
+
for base in node.children:
|
|
1309
|
+
if base.type == "argument_list":
|
|
1310
|
+
for arg in base.children:
|
|
1311
|
+
if arg.type.endswith("identifier") and arg.text:
|
|
1312
|
+
base_name = arg.text.decode("utf-8", errors="ignore")
|
|
1313
|
+
result.edges.append(_edge(cid, _make_id(base_name), "inherits", rel_path, _line(arg)))
|
|
1314
|
+
walk_children(node, cid, scope_id)
|
|
1315
|
+
else:
|
|
1316
|
+
walk_children(node, parent_id, scope_id)
|
|
1317
|
+
elif node.type in ("import_statement", "import_from_statement"):
|
|
1318
|
+
for module, dots in _python_import_modules(node):
|
|
1319
|
+
resolved = (
|
|
1320
|
+
source_index.resolve_python_module(rel_path, module, dots)
|
|
1321
|
+
if source_index is not None
|
|
1322
|
+
else None
|
|
1323
|
+
)
|
|
1324
|
+
if resolved:
|
|
1325
|
+
result.edges.append(_edge(
|
|
1326
|
+
file_id, _file_node_id(resolved), "imports", rel_path,
|
|
1327
|
+
_line(node), confidence="EXACT_IMPORT",
|
|
1328
|
+
))
|
|
1329
|
+
else:
|
|
1330
|
+
result.edges.append(_edge(
|
|
1331
|
+
file_id, _make_id(module) if module else _make_id("package"),
|
|
1332
|
+
"imports", rel_path, _line(node), confidence="EXTERNAL_IMPORT",
|
|
1333
|
+
))
|
|
1334
|
+
for sub in _python_from_import_submodules(node, rel_path, source_index):
|
|
1335
|
+
result.edges.append(_edge(
|
|
1336
|
+
file_id, _file_node_id(sub), "imports", rel_path,
|
|
1337
|
+
_line(node), confidence="EXACT_IMPORT",
|
|
1338
|
+
))
|
|
1339
|
+
walk_children(node, parent_id, scope_id)
|
|
1340
|
+
elif node.type == "call":
|
|
1341
|
+
func = node.child_by_field_name("function")
|
|
1342
|
+
bare, obj_name, attr = _python_call_target(func) if func is not None else (None, None, None)
|
|
1343
|
+
edge = None
|
|
1344
|
+
if bare and bare not in _LANGUAGE_BUILTIN_GLOBALS:
|
|
1345
|
+
target = symbol_map.get(bare) or _resolve_call(file_id, bare)
|
|
1346
|
+
edge = _edge(
|
|
1347
|
+
scope_id, target, "calls", rel_path, _line(node),
|
|
1348
|
+
confidence=_call_confidence(bare, external_names, symbol_map),
|
|
1349
|
+
)
|
|
1350
|
+
elif attr:
|
|
1351
|
+
dotted = f"{obj_name}.{attr}" if obj_name else attr
|
|
1352
|
+
if obj_name and obj_name in alias_map:
|
|
1353
|
+
edge = _edge(scope_id, _make_id(alias_map[obj_name], attr), "calls", rel_path, _line(node), confidence="LOCAL_CALL")
|
|
1354
|
+
elif should_keep_call_target(dotted):
|
|
1355
|
+
# Unresolved member call: file-scoped phantom now, re-pointed
|
|
1356
|
+
# (or dropped) by the method-dispatch post-pass via _member.
|
|
1357
|
+
# Confidence is classified off the recovered chain root
|
|
1358
|
+
# (falls back to `dotted` itself for a non-attribute
|
|
1359
|
+
# receiver, e.g. `foo().bar()`), not off `dotted` -- a
|
|
1360
|
+
# depth->=2 chain like `os.path.join` would otherwise
|
|
1361
|
+
# test "join" instead of the bound name "os".
|
|
1362
|
+
# When neither a simple receiver nor a chain root can be
|
|
1363
|
+
# recovered, `dotted` is the bare attribute name and carries
|
|
1364
|
+
# no information about the receiver -- classifying it would
|
|
1365
|
+
# be a false external (#14).
|
|
1366
|
+
recovered_root = obj_name or _python_attribute_root(func)
|
|
1367
|
+
root_name = recovered_root or dotted
|
|
1368
|
+
edge = _edge(
|
|
1369
|
+
scope_id, _resolve_call(file_id, dotted), "calls", rel_path, _line(node),
|
|
1370
|
+
confidence=_call_confidence(
|
|
1371
|
+
root_name, external_names, symbol_map,
|
|
1372
|
+
attributable=recovered_root is not None,
|
|
1373
|
+
),
|
|
1374
|
+
)
|
|
1375
|
+
edge["_member"] = attr
|
|
1376
|
+
if edge is not None:
|
|
1377
|
+
result.edges.append(edge)
|
|
1378
|
+
walk_children(node, parent_id, scope_id)
|
|
1379
|
+
else:
|
|
1380
|
+
walk_children(node, parent_id, scope_id)
|
|
1381
|
+
|
|
1382
|
+
def walk_children(node: Any, parent_id: str | None, scope_id: str) -> None:
|
|
1383
|
+
for child in node.children:
|
|
1384
|
+
walk(child, parent_id, scope_id)
|
|
1385
|
+
|
|
1386
|
+
walk(root, file_id, file_id)
|
|
1387
|
+
return result
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _extract_go(file_id: str, rel_path: str, source: bytes, tree: Any) -> ExtractionResult:
|
|
1391
|
+
"""Heuristic Go extraction: functions, methods, types, imports, calls.
|
|
1392
|
+
|
|
1393
|
+
Same fidelity tier as the Python path — no package-level import resolution.
|
|
1394
|
+
`recv.Method()` selector calls carry `_member` so the global method-dispatch
|
|
1395
|
+
post-pass links them to `method_declaration` definitions (tagged is_method);
|
|
1396
|
+
selector calls that resolve to no known method (fmt.Println, http.Get, ...)
|
|
1397
|
+
are dropped by the phantom filter. Known limitation: cross-file calls to
|
|
1398
|
+
plain package FUNCTIONS via selector (`utils.Helper()`) resolve only when
|
|
1399
|
+
the name matches a method; the file-level import edge still records the
|
|
1400
|
+
package dependency.
|
|
1401
|
+
"""
|
|
1402
|
+
result = ExtractionResult()
|
|
1403
|
+
root = tree.root_node
|
|
1404
|
+
|
|
1405
|
+
def _line(node: Any) -> int:
|
|
1406
|
+
return (node.start_point[0] + 1) if node.start_point else 1
|
|
1407
|
+
|
|
1408
|
+
def _text(node: Any) -> str | None:
|
|
1409
|
+
return node.text.decode("utf-8", errors="ignore") if node is not None and node.text else None
|
|
1410
|
+
|
|
1411
|
+
result.nodes.append(_node(file_id, "file", Path(rel_path).name, rel_path))
|
|
1412
|
+
|
|
1413
|
+
def _emit_import(spec: Any) -> None:
|
|
1414
|
+
path_node = spec.child_by_field_name("path")
|
|
1415
|
+
mod = (_text(path_node) or "").strip("'\"`")
|
|
1416
|
+
if mod:
|
|
1417
|
+
# No resolver for Go imports (no package-level resolution, see
|
|
1418
|
+
# docstring above) — default EXTRACTED confidence, not
|
|
1419
|
+
# EXTERNAL_IMPORT. EXTERNAL_IMPORT is reserved for genuinely
|
|
1420
|
+
# external/stdlib modules a resolver *tried and failed* to
|
|
1421
|
+
# resolve (health schema 2 excludes it from imports ratios);
|
|
1422
|
+
# tagging every Go import that way would hide 100% of this
|
|
1423
|
+
# language's phantom cross-file linkage from resolution_health.
|
|
1424
|
+
result.edges.append(_edge(file_id, _make_id(mod), "imports", rel_path, _line(spec)))
|
|
1425
|
+
|
|
1426
|
+
def walk(node: Any, parent_id: str | None, scope_id: str) -> None:
|
|
1427
|
+
t = node.type
|
|
1428
|
+
if t in ("function_declaration", "method_declaration"):
|
|
1429
|
+
name = _short_name(_text(node.child_by_field_name("name")))
|
|
1430
|
+
if name:
|
|
1431
|
+
mid = _make_id(file_id, name)
|
|
1432
|
+
extra = {"is_method": True} if t == "method_declaration" else None
|
|
1433
|
+
result.nodes.append(_node(mid, "function", name, rel_path, _line(node), extra=extra))
|
|
1434
|
+
if parent_id:
|
|
1435
|
+
result.edges.append(_edge(parent_id, mid, "contains", rel_path, _line(node)))
|
|
1436
|
+
walk_children(node, mid, mid)
|
|
1437
|
+
else:
|
|
1438
|
+
walk_children(node, parent_id, scope_id)
|
|
1439
|
+
elif t == "type_declaration":
|
|
1440
|
+
for spec in node.children:
|
|
1441
|
+
if spec.type == "type_spec":
|
|
1442
|
+
name = _short_name(_text(spec.child_by_field_name("name")))
|
|
1443
|
+
if name:
|
|
1444
|
+
tid = _make_id(file_id, name)
|
|
1445
|
+
result.nodes.append(_node(tid, "class", name, rel_path, _line(spec)))
|
|
1446
|
+
if parent_id:
|
|
1447
|
+
result.edges.append(_edge(parent_id, tid, "contains", rel_path, _line(spec)))
|
|
1448
|
+
walk_children(node, parent_id, scope_id)
|
|
1449
|
+
elif t == "import_declaration":
|
|
1450
|
+
for child in node.children:
|
|
1451
|
+
if child.type == "import_spec":
|
|
1452
|
+
_emit_import(child)
|
|
1453
|
+
elif child.type == "import_spec_list":
|
|
1454
|
+
for spec in child.children:
|
|
1455
|
+
if spec.type == "import_spec":
|
|
1456
|
+
_emit_import(spec)
|
|
1457
|
+
elif t == "call_expression":
|
|
1458
|
+
func = node.child_by_field_name("function")
|
|
1459
|
+
if func is not None:
|
|
1460
|
+
member: str | None = None
|
|
1461
|
+
if func.type == "selector_expression":
|
|
1462
|
+
operand = _text(func.child_by_field_name("operand"))
|
|
1463
|
+
field = _text(func.child_by_field_name("field"))
|
|
1464
|
+
called = _short_name(f"{operand}.{field}" if operand and field else field)
|
|
1465
|
+
member = field
|
|
1466
|
+
else:
|
|
1467
|
+
called = _call_target_name(func, source)
|
|
1468
|
+
if called and called not in _LANGUAGE_BUILTIN_GLOBALS and should_keep_call_target(called):
|
|
1469
|
+
edge = _edge(scope_id, _resolve_call(file_id, called), "calls", rel_path, _line(node), confidence=_call_confidence(called))
|
|
1470
|
+
if member:
|
|
1471
|
+
edge["_member"] = member
|
|
1472
|
+
result.edges.append(edge)
|
|
1473
|
+
walk_children(node, parent_id, scope_id)
|
|
1474
|
+
else:
|
|
1475
|
+
walk_children(node, parent_id, scope_id)
|
|
1476
|
+
|
|
1477
|
+
def walk_children(node: Any, parent_id: str | None, scope_id: str) -> None:
|
|
1478
|
+
for child in node.children:
|
|
1479
|
+
walk(child, parent_id, scope_id)
|
|
1480
|
+
|
|
1481
|
+
walk(root, file_id, file_id)
|
|
1482
|
+
return result
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def _extract_rust(
|
|
1486
|
+
file_id: str,
|
|
1487
|
+
rel_path: str,
|
|
1488
|
+
source: bytes,
|
|
1489
|
+
tree: Any,
|
|
1490
|
+
source_index: SourceIndex | None = None,
|
|
1491
|
+
) -> ExtractionResult:
|
|
1492
|
+
"""Heuristic Rust extraction: fns, impl methods, types, use decls, calls.
|
|
1493
|
+
|
|
1494
|
+
`x.method()` (field_expression) and `Type::assoc()` (scoped_identifier)
|
|
1495
|
+
calls carry `_member` so the method-dispatch post-pass links them to impl
|
|
1496
|
+
functions (tagged is_method); unresolved ones (.clone(), String::from, ...)
|
|
1497
|
+
are dropped by the phantom filter. Macro invocations (println!, vec!) are a
|
|
1498
|
+
different node type and are never extracted as calls.
|
|
1499
|
+
"""
|
|
1500
|
+
result = ExtractionResult()
|
|
1501
|
+
root = tree.root_node
|
|
1502
|
+
|
|
1503
|
+
def _line(node: Any) -> int:
|
|
1504
|
+
return (node.start_point[0] + 1) if node.start_point else 1
|
|
1505
|
+
|
|
1506
|
+
def _text(node: Any) -> str | None:
|
|
1507
|
+
return node.text.decode("utf-8", errors="ignore") if node is not None and node.text else None
|
|
1508
|
+
|
|
1509
|
+
result.nodes.append(_node(file_id, "file", Path(rel_path).name, rel_path))
|
|
1510
|
+
|
|
1511
|
+
def _use_target(node: Any) -> str | None:
|
|
1512
|
+
# `use a::b::{c, d};` -> record the common prefix `a::b`; `use x as y` -> `x`.
|
|
1513
|
+
raw = _text(node.child_by_field_name("argument"))
|
|
1514
|
+
if not raw:
|
|
1515
|
+
return None
|
|
1516
|
+
raw = raw.split("{")[0].split(" as ")[0].strip().rstrip(":").strip()
|
|
1517
|
+
return raw or None
|
|
1518
|
+
|
|
1519
|
+
def _inline_mod_depth(node: Any) -> int:
|
|
1520
|
+
"""How many inline `mod { ... }` blocks enclose this node.
|
|
1521
|
+
|
|
1522
|
+
`super::` inside an inline module still refers to *this* file, at any
|
|
1523
|
+
nesting depth, so a `#[cfg(test)] mod tests { use super::X; }` must not
|
|
1524
|
+
be resolved against the parent directory. Read from the ancestor chain
|
|
1525
|
+
rather than threaded through walk(), which would touch every recursive
|
|
1526
|
+
call site for one rarely-needed number.
|
|
1527
|
+
"""
|
|
1528
|
+
depth = 0
|
|
1529
|
+
current = node.parent
|
|
1530
|
+
while current is not None:
|
|
1531
|
+
if current.type == "mod_item" and current.child_by_field_name("body") is not None:
|
|
1532
|
+
depth += 1
|
|
1533
|
+
current = current.parent
|
|
1534
|
+
return depth
|
|
1535
|
+
|
|
1536
|
+
def walk(node: Any, parent_id: str | None, scope_id: str, in_impl: bool) -> None:
|
|
1537
|
+
t = node.type
|
|
1538
|
+
if t == "function_item":
|
|
1539
|
+
name = _short_name(_text(node.child_by_field_name("name")))
|
|
1540
|
+
if name:
|
|
1541
|
+
mid = _make_id(file_id, name)
|
|
1542
|
+
extra = {"is_method": True} if in_impl else None
|
|
1543
|
+
result.nodes.append(_node(mid, "function", name, rel_path, _line(node), extra=extra))
|
|
1544
|
+
if parent_id:
|
|
1545
|
+
result.edges.append(_edge(parent_id, mid, "contains", rel_path, _line(node)))
|
|
1546
|
+
walk_children(node, mid, mid, in_impl)
|
|
1547
|
+
else:
|
|
1548
|
+
walk_children(node, parent_id, scope_id, in_impl)
|
|
1549
|
+
elif t in ("struct_item", "enum_item", "trait_item"):
|
|
1550
|
+
name = _short_name(_text(node.child_by_field_name("name")))
|
|
1551
|
+
if name:
|
|
1552
|
+
cid = _make_id(file_id, name)
|
|
1553
|
+
result.nodes.append(_node(cid, "class", name, rel_path, _line(node)))
|
|
1554
|
+
if parent_id:
|
|
1555
|
+
result.edges.append(_edge(parent_id, cid, "contains", rel_path, _line(node)))
|
|
1556
|
+
walk_children(node, cid, scope_id, in_impl)
|
|
1557
|
+
else:
|
|
1558
|
+
walk_children(node, parent_id, scope_id, in_impl)
|
|
1559
|
+
elif t == "impl_item":
|
|
1560
|
+
impl_type = _text(node.child_by_field_name("type"))
|
|
1561
|
+
impl_parent = _make_id(file_id, impl_type.split("<")[0]) if impl_type else parent_id
|
|
1562
|
+
walk_children(node, impl_parent, scope_id, True)
|
|
1563
|
+
elif t == "mod_item":
|
|
1564
|
+
body = node.child_by_field_name("body")
|
|
1565
|
+
if body is None:
|
|
1566
|
+
# `mod foo;` -- a file module. This is Rust's file-inclusion
|
|
1567
|
+
# mechanism and the only structural link between the files of a
|
|
1568
|
+
# multi-file crate, so without it such a crate has no
|
|
1569
|
+
# module-structure edges at all.
|
|
1570
|
+
mod_name = _short_name(_text(node.child_by_field_name("name")))
|
|
1571
|
+
if mod_name and source_index is not None:
|
|
1572
|
+
resolved = source_index.resolve_rust_mod(rel_path, mod_name)
|
|
1573
|
+
if resolved is not None and resolved != rel_path:
|
|
1574
|
+
result.edges.append(
|
|
1575
|
+
_edge(
|
|
1576
|
+
file_id,
|
|
1577
|
+
_file_node_id(resolved),
|
|
1578
|
+
"imports",
|
|
1579
|
+
rel_path,
|
|
1580
|
+
_line(node),
|
|
1581
|
+
)
|
|
1582
|
+
)
|
|
1583
|
+
else:
|
|
1584
|
+
# Inline `mod foo { ... }` -- same file, no edge. Nesting depth
|
|
1585
|
+
# is read from the ancestor chain by _inline_mod_depth.
|
|
1586
|
+
walk_children(node, parent_id, scope_id, in_impl)
|
|
1587
|
+
elif t == "use_declaration":
|
|
1588
|
+
target = _use_target(node)
|
|
1589
|
+
if target:
|
|
1590
|
+
resolution = (
|
|
1591
|
+
source_index.resolve_rust_use(rel_path, target, _inline_mod_depth(node))
|
|
1592
|
+
if source_index is not None
|
|
1593
|
+
else None
|
|
1594
|
+
)
|
|
1595
|
+
if resolution is not None and resolution.rel_path is not None:
|
|
1596
|
+
# _file_node_id, NOT _make_id: the target must be the id the
|
|
1597
|
+
# file node was created under, or the edge stays unbound.
|
|
1598
|
+
if resolution.rel_path != rel_path:
|
|
1599
|
+
# A file importing itself carries no dependency.
|
|
1600
|
+
result.edges.append(
|
|
1601
|
+
_edge(
|
|
1602
|
+
file_id,
|
|
1603
|
+
_file_node_id(resolution.rel_path),
|
|
1604
|
+
"imports",
|
|
1605
|
+
rel_path,
|
|
1606
|
+
_line(node),
|
|
1607
|
+
)
|
|
1608
|
+
)
|
|
1609
|
+
elif resolution is not None and resolution.external:
|
|
1610
|
+
# Confirmed external (allowlisted root or declared Cargo
|
|
1611
|
+
# dependency) -- excluded from the imports ratio.
|
|
1612
|
+
result.edges.append(
|
|
1613
|
+
_edge(
|
|
1614
|
+
file_id,
|
|
1615
|
+
_make_id(target),
|
|
1616
|
+
"imports",
|
|
1617
|
+
rel_path,
|
|
1618
|
+
_line(node),
|
|
1619
|
+
confidence="EXTERNAL_IMPORT",
|
|
1620
|
+
)
|
|
1621
|
+
)
|
|
1622
|
+
else:
|
|
1623
|
+
# Unresolved, or no index at all: default confidence, so the
|
|
1624
|
+
# miss stays visible to resolution_health rather than being
|
|
1625
|
+
# laundered as external.
|
|
1626
|
+
result.edges.append(
|
|
1627
|
+
_edge(file_id, _make_id(target), "imports", rel_path, _line(node))
|
|
1628
|
+
)
|
|
1629
|
+
elif t == "call_expression":
|
|
1630
|
+
func = node.child_by_field_name("function")
|
|
1631
|
+
if func is not None:
|
|
1632
|
+
called: str | None
|
|
1633
|
+
member: str | None = None
|
|
1634
|
+
if func.type == "field_expression":
|
|
1635
|
+
value = _simple_rust_value(func.child_by_field_name("value"), _text)
|
|
1636
|
+
field = _text(func.child_by_field_name("field"))
|
|
1637
|
+
called = _short_name(f"{value}.{field}" if value and field else field)
|
|
1638
|
+
member = field
|
|
1639
|
+
elif func.type == "scoped_identifier":
|
|
1640
|
+
name = _text(func.child_by_field_name("name"))
|
|
1641
|
+
called = _short_name((_text(func) or "").replace("::", "."))
|
|
1642
|
+
member = name
|
|
1643
|
+
elif func.type == "generic_function":
|
|
1644
|
+
inner = func.child_by_field_name("function")
|
|
1645
|
+
called = _short_name((_text(inner) or "").replace("::", "."))
|
|
1646
|
+
member = _text(inner.child_by_field_name("name")) if inner is not None and inner.type == "scoped_identifier" else None
|
|
1647
|
+
else:
|
|
1648
|
+
called = _call_target_name(func, source)
|
|
1649
|
+
if called and called not in _LANGUAGE_BUILTIN_GLOBALS and should_keep_call_target(called):
|
|
1650
|
+
edge = _edge(scope_id, _resolve_call(file_id, called), "calls", rel_path, _line(node), confidence=_call_confidence(called))
|
|
1651
|
+
if member:
|
|
1652
|
+
edge["_member"] = member
|
|
1653
|
+
result.edges.append(edge)
|
|
1654
|
+
walk_children(node, parent_id, scope_id, in_impl)
|
|
1655
|
+
else:
|
|
1656
|
+
walk_children(node, parent_id, scope_id, in_impl)
|
|
1657
|
+
|
|
1658
|
+
def walk_children(node: Any, parent_id: str | None, scope_id: str, in_impl: bool) -> None:
|
|
1659
|
+
for child in node.children:
|
|
1660
|
+
walk(child, parent_id, scope_id, in_impl)
|
|
1661
|
+
|
|
1662
|
+
walk(root, file_id, file_id, False)
|
|
1663
|
+
return result
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
def _simple_rust_value(node: Any, _text: Any) -> str | None:
|
|
1667
|
+
"""Short receiver name for a Rust field_expression value, or None if complex."""
|
|
1668
|
+
if node is None:
|
|
1669
|
+
return None
|
|
1670
|
+
if node.type in ("identifier", "self"):
|
|
1671
|
+
return _text(node)
|
|
1672
|
+
return None
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def _resolve_import(rel_path: str, source_lit: str, source_index: SourceIndex | None = None) -> Any | None:
|
|
1676
|
+
"""Best-effort resolve relative/local imports to a known project file path."""
|
|
1677
|
+
if source_index is not None:
|
|
1678
|
+
return source_index.resolve_ts_import_detail(rel_path, source_lit)
|
|
1679
|
+
return None
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def _resolve_call(
|
|
1683
|
+
file_id: str,
|
|
1684
|
+
called: str,
|
|
1685
|
+
import_symbols: dict[str, str] | None = None,
|
|
1686
|
+
namespaces: dict[str, str] | None = None,
|
|
1687
|
+
) -> str:
|
|
1688
|
+
"""Convert a call target string into a node id.
|
|
1689
|
+
|
|
1690
|
+
A plain identifier that was imported into this file resolves to the
|
|
1691
|
+
definition node in the file that exports it (cross-file). A single-segment
|
|
1692
|
+
member call whose receiver is a whole-module binding -- `import * as ns` or
|
|
1693
|
+
`const m = require('./x')` -- resolves to that export in the module's file.
|
|
1694
|
+
Otherwise the call is attached to the current file's namespace (same-file
|
|
1695
|
+
resolution).
|
|
1696
|
+
"""
|
|
1697
|
+
if import_symbols and called and "." not in called:
|
|
1698
|
+
mapped = import_symbols.get(called)
|
|
1699
|
+
if mapped:
|
|
1700
|
+
return mapped
|
|
1701
|
+
if namespaces and called and called.count(".") == 1:
|
|
1702
|
+
receiver, member = called.split(".")
|
|
1703
|
+
module_file_id = namespaces.get(receiver)
|
|
1704
|
+
if module_file_id:
|
|
1705
|
+
return _make_id(module_file_id, member)
|
|
1706
|
+
# Local call: if called starts with lowercase or is relative, attach to file namespace.
|
|
1707
|
+
if called and not called.startswith(".") and not called.startswith("node_modules"):
|
|
1708
|
+
# Try namespaced under file first; fallback to bare id.
|
|
1709
|
+
return _make_id(file_id, called)
|
|
1710
|
+
return _make_id(called)
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def _extract_generic(file_id: str, rel_path: str, source: bytes, language: str) -> ExtractionResult:
|
|
1714
|
+
"""Fallback for languages we can't parse structurally yet."""
|
|
1715
|
+
result = ExtractionResult()
|
|
1716
|
+
result.nodes.append(_node(file_id, "file", Path(rel_path).name, rel_path))
|
|
1717
|
+
# TODO: add comment extraction, markdown headings, JSON keys, etc.
|
|
1718
|
+
return result
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def extract_file(entry: FileEntry, cfg: Config, cache: Cache | None = None, source_index: SourceIndex | None = None) -> ExtractionResult:
|
|
1722
|
+
"""Extract nodes and edges from a single file, using cache if available."""
|
|
1723
|
+
compiler_resolver_active = bool(
|
|
1724
|
+
source_index
|
|
1725
|
+
and source_index.typescript.available
|
|
1726
|
+
and entry.language in ("javascript", "typescript", "tsx", "jsx")
|
|
1727
|
+
)
|
|
1728
|
+
cache_language = f"{entry.language or 'unknown'}:{'tsc' if compiler_resolver_active else 'ast'}"
|
|
1729
|
+
if source_index is not None and entry.language in _RESOLVER_LANGUAGES:
|
|
1730
|
+
# Import resolution happens at extraction time, so a cached result
|
|
1731
|
+
# embeds which sibling modules existed when it was written. Keying on
|
|
1732
|
+
# content_hash alone let an unchanged importer keep stale EXACT_IMPORT /
|
|
1733
|
+
# EXTERNAL_IMPORT edges after a sibling was added or removed (#2).
|
|
1734
|
+
# Only resolver-consulting languages pay this invalidation: Go and Rust
|
|
1735
|
+
# extraction is file-local and cannot go stale this way.
|
|
1736
|
+
cache_language = f"{cache_language}:fs{source_index.file_set_digest()}"
|
|
1737
|
+
if cache is not None and not compiler_resolver_active:
|
|
1738
|
+
cached = cache.read("ast", entry.content_hash, cache_language)
|
|
1739
|
+
if cached is not None:
|
|
1740
|
+
return _result_from_dict(cached)
|
|
1741
|
+
|
|
1742
|
+
rel_path = entry.rel_path
|
|
1743
|
+
file_id = _file_node_id(rel_path)
|
|
1744
|
+
|
|
1745
|
+
try:
|
|
1746
|
+
with open(entry.abs_path, "rb") as f:
|
|
1747
|
+
source = f.read()
|
|
1748
|
+
except Exception as e:
|
|
1749
|
+
return ExtractionResult(error=f"read_error: {e}")
|
|
1750
|
+
|
|
1751
|
+
if entry.language in ("javascript", "typescript", "tsx", "jsx"):
|
|
1752
|
+
parser = _LOADER.parser(entry.language)
|
|
1753
|
+
if parser is None:
|
|
1754
|
+
return _extract_generic(file_id, rel_path, source, entry.language)
|
|
1755
|
+
try:
|
|
1756
|
+
tree = parser.parse(source)
|
|
1757
|
+
except Exception as e:
|
|
1758
|
+
return ExtractionResult(error=f"parse_error: {e}")
|
|
1759
|
+
result = _extract_ts_js(file_id, rel_path, source, tree, source_index)
|
|
1760
|
+
elif entry.language in ("python", "go", "rust"):
|
|
1761
|
+
parser = _LOADER.parser(entry.language)
|
|
1762
|
+
if parser is None:
|
|
1763
|
+
return _extract_generic(file_id, rel_path, source, entry.language)
|
|
1764
|
+
try:
|
|
1765
|
+
tree = parser.parse(source)
|
|
1766
|
+
except Exception as e:
|
|
1767
|
+
return ExtractionResult(error=f"parse_error: {e}")
|
|
1768
|
+
if entry.language == "python":
|
|
1769
|
+
result = _extract_python(file_id, rel_path, source, tree, source_index)
|
|
1770
|
+
elif entry.language == "rust":
|
|
1771
|
+
result = _extract_rust(file_id, rel_path, source, tree, source_index)
|
|
1772
|
+
else:
|
|
1773
|
+
result = _extract_go(file_id, rel_path, source, tree)
|
|
1774
|
+
else:
|
|
1775
|
+
result = _extract_generic(file_id, rel_path, source, entry.language or "unknown")
|
|
1776
|
+
|
|
1777
|
+
if cache is not None:
|
|
1778
|
+
cache.write(_result_to_dict(result), "ast", entry.content_hash, cache_language)
|
|
1779
|
+
|
|
1780
|
+
return result
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def _result_to_dict(result: ExtractionResult) -> dict[str, Any]:
|
|
1784
|
+
return {"nodes": result.nodes, "edges": result.edges, "error": result.error}
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
def _result_from_dict(data: dict[str, Any]) -> ExtractionResult:
|
|
1788
|
+
return ExtractionResult(
|
|
1789
|
+
nodes=data.get("nodes", []),
|
|
1790
|
+
edges=data.get("edges", []),
|
|
1791
|
+
error=data.get("error"),
|
|
1792
|
+
)
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def _error_record(rel_path: str, error: str) -> dict[str, Any]:
|
|
1796
|
+
code, _, _rest = error.partition(":")
|
|
1797
|
+
return {"code": code.strip() or "extract_error", "subject": rel_path, "detail": error}
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
def extract_all(entries: list[FileEntry], cfg: Config, cache: Cache | None = None) -> ExtractionResult:
|
|
1801
|
+
"""Extract all files, optionally in parallel."""
|
|
1802
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
1803
|
+
|
|
1804
|
+
results: list[ExtractionResult] = []
|
|
1805
|
+
error_records: list[dict[str, Any]] = []
|
|
1806
|
+
source_index = SourceIndex.from_entries(entries, cfg)
|
|
1807
|
+
if cfg.workers <= 1:
|
|
1808
|
+
for entry in entries:
|
|
1809
|
+
result = extract_file(entry, cfg, cache, source_index)
|
|
1810
|
+
if result.error:
|
|
1811
|
+
error_records.append(_error_record(entry.rel_path, result.error))
|
|
1812
|
+
results.append(result)
|
|
1813
|
+
merged = _merge(results)
|
|
1814
|
+
merged.errors = sorted(error_records, key=lambda r: r["subject"])
|
|
1815
|
+
return merged
|
|
1816
|
+
|
|
1817
|
+
with ThreadPoolExecutor(max_workers=cfg.workers) as pool:
|
|
1818
|
+
futures = {pool.submit(extract_file, entry, cfg, cache, source_index): entry for entry in entries}
|
|
1819
|
+
for future in as_completed(futures):
|
|
1820
|
+
entry = futures[future]
|
|
1821
|
+
try:
|
|
1822
|
+
result = future.result()
|
|
1823
|
+
except Exception as e:
|
|
1824
|
+
result = ExtractionResult(error=f"worker_error: {entry.rel_path}: {e}", nodes=[], edges=[])
|
|
1825
|
+
if result.error:
|
|
1826
|
+
error_records.append(_error_record(entry.rel_path, result.error))
|
|
1827
|
+
results.append(result)
|
|
1828
|
+
merged = _merge(results)
|
|
1829
|
+
merged.errors = sorted(error_records, key=lambda r: r["subject"])
|
|
1830
|
+
return merged
|
|
1831
|
+
|
|
1832
|
+
|
|
1833
|
+
# Cap on how many same-named class methods one `recv.method()` call may link to.
|
|
1834
|
+
# Method names in real codebases are almost always globally unique, so the common
|
|
1835
|
+
# path is a 1:1 re-point. A small ambiguous set (2..cap) links to every candidate
|
|
1836
|
+
# (bounded fan-out that keeps reachability useful). Above the cap the name is too
|
|
1837
|
+
# generic to guess, so resolution is skipped and the original file-scoped phantom
|
|
1838
|
+
# edge is left untouched (pre-fix behavior).
|
|
1839
|
+
_MAX_METHOD_DISPATCH_CANDIDATES = 3
|
|
1840
|
+
|
|
1841
|
+
|
|
1842
|
+
def _resolve_method_dispatch(
|
|
1843
|
+
nodes: list[dict[str, Any]], edges: list[dict[str, Any]]
|
|
1844
|
+
) -> list[dict[str, Any]]:
|
|
1845
|
+
"""Re-point `recv.method(...)` member-call edges to class method definitions.
|
|
1846
|
+
|
|
1847
|
+
NAME-BASED heuristic (full TS type inference is out of scope). Each member call
|
|
1848
|
+
carries the bare method name in ``_member`` (set during the TS/JS walk); the
|
|
1849
|
+
call target as extracted is only a file-scoped phantom (``<file>_<recv>_<method>``).
|
|
1850
|
+
Here we map the method name to every class-method definition node (tagged
|
|
1851
|
+
``is_method``) sharing that name and re-point the edge accordingly, which is what
|
|
1852
|
+
makes ``store.X()`` / ``cj.X()`` / ``this.X()`` dispatch visible to callers/calls/
|
|
1853
|
+
reaches, including across files.
|
|
1854
|
+
|
|
1855
|
+
Ambiguity policy (``_MAX_METHOD_DISPATCH_CANDIDATES``): a unique name re-points to
|
|
1856
|
+
the single definition; 2..cap candidates each get an edge; more than cap (or zero)
|
|
1857
|
+
candidates leaves the original phantom edge unchanged. Arrow-valued class fields
|
|
1858
|
+
(``foo = () => {}``) parse as ``public_field_definition`` rather than
|
|
1859
|
+
``method_definition``; they are tagged ``is_method`` at extraction so they are
|
|
1860
|
+
indexed here on equal terms (#19).
|
|
1861
|
+
|
|
1862
|
+
``_member`` is stripped from every returned edge so it never leaks into the graph.
|
|
1863
|
+
"""
|
|
1864
|
+
# method name (casefolded) -> set of definition node ids. A set collapses the
|
|
1865
|
+
# same method appearing twice in the pre-dedup node list, so the cap counts
|
|
1866
|
+
# distinct definitions.
|
|
1867
|
+
methods_by_name: dict[str, set[str]] = {}
|
|
1868
|
+
for n in nodes:
|
|
1869
|
+
if n.get("is_method") and n.get("name"):
|
|
1870
|
+
methods_by_name.setdefault(n["name"].casefold(), set()).add(n["id"])
|
|
1871
|
+
known_ids = {n["id"] for n in nodes}
|
|
1872
|
+
|
|
1873
|
+
out: list[dict[str, Any]] = []
|
|
1874
|
+
for e in edges:
|
|
1875
|
+
method = e.pop("_member", None) # strip from every edge, resolved or not
|
|
1876
|
+
if not method or e.get("relation") != "calls":
|
|
1877
|
+
out.append(e)
|
|
1878
|
+
continue
|
|
1879
|
+
candidates = methods_by_name.get(method.casefold())
|
|
1880
|
+
if not candidates or len(candidates) > _MAX_METHOD_DISPATCH_CANDIDATES:
|
|
1881
|
+
# Member call that resolves to no known definition. Keep it when
|
|
1882
|
+
# the target is a real node, or when the call was already
|
|
1883
|
+
# classified EXTERNAL_CALL. That classification does NOT require
|
|
1884
|
+
# an attributable receiver: _call_confidence tests the call's
|
|
1885
|
+
# classified root, and _call_target_name (:603-619) falls back to
|
|
1886
|
+
# the bare method name whenever _simple_object_name (:585-600)
|
|
1887
|
+
# can't stringify the receiver -- so a member call with an
|
|
1888
|
+
# unresolvable receiver is ALSO kept when its bare method name
|
|
1889
|
+
# alone collides with _EXTERNAL_GLOBALS (e.g. `/re/.test(x)`,
|
|
1890
|
+
# `"{}".format(x)`), even though the receiver was never proven
|
|
1891
|
+
# external.
|
|
1892
|
+
#
|
|
1893
|
+
# Everything else still DROPS: `c.json()`, `db.prepare()`,
|
|
1894
|
+
# `stmt.bind()` -- unattributable receivers whose bare method name
|
|
1895
|
+
# does NOT collide with _EXTERNAL_GLOBALS -- are the
|
|
1896
|
+
# framework/runtime noise this filter exists to remove.
|
|
1897
|
+
if e.get("target") in known_ids or e.get("confidence") == "EXTERNAL_CALL":
|
|
1898
|
+
out.append(e)
|
|
1899
|
+
continue
|
|
1900
|
+
# Unique -> single re-point; small set -> one edge per candidate (sorted
|
|
1901
|
+
# for determinism). Duplicates are absorbed by the dedup in _merge.
|
|
1902
|
+
for target in sorted(candidates):
|
|
1903
|
+
re_pointed = dict(e)
|
|
1904
|
+
re_pointed["target"] = target
|
|
1905
|
+
out.append(re_pointed)
|
|
1906
|
+
return out
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
def _merge(results: list[ExtractionResult]) -> ExtractionResult:
|
|
1910
|
+
merged = ExtractionResult()
|
|
1911
|
+
# Collect all nodes/edges, then sort deterministically before dedup.
|
|
1912
|
+
all_nodes: list[dict[str, Any]] = []
|
|
1913
|
+
all_edges: list[dict[str, Any]] = []
|
|
1914
|
+
for r in results:
|
|
1915
|
+
all_nodes.extend(r.nodes)
|
|
1916
|
+
all_edges.extend(r.edges)
|
|
1917
|
+
if r.error:
|
|
1918
|
+
merged.error = r.error
|
|
1919
|
+
|
|
1920
|
+
# Resolve `recv.method()` member calls against the global set of class-method
|
|
1921
|
+
# definitions. Done here (post-merge, pre-dedup) because it needs every file's
|
|
1922
|
+
# methods at once, and because re-pointing can create duplicate edges (e.g.
|
|
1923
|
+
# `a.foo()` and `b.foo()` both -> the real `foo`) that the dedup below merges.
|
|
1924
|
+
all_edges = _resolve_method_dispatch(all_nodes, all_edges)
|
|
1925
|
+
|
|
1926
|
+
all_nodes.sort(key=lambda n: (n.get("id", ""), n.get("source_file", "")))
|
|
1927
|
+
seen_nodes: set[str] = set()
|
|
1928
|
+
for n in all_nodes:
|
|
1929
|
+
if n["id"] not in seen_nodes:
|
|
1930
|
+
merged.nodes.append(n)
|
|
1931
|
+
seen_nodes.add(n["id"])
|
|
1932
|
+
|
|
1933
|
+
all_edges.sort(
|
|
1934
|
+
key=lambda e: (
|
|
1935
|
+
e.get("source", ""),
|
|
1936
|
+
e.get("target", ""),
|
|
1937
|
+
e.get("relation", ""),
|
|
1938
|
+
e.get("source_file", ""),
|
|
1939
|
+
e.get("source_location", ""),
|
|
1940
|
+
)
|
|
1941
|
+
)
|
|
1942
|
+
# Duplicate (source, target, relation) triples collapse to one edge, but the
|
|
1943
|
+
# duplicate's weight is folded into the survivor rather than discarded — two
|
|
1944
|
+
# distinct call sites reaching the same target (e.g. `tdd.auto_resolve_tdd(...)`
|
|
1945
|
+
# and an aliased `art(...)` both binding to the same def) is a real multiplicity
|
|
1946
|
+
# signal, not noise, and dropping it silently understated call weight for every
|
|
1947
|
+
# language before this fix.
|
|
1948
|
+
seen_edges: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
1949
|
+
for e in all_edges:
|
|
1950
|
+
key = (e["source"], e["target"], e["relation"])
|
|
1951
|
+
survivor = seen_edges.get(key)
|
|
1952
|
+
if survivor is None:
|
|
1953
|
+
seen_edges[key] = e
|
|
1954
|
+
merged.edges.append(e)
|
|
1955
|
+
else:
|
|
1956
|
+
survivor["weight"] = survivor.get("weight", 1.0) + e.get("weight", 1.0)
|
|
1957
|
+
return merged
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
|