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.
Files changed (40) hide show
  1. diffcontext/__init__.py +233 -0
  2. diffcontext/_warn_once.py +112 -0
  3. diffcontext/cache.py +216 -0
  4. diffcontext/cli/__init__.py +655 -0
  5. diffcontext/context/__init__.py +1 -0
  6. diffcontext/context/compiler.py +643 -0
  7. diffcontext/context/selector.py +258 -0
  8. diffcontext/diff/__init__.py +1 -0
  9. diffcontext/diff/git_diff.py +298 -0
  10. diffcontext/diff/state_manager.py +75 -0
  11. diffcontext/graph_builder.py +1026 -0
  12. diffcontext/history.py +154 -0
  13. diffcontext/impact/__init__.py +1 -0
  14. diffcontext/impact/blast_radius.py +58 -0
  15. diffcontext/impact/scoring.py +223 -0
  16. diffcontext/impact/traversal.py +58 -0
  17. diffcontext/impact/visualizer.py +338 -0
  18. diffcontext/languages/__init__.py +80 -0
  19. diffcontext/languages/typescript.py +960 -0
  20. diffcontext/lexical.py +108 -0
  21. diffcontext/models.py +180 -0
  22. diffcontext/parser.py +183 -0
  23. diffcontext/pipeline.py +887 -0
  24. diffcontext/py.typed +0 -0
  25. diffcontext/rerank/__init__.py +17 -0
  26. diffcontext/rerank/features.py +356 -0
  27. diffcontext/rerank/model.py +175 -0
  28. diffcontext/resolver.py +288 -0
  29. diffcontext/scanner.py +153 -0
  30. diffcontext/symbols.py +254 -0
  31. diffcontext/verify/__init__.py +68 -0
  32. diffcontext/verify/cases.py +631 -0
  33. diffcontext/verify/history.py +396 -0
  34. diffcontext/verify/sufficiency.py +324 -0
  35. diffcontext-0.5.1.dist-info/METADATA +219 -0
  36. diffcontext-0.5.1.dist-info/RECORD +40 -0
  37. diffcontext-0.5.1.dist-info/WHEEL +5 -0
  38. diffcontext-0.5.1.dist-info/entry_points.txt +2 -0
  39. diffcontext-0.5.1.dist-info/licenses/LICENSE +21 -0
  40. 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)