diffcontext 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffcontext/__init__.py +233 -0
- diffcontext/_warn_once.py +112 -0
- diffcontext/cache.py +216 -0
- diffcontext/cli/__init__.py +655 -0
- diffcontext/context/__init__.py +1 -0
- diffcontext/context/compiler.py +643 -0
- diffcontext/context/selector.py +258 -0
- diffcontext/diff/__init__.py +1 -0
- diffcontext/diff/git_diff.py +298 -0
- diffcontext/diff/state_manager.py +75 -0
- diffcontext/graph_builder.py +1026 -0
- diffcontext/history.py +154 -0
- diffcontext/impact/__init__.py +1 -0
- diffcontext/impact/blast_radius.py +58 -0
- diffcontext/impact/scoring.py +223 -0
- diffcontext/impact/traversal.py +58 -0
- diffcontext/impact/visualizer.py +338 -0
- diffcontext/languages/__init__.py +80 -0
- diffcontext/languages/typescript.py +960 -0
- diffcontext/lexical.py +108 -0
- diffcontext/models.py +180 -0
- diffcontext/parser.py +183 -0
- diffcontext/pipeline.py +887 -0
- diffcontext/py.typed +0 -0
- diffcontext/rerank/__init__.py +17 -0
- diffcontext/rerank/features.py +356 -0
- diffcontext/rerank/model.py +175 -0
- diffcontext/resolver.py +288 -0
- diffcontext/scanner.py +153 -0
- diffcontext/symbols.py +254 -0
- diffcontext/verify/__init__.py +68 -0
- diffcontext/verify/cases.py +631 -0
- diffcontext/verify/history.py +396 -0
- diffcontext/verify/sufficiency.py +324 -0
- diffcontext-0.5.1.dist-info/METADATA +219 -0
- diffcontext-0.5.1.dist-info/RECORD +40 -0
- diffcontext-0.5.1.dist-info/WHEEL +5 -0
- diffcontext-0.5.1.dist-info/entry_points.txt +2 -0
- diffcontext-0.5.1.dist-info/licenses/LICENSE +21 -0
- diffcontext-0.5.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
"""
|
|
2
|
+
typescript.py — TypeScript / JavaScript adapter built on tree-sitter.
|
|
3
|
+
|
|
4
|
+
Supplies the two things the language-agnostic pipeline needs (see
|
|
5
|
+
languages/__init__.py): per-file symbols and a dependency graph.
|
|
6
|
+
|
|
7
|
+
What it resolves (asserted by tests/test_typescript_adapter.py):
|
|
8
|
+
- function declarations, class methods (incl. static/async/generators),
|
|
9
|
+
const/let/var arrow-function and function-expression bindings,
|
|
10
|
+
namespace members ("Ns.fn" ids), enums, interfaces and type aliases
|
|
11
|
+
(as retrievable context symbols; they take no call edges)
|
|
12
|
+
- ES imports: named (with aliases), default, and namespace imports,
|
|
13
|
+
resolved through relative specifiers, index files (barrel re-export
|
|
14
|
+
following, `export {X} from './y'` and `export * from`, depth-capped),
|
|
15
|
+
and the ESM ".js"-suffix-means-".ts" convention
|
|
16
|
+
- call edges: bare calls, `this.method()`, namespace-member calls,
|
|
17
|
+
`new Class()` → Class.constructor, `super()` → parent constructor,
|
|
18
|
+
child→parent method override edges via `extends`, and function
|
|
19
|
+
references passed as call arguments (parameter-shadowing guarded)
|
|
20
|
+
|
|
21
|
+
What it deliberately does NOT do (v1, disclosed): no type inference —
|
|
22
|
+
`obj.method()` on an arbitrary object is unresolved; no tsconfig path
|
|
23
|
+
aliases (`@/utils`); no CommonJS `require()`. These lower graph
|
|
24
|
+
confidence, which the meta header reports per-package as always.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
32
|
+
|
|
33
|
+
from ..models import Symbol
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Imported at module load so languages/__init__ availability probing fails
|
|
38
|
+
# fast when the optional extras are missing.
|
|
39
|
+
from tree_sitter import Language, Parser
|
|
40
|
+
import tree_sitter_typescript as _ts_grammar
|
|
41
|
+
import tree_sitter_javascript as _js_grammar
|
|
42
|
+
|
|
43
|
+
_LANG_TS = Language(_ts_grammar.language_typescript())
|
|
44
|
+
_LANG_TSX = Language(_ts_grammar.language_tsx())
|
|
45
|
+
_LANG_JS = Language(_js_grammar.language())
|
|
46
|
+
|
|
47
|
+
_FUNCTION_DECLS = ("function_declaration", "generator_function_declaration")
|
|
48
|
+
_TYPE_DECLS = ("interface_declaration", "type_alias_declaration", "enum_declaration")
|
|
49
|
+
_CLASS_DECLS = ("class_declaration", "abstract_class_declaration")
|
|
50
|
+
_FUNCTION_VALUES = ("arrow_function", "function_expression", "function")
|
|
51
|
+
# Statement nodes whose blocks can contain further definitions.
|
|
52
|
+
_DESCEND_STMTS = (
|
|
53
|
+
"statement_block", "if_statement", "for_statement", "for_in_statement",
|
|
54
|
+
"while_statement", "do_statement", "try_statement", "switch_statement",
|
|
55
|
+
"labeled_statement", "ambient_declaration",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _FileFacts:
|
|
60
|
+
"""Everything the graph builder needs about one parsed file."""
|
|
61
|
+
|
|
62
|
+
__slots__ = (
|
|
63
|
+
"symbols", "class_nodes", "class_methods", "class_fields",
|
|
64
|
+
"reexports", "tree",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def __init__(self, tree):
|
|
68
|
+
self.tree = tree
|
|
69
|
+
# [(qualified_name, node)] for function/method/type symbols
|
|
70
|
+
self.symbols: List[Tuple[str, object]] = []
|
|
71
|
+
# qualified class name -> class node (for extends resolution)
|
|
72
|
+
self.class_nodes: Dict[str, object] = {}
|
|
73
|
+
# qualified class name -> set of method names
|
|
74
|
+
self.class_methods: Dict[str, Set[str]] = {}
|
|
75
|
+
# qualified class name -> {field name -> declared type name}, from
|
|
76
|
+
# typed field declarations and constructor parameter properties
|
|
77
|
+
# (`constructor(private db: Database)`) — the receiver types that
|
|
78
|
+
# make `this.db.query()` resolvable.
|
|
79
|
+
self.class_fields: Dict[str, Dict[str, str]] = {}
|
|
80
|
+
# ({exported: (specifier, original)}, [star specifiers])
|
|
81
|
+
self.reexports: Tuple[Dict[str, Tuple[str, str]], List[str]] = ({}, [])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TypeScriptAdapter:
|
|
85
|
+
name = "typescript"
|
|
86
|
+
extensions = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
|
|
87
|
+
|
|
88
|
+
# Resolution candidates for an extensionless import specifier, in
|
|
89
|
+
# Node/bundler probe order.
|
|
90
|
+
_RESOLVE_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
|
|
91
|
+
|
|
92
|
+
# Directories whose JS/TS is not project source: build output, vendored
|
|
93
|
+
# libraries, and web assets. Measured hazard, not hypothetical: without
|
|
94
|
+
# this, indexing django pulls in its tracked admin static JS — jquery
|
|
95
|
+
# included — polluting a Python repo's index with 47 vendor symbols.
|
|
96
|
+
# Deliberately adapter-scoped, NOT in scanner.EXCLUDED_DIRS: a Python
|
|
97
|
+
# package named `static/` must keep being indexed.
|
|
98
|
+
_EXCLUDED_DIR_PARTS = {
|
|
99
|
+
"static", "staticfiles", "assets", "public", "vendor", "vendors",
|
|
100
|
+
"coverage", ".next", ".nuxt", "out",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def should_index(self, path: str) -> bool:
|
|
104
|
+
"""Indexing policy for a discovered file of this language."""
|
|
105
|
+
base = os.path.basename(path).lower()
|
|
106
|
+
if ".min." in base:
|
|
107
|
+
return False # minified bundles: one unreadable megasymbol
|
|
108
|
+
# Colocated test files (foo.test.ts / foo.spec.ts) — same policy
|
|
109
|
+
# as the scanner's tests/ dir exclusion.
|
|
110
|
+
stem = base
|
|
111
|
+
for ext in self.extensions:
|
|
112
|
+
if stem.endswith(ext):
|
|
113
|
+
stem = stem[: -len(ext)]
|
|
114
|
+
break
|
|
115
|
+
if stem.endswith((".test", ".spec")):
|
|
116
|
+
return False
|
|
117
|
+
parts = path.replace(os.sep, "/").lower().split("/")[:-1]
|
|
118
|
+
return not any(p in self._EXCLUDED_DIR_PARTS for p in parts)
|
|
119
|
+
|
|
120
|
+
def _parse(self, path: str, source: str):
|
|
121
|
+
if path.endswith(".tsx"):
|
|
122
|
+
lang = _LANG_TSX
|
|
123
|
+
elif path.endswith(".ts"):
|
|
124
|
+
lang = _LANG_TS
|
|
125
|
+
else:
|
|
126
|
+
lang = _LANG_JS
|
|
127
|
+
return Parser(lang).parse(source.encode("utf-8"))
|
|
128
|
+
|
|
129
|
+
# ── Symbol extraction (pipeline cache entry point) ───────────────────
|
|
130
|
+
|
|
131
|
+
def extract_file_symbols(
|
|
132
|
+
self, filename: str, repo_path: str, source: str
|
|
133
|
+
) -> Dict[str, Symbol]:
|
|
134
|
+
"""Symbols for one file: id "./rel/path.ts:Container.name"."""
|
|
135
|
+
rel = "./" + os.path.relpath(filename, repo_path)
|
|
136
|
+
facts = _gather_facts(self._parse(filename, source))
|
|
137
|
+
raw = source.encode("utf-8")
|
|
138
|
+
symbols: Dict[str, Symbol] = {}
|
|
139
|
+
for name, node in facts.symbols:
|
|
140
|
+
sym_id = f"{rel}:{name}"
|
|
141
|
+
code = raw[node.start_byte : node.end_byte].decode("utf-8", "ignore")
|
|
142
|
+
symbols[sym_id] = Symbol(
|
|
143
|
+
id=sym_id,
|
|
144
|
+
file=filename,
|
|
145
|
+
name=name,
|
|
146
|
+
code=code,
|
|
147
|
+
lineno=node.start_point[0] + 1,
|
|
148
|
+
)
|
|
149
|
+
return symbols
|
|
150
|
+
|
|
151
|
+
# ── Graph construction ───────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def build_language_graph(
|
|
154
|
+
self, repo_path: str, sources: Dict[str, str]
|
|
155
|
+
) -> Dict[str, List[str]]:
|
|
156
|
+
"""
|
|
157
|
+
Dependency graph over this language's symbols. `sources` maps
|
|
158
|
+
"./rel/path.ts" -> file text; every file is parsed exactly once.
|
|
159
|
+
"""
|
|
160
|
+
repo_abs = os.path.abspath(repo_path)
|
|
161
|
+
facts: Dict[str, _FileFacts] = {
|
|
162
|
+
rel: _gather_facts(self._parse(rel, text))
|
|
163
|
+
for rel, text in sources.items()
|
|
164
|
+
}
|
|
165
|
+
resolver = _Resolver(repo_abs, facts, self._RESOLVE_EXTS)
|
|
166
|
+
for rel, f in facts.items():
|
|
167
|
+
resolver.import_maps[rel] = _file_import_map(resolver, rel, f)
|
|
168
|
+
|
|
169
|
+
graph: Dict[str, List[str]] = {}
|
|
170
|
+
for rel, f in facts.items():
|
|
171
|
+
_add_file_edges(resolver, graph, rel, f)
|
|
172
|
+
return graph
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _Resolver:
|
|
176
|
+
"""
|
|
177
|
+
Cross-file resolution over one build's parsed facts: import
|
|
178
|
+
specifiers, barrel re-exports, callable names, types, and extends
|
|
179
|
+
chains. One instance per build_language_graph run, so the tsconfig
|
|
180
|
+
cache and import maps have build lifetime.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(
|
|
184
|
+
self, repo_abs: str, facts: "Dict[str, _FileFacts]", resolve_exts
|
|
185
|
+
):
|
|
186
|
+
self.repo_abs = repo_abs
|
|
187
|
+
self.facts = facts
|
|
188
|
+
self.resolve_exts = resolve_exts
|
|
189
|
+
self.tsconfig_cache: Dict[str, Optional[Tuple[str, str, Dict[str, List[str]]]]] = {}
|
|
190
|
+
# Per file: local import name -> (target_rel, exported_name).
|
|
191
|
+
# exported_name is "*" for namespace imports, None for default
|
|
192
|
+
# imports (whose exported name we can't know without evaluating
|
|
193
|
+
# the target's `export default`).
|
|
194
|
+
self.import_maps: Dict[str, Dict[str, Tuple[str, Optional[str]]]] = {}
|
|
195
|
+
|
|
196
|
+
def probe(self, target: str) -> Optional[str]:
|
|
197
|
+
"""Abs path guess -> "./rel" of an indexed file, trying the
|
|
198
|
+
extension/index-file conventions."""
|
|
199
|
+
candidates = [target]
|
|
200
|
+
root, ext = os.path.splitext(target)
|
|
201
|
+
# ESM convention: `import ... from './x.js'` refers to x.ts on disk
|
|
202
|
+
if ext in (".js", ".mjs", ".cjs"):
|
|
203
|
+
candidates += [root + ".ts", root + ".tsx"]
|
|
204
|
+
if ext == "" or ext not in self.resolve_exts:
|
|
205
|
+
candidates += [target + e for e in self.resolve_exts]
|
|
206
|
+
candidates += [
|
|
207
|
+
os.path.join(target, "index" + e) for e in self.resolve_exts
|
|
208
|
+
]
|
|
209
|
+
for cand in candidates:
|
|
210
|
+
rel_cand = "./" + os.path.relpath(cand, self.repo_abs)
|
|
211
|
+
if rel_cand in self.facts:
|
|
212
|
+
return rel_cand
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
def nearest_tsconfig(self, dir_abs: str):
|
|
216
|
+
"""(config_dir, baseUrl, paths) from the nearest tsconfig.json /
|
|
217
|
+
jsconfig.json at or above dir_abs (stopping at the repo root)."""
|
|
218
|
+
if dir_abs in self.tsconfig_cache:
|
|
219
|
+
return self.tsconfig_cache[dir_abs]
|
|
220
|
+
result = None
|
|
221
|
+
cur = dir_abs
|
|
222
|
+
while True:
|
|
223
|
+
for name in ("tsconfig.json", "jsconfig.json"):
|
|
224
|
+
cfg_path = os.path.join(cur, name)
|
|
225
|
+
if os.path.isfile(cfg_path):
|
|
226
|
+
result = _load_tsconfig(cfg_path)
|
|
227
|
+
break
|
|
228
|
+
if result is not None or cur == self.repo_abs or len(cur) <= len(self.repo_abs):
|
|
229
|
+
break
|
|
230
|
+
cur = os.path.dirname(cur)
|
|
231
|
+
self.tsconfig_cache[dir_abs] = result
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
def resolve_specifier(self, importing_rel: str, spec: str) -> Optional[str]:
|
|
235
|
+
"""'./x', '@/x' (tsconfig paths), or baseUrl-relative specifier
|
|
236
|
+
-> "./resolved/x.ts" rel; None for external packages."""
|
|
237
|
+
base_dir = os.path.dirname(os.path.join(self.repo_abs, importing_rel[2:]))
|
|
238
|
+
if spec.startswith("."):
|
|
239
|
+
return self.probe(os.path.normpath(os.path.join(base_dir, spec)))
|
|
240
|
+
|
|
241
|
+
cfg = self.nearest_tsconfig(base_dir)
|
|
242
|
+
if cfg is None:
|
|
243
|
+
return None
|
|
244
|
+
cfg_dir, base_url, paths = cfg
|
|
245
|
+
base_abs = os.path.normpath(os.path.join(cfg_dir, base_url))
|
|
246
|
+
for pattern, targets in paths.items():
|
|
247
|
+
if pattern.endswith("*"):
|
|
248
|
+
prefix = pattern[:-1]
|
|
249
|
+
if not spec.startswith(prefix):
|
|
250
|
+
continue
|
|
251
|
+
star = spec[len(prefix):]
|
|
252
|
+
elif spec == pattern:
|
|
253
|
+
star = ""
|
|
254
|
+
else:
|
|
255
|
+
continue
|
|
256
|
+
for target in targets:
|
|
257
|
+
resolved = self.probe(os.path.normpath(os.path.join(
|
|
258
|
+
base_abs, target.replace("*", star)
|
|
259
|
+
)))
|
|
260
|
+
if resolved:
|
|
261
|
+
return resolved
|
|
262
|
+
# Bare specifier via baseUrl (`import x from 'utils/x'` with
|
|
263
|
+
# baseUrl=./src). Only ever hits files we indexed, so real npm
|
|
264
|
+
# packages can't be mis-resolved.
|
|
265
|
+
if base_url:
|
|
266
|
+
return self.probe(os.path.normpath(os.path.join(base_abs, spec)))
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
def defined_names(self, file_rel: str) -> Set[str]:
|
|
270
|
+
f = self.facts.get(file_rel)
|
|
271
|
+
if f is None:
|
|
272
|
+
return set()
|
|
273
|
+
return {n for n, _node in f.symbols} | set(f.class_nodes)
|
|
274
|
+
|
|
275
|
+
def follow_barrel(self, file_rel: str, name: str, _depth: int = 0) -> Tuple[str, str]:
|
|
276
|
+
"""If file re-exports `name` from elsewhere, return the real
|
|
277
|
+
(file, name); else (file_rel, name). Depth-capped like the
|
|
278
|
+
Python __init__.py transparency."""
|
|
279
|
+
if _depth > 2 or file_rel not in self.facts:
|
|
280
|
+
return file_rel, name
|
|
281
|
+
named, stars = self.facts[file_rel].reexports
|
|
282
|
+
if name in named:
|
|
283
|
+
spec, orig = named[name]
|
|
284
|
+
target = self.resolve_specifier(file_rel, spec)
|
|
285
|
+
if target:
|
|
286
|
+
return self.follow_barrel(target, orig, _depth + 1)
|
|
287
|
+
if name in self.defined_names(file_rel):
|
|
288
|
+
return file_rel, name
|
|
289
|
+
for spec in stars:
|
|
290
|
+
target = self.resolve_specifier(file_rel, spec)
|
|
291
|
+
if target:
|
|
292
|
+
t_file, t_name = self.follow_barrel(target, name, _depth + 1)
|
|
293
|
+
if t_name in self.defined_names(t_file):
|
|
294
|
+
return t_file, t_name
|
|
295
|
+
return file_rel, name
|
|
296
|
+
|
|
297
|
+
def lookup_callable(self, target_file: str, name: Optional[str]) -> Optional[str]:
|
|
298
|
+
"""Symbol id for calling `name` defined in target_file:
|
|
299
|
+
function/const binding, or Class -> Class.constructor."""
|
|
300
|
+
if name is None or target_file not in self.facts:
|
|
301
|
+
return None
|
|
302
|
+
f = self.facts[target_file]
|
|
303
|
+
for sym_name, node in f.symbols:
|
|
304
|
+
if sym_name == name:
|
|
305
|
+
if node.type in _TYPE_DECLS:
|
|
306
|
+
return None # types take no call edges
|
|
307
|
+
return f"{target_file}:{name}"
|
|
308
|
+
if name in f.class_nodes and "constructor" in f.class_methods.get(name, ()):
|
|
309
|
+
return f"{target_file}:{name}.constructor"
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def resolve_name(self, rel: str, name: str) -> Optional[str]:
|
|
313
|
+
"""A bare identifier used in `rel`: local def, else import."""
|
|
314
|
+
local = self.lookup_callable(rel, name)
|
|
315
|
+
if local:
|
|
316
|
+
return local
|
|
317
|
+
imported = self.import_maps[rel].get(name)
|
|
318
|
+
if not imported:
|
|
319
|
+
return None
|
|
320
|
+
t_file, t_name = imported
|
|
321
|
+
if t_name == "*":
|
|
322
|
+
return None # the namespace object itself, not a callable
|
|
323
|
+
# Default imports (t_name None): best effort — try the local
|
|
324
|
+
# binding name against the target file's definitions.
|
|
325
|
+
return self.lookup_callable(t_file, t_name or name)
|
|
326
|
+
|
|
327
|
+
def resolve_extends(self, rel: str, class_name: str) -> Optional[Tuple[str, str]]:
|
|
328
|
+
"""(file, ParentClass) for `class X extends Parent` when Parent
|
|
329
|
+
is a class we indexed (locally or via import)."""
|
|
330
|
+
node = self.facts[rel].class_nodes.get(class_name)
|
|
331
|
+
parent = _extends_name(node)
|
|
332
|
+
if parent is None:
|
|
333
|
+
return None
|
|
334
|
+
if parent in self.facts[rel].class_nodes:
|
|
335
|
+
return rel, parent
|
|
336
|
+
imported = self.import_maps[rel].get(parent)
|
|
337
|
+
if imported and imported[1] != "*":
|
|
338
|
+
t_file, t_name = imported
|
|
339
|
+
t_name = t_name or parent
|
|
340
|
+
if t_file in self.facts and t_name in self.facts[t_file].class_nodes:
|
|
341
|
+
return t_file, t_name
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
def resolve_type(self, rel: str, type_name: str):
|
|
345
|
+
"""Where a type name used in `rel` is defined: ("class", file,
|
|
346
|
+
name) for classes, ("type", file, symbol_id) for interfaces /
|
|
347
|
+
type aliases / enums, None if not indexed."""
|
|
348
|
+
def check(t_file: str, t_name: str):
|
|
349
|
+
if t_file not in self.facts:
|
|
350
|
+
return None
|
|
351
|
+
if t_name in self.facts[t_file].class_nodes:
|
|
352
|
+
return ("class", t_file, t_name)
|
|
353
|
+
for sym_name, node in self.facts[t_file].symbols:
|
|
354
|
+
if sym_name == t_name and node.type in _TYPE_DECLS:
|
|
355
|
+
return ("type", t_file, f"{t_file}:{t_name}")
|
|
356
|
+
return None
|
|
357
|
+
found = check(rel, type_name)
|
|
358
|
+
if found:
|
|
359
|
+
return found
|
|
360
|
+
imported = self.import_maps[rel].get(type_name)
|
|
361
|
+
if imported and imported[1] != "*":
|
|
362
|
+
t_file, t_name = imported
|
|
363
|
+
return check(t_file, t_name or type_name)
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
def method_edge_for_type(self, rel: str, type_name: str, method: str) -> Optional[str]:
|
|
367
|
+
"""Edge target for `receiver.method()` when receiver's declared
|
|
368
|
+
type is `type_name`: the class method if it exists (following
|
|
369
|
+
extends one level), else the interface/type symbol itself —
|
|
370
|
+
the contract being invoked co-changes with its callers."""
|
|
371
|
+
resolved = self.resolve_type(rel, type_name)
|
|
372
|
+
if resolved is None:
|
|
373
|
+
return None
|
|
374
|
+
kind, t_file, t_name = resolved
|
|
375
|
+
if kind == "type":
|
|
376
|
+
return t_name # symbol id of the interface/alias
|
|
377
|
+
if method in self.facts[t_file].class_methods.get(t_name, ()):
|
|
378
|
+
return f"{t_file}:{t_name}.{method}"
|
|
379
|
+
parent = self.resolve_extends(t_file, t_name)
|
|
380
|
+
if parent and method in self.facts[parent[0]].class_methods.get(parent[1], ()):
|
|
381
|
+
return f"{parent[0]}:{parent[1]}.{method}"
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ── Graph construction helpers (extracted from build_language_graph) ─────
|
|
386
|
+
|
|
387
|
+
def _file_import_map(
|
|
388
|
+
resolver: _Resolver, rel: str, f: _FileFacts
|
|
389
|
+
) -> Dict[str, Tuple[str, Optional[str]]]:
|
|
390
|
+
"""Local import name -> (target_rel, exported_name) for one file,
|
|
391
|
+
skipping external packages and unresolvable specifiers."""
|
|
392
|
+
imap: Dict[str, Tuple[str, Optional[str]]] = {}
|
|
393
|
+
for node in f.tree.root_node.named_children:
|
|
394
|
+
if node.type != "import_statement":
|
|
395
|
+
continue
|
|
396
|
+
spec = _import_source(node)
|
|
397
|
+
if spec is None:
|
|
398
|
+
continue
|
|
399
|
+
target = resolver.resolve_specifier(rel, spec)
|
|
400
|
+
if target is None:
|
|
401
|
+
continue
|
|
402
|
+
clause = next(
|
|
403
|
+
(c for c in node.named_children if c.type == "import_clause"),
|
|
404
|
+
None,
|
|
405
|
+
)
|
|
406
|
+
if clause is None:
|
|
407
|
+
continue
|
|
408
|
+
_add_import_bindings(resolver, imap, target, clause)
|
|
409
|
+
return imap
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _add_import_bindings(resolver, imap, target, clause):
|
|
413
|
+
"""Record the local bindings one import clause introduces."""
|
|
414
|
+
for item in clause.named_children:
|
|
415
|
+
if item.type == "identifier": # default import
|
|
416
|
+
imap[_text(item)] = (target, None)
|
|
417
|
+
elif item.type == "namespace_import":
|
|
418
|
+
ns_name = next(
|
|
419
|
+
(_text(c) for c in item.named_children
|
|
420
|
+
if c.type == "identifier"), None,
|
|
421
|
+
)
|
|
422
|
+
if ns_name:
|
|
423
|
+
imap[ns_name] = (target, "*")
|
|
424
|
+
elif item.type == "named_imports":
|
|
425
|
+
for imp_spec in item.named_children:
|
|
426
|
+
if imp_spec.type != "import_specifier":
|
|
427
|
+
continue
|
|
428
|
+
orig = imp_spec.child_by_field_name("name")
|
|
429
|
+
alias = imp_spec.child_by_field_name("alias")
|
|
430
|
+
if orig is None:
|
|
431
|
+
continue
|
|
432
|
+
local = _text(alias) if alias is not None else _text(orig)
|
|
433
|
+
imap[local] = resolver.follow_barrel(target, _text(orig))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _add_file_edges(resolver, graph, rel, f):
|
|
437
|
+
"""All edges out of one file's symbols."""
|
|
438
|
+
def_node_ids = {id(node) for _n, node in f.symbols}
|
|
439
|
+
|
|
440
|
+
for name, node in f.symbols:
|
|
441
|
+
sym_id = f"{rel}:{name}"
|
|
442
|
+
graph.setdefault(sym_id, [])
|
|
443
|
+
if node.type in _TYPE_DECLS:
|
|
444
|
+
continue
|
|
445
|
+
_add_symbol_edges(resolver, graph, rel, f, name, node, def_node_ids)
|
|
446
|
+
|
|
447
|
+
_add_extends_override_edges(resolver, graph, rel, f)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _add_symbol_edges(resolver, graph, rel, f, name, node, def_node_ids):
|
|
451
|
+
"""Call edges, fn-ref-argument edges, and annotation-reference edges
|
|
452
|
+
out of one (non-type) symbol."""
|
|
453
|
+
sym_id = f"{rel}:{name}"
|
|
454
|
+
class_name = name.rsplit(".", 1)[0] if "." in name else None
|
|
455
|
+
param_info = _param_info(node)
|
|
456
|
+
param_names = set(param_info)
|
|
457
|
+
local_types = _collect_local_types(_body_of(node), def_node_ids)
|
|
458
|
+
edges = graph[sym_id]
|
|
459
|
+
|
|
460
|
+
def add_edge(dep: Optional[str]):
|
|
461
|
+
if dep and dep != sym_id and dep not in edges:
|
|
462
|
+
edges.append(dep)
|
|
463
|
+
|
|
464
|
+
for call in _iter_calls(_body_of(node), def_node_ids):
|
|
465
|
+
if call.type == "new_expression":
|
|
466
|
+
add_edge(_new_expression_target(resolver, rel, call, param_names))
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
fn = call.child_by_field_name("function")
|
|
470
|
+
if fn is None:
|
|
471
|
+
continue
|
|
472
|
+
add_edge(_call_target(
|
|
473
|
+
resolver, rel, f, fn, class_name, param_names,
|
|
474
|
+
param_info, local_types,
|
|
475
|
+
))
|
|
476
|
+
_add_arg_reference_edges(resolver, rel, call, param_names, add_edge)
|
|
477
|
+
|
|
478
|
+
_add_annotation_edges(resolver, rel, node, param_info, local_types, add_edge)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _new_expression_target(resolver, rel, call, param_names):
|
|
482
|
+
"""`new Class()` -> the class's constructor (param-shadow guarded)."""
|
|
483
|
+
ctor = call.child_by_field_name("constructor")
|
|
484
|
+
if ctor is not None and ctor.type == "identifier":
|
|
485
|
+
cname = _text(ctor)
|
|
486
|
+
if cname not in param_names:
|
|
487
|
+
return resolver.resolve_name(rel, cname)
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _call_target(
|
|
492
|
+
resolver, rel, f, fn, class_name, param_names, param_info, local_types
|
|
493
|
+
):
|
|
494
|
+
"""Edge target for one call's callee expression, or None."""
|
|
495
|
+
if fn.type == "identifier":
|
|
496
|
+
callee = _text(fn)
|
|
497
|
+
if callee not in param_names:
|
|
498
|
+
return resolver.resolve_name(rel, callee)
|
|
499
|
+
return None
|
|
500
|
+
if fn.type == "super" and class_name:
|
|
501
|
+
parent = resolver.resolve_extends(rel, class_name)
|
|
502
|
+
if parent:
|
|
503
|
+
return resolver.lookup_callable(parent[0], parent[1])
|
|
504
|
+
return None
|
|
505
|
+
if fn.type == "member_expression":
|
|
506
|
+
return _member_call_target(
|
|
507
|
+
resolver, rel, f, fn, class_name, param_info, local_types
|
|
508
|
+
)
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _member_call_target(
|
|
513
|
+
resolver, rel, f, fn, class_name, param_info, local_types
|
|
514
|
+
):
|
|
515
|
+
"""Edge target for `receiver.method()`: this-methods, namespace-member
|
|
516
|
+
calls, typed receivers, and `this.field.method()`."""
|
|
517
|
+
obj = fn.child_by_field_name("object")
|
|
518
|
+
prop = fn.child_by_field_name("property")
|
|
519
|
+
if obj is None or prop is None:
|
|
520
|
+
return None
|
|
521
|
+
method = _text(prop)
|
|
522
|
+
if obj.type == "this" and class_name:
|
|
523
|
+
if method in f.class_methods.get(class_name, ()):
|
|
524
|
+
return f"{rel}:{class_name}.{method}"
|
|
525
|
+
return None
|
|
526
|
+
if obj.type == "identifier":
|
|
527
|
+
return _identifier_receiver_target(
|
|
528
|
+
resolver, rel, _text(obj), method, param_info, local_types
|
|
529
|
+
)
|
|
530
|
+
if obj.type == "member_expression" and class_name:
|
|
531
|
+
return _this_field_call_target(resolver, rel, f, obj, method, class_name)
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _identifier_receiver_target(
|
|
536
|
+
resolver, rel, obj_name, method, param_info, local_types
|
|
537
|
+
):
|
|
538
|
+
"""`ns.fn()` through a namespace import, or `u.login()` where u is a
|
|
539
|
+
param/local declared `: User` or `new User()`. Locals (annotation or
|
|
540
|
+
`new X()`) shadow parameters."""
|
|
541
|
+
imported = resolver.import_maps[rel].get(obj_name)
|
|
542
|
+
if imported and imported[1] == "*":
|
|
543
|
+
return resolver.lookup_callable(imported[0], method)
|
|
544
|
+
rtype = local_types.get(obj_name) or param_info.get(obj_name)
|
|
545
|
+
if rtype:
|
|
546
|
+
return resolver.method_edge_for_type(rel, rtype, method)
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _this_field_call_target(resolver, rel, f, obj, method, class_name):
|
|
551
|
+
"""`this.field.method()` through a typed field or constructor
|
|
552
|
+
parameter property."""
|
|
553
|
+
inner_obj = obj.child_by_field_name("object")
|
|
554
|
+
inner_prop = obj.child_by_field_name("property")
|
|
555
|
+
if (
|
|
556
|
+
inner_obj is not None
|
|
557
|
+
and inner_obj.type == "this"
|
|
558
|
+
and inner_prop is not None
|
|
559
|
+
):
|
|
560
|
+
ftype = f.class_fields.get(class_name, {}).get(_text(inner_prop))
|
|
561
|
+
if ftype:
|
|
562
|
+
return resolver.method_edge_for_type(rel, ftype, method)
|
|
563
|
+
return None
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _add_arg_reference_edges(resolver, rel, call, param_names, add_edge):
|
|
567
|
+
"""Function references passed as arguments (`arr.map(fn)`,
|
|
568
|
+
`on('x', handler)`) are dependencies even though never called at this
|
|
569
|
+
site — same rationale as the Python fn-ref edges."""
|
|
570
|
+
args = call.child_by_field_name("arguments")
|
|
571
|
+
if args is None:
|
|
572
|
+
return
|
|
573
|
+
for arg in args.named_children:
|
|
574
|
+
if arg.type != "identifier":
|
|
575
|
+
continue
|
|
576
|
+
ref = _text(arg)
|
|
577
|
+
if ref in param_names:
|
|
578
|
+
continue
|
|
579
|
+
target = resolver.resolve_name(rel, ref)
|
|
580
|
+
if target and not target.endswith(".constructor"):
|
|
581
|
+
add_edge(target)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _add_annotation_edges(resolver, rel, node, param_info, local_types, add_edge):
|
|
585
|
+
"""Annotation-reference edges: consumer → interface/alias it mentions
|
|
586
|
+
in its signature (Python's annotated-return-edge analog). Only TYPE
|
|
587
|
+
declarations — classes get edges from real calls. Direction is
|
|
588
|
+
consumer → type, so a changed interface pulls all its consumers into
|
|
589
|
+
the blast radius via the reverse graph, which is exactly what a
|
|
590
|
+
types/*.ts edit's co-change history shows."""
|
|
591
|
+
ann_types = _symbol_annotation_types(node)
|
|
592
|
+
ann_types.update(v for v in param_info.values() if v)
|
|
593
|
+
ann_types.update(local_types.values())
|
|
594
|
+
for tname in ann_types:
|
|
595
|
+
resolved_t = resolver.resolve_type(rel, tname)
|
|
596
|
+
if resolved_t is not None and resolved_t[0] == "type":
|
|
597
|
+
add_edge(resolved_t[2])
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _add_extends_override_edges(resolver, graph, rel, f):
|
|
601
|
+
"""Child → parent override edges via extends (mirrors the Python
|
|
602
|
+
graph's Phase 1A; same direction rationale — never parent → all
|
|
603
|
+
children, which would create mega-hubs)."""
|
|
604
|
+
for cls, methods in f.class_methods.items():
|
|
605
|
+
parent = resolver.resolve_extends(rel, cls)
|
|
606
|
+
if not parent:
|
|
607
|
+
continue
|
|
608
|
+
p_file, p_cls = parent
|
|
609
|
+
parent_methods = resolver.facts[p_file].class_methods.get(p_cls, set())
|
|
610
|
+
for m in methods:
|
|
611
|
+
if m in parent_methods:
|
|
612
|
+
child_id = f"{rel}:{cls}.{m}"
|
|
613
|
+
parent_id = f"{p_file}:{p_cls}.{m}"
|
|
614
|
+
graph.setdefault(child_id, [])
|
|
615
|
+
if parent_id not in graph[child_id]:
|
|
616
|
+
graph[child_id].append(parent_id)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# TSConfig is JSONC: comments and trailing commas are legal. This regex
|
|
620
|
+
# pass is approximate (a `//` inside a string would be eaten) but tsconfig
|
|
621
|
+
# path values are file globs where that can't occur in practice.
|
|
622
|
+
_JSONC_COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.DOTALL)
|
|
623
|
+
_JSONC_TRAILING_COMMA = re.compile(r",\s*([}\]])")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _load_tsconfig(cfg_path: str):
|
|
627
|
+
"""(config_dir, baseUrl, paths) from a tsconfig/jsconfig file, or None
|
|
628
|
+
when unreadable. `extends` chains are not followed (v1, disclosed)."""
|
|
629
|
+
try:
|
|
630
|
+
with open(cfg_path, "r", encoding="utf-8", errors="ignore") as fh:
|
|
631
|
+
text = fh.read()
|
|
632
|
+
text = _JSONC_COMMENT.sub("", text)
|
|
633
|
+
text = _JSONC_TRAILING_COMMA.sub(r"\1", text)
|
|
634
|
+
data = json.loads(text)
|
|
635
|
+
except (OSError, ValueError):
|
|
636
|
+
return None
|
|
637
|
+
opts = data.get("compilerOptions", {}) if isinstance(data, dict) else {}
|
|
638
|
+
base_url = opts.get("baseUrl", "") or ""
|
|
639
|
+
raw_paths = opts.get("paths", {}) or {}
|
|
640
|
+
paths = {
|
|
641
|
+
k: v for k, v in raw_paths.items()
|
|
642
|
+
if isinstance(k, str) and isinstance(v, list)
|
|
643
|
+
}
|
|
644
|
+
if not base_url and not paths:
|
|
645
|
+
return None
|
|
646
|
+
return os.path.dirname(os.path.abspath(cfg_path)), base_url, paths
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# ── Tree walking (module-level; stateless) ───────────────────────────────
|
|
650
|
+
|
|
651
|
+
def _text(node) -> str:
|
|
652
|
+
return node.text.decode("utf-8", "ignore")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _import_source(node) -> Optional[str]:
|
|
656
|
+
src = node.child_by_field_name("source")
|
|
657
|
+
if src is None:
|
|
658
|
+
return None
|
|
659
|
+
frag = next(
|
|
660
|
+
(c for c in src.named_children if c.type == "string_fragment"), None
|
|
661
|
+
)
|
|
662
|
+
return _text(frag) if frag is not None else None
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _gather_facts(tree) -> _FileFacts:
|
|
666
|
+
"""One walk: symbols, class nodes/methods, and barrel re-exports."""
|
|
667
|
+
facts = _FileFacts(tree)
|
|
668
|
+
stack: List[str] = []
|
|
669
|
+
|
|
670
|
+
def qualify(name: str) -> str:
|
|
671
|
+
return ".".join(stack + [name]) if stack else name
|
|
672
|
+
|
|
673
|
+
def walk(node):
|
|
674
|
+
for child in node.named_children:
|
|
675
|
+
if child.type == "export_statement":
|
|
676
|
+
decl = child.child_by_field_name("declaration")
|
|
677
|
+
if decl is not None:
|
|
678
|
+
handle(decl)
|
|
679
|
+
else:
|
|
680
|
+
handle(child)
|
|
681
|
+
|
|
682
|
+
def handle(node):
|
|
683
|
+
t = node.type
|
|
684
|
+
if t in _FUNCTION_DECLS:
|
|
685
|
+
name_node = node.child_by_field_name("name")
|
|
686
|
+
if name_node is not None:
|
|
687
|
+
facts.symbols.append((qualify(_text(name_node)), node))
|
|
688
|
+
body = node.child_by_field_name("body")
|
|
689
|
+
if body is not None:
|
|
690
|
+
walk(body)
|
|
691
|
+
elif t in _CLASS_DECLS:
|
|
692
|
+
name_node = node.child_by_field_name("name")
|
|
693
|
+
body = node.child_by_field_name("body")
|
|
694
|
+
if name_node is not None and body is not None:
|
|
695
|
+
cls_qualified = qualify(_text(name_node))
|
|
696
|
+
facts.class_nodes[cls_qualified] = node
|
|
697
|
+
methods = facts.class_methods.setdefault(cls_qualified, set())
|
|
698
|
+
fields = facts.class_fields.setdefault(cls_qualified, {})
|
|
699
|
+
stack.append(_text(name_node))
|
|
700
|
+
for member in body.named_children:
|
|
701
|
+
if member.type == "method_definition":
|
|
702
|
+
m_name = member.child_by_field_name("name")
|
|
703
|
+
if m_name is not None:
|
|
704
|
+
facts.symbols.append((qualify(_text(m_name)), member))
|
|
705
|
+
methods.add(_text(m_name))
|
|
706
|
+
if _text(m_name) == "constructor":
|
|
707
|
+
# Parameter properties: `constructor(private
|
|
708
|
+
# db: Database)` declares field `db`.
|
|
709
|
+
for pname, ptype in _parameter_properties(member):
|
|
710
|
+
if ptype:
|
|
711
|
+
fields[pname] = ptype
|
|
712
|
+
m_body = member.child_by_field_name("body")
|
|
713
|
+
if m_body is not None:
|
|
714
|
+
walk(m_body)
|
|
715
|
+
elif member.type == "public_field_definition":
|
|
716
|
+
f_name = member.child_by_field_name("name")
|
|
717
|
+
f_type = _annotation_type(member.child_by_field_name("type"))
|
|
718
|
+
if f_name is not None and f_type:
|
|
719
|
+
fields[_text(f_name)] = f_type
|
|
720
|
+
stack.pop()
|
|
721
|
+
elif t in _TYPE_DECLS:
|
|
722
|
+
name_node = node.child_by_field_name("name")
|
|
723
|
+
if name_node is not None:
|
|
724
|
+
facts.symbols.append((qualify(_text(name_node)), node))
|
|
725
|
+
elif t in ("lexical_declaration", "variable_declaration"):
|
|
726
|
+
for declarator in node.named_children:
|
|
727
|
+
if declarator.type != "variable_declarator":
|
|
728
|
+
continue
|
|
729
|
+
name_node = declarator.child_by_field_name("name")
|
|
730
|
+
value = declarator.child_by_field_name("value")
|
|
731
|
+
if (
|
|
732
|
+
name_node is not None
|
|
733
|
+
and name_node.type == "identifier"
|
|
734
|
+
and value is not None
|
|
735
|
+
and value.type in _FUNCTION_VALUES
|
|
736
|
+
):
|
|
737
|
+
facts.symbols.append((qualify(_text(name_node)), declarator))
|
|
738
|
+
body = value.child_by_field_name("body")
|
|
739
|
+
if body is not None and body.type == "statement_block":
|
|
740
|
+
walk(body)
|
|
741
|
+
elif t == "internal_module": # namespace X { ... }
|
|
742
|
+
name_node = node.child_by_field_name("name")
|
|
743
|
+
body = node.child_by_field_name("body")
|
|
744
|
+
if name_node is not None and body is not None:
|
|
745
|
+
stack.append(_text(name_node))
|
|
746
|
+
walk(body)
|
|
747
|
+
stack.pop()
|
|
748
|
+
elif t == "expression_statement":
|
|
749
|
+
for child in node.named_children:
|
|
750
|
+
handle(child)
|
|
751
|
+
elif t in _DESCEND_STMTS:
|
|
752
|
+
walk(node)
|
|
753
|
+
|
|
754
|
+
walk(tree.root_node)
|
|
755
|
+
facts.reexports = _collect_reexports(tree.root_node)
|
|
756
|
+
return facts
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _collect_reexports(root) -> Tuple[Dict[str, Tuple[str, str]], List[str]]:
|
|
760
|
+
"""
|
|
761
|
+
Barrel-file exports: ({exported_name: (specifier, original_name)},
|
|
762
|
+
[star_specifiers]) from `export {X as Y} from './x'` / `export * from`.
|
|
763
|
+
"""
|
|
764
|
+
named: Dict[str, Tuple[str, str]] = {}
|
|
765
|
+
stars: List[str] = []
|
|
766
|
+
for node in root.named_children:
|
|
767
|
+
if node.type != "export_statement":
|
|
768
|
+
continue
|
|
769
|
+
spec = _import_source(node)
|
|
770
|
+
if spec is None:
|
|
771
|
+
continue
|
|
772
|
+
clause = next(
|
|
773
|
+
(c for c in node.named_children if c.type == "export_clause"), None
|
|
774
|
+
)
|
|
775
|
+
if clause is None:
|
|
776
|
+
stars.append(spec) # export * from './x'
|
|
777
|
+
continue
|
|
778
|
+
for exp in clause.named_children:
|
|
779
|
+
if exp.type != "export_specifier":
|
|
780
|
+
continue
|
|
781
|
+
orig = exp.child_by_field_name("name")
|
|
782
|
+
alias = exp.child_by_field_name("alias")
|
|
783
|
+
if orig is None:
|
|
784
|
+
continue
|
|
785
|
+
exported = _text(alias) if alias is not None else _text(orig)
|
|
786
|
+
named[exported] = (spec, _text(orig))
|
|
787
|
+
return named, stars
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _body_of(node):
|
|
791
|
+
"""The block whose calls belong to this symbol. For arrow/function
|
|
792
|
+
consts the body lives on the declarator's value, not the node."""
|
|
793
|
+
if node.type == "variable_declarator":
|
|
794
|
+
value = node.child_by_field_name("value")
|
|
795
|
+
return value.child_by_field_name("body") if value is not None else None
|
|
796
|
+
return node.child_by_field_name("body")
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _annotation_type(type_annotation) -> Optional[str]:
|
|
800
|
+
"""Class-ish type name from a type_annotation node: `: User` -> "User",
|
|
801
|
+
`: Repo<User>` -> "Repo", `: ns.User` -> "User". Predefined types
|
|
802
|
+
(string, number...) and complex types return None."""
|
|
803
|
+
if type_annotation is None:
|
|
804
|
+
return None
|
|
805
|
+
for child in type_annotation.named_children:
|
|
806
|
+
if child.type == "type_identifier":
|
|
807
|
+
return _text(child)
|
|
808
|
+
if child.type == "generic_type":
|
|
809
|
+
name = child.child_by_field_name("name")
|
|
810
|
+
if name is not None and name.type == "type_identifier":
|
|
811
|
+
return _text(name)
|
|
812
|
+
if child.type == "nested_type_identifier":
|
|
813
|
+
return _text(child).rsplit(".", 1)[-1]
|
|
814
|
+
return None
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _params_of(node):
|
|
818
|
+
"""The formal_parameters node of a function-ish definition."""
|
|
819
|
+
params = node.child_by_field_name("parameters")
|
|
820
|
+
if params is None and node.type == "variable_declarator":
|
|
821
|
+
value = node.child_by_field_name("value")
|
|
822
|
+
if value is not None:
|
|
823
|
+
params = value.child_by_field_name("parameters")
|
|
824
|
+
return params
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _param_info(node) -> "Dict[str, Optional[str]]":
|
|
828
|
+
"""Parameter name -> declared type name (or None) of a definition
|
|
829
|
+
node. Names double as the shadow guard; types feed receiver
|
|
830
|
+
resolution (`u: User` ... `u.login()` -> User.login)."""
|
|
831
|
+
info: "Dict[str, Optional[str]]" = {}
|
|
832
|
+
params = _params_of(node)
|
|
833
|
+
if params is None:
|
|
834
|
+
return info
|
|
835
|
+
for p in params.named_children:
|
|
836
|
+
if p.type == "identifier":
|
|
837
|
+
info[_text(p)] = None
|
|
838
|
+
elif p.type in ("required_parameter", "optional_parameter"):
|
|
839
|
+
pattern = p.child_by_field_name("pattern")
|
|
840
|
+
if pattern is not None and pattern.type == "identifier":
|
|
841
|
+
info[_text(pattern)] = _annotation_type(
|
|
842
|
+
p.child_by_field_name("type")
|
|
843
|
+
)
|
|
844
|
+
return info
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _type_names_under(node, out: Set[str]) -> None:
|
|
848
|
+
"""Every type_identifier under `node`, generic arguments included
|
|
849
|
+
(`Partial<KyOptions>` yields both Partial and KyOptions)."""
|
|
850
|
+
if node.type == "type_identifier":
|
|
851
|
+
out.add(_text(node))
|
|
852
|
+
elif node.type == "nested_type_identifier":
|
|
853
|
+
out.add(_text(node).rsplit(".", 1)[-1])
|
|
854
|
+
return
|
|
855
|
+
for child in node.named_children:
|
|
856
|
+
_type_names_under(child, out)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _symbol_annotation_types(node) -> Set[str]:
|
|
860
|
+
"""
|
|
861
|
+
Type names this definition MENTIONS in its signature — parameter and
|
|
862
|
+
return annotations. In TypeScript, implementations co-change with the
|
|
863
|
+
interfaces/aliases they annotate with (a `types/*.ts` edit lands in
|
|
864
|
+
the same commit as its consumers), a dependency class that call
|
|
865
|
+
scanning can never see because types are never called.
|
|
866
|
+
"""
|
|
867
|
+
names: Set[str] = set()
|
|
868
|
+
params = _params_of(node)
|
|
869
|
+
if params is not None:
|
|
870
|
+
_type_names_under(params, names)
|
|
871
|
+
ret = node.child_by_field_name("return_type")
|
|
872
|
+
if ret is None and node.type == "variable_declarator":
|
|
873
|
+
value = node.child_by_field_name("value")
|
|
874
|
+
if value is not None:
|
|
875
|
+
ret = value.child_by_field_name("return_type")
|
|
876
|
+
if ret is not None:
|
|
877
|
+
_type_names_under(ret, names)
|
|
878
|
+
return names
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _parameter_properties(ctor_node):
|
|
882
|
+
"""(name, type) for constructor params with an accessibility modifier
|
|
883
|
+
(`constructor(private db: Database)`) — TS declares them as fields."""
|
|
884
|
+
params = _params_of(ctor_node)
|
|
885
|
+
if params is None:
|
|
886
|
+
return
|
|
887
|
+
for p in params.named_children:
|
|
888
|
+
if p.type not in ("required_parameter", "optional_parameter"):
|
|
889
|
+
continue
|
|
890
|
+
if not any(c.type == "accessibility_modifier" for c in p.children):
|
|
891
|
+
continue
|
|
892
|
+
pattern = p.child_by_field_name("pattern")
|
|
893
|
+
if pattern is not None and pattern.type == "identifier":
|
|
894
|
+
yield _text(pattern), _annotation_type(p.child_by_field_name("type"))
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _collect_local_types(body, def_node_ids: Set[int]) -> Dict[str, str]:
|
|
898
|
+
"""
|
|
899
|
+
Local variable name -> type name within a symbol's body, from explicit
|
|
900
|
+
annotations (`const u: User = ...`) and constructor inference
|
|
901
|
+
(`const u = new User()`). Walk boundaries match _iter_calls, so the
|
|
902
|
+
env covers exactly the calls it will be used to resolve.
|
|
903
|
+
"""
|
|
904
|
+
env: Dict[str, str] = {}
|
|
905
|
+
if body is None:
|
|
906
|
+
return env
|
|
907
|
+
stack = [body]
|
|
908
|
+
while stack:
|
|
909
|
+
node = stack.pop()
|
|
910
|
+
if id(node) in def_node_ids:
|
|
911
|
+
continue
|
|
912
|
+
if node.type == "variable_declarator":
|
|
913
|
+
name_node = node.child_by_field_name("name")
|
|
914
|
+
if name_node is not None and name_node.type == "identifier":
|
|
915
|
+
declared = _annotation_type(node.child_by_field_name("type"))
|
|
916
|
+
if declared is None:
|
|
917
|
+
value = node.child_by_field_name("value")
|
|
918
|
+
if value is not None and value.type == "new_expression":
|
|
919
|
+
ctor = value.child_by_field_name("constructor")
|
|
920
|
+
if ctor is not None and ctor.type == "identifier":
|
|
921
|
+
declared = _text(ctor)
|
|
922
|
+
if declared:
|
|
923
|
+
env[_text(name_node)] = declared
|
|
924
|
+
stack.extend(node.named_children)
|
|
925
|
+
return env
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _extends_name(node) -> Optional[str]:
|
|
929
|
+
"""Parent class identifier from `class X extends Parent`, else None."""
|
|
930
|
+
if node is None or node.type not in _CLASS_DECLS:
|
|
931
|
+
return None
|
|
932
|
+
for child in node.named_children:
|
|
933
|
+
if child.type == "class_heritage":
|
|
934
|
+
for clause in child.named_children:
|
|
935
|
+
if clause.type == "extends_clause":
|
|
936
|
+
value = clause.child_by_field_name("value")
|
|
937
|
+
if value is not None and value.type == "identifier":
|
|
938
|
+
return _text(value)
|
|
939
|
+
return None
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _iter_calls(body, def_node_ids: Set[int]):
|
|
943
|
+
"""
|
|
944
|
+
Yield call_expression / new_expression nodes inside `body`, without
|
|
945
|
+
descending into nested nodes that are themselves collected symbols
|
|
946
|
+
(their calls belong to them) — but descending into anonymous inline
|
|
947
|
+
callbacks, whose calls belong to the enclosing symbol.
|
|
948
|
+
"""
|
|
949
|
+
if body is None:
|
|
950
|
+
return
|
|
951
|
+
# Seed with the body node itself, not its children: an expression-bodied
|
|
952
|
+
# arrow (`=> new Foo()`) has the call AS the body.
|
|
953
|
+
stack = [body]
|
|
954
|
+
while stack:
|
|
955
|
+
node = stack.pop()
|
|
956
|
+
if id(node) in def_node_ids:
|
|
957
|
+
continue
|
|
958
|
+
if node.type in ("call_expression", "new_expression"):
|
|
959
|
+
yield node
|
|
960
|
+
stack.extend(node.named_children)
|