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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+