cg-code-graph 0.10.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
codegraph/query.py ADDED
@@ -0,0 +1,2089 @@
1
+ """Graph queries. Traversals are recursive CTEs over the SQLite edge table."""
2
+ from __future__ import annotations
3
+
4
+ import contextvars
5
+ import json
6
+ import os
7
+ import re
8
+ import time
9
+ from collections import defaultdict
10
+
11
+ from .core.model import (CONFIDENCE_RANK, DEV_ENTRY_KINDS, LIBRARY_ENTRY_KINDS, OPERATOR_ENTRY_KINDS, PROPAGATING,
12
+ RUNTIME_ENTRY_KINDS, UI_ENTRY_KINDS)
13
+ from .core.store import GraphStore
14
+
15
+ CALL_LIKE = ["CALLS", "IMPLEMENTED_BY", "OVERRIDDEN_BY", "BOUND_TO", "ROUTES_TO", "HANDLED_BY", "SCHEDULES",
16
+ "DISPATCHES", "LISTENED_BY", "USES_MIDDLEWARE",
17
+ # TypeScript / Vue / Nuxt + cross-repo link
18
+ "USES_COMPOSABLE", "USES_STORE", "RENDERS", "HTTP_CALLS", "MATCHES_ROUTE",
19
+ # native code: function pointers / callbacks / dispatch tables
20
+ "REFERENCES_FN",
21
+ # realtime: broadcasting auth route -> channel callbacks; client subscriptions -> backend channels
22
+ "AUTHORIZES_CHANNEL", "SUBSCRIBES_CHANNEL", "MATCHES_CHANNEL",
23
+ # protocol endpoints: web / native bridges (JS / Dart call -> endpoint -> Kotlin / Swift handler)
24
+ "SENDS_TO", "RECEIVED_BY"]
25
+ TS_CODE_KINDS = ("composable", "store", "component", "module")
26
+ CODE_KINDS = ("method", "function", "script") + TS_CODE_KINDS
27
+ ENTRY_NODE_KINDS = ("route", "command", "schedule", "job", "listener", "admin", "observer", "page", "layout", "app", "message",
28
+ "channel")
29
+ CLASS_KINDS = ("class", "interface", "trait", "enum")
30
+
31
+
32
+ def resolve_targets(st: GraphStore, spec: str) -> list[str]:
33
+ """spec: kind:key (glob * allowed) | table.column | Class::method | Class (short or FQN)
34
+ | page:/route/path | a source file path (repo-relative, or repo/... in a combined DB)
35
+ | TS symbol (useX, useX.fn, fn). `Sub.method` for a method Sub inherits without redefining it resolves to the
36
+ inherited definition (inherited_targets has the details)."""
37
+ out = _resolve_direct(st, spec)
38
+ if out:
39
+ return out
40
+ return [i["method"] for i in inherited_targets(st, spec)]
41
+
42
+
43
+ INHERIT_KINDS = ("EXTENDS", "IMPLEMENTS", "USES_TRAIT")
44
+ _INHERITED: dict = {}
45
+
46
+
47
+ def inherited_targets(st: GraphStore, spec: str) -> list[dict]:
48
+ """`Sub.method` / `Sub::method` where Sub defines no `method` but an ancestor class does: one entry per matching
49
+ class, {spec, class, class_fqn, method, method_fqn, defined_in}: the nearest ancestor's definition (breadth-first
50
+ over EXTENDS / IMPLEMENTS / USES_TRAIT, in declaration order). [] when the spec names a defined symbol or no
51
+ ancestor defines the method."""
52
+ path = getattr(st, "path", None)
53
+ try:
54
+ key = (path, os.path.getmtime(path), spec) if path else None
55
+ except OSError:
56
+ key = None
57
+ if key is None:
58
+ return _inherited_uncached(st, spec)
59
+ if key not in _INHERITED:
60
+ if len(_INHERITED) > 256:
61
+ _INHERITED.clear()
62
+ _INHERITED[key] = _inherited_uncached(st, spec)
63
+ return _INHERITED[key]
64
+
65
+
66
+ def _inherited_uncached(st: GraphStore, spec: str) -> list[dict]:
67
+ out: list[dict] = []
68
+ m = re.fullmatch(r"(.+?)(?:\.|::)([A-Za-z_$][\w$]*)", spec)
69
+ if m and not spec.startswith(("page:", "/")) and not _resolve_direct(st, spec):
70
+ cls_spec, meth = m.group(1), m.group(2)
71
+ classes = [c for c in _resolve_direct(st, cls_spec) if c.split(":", 1)[0] in CLASS_KINDS]
72
+ for cid in dict.fromkeys(classes):
73
+ seen, frontier, hit = {cid}, [cid], None
74
+ while frontier and not hit:
75
+ nxt = []
76
+ for c in frontier:
77
+ for r in st.q(f"SELECT dst FROM edges WHERE src=? AND kind IN ({','.join('?' * len(INHERIT_KINDS))}) "
78
+ "ORDER BY line, dst", (c, *INHERIT_KINDS)):
79
+ if r["dst"] not in seen:
80
+ seen.add(r["dst"])
81
+ nxt.append(r["dst"])
82
+ for a in nxt:
83
+ hit = _member_of(st, a, meth)
84
+ if hit:
85
+ hit = (a, hit)
86
+ break
87
+ frontier = nxt
88
+ if hit:
89
+ fq = _fqn_of(st, [cid, hit[0], hit[1]])
90
+ out.append({"spec": spec, "class": cid, "class_fqn": fq.get(cid, cid), "method": hit[1],
91
+ "method_fqn": fq.get(hit[1], hit[1]), "defined_in": hit[0]})
92
+ return out
93
+
94
+
95
+ def _member_of(st: GraphStore, cls: str, name: str) -> str | None:
96
+ """The method `name` a class node contains (Python `Base.run`, TS `Shape.area`, PHP `X::run`)."""
97
+ for r in st.q("SELECT n.id, n.name FROM edges e JOIN nodes n ON n.id=e.dst WHERE e.src=? AND e.kind='CONTAINS' "
98
+ "AND n.kind IN ('method','function')", (cls,)):
99
+ nm = r["name"] or ""
100
+ if nm == name or nm.endswith("." + name) or nm.endswith("::" + name):
101
+ return r["id"]
102
+ fq = st.q("SELECT fqn FROM nodes WHERE id=?", (cls,))
103
+ if fq and fq[0]["fqn"]: # PHP classes hold their methods by fqn (Class::method), not CONTAINS
104
+ r = st.q("SELECT id FROM nodes WHERE kind='method' AND fqn=?", (fq[0]["fqn"] + "::" + name,))
105
+ if r:
106
+ return r[0]["id"]
107
+ return None
108
+
109
+
110
+ def _descendants(st: GraphStore, classes: list[str]) -> set[str]:
111
+ seen, frontier = set(classes), list(classes)
112
+ kq = ",".join("?" * len(INHERIT_KINDS))
113
+ while frontier:
114
+ nxt = []
115
+ for c in frontier:
116
+ for r in st.q(f"SELECT src FROM edges WHERE dst=? AND kind IN ({kq})", (c, *INHERIT_KINDS)):
117
+ if r["src"] not in seen:
118
+ seen.add(r["src"])
119
+ nxt.append(r["src"])
120
+ frontier = nxt
121
+ return seen
122
+
123
+
124
+ def override_seeds(st: GraphStore, spec_or_specs, targets: list[str], min_conf: str) -> list[str]:
125
+ """The overrides / implementations below method targets (impact, tests and reaches follow them: a call through a
126
+ base-typed value or a collection lands on an override). For an inherited spec (`Sub.method` resolved to
127
+ `Base.method`) only the overrides in Sub and its subclasses count: a Sub instance never dispatches to a sibling's."""
128
+ if not targets or _has_class_target(targets):
129
+ return []
130
+ below = _all_overrides(st, targets, min_conf)
131
+ specs = [spec_or_specs] if isinstance(spec_or_specs, str) else list(spec_or_specs or [])
132
+ inh = [i for s in specs for i in inherited_targets(st, s)]
133
+ if below and inh:
134
+ sub = _descendants(st, [i["class"] for i in inh])
135
+ owner = {r["dst"]: r["src"] for i in range(0, len(below), 500) for r in st.q(
136
+ f"SELECT src, dst FROM edges WHERE kind='CONTAINS' AND dst IN ({','.join('?' * len(below[i:i + 500]))})",
137
+ below[i:i + 500])}
138
+ below = [b for b in below if owner.get(b) in sub]
139
+ return below
140
+
141
+
142
+ def _ancestors(st: GraphStore, cls: str) -> set[str]:
143
+ seen, frontier = {cls}, [cls]
144
+ kq = ",".join("?" * len(INHERIT_KINDS))
145
+ while frontier:
146
+ nxt = []
147
+ for c in frontier:
148
+ for r in st.q(f"SELECT dst FROM edges WHERE src=? AND kind IN ({kq})", (c, *INHERIT_KINDS)):
149
+ if r["dst"] not in seen:
150
+ seen.add(r["dst"])
151
+ nxt.append(r["dst"])
152
+ frontier = nxt
153
+ return seen
154
+
155
+
156
+ def narrow_inherited(st: GraphStore, inh: list[dict]) -> set[int]:
157
+ """For `Sub.method` resolved to an ancestor's definition: the call edges into that definition whose receiver type
158
+ is known (edge attrs.recv: the TypeScript checker type, a Python inferred instance or collection element) and can
159
+ not be a Sub (neither Sub, a subclass, nor an ancestor of it). Each entry of `inh` gets `narrowed` counts."""
160
+ allowed: dict[str, set] = defaultdict(set)
161
+ known: dict[str, set] = {}
162
+ for i in inh:
163
+ allowed[i["method"]] |= _descendants(st, [i["class"]]) | _ancestors(st, i["class"])
164
+ # receivers whose path to the defining class the graph knows; any other receiver (a mixin `extends mix(B)`,
165
+ # a merged interface + class declaration) is kept: its hierarchy is incomplete, so it may still be a Sub
166
+ known.setdefault(i["method"], _descendants(st, [i["defined_in"]]))
167
+ skip, counts = set(), {}
168
+ for meth, ok in allowed.items():
169
+ n = typed = dropped = 0
170
+ for r in st.q("SELECT id, attrs FROM edges WHERE dst=? AND kind IN ('CALLS','TEST_CALLS')", (meth,)):
171
+ n += 1
172
+ try:
173
+ rc = (json.loads(r["attrs"]) if r["attrs"] else {}).get("recv")
174
+ except ValueError:
175
+ rc = None
176
+ if not rc:
177
+ continue
178
+ typed += 1
179
+ if not set(rc) & ok and set(rc) <= known[meth]:
180
+ skip.add(r["id"])
181
+ dropped += 1
182
+ counts[meth] = {"calls": n, "receiver_typed": typed, "dropped": dropped}
183
+ for i in inh:
184
+ i["narrowed"] = counts[i["method"]]
185
+ return skip
186
+
187
+
188
+ def override_bases(st: GraphStore, specs) -> list[dict]:
189
+ """For a spec naming an override (`Sub.peek` defined in Sub, overriding / implementing `Base.peek`): one entry per
190
+ base / interface method above it, shaped like inherited_targets' ({spec, class, method, defined_in, override}),
191
+ so the calls into the base method whose receiver type is known and cannot be a Sub are narrowed the same way
192
+ (`sib.peek()` with Sib inheriting Base.peek never runs Sub.peek)."""
193
+ out: list[dict] = []
194
+ dq = ",".join("?" * len(DISPATCH_KINDS))
195
+ for spec in ([specs] if isinstance(specs, str) else list(specs or [])):
196
+ for t in dict.fromkeys(x for x in _resolve_direct(st, spec) if x.startswith("method:")):
197
+ own = st.q("SELECT src FROM edges WHERE dst=? AND kind='CONTAINS'", (t,))
198
+ cls = own[0]["src"] if own and own[0]["src"].split(":", 1)[0] in CLASS_KINDS + ("type",) else None
199
+ if not cls:
200
+ continue
201
+ seen, frontier = {t}, [t]
202
+ while frontier:
203
+ nxt = []
204
+ for m in frontier:
205
+ for r in st.q(f"SELECT src FROM edges WHERE dst=? AND kind IN ({dq})", (m, *DISPATCH_KINDS)):
206
+ if r["src"] not in seen and r["src"].startswith("method:"):
207
+ seen.add(r["src"])
208
+ nxt.append(r["src"])
209
+ frontier = nxt
210
+ for b in sorted(seen - {t}):
211
+ bo = st.q("SELECT src FROM edges WHERE dst=? AND kind='CONTAINS'", (b,))
212
+ if not bo:
213
+ continue
214
+ fq = _fqn_of(st, [cls, b])
215
+ out.append({"spec": spec, "class": cls, "class_fqn": fq.get(cls, cls), "method": b,
216
+ "method_fqn": fq.get(b, b), "defined_in": bo[0]["src"], "override": True})
217
+ return out
218
+
219
+
220
+ def _narrowed_bases(ob: list[dict]) -> list[dict]:
221
+ return [o for o in ob if (o.get("narrowed") or {}).get("dropped")]
222
+
223
+
224
+ class narrowed:
225
+ """`with narrowed(st, inh):` the traversals of one query leave out the calls `narrow_inherited` drops."""
226
+
227
+ def __init__(self, st: GraphStore, inh: list[dict]):
228
+ self.skip = narrow_inherited(st, inh) if inh else set()
229
+
230
+ def __enter__(self):
231
+ self.tok = _SKIP_EDGES.set(self.skip or None)
232
+ return self
233
+
234
+ def __exit__(self, *a):
235
+ _SKIP_EDGES.reset(self.tok)
236
+
237
+
238
+ def _short(fqn: str) -> str:
239
+ """`Class.method` of a dotted / `::` / file-qualified fqn (pkg.mod.Class.method -> Class.method)."""
240
+ tail = re.split(r"[#/\\]", fqn)[-1]
241
+ parts = re.split(r"\.|::", tail)
242
+ return ".".join(parts[-2:]) if len(parts) >= 2 else tail
243
+
244
+
245
+ def inherited_lines(res: dict) -> list[str]:
246
+ """'B.run -> inherited from Base.run' notes for a result whose spec resolved through a class's ancestors, with how
247
+ the calls into the inherited definition were narrowed to receivers that can be a B (short names; JSON has fqns)."""
248
+ out = []
249
+ for i in res.get("override_narrowed") or []:
250
+ cls, nw = _short(i["class_fqn"]).split(".")[-1], i["narrowed"]
251
+ out.append(f"{i['spec']} overrides {_short(i['method_fqn'])} (its callers narrowed to {cls}: {nw['dropped']} of "
252
+ f"{nw['calls']} calls on other classes left out)")
253
+ for i in res.get("inherited") or []:
254
+ cls = _short(i["class_fqn"]).split(".")[-1]
255
+ line = f"{i['spec']} -> inherited from {_short(i['method_fqn'])}"
256
+ nw = i.get("narrowed")
257
+ if nw and nw["dropped"]:
258
+ line += f" (callers narrowed to {cls}: {nw['dropped']} of {nw['calls']} calls on other classes left out)"
259
+ elif nw and nw["receiver_typed"] == nw["calls"]:
260
+ line += f" (every call is on a receiver that can be a {cls})"
261
+ else:
262
+ line += f" (calls with an unknown receiver type count for {cls})"
263
+ out.append(line)
264
+ return out
265
+
266
+
267
+ # `Class::method` and `Class.method` name the same member in every language (#75): a spec that matches nothing with
268
+ # its own separator is retried with the other one (Swift / Kotlin / Python / TS / Dart fqns use `.`, PHP / Rust / C++ `::`)
269
+ MEMBER_PATH_RE = re.compile(r"\\?[A-Za-z_$][\w$\\]*(?:(?:\.|::)[A-Za-z_$][\w$]*)+")
270
+
271
+
272
+ def _resolve_direct(st: GraphStore, spec: str) -> list[str]:
273
+ out = _resolve_direct_one(st, spec)
274
+ if out or not MEMBER_PATH_RE.fullmatch(spec) or re.search(r"\.(vue|[cm]?[jt]sx?|py|dart|rs|c|h|cc|cpp|cxx|hh|hpp|hxx|m|mm|swift|kt|php)$", spec):
275
+ return out
276
+ alt = spec.replace("::", ".") if "::" in spec else spec.replace(".", "::")
277
+ return _resolve_direct_one(st, alt)
278
+
279
+
280
+ def _resolve_direct_one(st: GraphStore, spec: str) -> list[str]:
281
+ if spec.startswith("page:/"):
282
+ rows = st.q("SELECT id FROM nodes WHERE kind='page' AND json_extract(attrs,'$.route')=?", (spec[5:],))
283
+ return [r["id"] for r in rows]
284
+ if st.q("SELECT 1 FROM nodes WHERE id=? LIMIT 1", (spec,)): # an exact node id (e.g. method:App\X::y)
285
+ return [spec]
286
+ fn = _file_name_targets(st, spec)
287
+ if fn is not None:
288
+ return fn
289
+ if re.search(r"\.(vue|ts|tsx|js|mjs|py|dart)$", spec) and "::" not in spec:
290
+ rows = st.q("SELECT id FROM nodes WHERE kind IN ('page','component','layout','app','module') AND (file=? OR file LIKE ?)",
291
+ (spec, "%/" + spec))
292
+ if rows:
293
+ return [r["id"] for r in rows]
294
+ if ":" in spec and not "::" in spec:
295
+ kind, key = spec.split(":", 1)
296
+ pat = f"{kind}:{key}".replace("*", "%")
297
+ rows = st.q("SELECT id FROM nodes WHERE id LIKE ?", (pat,)) if "%" in pat else st.q("SELECT id FROM nodes WHERE id=?", (pat,))
298
+ return [r["id"] for r in rows]
299
+ nat = _native_targets(st, spec)
300
+ if nat:
301
+ return nat
302
+ if "::" not in spec and "." in spec and "\\" not in spec:
303
+ # python dotted path (pkg.mod.func / pkg.mod.Class.method) or Dart Class.method
304
+ rows = st.q("""SELECT id FROM nodes WHERE lang IN ('python','dart') AND kind IN ('function','method','class','module')
305
+ AND (fqn=? OR fqn LIKE ?)""", (spec, "%#" + spec))
306
+ if rows:
307
+ return [r["id"] for r in rows]
308
+ # a dotted suffix of a Python fqn: `PyProgram.load`, `plugin.PyProgram.load`, `checks.validate` in a package
309
+ rows = st.q("""SELECT id FROM nodes WHERE lang='python' AND kind IN ('function','method','class')
310
+ AND fqn LIKE ? ESCAPE '\\'""", ("%." + spec.replace("_", "\\_"),))
311
+ if rows:
312
+ return [r["id"] for r in rows]
313
+ # Kotlin / Swift (and the Java / ObjC bridge stubs): pkg.Class.method, Class.method
314
+ rows = st.q("""SELECT id FROM nodes WHERE lang IN ('kotlin','swift','java','objc') AND kind IN ('function','method','class')
315
+ AND (fqn=? OR fqn LIKE ? ESCAPE '\\' OR fqn LIKE ? ESCAPE '\\')""",
316
+ (spec, "%." + _like(spec), "%:" + _like(spec)))
317
+ if rows:
318
+ return [r["id"] for r in rows]
319
+ rows = st.q("SELECT id FROM nodes WHERE id=?", (f"column:{spec}",))
320
+ if rows:
321
+ return [rows[0]["id"]]
322
+ rows = st.q("SELECT id FROM nodes WHERE id=?", (f"config:{spec}",))
323
+ if rows:
324
+ return [r["id"] for r in rows]
325
+ if re.fullmatch(r"[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*", spec) and "\\" not in spec:
326
+ rows = st.q("""SELECT id FROM nodes WHERE lang IN ('ts','python','dart')
327
+ AND kind IN ('function','composable','store','method','class','type')
328
+ AND (name=? OR name LIKE ?)""", (spec, "%." + spec))
329
+ if rows:
330
+ return [r["id"] for r in rows]
331
+ spec = spec.lstrip("\\")
332
+ if "::" in spec:
333
+ rows = st.q("SELECT id FROM nodes WHERE kind='method' AND (fqn=? OR fqn LIKE ?)", (spec, "%\\" + spec))
334
+ return [r["id"] for r in rows]
335
+ rows = st.q("SELECT id, fqn FROM nodes WHERE kind IN ('class','interface','trait','enum') AND (fqn=? OR fqn LIKE ?)", (spec, "%\\" + spec))
336
+ out = []
337
+ for r in rows:
338
+ out.append(r["id"])
339
+ out += [x["id"] for x in st.q("SELECT id FROM nodes WHERE kind='method' AND fqn LIKE ?", (r["fqn"] + "::%",))]
340
+ # an event / job class also selects its dispatch node (event:X, job:X): `impact OrderShipped` follows dispatches
341
+ out += [x["id"] for x in st.q("SELECT id FROM nodes WHERE id IN (?, ?)", ("event:" + r["fqn"], "job:" + r["fqn"]))]
342
+ if not out and re.fullmatch(r"[A-Za-z_][\w]*", spec):
343
+ # a bare free-function name in any language, as `search` finds it (Swift / Kotlin / PHP / Rust / C functions:
344
+ # `formatPrice`, Kotlin `pkg.formatPrice`, PHP `App\helpers\formatPrice`)
345
+ rows = st.q("""SELECT id FROM nodes WHERE kind='function' AND (name=? OR fqn=?) ORDER BY file, line""",
346
+ (spec, spec))
347
+ out = [r["id"] for r in rows]
348
+ return out
349
+
350
+
351
+ FILE_NAME_RE = re.compile(r"(?P<file>[^#:]+\.(?:vue|ts|tsx|mts|cts|js|jsx|mjs|cjs|svelte))#(?P<name>[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:~\d+)?)")
352
+ FILE_NAME_KINDS = ("function", "method", "class", "composable", "store", "type", "interface", "enum")
353
+
354
+
355
+ def _like(s: str) -> str:
356
+ return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
357
+
358
+
359
+ def _file_name_targets(st: GraphStore, spec: str) -> list[str] | None:
360
+ """`file#name` for TypeScript / JavaScript / Vue nodes (ids `kind:file#name`): the file part matches the node's
361
+ file exactly or as a path suffix (`app.ts#listOrders`, `src/app.ts#listOrders`), the name part the declared name,
362
+ `Class.method` included; a bare member name (`svc.ts#create`) matches `Class.create` in that file when nothing
363
+ is declared under the name itself. None when the spec is not of that form or nothing matches (the other forms
364
+ are tried then)."""
365
+ m = FILE_NAME_RE.fullmatch(spec)
366
+ if not m:
367
+ return None
368
+ f, name = m["file"].removeprefix("./"), m["name"]
369
+ rows = st.q("""SELECT id, substr(id, instr(id, '#') + 1) AS key FROM nodes
370
+ WHERE instr(id, '#') > 0 AND (file = ? OR file LIKE ? ESCAPE '\\') ORDER BY file, line""",
371
+ (f, "%/" + _like(f)))
372
+ # `name~2`: a second declaration of the name in that file, `name@line`: a per-branch platform variant
373
+ keys = [(r["id"], r["key"] if "~" in name else re.sub(r"(~\d+|@\d+)$", "", r["key"])) for r in rows]
374
+ hit = [i for i, k in keys if k == name] or [i for i, k in keys if k.endswith("." + name)]
375
+ code = [i for i in hit if i.split(":", 1)[0] in FILE_NAME_KINDS] # the declaration, not its listener / route node
376
+ return code or hit or None
377
+
378
+
379
+ NATIVE_LANGS = ("rust", "c", "cpp")
380
+ NATIVE_CODE = ("function", "method", "ffi", "macro")
381
+ NATIVE_TYPES = ("struct", "enum", "union", "trait", "type_alias", "class", "typedef")
382
+ NATIVE_FILE_RE = re.compile(r"\.(rs|c|h|cc|cpp|cxx|hh|hpp|hxx|ipp|inl|m|mm)$")
383
+
384
+
385
+ def _native_targets(st: GraphStore, spec: str) -> list[str]:
386
+ """Rust / C / C++ specs: a source file (all items defined in it, plus its file node), a path
387
+ (crate::module::fn, Type::method, ns::Class::method; `Type::m` also matches `<Type as Trait>::m`), or a bare
388
+ function / type name. Returns [] when nothing native matches (other resolvers take over)."""
389
+ if not st.q("SELECT 1 FROM nodes WHERE lang IN ('rust','c','cpp') LIMIT 1"):
390
+ return []
391
+ langs = ",".join(f"'{x}'" for x in NATIVE_LANGS)
392
+ if NATIVE_FILE_RE.search(spec) and "::" not in spec:
393
+ rows = st.q(f"""SELECT id FROM nodes WHERE lang IN ({langs}) AND (file=? OR file LIKE ?)
394
+ AND kind IN ('file','function','method','ffi','struct','enum','union','trait','class','typedef')
395
+ ORDER BY kind='file' DESC, line""", (spec, "%/" + spec))
396
+ return [r["id"] for r in rows]
397
+ code = ",".join(f"'{k}'" for k in NATIVE_CODE)
398
+ types = ",".join(f"'{k}'" for k in NATIVE_TYPES)
399
+ if spec.startswith(("mod:", "crate:")) or st.q("SELECT 1 FROM nodes WHERE kind IN ('mod','crate') AND id IN (?, ?)",
400
+ ("mod:" + spec, "crate:" + spec)):
401
+ # a Rust module / crate: every function and method defined in it (and its submodules)
402
+ m = spec.split(":", 1)[1] if spec.startswith(("mod:", "crate:")) else spec
403
+ rows = st.q(f"SELECT id FROM nodes WHERE lang='rust' AND kind IN ({code}) AND (module=? OR module LIKE ?) ORDER BY file, line",
404
+ (m, m + "::%"))
405
+ return [r["id"] for r in rows]
406
+ if "::" in spec:
407
+ head, _, last = spec.rpartition("::")
408
+ rows = st.q(f"""SELECT id FROM nodes WHERE lang IN ({langs}) AND kind IN ({code},{types})
409
+ AND (fqn=? OR fqn LIKE ? OR fqn LIKE ? OR fqn LIKE ?)""",
410
+ (spec, "%::" + spec, f"%<{head} as %>::{last}", f"%<{head.rsplit('::', 1)[-1]} as %>::{last}"))
411
+ ids = [r["id"] for r in rows]
412
+ tys = [i for i in ids if i.split(":", 1)[0] in NATIVE_TYPES]
413
+ for t in tys: # a type spec also selects its methods (like Class in PHP)
414
+ fq = t.split(":", 1)[1]
415
+ ids += [r["id"] for r in st.q("SELECT id FROM nodes WHERE kind='method' AND (fqn LIKE ? OR fqn LIKE ?)",
416
+ (fq + "::%", fq.rsplit("::", 1)[0] + f"::<{fq.rsplit('::', 1)[-1]} as %"))]
417
+ return list(dict.fromkeys(ids))
418
+ if re.fullmatch(r"[A-Za-z_]\w*", spec):
419
+ rows = st.q(f"SELECT id FROM nodes WHERE lang IN ({langs}) AND kind IN ({code}) AND name=?", (spec,))
420
+ if rows:
421
+ return [r["id"] for r in rows]
422
+ rows = st.q(f"SELECT id FROM nodes WHERE lang IN ({langs}) AND kind IN ({types}) AND name=?", (spec,))
423
+ return [r["id"] for r in rows]
424
+ return []
425
+
426
+
427
+ def default_gate(st: GraphStore) -> str | None:
428
+ """Gate scenario the DB was indexed with (first one), if any."""
429
+ r = st.q("SELECT DISTINCT scenario FROM node_entry_live LIMIT 1")
430
+ return r[0]["scenario"] if r else None
431
+
432
+
433
+ def _px(st: GraphStore, platform: str | None) -> set:
434
+ """Edge ids that do not exist on `platform` (codegraph/platforms.py), empty without a platform filter."""
435
+ if not platform:
436
+ return set()
437
+ from .platforms import exclusions
438
+ return exclusions(st, platform)["edges"]
439
+
440
+
441
+ # edge ids a query leaves out (calls to an inherited definition on receivers that cannot be the spec's class):
442
+ # set by `narrowed()` for the duration of one impact / reaches / tests query
443
+ _SKIP_EDGES: contextvars.ContextVar = contextvars.ContextVar("cg_skip_edges", default=None)
444
+
445
+
446
+ def reverse_closure(st: GraphStore, targets: list[str], kinds=None, min_conf="heuristic", max_depth=30,
447
+ exclude_gate: str | None = None, seed_inst: bool = False, platform: str | None = None) -> dict[str, int]:
448
+ """Reverse transitive closure over `kinds`. seed_inst: code that instantiates a targeted class (`new X`) is a
449
+ dependent too (INSTANTIATES is followed into the targets only, never further up). platform: only over references
450
+ that exist on that target (codegraph/platforms.py)."""
451
+ kinds = kinds or PROPAGATING
452
+ if not targets:
453
+ return {}
454
+ pclause = ""
455
+ if platform and _px(st, platform):
456
+ from .platforms import temp_table
457
+ pclause = f"AND NOT EXISTS (SELECT 1 FROM {temp_table(st, platform)} x WHERE x.id = e.id)"
458
+ st.db.execute("DROP TABLE IF EXISTS temp.t_targets")
459
+ st.db.execute("CREATE TEMP TABLE t_targets(id TEXT PRIMARY KEY)")
460
+ st.db.executemany("INSERT OR IGNORE INTO t_targets VALUES (?)", [(t,) for t in targets])
461
+ kq = ",".join("?" * len(kinds))
462
+ inst = "OR (e.kind = 'INSTANTIATES' AND r.depth = 0)" if seed_inst else ""
463
+ sql = f"""
464
+ WITH RECURSIVE r(id, depth) AS (
465
+ SELECT id, 0 FROM t_targets
466
+ UNION
467
+ SELECT e.src, r.depth + 1 FROM edges e JOIN r ON e.dst = r.id
468
+ WHERE (e.kind IN ({kq}) {inst}) AND e.conf_rank >= ? AND r.depth < ? AND (e.gate IS NULL OR e.gate != ?) {pclause}
469
+ )
470
+ SELECT id, MIN(depth) AS depth FROM r GROUP BY id"""
471
+ skip = _SKIP_EDGES.get()
472
+ if skip:
473
+ st.db.execute("DROP TABLE IF EXISTS temp.t_skip_edges")
474
+ st.db.execute("CREATE TEMP TABLE t_skip_edges(id INTEGER PRIMARY KEY)")
475
+ st.db.executemany("INSERT OR IGNORE INTO t_skip_edges VALUES (?)", [(x,) for x in skip])
476
+ sql = sql.replace(
477
+ "AND (e.gate IS NULL OR e.gate != ?)", "AND (e.gate IS NULL OR e.gate != ?) AND e.id NOT IN (SELECT id FROM t_skip_edges)")
478
+ rows = st.q(sql, (*kinds, CONFIDENCE_RANK[min_conf], max_depth, exclude_gate or "\x00"))
479
+ return {r["id"]: r["depth"] for r in rows}
480
+
481
+
482
+ def shortest_paths(st: GraphStore, depth: dict[str, int], kinds=None, min_conf="heuristic", exclude_gate: str | None = None,
483
+ seed_inst: bool = False, platform: str | None = None) -> dict[str, list[dict]]:
484
+ """For each reached node pick an outgoing edge to a node one step closer to a target."""
485
+ kinds = set(kinds or PROPAGATING)
486
+ px = _px(st, platform)
487
+ skip = _SKIP_EDGES.get()
488
+ best = {}
489
+ ids = list(depth)
490
+ for i in range(0, len(ids), 500):
491
+ chunk = ids[i:i + 500]
492
+ q = ",".join("?" * len(chunk))
493
+ for e in st.q(f"SELECT id,src,dst,kind,file,line,confidence,conf_rank,gate,attrs FROM edges WHERE src IN ({q})", chunk):
494
+ if e["conf_rank"] < CONFIDENCE_RANK[min_conf] or (px and e["id"] in px) or (skip and e["id"] in skip):
495
+ continue
496
+ if e["kind"] not in kinds and not (seed_inst and e["kind"] == "INSTANTIATES" and depth.get(e["dst"]) == 0):
497
+ continue
498
+ if exclude_gate and e["gate"] == exclude_gate:
499
+ continue
500
+ s, d = e["src"], e["dst"]
501
+ if d in depth and depth[d] == depth[s] - 1:
502
+ cur = best.get(s)
503
+ # prefer stronger confidence, then live (ungated) edges
504
+ rank = (e["conf_rank"], e["gate"] is None)
505
+ if cur is None or rank > (cur["conf_rank"], cur["gate"] is None):
506
+ best[s] = dict(e)
507
+ paths = {}
508
+ for n in depth:
509
+ path, x, guard = [], n, 0
510
+ while depth.get(x, 0) > 0 and x in best and guard < 60:
511
+ e = best[x]
512
+ path.append(_hop_of(e))
513
+ x = e["dst"]
514
+ guard += 1
515
+ paths[n] = path
516
+ return paths
517
+
518
+
519
+ class Deadline(Exception):
520
+ """A traversal ran past its deadline (time.time() value), e.g. a starter query over its time budget."""
521
+
522
+
523
+ class GroupClosures:
524
+ """Reverse closures of many target groups in one pass: what reverse_closure + shortest_paths give for each group
525
+ separately, from a single read of the edge table and one multi-source breadth-first walk (a bitmask of groups
526
+ per node and level). `within`: entry nodes (e.g. routes); the walk stays on nodes they reach, which holds every
527
+ node of every shortest path from them, so depths and paths of those nodes equal the per-group answers."""
528
+
529
+ def __init__(self, st: GraphStore, groups: list[list[str]], kinds=None, min_conf="heuristic", max_depth=30,
530
+ exclude_gate: str | None = None, platform: str | None = None, within: list[str] | None = None,
531
+ deadline: float | None = None):
532
+ kinds = list(kinds or PROPAGATING)
533
+ self.st, self.deadline = st, deadline
534
+ px = _px(st, platform)
535
+ rev: dict[str, list] = defaultdict(list)
536
+ fwd: dict[str, list] = defaultdict(list)
537
+ kq = ",".join("?" * len(kinds))
538
+ for eid, src, dst, kind, rank, gate in st.db.execute(
539
+ f"SELECT id, src, dst, kind, conf_rank, gate FROM edges WHERE kind IN ({kq}) AND conf_rank >= ?",
540
+ (*kinds, CONFIDENCE_RANK[min_conf])):
541
+ if px and eid in px:
542
+ continue
543
+ rev[dst].append((src, gate))
544
+ fwd[src].append((kind, eid, dst, rank, gate))
545
+ for v in fwd.values():
546
+ v.sort(key=lambda e: (e[0], e[1])) # the (src, kind, id) order of the edge index, for equal ties
547
+ self.fwd = fwd
548
+ self._check()
549
+ scope = None
550
+ if within is not None:
551
+ scope, todo = set(within), list(within)
552
+ while todo:
553
+ for e in fwd.get(todo.pop(), ()):
554
+ if e[2] not in scope:
555
+ scope.add(e[2])
556
+ todo.append(e[2])
557
+ self._check()
558
+ self.levels = self._walk(rev, groups, max_depth, None, scope)
559
+ self.live = self._walk(rev, groups, max_depth, exclude_gate, scope) if exclude_gate else None
560
+
561
+ def _check(self):
562
+ if self.deadline is not None and time.time() > self.deadline:
563
+ raise Deadline()
564
+
565
+ def _walk(self, rev, groups, max_depth, exclude_gate, scope) -> dict[str, list[tuple[int, int]]]:
566
+ """node -> [(depth, groups first reached at that depth as a bitmask)]."""
567
+ frontier: dict[str, int] = {}
568
+ for i, ts in enumerate(groups):
569
+ for t in ts:
570
+ if scope is None or t in scope:
571
+ frontier[t] = frontier.get(t, 0) | (1 << i)
572
+ seen = dict(frontier)
573
+ levels = {t: [(0, m)] for t, m in frontier.items()}
574
+ d = 0
575
+ while frontier and d < max_depth:
576
+ self._check()
577
+ nxt: dict[str, int] = {}
578
+ for x, m in frontier.items():
579
+ for src, gate in rev.get(x, ()):
580
+ if exclude_gate is not None and gate == exclude_gate:
581
+ continue
582
+ if scope is not None and src not in scope:
583
+ continue
584
+ new = m & ~seen.get(src, 0)
585
+ if new:
586
+ seen[src] = seen.get(src, 0) | new
587
+ nxt[src] = nxt.get(src, 0) | new
588
+ d += 1
589
+ for x, m in nxt.items():
590
+ levels.setdefault(x, []).append((d, m))
591
+ frontier = nxt
592
+ return levels
593
+
594
+ @staticmethod
595
+ def _depth(levels, node: str, bit: int) -> int | None:
596
+ for d, m in levels.get(node, ()):
597
+ if m & bit:
598
+ return d
599
+ return None
600
+
601
+ def depth(self, node: str, group: int) -> int | None:
602
+ return self._depth(self.levels, node, 1 << group)
603
+
604
+ def reached(self, node: str, group: int, live: bool = False) -> bool:
605
+ lv = self.live if live and self.live is not None else self.levels
606
+ return self._depth(lv, node, 1 << group) is not None
607
+
608
+ def groups_of(self, node: str) -> int:
609
+ m = 0
610
+ for _, b in self.levels.get(node, ()):
611
+ m |= b
612
+ return m
613
+
614
+ def path_edges(self, node: str, group: int) -> list[int]:
615
+ """Edge ids of the path shortest_paths picks from `node` to the group (stronger confidence, then live edges)."""
616
+ bit, out, x, guard = 1 << group, [], node, 0
617
+ dx = self._depth(self.levels, x, bit) or 0
618
+ while dx > 0 and guard < 60:
619
+ best = None
620
+ for kind, eid, dst, rank, gate in self.fwd.get(x, ()):
621
+ if self._depth(self.levels, dst, bit) == dx - 1:
622
+ r = (rank, gate is None)
623
+ if best is None or r > best[0]:
624
+ best = (r, eid, dst)
625
+ if best is None:
626
+ break
627
+ out.append(best[1])
628
+ x, dx, guard = best[2], dx - 1, guard + 1
629
+ return out
630
+
631
+ def hops(self, ids: list[int]) -> dict[int, dict]:
632
+ """Edge id -> path hop as shortest_paths renders it."""
633
+ out = {}
634
+ ids = list(dict.fromkeys(ids))
635
+ for i in range(0, len(ids), 500):
636
+ chunk = ids[i:i + 500]
637
+ for e in self.st.q(f"SELECT id,src,dst,kind,file,line,confidence,gate,attrs FROM edges WHERE id IN ({','.join('?' * len(chunk))})", chunk):
638
+ out[e["id"]] = _hop_of(e)
639
+ return out
640
+
641
+
642
+ def _hop_of(e) -> dict:
643
+ hop = {"from": e["src"], "kind": e["kind"], "to": e["dst"], "at": f"{e['file']}:{e['line']}", "confidence": e["confidence"]}
644
+ if e["kind"] == "REFERENCES_FN" and e["attrs"]:
645
+ hop["how"] = json.loads(e["attrs"]).get("how")
646
+ elif e["kind"] == "CALLS" and e["attrs"] and '"collection"' in e["attrs"]:
647
+ hop["via"] = json.loads(e["attrs"]).get("via")
648
+ if e["kind"] in ("CALLS", "TEST_CALLS") and e["attrs"] and '"candidate"' in e["attrs"]:
649
+ a = json.loads(e["attrs"])
650
+ if a.get("binding") == "candidate": # one of several same-name methods, receiver type unknown (#83)
651
+ hop["candidate"] = a.get("candidates") or True
652
+ if e["kind"] in ("CALLS", "TEST_CALLS") and e["attrs"] and '"accessor"' in e["attrs"]:
653
+ hop["accessor"] = json.loads(e["attrs"]).get("accessor") # inside `didSet { }` / `get { }` (Swift, #72)
654
+ if e["gate"]:
655
+ hop["gated"] = e["gate"]
656
+ hop["guard"] = json.loads(e["attrs"] or "{}").get("guard")
657
+ if e["attrs"] and '"platforms"' in e["attrs"]:
658
+ a = json.loads(e["attrs"])
659
+ if "platforms" in a:
660
+ hop["platforms"] = a["platforms"]
661
+ return hop
662
+
663
+
664
+ def entry_info(st: GraphStore, ids: list[str], gate: str | None = None) -> dict[str, dict[str, tuple[int, str]]]:
665
+ """Entry kinds reaching each node; with `gate`, only over edges live under that scenario."""
666
+ out = defaultdict(dict)
667
+ for i in range(0, len(ids), 500):
668
+ chunk = ids[i:i + 500]
669
+ q = ",".join("?" * len(chunk))
670
+ if gate:
671
+ rows = st.q(f"SELECT * FROM node_entry_live WHERE scenario=? AND node_id IN ({q})", (gate, *chunk))
672
+ else:
673
+ rows = st.q(f"SELECT * FROM node_entry WHERE node_id IN ({q})", chunk)
674
+ for r in rows:
675
+ out[r["node_id"]][r["entry_kind"]] = (r["entry_count"], r["sample_entry"])
676
+ return out
677
+
678
+
679
+ def nearest_gated_inbound(st: GraphStore, nid: str, gate: str, max_depth=6) -> dict | None:
680
+ """Walk callers (reverse over propagating edges) until the first edge gated under `gate`."""
681
+ kinds = set(PROPAGATING)
682
+ seen, frontier = {nid}, [nid]
683
+ for _ in range(max_depth):
684
+ nxt = []
685
+ for x in frontier:
686
+ for e in st.q("SELECT src,dst,kind,file,line,gate,attrs FROM edges WHERE dst=?", (x,)):
687
+ if e["kind"] not in kinds:
688
+ continue
689
+ if e["gate"] == gate:
690
+ a = json.loads(e["attrs"] or "{}")
691
+ return {"from": e["src"], "kind": e["kind"], "to": e["dst"], "at": f"{e['file']}:{e['line']}",
692
+ "guard": a.get("guard"), "guard_expr": a.get("guard_expr")}
693
+ if e["src"] not in seen:
694
+ seen.add(e["src"])
695
+ nxt.append(e["src"])
696
+ frontier = nxt
697
+ return None
698
+
699
+
700
+ def classify(kinds: dict) -> str:
701
+ if any(k in kinds for k in RUNTIME_ENTRY_KINDS):
702
+ return "runtime"
703
+ if any(k in kinds for k in LIBRARY_ENTRY_KINDS):
704
+ return "library"
705
+ if any(k in kinds for k in OPERATOR_ENTRY_KINDS):
706
+ return "operator"
707
+ if any(k in kinds for k in UI_ENTRY_KINDS):
708
+ return "ui"
709
+ if any(k in kinds for k in DEV_ENTRY_KINDS):
710
+ return "dev"
711
+ if kinds:
712
+ return "other_entry"
713
+ return "no_entry"
714
+
715
+
716
+ def path_candidate(path: list[dict] | None) -> bool:
717
+ """The path goes through a candidate call edge: a call whose receiver type is unknown, bound to each of the
718
+ project methods with that selector (Swift / Kotlin, #83), so it may reach a sibling instead of the target."""
719
+ return any(p.get("candidate") for p in path or ())
720
+
721
+
722
+ def path_confidence(path: list[dict]) -> str:
723
+ if not path:
724
+ return "exact"
725
+ return min((p["confidence"] for p in path), key=lambda c: CONFIDENCE_RANK[c])
726
+
727
+
728
+ def _platform_entries(st: GraphStore, depth: dict[str, int], platform: str, min_conf: str) -> dict[str, dict[str, tuple[int, str]]]:
729
+ """entry_info restricted to `platform`: which entry points reach each dependent over references that exist there.
730
+ Every entry point that reaches a dependent of the targets is itself a dependent, so the walk stays inside `depth`."""
731
+ px = _px(st, platform)
732
+ prop, rank = set(PROPAGATING), CONFIDENCE_RANK[min_conf]
733
+ fwd = defaultdict(list)
734
+ ids = list(depth)
735
+ for i in range(0, len(ids), 500):
736
+ chunk = ids[i:i + 500]
737
+ q = ",".join("?" * len(chunk))
738
+ for e in st.q(f"SELECT id, src, dst, kind, conf_rank FROM edges WHERE src IN ({q})", chunk):
739
+ if e["kind"] in prop and e["conf_rank"] >= rank and e["id"] not in px and e["dst"] in depth:
740
+ fwd[e["src"]].append(e["dst"])
741
+ entries = {}
742
+ for i in range(0, len(ids), 500):
743
+ chunk = ids[i:i + 500]
744
+ q = ",".join("?" * len(chunk))
745
+ for r in st.q(f"SELECT id, entry_kind FROM nodes WHERE id IN ({q}) AND entry_kind IS NOT NULL", chunk):
746
+ entries[r["id"]] = r["entry_kind"]
747
+ out = defaultdict(dict)
748
+ for en, ek in entries.items():
749
+ seen, todo = {en}, [en]
750
+ while todo:
751
+ x = todo.pop()
752
+ for y in fwd.get(x, ()):
753
+ if y not in seen:
754
+ seen.add(y)
755
+ todo.append(y)
756
+ for x in seen:
757
+ c, smp = out[x].get(ek, (0, en))
758
+ out[x][ek] = (c + 1, smp)
759
+ return out
760
+
761
+
762
+ def reaches(st: GraphStore, specs: list[str], min_conf="heuristic", max_depth=30, gate: str | None = "auto",
763
+ platform: str | None = None) -> dict:
764
+ """Reverse transitive dependents of the targets (see _reaches). An inherited `Sub.method` spec leaves out the
765
+ calls into the inherited definition whose receiver cannot be a Sub (narrow_inherited)."""
766
+ ob = override_bases(st, specs)
767
+ with narrowed(st, [i for s in specs for i in inherited_targets(st, s)] + ob):
768
+ res = _reaches(st, specs, min_conf, max_depth, gate, platform)
769
+ if _narrowed_bases(ob):
770
+ res["override_narrowed"] = _narrowed_bases(ob)
771
+ return res
772
+
773
+
774
+ def _reaches(st: GraphStore, specs: list[str], min_conf="heuristic", max_depth=30, gate: str | None = "auto",
775
+ platform: str | None = None) -> dict:
776
+ """Reverse transitive dependents of the targets.
777
+
778
+ With a gate scenario (default: the one the DB was indexed with), every dependent also gets
779
+ gate_status:
780
+ live - some entry point -> node -> target path has no edge that is dead under the scenario
781
+ gated_target - every path from the node to the target passes a gated edge
782
+ gated_entry - node reaches the target live, but every entry point reaches the node only through a gated edge
783
+ """
784
+ if gate == "auto":
785
+ gate = default_gate(st)
786
+ targets, resolved = [], {}
787
+ for s in specs:
788
+ t = resolve_targets(st, s)
789
+ resolved[s] = t
790
+ targets += t
791
+ si = _has_class_target(targets)
792
+ pf = platform
793
+ # the dependents of a base / interface method include those of its overrides (as in impact), marked via_override
794
+ below = override_seeds(st, specs, targets, min_conf)
795
+ seeds = targets + [b for b in below if b not in targets]
796
+ depth = reverse_closure(st, seeds, min_conf=min_conf, max_depth=max_depth, seed_inst=si, platform=pf)
797
+ own = set(reverse_closure(st, targets, min_conf=min_conf, max_depth=max_depth, seed_inst=si, platform=pf)) if below else None
798
+ paths = shortest_paths(st, depth, min_conf=min_conf, seed_inst=si, platform=pf)
799
+ ids = list(depth)
800
+ ents = _platform_entries(st, depth, pf, min_conf) if pf else entry_info(st, ids)
801
+ if pf and gate:
802
+ gate = None # one filter at a time: the platform view replaces the gate scenario split
803
+ live_depth = reverse_closure(st, seeds, min_conf=min_conf, max_depth=max_depth, exclude_gate=gate, seed_inst=si) if gate else depth
804
+ live_ents = entry_info(st, ids, gate=gate) if gate else ents
805
+ live_paths = shortest_paths(st, live_depth, min_conf=min_conf, exclude_gate=gate, seed_inst=si) if gate else paths
806
+ nodes = {}
807
+ for i in range(0, len(ids), 500):
808
+ chunk = ids[i:i + 500]
809
+ q = ",".join("?" * len(chunk))
810
+ for r in st.q(f"SELECT id,kind,name,fqn,file,line,module,entry_kind,attrs FROM nodes WHERE id IN ({q})", chunk):
811
+ nodes[r["id"]] = with_generated(dict(r))
812
+ items = []
813
+ tset, bfq = set(targets), (_fqn_of(st, below) if below else {})
814
+ for nid, d in depth.items():
815
+ n = nodes.get(nid, {"id": nid, "kind": nid.split(":")[0]})
816
+ ek = {k: v[0] for k, v in ents.get(nid, {}).items()}
817
+ if n.get("entry_kind"):
818
+ ek[n["entry_kind"]] = ek.get(n["entry_kind"], 0) or 1
819
+ it = {**n, "depth": d, "entry_kinds": ek, "class": classify(ek),
820
+ "path": paths.get(nid, []), "path_confidence": path_confidence(paths.get(nid, [])),
821
+ "is_target": d == 0, "gate_status": "live"}
822
+ if nid in bfq and nid not in tset:
823
+ it["override_seed"] = True # an override followed from a base / interface target, not a dependent
824
+ elif own is not None and nid not in own and it["path"]:
825
+ end = it["path"][-1]["to"]
826
+ it["via_override"] = bfq.get(end, end)
827
+ if gate and d > 0:
828
+ lek = {k: v[0] for k, v in live_ents.get(nid, {}).items()}
829
+ if n.get("entry_kind"):
830
+ lek[n["entry_kind"]] = lek.get(n["entry_kind"], 0) or 1
831
+ it["live_entry_kinds"] = lek
832
+ if nid not in live_depth:
833
+ it["gate_status"] = "gated_target"
834
+ it["gate_evidence"] = next(({k: h.get(k) for k in ("from", "kind", "to", "at", "guard")} for h in it["path"] if h.get("gated")), None)
835
+ elif ek and not lek:
836
+ it["gate_status"] = "gated_entry"
837
+ it["gate_evidence"] = nearest_gated_inbound(st, nid, gate)
838
+ elif lek:
839
+ it["class"] = classify(lek) # classify by entry points that still reach it live
840
+ it["path"] = live_paths.get(nid, it["path"])
841
+ it["depth"] = live_depth.get(nid, d)
842
+ it["path_confidence"] = path_confidence(it["path"])
843
+ items.append(it)
844
+ items.sort(key=lambda x: (x["class"], x.get("module") or "", x.get("fqn") or x["id"]))
845
+ out = {"targets": resolved, "min_confidence": min_conf, "gate": gate, "items": items}
846
+ if below:
847
+ out["overrides_followed"] = sorted(bfq.get(b, b) for b in below)
848
+ inh = [i for s in specs for i in inherited_targets(st, s)]
849
+ if inh:
850
+ out["inherited"] = inh
851
+ if pf:
852
+ from .platforms import filter_info
853
+ out["platform"] = filter_info(st, pf)
854
+ out["platform"]["targets_not_built"] = _not_built(st, targets, pf)
855
+ return out
856
+
857
+
858
+ def _not_built(st: GraphStore, targets: list[str], platform: str) -> list[str]:
859
+ from .platforms import exclusions
860
+ x = exclusions(st, platform)["nodes"]
861
+ return [t for t in targets if t in x]
862
+
863
+
864
+ def fmt_path(path: list[dict]) -> str:
865
+ if not path:
866
+ return "(target)"
867
+ s = path[0]["from"]
868
+ for p in path:
869
+ g = f" GATED:{p['gated']} guard {p.get('guard')}" if p.get("gated") else ""
870
+ pl = f" only on {', '.join(p['platforms']) or 'no known target'}" if p.get("platforms") is not None else ""
871
+ s += f"\n -{p['kind']}[{p['confidence']} @ {p['at']}{g}{pl}]-> {p['to']}"
872
+ return s
873
+
874
+
875
+ def render_reaches(res: dict, show_paths=True, kinds=CODE_KINDS + ENTRY_NODE_KINDS) -> str:
876
+ out = []
877
+ if res.get("platform"):
878
+ from .platforms import render_filter
879
+ out.append(render_filter(res["platform"]))
880
+ if res["platform"].get("targets_not_built"):
881
+ out.append(f" not built for {res['platform']['platform']}: {', '.join(res['platform']['targets_not_built'][:6])}")
882
+ out.append("targets:")
883
+ for s, t in res["targets"].items():
884
+ out.append(f" {s} -> {len(t)} node(s): {', '.join(t[:6])}{' ...' if len(t) > 6 else ''}")
885
+ out += [" " + x for x in inherited_lines(res)]
886
+ if res.get("overrides_followed"):
887
+ ov = res["overrides_followed"]
888
+ out.append(f" overrides followed (their dependents count, marked via override): {', '.join(ov[:8])}"
889
+ + (f" …+{len(ov) - 8}" if len(ov) > 8 else ""))
890
+ items = [i for i in res["items"] if not i["is_target"]]
891
+ code = [i for i in items if i["kind"] in CODE_KINDS]
892
+ entries = [i for i in items if i["kind"] in ENTRY_NODE_KINDS or (i.get("entry_kind") and i["kind"] in CODE_KINDS)]
893
+ other = [i for i in items if i["kind"] not in CODE_KINDS + ENTRY_NODE_KINDS]
894
+ out.append(f"dependents: {len(items)} nodes (code: {len(code)}, entry points: {len(entries)}, other: {len(other)}) min_confidence={res['min_confidence']}")
895
+ labels = {"runtime": f"RUNTIME (reached from {' / '.join(RUNTIME_ENTRY_KINDS)})",
896
+ "library": "LIBRARY API (reached only through the public API of a library: pub items / exported symbols)",
897
+ "dev": "DEV/BUILD-ONLY (reached only from tests, benches, examples or build scripts)",
898
+ "operator": f"OPERATOR-ONLY ({' / '.join(OPERATOR_ENTRY_KINDS)}; one-off import & provisioning)",
899
+ "ui": "UI-ONLY (reached from frontend pages / layouts / app shell; no backend entry)",
900
+ "other_entry": "OTHER ENTRY (observer only)", "no_entry": "NOT REACHED FROM ANY INDEXED ENTRY POINT"}
901
+ gated = [i for i in code if i.get("gate_status", "live") != "live" and i["class"] != "no_entry"]
902
+ code = [i for i in code if i not in gated]
903
+ for cls in ("runtime", "library", "operator", "ui", "dev", "other_entry", "no_entry"):
904
+ group = [i for i in code if i["class"] == cls]
905
+ if not group:
906
+ continue
907
+ out.append("")
908
+ out.append(f"== {labels[cls]}: {len(group)} functions/methods")
909
+ bymod = defaultdict(list)
910
+ for i in group:
911
+ bymod[i.get("module") or "?"].append(i)
912
+ for mod in sorted(bymod):
913
+ out.append(f" [{mod}]")
914
+ for i in bymod[mod]:
915
+ ek = ", ".join(f"{k}({v})" for k, v in sorted(i["entry_kinds"].items()))
916
+ out.append(f" {i.get('fqn') or i['id']} depth={i['depth']} conf={i['path_confidence']} {ek}"
917
+ + (f" [{i['kind']} @ {i.get('file')}]" if i['kind'] in TS_CODE_KINDS or (i.get('file') or '').endswith(('.ts', '.vue')) else "")
918
+ + (f" @ {i.get('file')}:{i.get('line')}" if NATIVE_FILE_RE.search(i.get('file') or '') else "")
919
+ + generated_label(i) + platform_label(i)
920
+ + (f" (via override {i['via_override']})" if i.get("via_override") else ""))
921
+ if show_paths:
922
+ out.append(f" path: {fmt_path(i['path'])}")
923
+ if gated:
924
+ out.append("")
925
+ out.append(f"== GATED UNDER SCENARIO '{res.get('gate')}' (dead when the scenario holds; live otherwise): {len(gated)} functions/methods")
926
+ bymod = defaultdict(list)
927
+ for i in gated:
928
+ bymod[i.get("module") or "?"].append(i)
929
+ for mod in sorted(bymod):
930
+ out.append(f" [{mod}]")
931
+ for i in bymod[mod]:
932
+ ev = i.get("gate_evidence") or {}
933
+ ek = ", ".join(f"{k}({v})" for k, v in sorted(i["entry_kinds"].items()))
934
+ out.append(f" {i.get('fqn') or i['id']} {i['gate_status']} entry-when-off: {ek}")
935
+ out.append(f" guard: {ev.get('guard')} gated hop: {ev.get('kind')} @ {ev.get('at')} -> {ev.get('to')}")
936
+ if entries:
937
+ live_e = [i for i in entries if i.get("gate_status", "live") == "live"]
938
+ out.append("")
939
+ out.append(f"== ENTRY POINTS THAT REACH THE TARGET(S): {len(entries)} (live under gate: {len(live_e)})")
940
+ byk = defaultdict(list)
941
+ for i in entries:
942
+ byk[i["kind"] if i["kind"] in ENTRY_NODE_KINDS else i["entry_kind"]].append(i)
943
+ for k in sorted(byk):
944
+ out.append(f" {k}: {len(byk[k])}")
945
+ for i in sorted(byk[k], key=lambda x: x["id"]):
946
+ g = "" if i.get("gate_status", "live") == "live" else f" [{i['gate_status']}]"
947
+ nm = (i.get("fqn") or i["name"]) if NATIVE_FILE_RE.search(i.get("file") or "") else i["name"]
948
+ out.append(f" {nm} ({i.get('file')}:{i.get('line')}) depth={i['depth']} conf={i['path_confidence']}{g}{generated_label(i)}{platform_label(i)}")
949
+ return "\n".join(out)
950
+
951
+
952
+ def _has_class_target(targets: list[str]) -> bool:
953
+ return any(t.split(":", 1)[0] in CLASS_KINDS for t in targets)
954
+
955
+
956
+ def _first_hop(path: list[dict] | None) -> dict:
957
+ """How a caller reaches the next node towards the target: `edge` kind, plus `how` for a function reference."""
958
+ if not path:
959
+ return {}
960
+ h = path[0]
961
+ return {"edge": h["kind"], **({"how": h["how"]} if h.get("how") else {}), **({"via": h["via"]} if h.get("via") else {}),
962
+ **({"accessor": h["accessor"]} if h.get("accessor") else {})}
963
+
964
+
965
+ def with_generated(row: dict) -> dict:
966
+ """A node row without its raw attrs, plus `generated` (the reason) for a node from a generated / copied / vendored
967
+ file (indexed with --include-generated)."""
968
+ raw = row.pop("attrs", None)
969
+ if raw and ('"platforms"' in raw or '"available"' in raw):
970
+ from .platforms import node_platforms
971
+ row.update(node_platforms(raw))
972
+ if raw and '"generated"' in raw:
973
+ try:
974
+ g = (json.loads(raw) or {}).get("generated")
975
+ except ValueError:
976
+ g = None
977
+ if g:
978
+ row["generated"] = g.get("reason") or g.get("kind") or "generated"
979
+ if g.get("copy_of"):
980
+ row["copy_of"] = g["copy_of"]
981
+ return row
982
+
983
+
984
+ def platform_label(n: dict) -> str:
985
+ """' [ios, android]' for code that exists only on some targets (codegraph/platforms.py), else ''."""
986
+ from .platforms import label
987
+ return label(n)
988
+
989
+
990
+ def generated_label(n: dict) -> str:
991
+ """' [generated: protoc output (Python)]' / ' [copied: copy of dist/ ..., source dist/app.js]' or ''."""
992
+ g = n.get("generated")
993
+ if not g:
994
+ return ""
995
+ return f" [generated: {g}" + (f", source {n['copy_of']}" if n.get("copy_of") else "") + "]"
996
+
997
+
998
+ CANDIDATE_LABEL = " (candidate)"
999
+ CANDIDATE_NOTE = ("(candidate): through a call whose receiver type is unknown, bound to each project method with that "
1000
+ "selector; it may reach a same-name sibling instead")
1001
+
1002
+
1003
+ def caller_label(c: dict) -> str:
1004
+ """'' for a call; ' (ref: collection)' when the caller holds a reference to the function instead of calling it;
1005
+ ' (didSet)' when the call sits in that accessor of a property; plus the generated-file label of a caller in a generated file."""
1006
+ if c.get("edge") == "REFERENCES_FN":
1007
+ lab = f" (ref: {c['how']})" if c.get("how") else " (ref)"
1008
+ elif c.get("via") == "collection":
1009
+ lab = " (call through a collection)"
1010
+ else:
1011
+ lab = ""
1012
+ if c.get("accessor"): # the call sits in that accessor of a property node (Swift, #72)
1013
+ lab += f" ({c['accessor']})"
1014
+ if c.get("in_property"): # a type node: the call is in a property of that type (#72)
1015
+ lab += " (in a property)"
1016
+ if c.get("candidate"):
1017
+ lab += CANDIDATE_LABEL
1018
+ if c.get("via_override"):
1019
+ v = c["via_override"]
1020
+ lab += f" (via override {v[0]}" + (f" +{len(v) - 1}" if len(v) > 1 else "") + ")"
1021
+ elif c.get("via_base"):
1022
+ lab += f" (via base {c['via_base']})"
1023
+ return lab + generated_label(c) + platform_label(c)
1024
+
1025
+
1026
+ # override / implementation relation between methods (base -> override); a dispatch hop, not a call. A container
1027
+ # binding between two methods (Nest `{ provide: AbstractRepo, useClass: Impl }`, Laravel `bind`) is one too
1028
+ DISPATCH_KINDS = ("OVERRIDDEN_BY", "IMPLEMENTED_BY")
1029
+ _DISPATCH_SQL = ("(kind IN ('OVERRIDDEN_BY','IMPLEMENTED_BY') OR (kind='BOUND_TO' AND src LIKE 'method:%' "
1030
+ "AND dst LIKE 'method:%'))")
1031
+
1032
+
1033
+ def is_dispatch_hop(kind: str, src: str, dst: str) -> bool:
1034
+ """An override / implementation hop, or a method-to-method container binding."""
1035
+ return kind in DISPATCH_KINDS or (kind == "BOUND_TO" and src.startswith("method:") and dst.startswith("method:"))
1036
+
1037
+
1038
+ def _fqn_of(st: GraphStore, ids) -> dict[str, str]:
1039
+ ids, out = list(ids), {}
1040
+ for i in range(0, len(ids), 500):
1041
+ chunk = ids[i:i + 500]
1042
+ for r in st.q(f"SELECT id, fqn FROM nodes WHERE id IN ({','.join('?' * len(chunk))})", chunk):
1043
+ out[r["id"]] = r["fqn"] or r["id"]
1044
+ return out
1045
+
1046
+
1047
+ def override_relations(st: GraphStore, targets: list[str], min_conf="heuristic") -> dict[str, list[dict]]:
1048
+ """The direct override relation of the targets: `overrides` (the base / interface declarations a target
1049
+ overrides or implements) and `overridden_by` (the overrides / implementations of a target)."""
1050
+ out = {"overrides": [], "overridden_by": []}
1051
+ if not targets:
1052
+ return out
1053
+ tq = ",".join("?" * len(targets))
1054
+ rank = CONFIDENCE_RANK[min_conf]
1055
+ order = "ORDER BY CASE kind WHEN 'BOUND_TO' THEN 1 ELSE 0 END" # one entry per pair, the type relation first
1056
+ up = _first_per_pair(st.q(f"SELECT DISTINCT src, dst, kind FROM edges WHERE dst IN ({tq}) AND {_DISPATCH_SQL} "
1057
+ f"AND conf_rank >= ? {order}", (*targets, rank)))
1058
+ down = _first_per_pair(st.q(f"SELECT DISTINCT src, dst, kind FROM edges WHERE src IN ({tq}) AND {_DISPATCH_SQL} "
1059
+ f"AND conf_rank >= ? {order}", (*targets, rank)))
1060
+ fq = _fqn_of(st, {r["src"] for r in up} | {r["dst"] for r in down})
1061
+ tset = set(targets)
1062
+ out["overrides"] = sorted(({"id": r["src"], "fqn": fq.get(r["src"], r["src"]), "of": r["dst"], "edge": r["kind"]}
1063
+ for r in up if r["src"] not in tset), key=lambda x: (x["fqn"], x["of"]))
1064
+ out["overridden_by"] = sorted(({"id": r["dst"], "fqn": fq.get(r["dst"], r["dst"]), "of": r["src"], "edge": r["kind"]}
1065
+ for r in down if r["dst"] not in tset), key=lambda x: (x["fqn"], x["of"]))
1066
+ return out
1067
+
1068
+
1069
+ def _first_per_pair(rows) -> list:
1070
+ seen, out = set(), []
1071
+ for r in rows:
1072
+ if (r["src"], r["dst"]) not in seen:
1073
+ seen.add((r["src"], r["dst"]))
1074
+ out.append(r)
1075
+ return out
1076
+
1077
+
1078
+ def _all_overrides(st: GraphStore, targets: list[str], min_conf: str) -> list[str]:
1079
+ """Every override / implementation below the targets (transitively), not counting the targets themselves."""
1080
+ seen, frontier = set(targets), list(targets)
1081
+ while frontier:
1082
+ nxt = []
1083
+ for i in range(0, len(frontier), 500):
1084
+ chunk = frontier[i:i + 500]
1085
+ for r in st.q(f"SELECT dst FROM edges WHERE src IN ({','.join('?' * len(chunk))}) AND {_DISPATCH_SQL} AND conf_rank >= ?",
1086
+ (*chunk, CONFIDENCE_RANK[min_conf])):
1087
+ if r["dst"] not in seen:
1088
+ seen.add(r["dst"])
1089
+ nxt.append(r["dst"])
1090
+ frontier = nxt
1091
+ return [x for x in seen if x not in set(targets)]
1092
+
1093
+
1094
+ def _dispatch_only(st: GraphStore, depth: dict[str, int], paths: dict[str, list[dict]], min_conf: str,
1095
+ platform: str | None) -> set[str]:
1096
+ """Reached nodes whose every step towards the targets is an override hop (a base / interface method): they do not
1097
+ call the target; their callers do, through the base type."""
1098
+ cand = [n for n, d in depth.items() if d > 0 and paths.get(n)
1099
+ and is_dispatch_hop(paths[n][0]["kind"], paths[n][0]["from"], paths[n][0]["to"])]
1100
+ if not cand:
1101
+ return set()
1102
+ px, rank, kinds = _px(st, platform), CONFIDENCE_RANK[min_conf], set(CALL_LIKE)
1103
+ calls = set()
1104
+ for i in range(0, len(cand), 500):
1105
+ chunk = cand[i:i + 500]
1106
+ for e in st.q(f"SELECT id, src, dst, kind, conf_rank FROM edges WHERE src IN ({','.join('?' * len(chunk))})", chunk):
1107
+ if e["kind"] in kinds and not is_dispatch_hop(e["kind"], e["src"], e["dst"]) and e["conf_rank"] >= rank \
1108
+ and not (px and e["id"] in px) \
1109
+ and depth.get(e["dst"]) == depth[e["src"]] - 1:
1110
+ calls.add(e["src"])
1111
+ return {n for n in cand if n not in calls}
1112
+
1113
+
1114
+ def impact(st: GraphStore, spec: str, min_conf="heuristic", platform: str | None = None) -> dict:
1115
+ """Callers up to entry points. The override relation is kept apart from the callers: a base method is listed
1116
+ under `overrides`, not as a caller of its override (its callers are, `via_base`), and the callers of a base
1117
+ method include the callers of its overrides (`via_override`): calls through a collection or a base-typed value
1118
+ land on the concrete overrides."""
1119
+ inh = inherited_targets(st, spec)
1120
+ ob = override_bases(st, spec)
1121
+ with narrowed(st, inh + ob):
1122
+ out = _impact(st, spec, min_conf, platform)
1123
+ if _narrowed_bases(ob):
1124
+ out["override_narrowed"] = _narrowed_bases(ob)
1125
+ if inh:
1126
+ out["inherited"] = inh
1127
+ keep = set(override_seeds(st, spec, out["targets"], min_conf)) # a sibling class's override is not a Sub's method
1128
+ out["overridden_by"] = [x for x in out["overridden_by"] if x["id"] in keep]
1129
+ return out
1130
+
1131
+
1132
+ # impact on a data node (#77): its users are the code that connects to / uses / reads / writes it; these edges only
1133
+ # point at data nodes, so following them never widens the walk above the first code hop
1134
+ DATA_USE = ["CONNECTS_TO", "USES_CONNECTION", "READS_TABLE", "WRITES_TABLE", "MAPS_TO_TABLE"]
1135
+ DATA_TARGET = ("external:", "connection:", "table:")
1136
+
1137
+
1138
+ def _impact(st: GraphStore, spec: str, min_conf: str, platform: str | None) -> dict:
1139
+ targets = resolve_targets(st, spec)
1140
+ kinds = CALL_LIKE + DATA_USE if any(t.startswith(DATA_TARGET) for t in targets) else CALL_LIKE
1141
+ si = _has_class_target(targets)
1142
+ rel = override_relations(st, targets, min_conf)
1143
+ below = override_seeds(st, spec, targets, min_conf)
1144
+ seeds = targets + below
1145
+ depth = reverse_closure(st, seeds, kinds=kinds, min_conf=min_conf, seed_inst=si, platform=platform)
1146
+ own = reverse_closure(st, targets, kinds=kinds, min_conf=min_conf, seed_inst=si, platform=platform) if below else depth
1147
+ paths = shortest_paths(st, depth, kinds=kinds, min_conf=min_conf, seed_inst=si, platform=platform)
1148
+ bases = _dispatch_only(st, depth, paths, min_conf, platform)
1149
+ for b in list(depth):
1150
+ if b in bases or b in below:
1151
+ depth.pop(b)
1152
+ fq = _fqn_of(st, bases | set(below))
1153
+ direct = defaultdict(set) # caller only through the overrides -> the overrides it calls directly
1154
+ if below:
1155
+ bset, kq = set(below), ",".join("?" * len(CALL_LIKE))
1156
+ for i in range(0, len(below), 500):
1157
+ chunk = below[i:i + 500]
1158
+ for e in st.q(f"SELECT src, dst FROM edges WHERE dst IN ({','.join('?' * len(chunk))}) AND kind IN ({kq}) "
1159
+ f"AND conf_rank >= ?", (*chunk, *CALL_LIKE, CONFIDENCE_RANK[min_conf])):
1160
+ if e["src"] not in own and e["src"] not in bset and e["src"] in depth:
1161
+ direct[e["src"]].add(e["dst"])
1162
+ rows = {}
1163
+ ids = list(depth)
1164
+ for i in range(0, len(ids), 500):
1165
+ chunk = ids[i:i + 500]
1166
+ q = ",".join("?" * len(chunk))
1167
+ for r in st.q(f"SELECT id,kind,name,fqn,file,line,module,entry_kind,attrs FROM nodes WHERE id IN ({q})", chunk):
1168
+ rows[r["id"]] = with_generated(dict(r))
1169
+ entries = [dict(rows[n], depth=d, path=paths[n], path_confidence=path_confidence(paths[n]),
1170
+ **({"candidate": True} if path_candidate(paths[n]) else {})) for n, d in depth.items()
1171
+ if n in rows and rows[n]["entry_kind"]]
1172
+ callers = []
1173
+ typed = _type_level_callers(st, [n for n, d in depth.items() if d and n in rows and rows[n]["kind"] == "class"
1174
+ and (paths.get(n) or [{}])[0].get("kind") == "CALLS"])
1175
+ for n, d in depth.items():
1176
+ if n not in rows or (rows[n]["kind"] not in CODE_KINDS + ("http",) and n not in typed) or d == 0:
1177
+ continue
1178
+ c = dict(rows[n], depth=d, **_first_hop(paths.get(n)))
1179
+ if n in typed: # a call in a property initializer / Kotlin accessor of that type (#72)
1180
+ c["in_property"] = True
1181
+ p = paths.get(n) or []
1182
+ if path_candidate(p):
1183
+ c["candidate"] = True
1184
+ if p and p[0]["to"] in bases: # calls the base declaration; reaches the target through the override
1185
+ c["via_base"] = fq.get(p[0]["to"], p[0]["to"])
1186
+ if n in direct: # calls an override (not the target itself): reaches the target's API through it
1187
+ c["via_override"] = sorted(fq.get(x, x) for x in direct[n])
1188
+ callers.append(c)
1189
+ out = {"targets": targets, "entry_points": sorted(entries, key=lambda x: (x["entry_kind"], x["name"])),
1190
+ "callers": sorted(callers, key=lambda x: (x["depth"], x["fqn"] or "")), **rel}
1191
+ if platform:
1192
+ from .platforms import filter_info
1193
+ out["platform"] = filter_info(st, platform)
1194
+ out["platform"]["targets_not_built"] = _not_built(st, targets, platform)
1195
+ return out
1196
+
1197
+
1198
+ TYPE_LEVEL_LANGS = ("swift", "kotlin")
1199
+
1200
+
1201
+ def _type_level_callers(st: GraphStore, ids: list[str]) -> set[str]:
1202
+ """Swift / Kotlin type nodes that call the target themselves: the call sits in a stored property's initializer
1203
+ or (Kotlin) a custom getter / setter, which have no node of their own. They count as callers, labelled
1204
+ `(in a property)` (#72), rather than leaving "no callers"."""
1205
+ out = set()
1206
+ for i in range(0, len(ids), 500):
1207
+ chunk = ids[i:i + 500]
1208
+ out |= {r["id"] for r in st.q(f"SELECT id FROM nodes WHERE id IN ({','.join('?' * len(chunk))}) "
1209
+ f"AND lang IN ({','.join('?' * len(TYPE_LEVEL_LANGS))})",
1210
+ (*chunk, *TYPE_LEVEL_LANGS))}
1211
+ return out
1212
+
1213
+
1214
+ def _file_of_id(nid: str) -> str:
1215
+ """The file part of a node id (`method:src/a.ts#X.m` -> src/a.ts), else the id."""
1216
+ body = nid.split(":", 1)[1] if ":" in nid else nid
1217
+ return body.partition("#")[0] if "#" in body else nid
1218
+
1219
+
1220
+ def override_lines(res: dict, limit: int = 8) -> list[str]:
1221
+ """'overrides: Base.m' / 'overridden by: A.m, B.m' lines for an impact result (the relation, not callers)."""
1222
+ out = inherited_lines(res)
1223
+ for key, label in (("overrides", "overrides"), ("overridden_by", "overridden by")):
1224
+ items = list({x["id"]: x for x in res.get(key) or []}.values())
1225
+ ids_of = defaultdict(set)
1226
+ for x in items:
1227
+ ids_of[x["fqn"]].add(x["id"])
1228
+ # two declarations with one name (two `FeedAPI` interfaces): each with its file (#62)
1229
+ xs = list(dict.fromkeys(x["fqn"] if len(ids_of[x["fqn"]]) < 2 else f"{x['fqn']} ({_file_of_id(x['id'])})"
1230
+ for x in items))
1231
+ if xs:
1232
+ out.append(f"{label}: {', '.join(xs[:limit])}" + (f" …+{len(xs) - limit}" if len(xs) > limit else ""))
1233
+ return out
1234
+
1235
+
1236
+ def prop_fields(st: GraphStore, spec: str) -> list[str]:
1237
+ """Stored-property field nodes for `Type.prop` / `field:Type.prop` / a qualified `Module.Type.prop` (#88)."""
1238
+ spec = spec[len("field:"):] if spec.startswith("field:") else spec
1239
+ spec = spec[len("property:"):] if spec.startswith("property:") else spec
1240
+ if ("." not in spec and "::" not in spec) or spec.startswith(("table:", "column:")):
1241
+ return []
1242
+ # PHP: `User.name`, `User::$name`, `App\Models\User::$name` -> the `property:` node (fqn `App\Models\User::$name`)
1243
+ cls, _, prop = spec.replace("::$", "::").replace("::", ".").rpartition(".")
1244
+ php = f"{cls}::${prop.lstrip('$')}"
1245
+ return [r["id"] for r in st.q("""SELECT id FROM nodes WHERE kind IN ('field', 'property')
1246
+ AND attrs LIKE '%"property": "stored"%'
1247
+ AND (fqn=? OR fqn LIKE ? OR fqn=? OR fqn LIKE ?) ORDER BY id""",
1248
+ (spec, f"%.{spec}", php, f"%\\{php}"))]
1249
+
1250
+
1251
+ def prop_access(st: GraphStore, spec: str, kind: str) -> list[dict]:
1252
+ """READS_PROP / WRITES_PROP edges into the field nodes of `spec`, with the entry kinds reaching each site. Test code's
1253
+ accesses (retyped TEST_USES with attrs.orig = kind) come last, with `test: True`."""
1254
+ fids = prop_fields(st, spec)
1255
+ if not fids:
1256
+ return []
1257
+ q = ",".join("?" * len(fids))
1258
+ rows = st.q(f"""SELECT e.src, e.kind, e.dst, e.file, e.line, e.confidence, e.attrs, n.module, n.fqn
1259
+ FROM edges e JOIN nodes n ON n.id = e.src
1260
+ WHERE (e.kind=? OR (e.kind='TEST_USES' AND e.attrs LIKE ?)) AND e.dst IN ({q})
1261
+ ORDER BY e.kind='TEST_USES', n.module, n.fqn, e.line""", (kind, f'%"orig": "{kind}"%', *fids))
1262
+ ents = entry_info(st, list({r["src"] for r in rows}))
1263
+ out = []
1264
+ for r in rows:
1265
+ d = dict(r)
1266
+ d["attrs"] = json.loads(d["attrs"] or "{}")
1267
+ d["test"] = r["kind"] == "TEST_USES"
1268
+ d["entry_kinds"] = {k: v[0] for k, v in ents.get(r["src"], {}).items()}
1269
+ out.append(d)
1270
+ return out
1271
+
1272
+
1273
+ def readers(st: GraphStore, spec: str) -> list[dict]:
1274
+ """Code that reads a stored property `Type.prop` (READS_PROP)."""
1275
+ return prop_access(st, spec, "READS_PROP")
1276
+
1277
+
1278
+ def writers(st: GraphStore, table: str) -> list[dict]:
1279
+ if prop_fields(st, table): # `writers Type.prop`: a stored property (#88)
1280
+ return prop_access(st, table, "WRITES_PROP")
1281
+ table = table[len("table:"):] if table.startswith("table:") else table # `writers table:X` == `writers X`
1282
+ rows = st.q("""SELECT e.src, e.kind, e.dst, e.file, e.line, e.confidence, n.module, n.fqn
1283
+ FROM edges e JOIN nodes n ON n.id = e.src
1284
+ WHERE (e.kind='WRITES_TABLE' AND e.dst=?) OR (e.kind='WRITES_COLUMN' AND e.dst LIKE ?)
1285
+ ORDER BY n.module, n.fqn, e.line""", (f"table:{table}", f"column:{table}.%"))
1286
+ ents = entry_info(st, list({r["src"] for r in rows}))
1287
+ out = []
1288
+ for r in rows:
1289
+ d = dict(r)
1290
+ d["entry_kinds"] = {k: v[0] for k, v in ents.get(r["src"], {}).items()}
1291
+ out.append(d)
1292
+ return out
1293
+
1294
+
1295
+ def siblings(st: GraphStore, spec: str, limit=40) -> dict:
1296
+ ids = resolve_targets(st, spec)
1297
+ res = {"targets": ids, "hierarchy": [], "same_method_in_siblings": [], "shared_resources": [], "co_callers": []}
1298
+ if not ids:
1299
+ return res
1300
+ main = ids[0]
1301
+ node = st.node(main)
1302
+ cls_fqn = node["fqn"].split("::")[0] if node["fqn"] else None
1303
+ cls_row = st.q("SELECT id FROM nodes WHERE fqn=? AND kind IN ('class','interface','trait','enum')", (cls_fqn,))
1304
+ if cls_row:
1305
+ cid = cls_row[0]["id"]
1306
+ for r in st.q("""SELECT e.kind, e.dst AS parent, s.src AS sibling FROM edges e
1307
+ JOIN edges s ON s.dst = e.dst AND s.kind = e.kind AND s.src != e.src
1308
+ WHERE e.src=? AND e.kind IN ('EXTENDS','IMPLEMENTS','USES_TRAIT')""", (cid,)):
1309
+ res["hierarchy"].append(dict(r))
1310
+ if node["kind"] == "method":
1311
+ sib_classes = {r["sibling"] for r in res["hierarchy"]}
1312
+ for sc in sib_classes:
1313
+ fq = sc.split(":", 1)[1]
1314
+ for m in st.q("SELECT id, file, line FROM nodes WHERE kind='method' AND fqn=?", (f"{fq}::{node['name']}",)):
1315
+ res["same_method_in_siblings"].append(dict(m))
1316
+ if node["kind"] in ("method", "function"):
1317
+ res_kinds = ("READS_COLUMN", "WRITES_COLUMN", "MENTIONS_COLUMN", "READS_TABLE", "WRITES_TABLE", "READS_CONFIG",
1318
+ "WRITES_CONFIG", "READS_ENV", "USES_CONNECTION", "REGISTERS_CONNECTION")
1319
+ kq = ",".join("?" * len(res_kinds))
1320
+ mine = {r["dst"] for r in st.q(f"SELECT DISTINCT dst FROM edges WHERE src=? AND kind IN ({kq})", (main, *res_kinds))}
1321
+ if mine:
1322
+ q = ",".join("?" * len(mine))
1323
+ agg = defaultdict(set)
1324
+ for r in st.q(f"SELECT src, dst FROM edges WHERE dst IN ({q}) AND kind IN ({kq}) AND src != ?", (*mine, *res_kinds, main)):
1325
+ agg[r["src"]].add(r["dst"])
1326
+ res["shared_resources"] = sorted(({"node": k, "shared": sorted(v)} for k, v in agg.items()), key=lambda x: -len(x["shared"]))[:limit]
1327
+ callees = {r["dst"] for r in st.q("SELECT DISTINCT dst FROM edges WHERE src=? AND kind='CALLS' AND dst LIKE 'method:%'", (main,))}
1328
+ callees = {c for c in callees if not c.startswith(f"method:{cls_fqn}::")}
1329
+ if callees:
1330
+ q = ",".join("?" * len(callees))
1331
+ agg = defaultdict(set)
1332
+ for r in st.q(f"SELECT src, dst FROM edges WHERE dst IN ({q}) AND kind='CALLS' AND src != ?", (*callees, main)):
1333
+ agg[r["src"]].add(r["dst"])
1334
+ res["co_callers"] = sorted(({"node": k, "shared_callees": sorted(v), "jaccard": round(len(v) / len(callees | v), 2)}
1335
+ for k, v in agg.items()), key=lambda x: (-len(x["shared_callees"]), x["node"]))[:limit]
1336
+ return res
1337
+
1338
+
1339
+ DOWNSTREAM_SINKS = ("table", "column", "connection", "config", "env", "route", "http", "job", "command",
1340
+ "unsafe", "ffi", "feature", "cfg", "define", "endpoint")
1341
+
1342
+
1343
+ def downstream(st: GraphStore, spec: str, min_conf="heuristic", max_depth=30, kinds=None, sinks=DOWNSTREAM_SINKS,
1344
+ gate: str | None = "auto", platform: str | None = None) -> dict:
1345
+ """Forward closure: everything the target depends on (e.g. a frontend page -> composables ->
1346
+ HTTP endpoints -> backend routes -> controllers/services -> tables). Returns reached sink nodes
1347
+ (tables, columns, routes, ...) with one shortest evidence path each. With a gate scenario,
1348
+ sinks reachable only through gated edges are flagged."""
1349
+ if gate == "auto":
1350
+ gate = default_gate(st)
1351
+ targets = resolve_targets(st, spec)
1352
+ kinds = kinds or PROPAGATING
1353
+ kset = set(kinds)
1354
+ rank = CONFIDENCE_RANK[min_conf]
1355
+ px = _px(st, platform)
1356
+ prev, depth = {}, {t: 0 for t in targets}
1357
+ frontier = list(targets)
1358
+ live = set(targets)
1359
+ while frontier:
1360
+ nxt = []
1361
+ chunk_all = frontier
1362
+ for i in range(0, len(chunk_all), 500):
1363
+ chunk = chunk_all[i:i + 500]
1364
+ q = ",".join("?" * len(chunk))
1365
+ for e in st.q(f"SELECT id,src,dst,kind,file,line,confidence,conf_rank,gate FROM edges WHERE src IN ({q})", chunk):
1366
+ if e["kind"] not in kset or e["conf_rank"] < rank or (px and e["id"] in px):
1367
+ continue
1368
+ d = e["dst"]
1369
+ if d not in depth and depth[e["src"]] < max_depth:
1370
+ depth[d] = depth[e["src"]] + 1
1371
+ prev[d] = dict(e)
1372
+ nxt.append(d)
1373
+ if e["src"] in live and not (gate and e["gate"] == gate):
1374
+ live.add(d)
1375
+ frontier = nxt
1376
+ # live set needs a fixpoint (edges seen before their src became live)
1377
+ changed = True
1378
+ while changed and gate:
1379
+ changed = False
1380
+ ids = list(live)
1381
+ for i in range(0, len(ids), 500):
1382
+ chunk = ids[i:i + 500]
1383
+ q = ",".join("?" * len(chunk))
1384
+ for e in st.q(f"SELECT id,src,dst,kind,conf_rank,gate FROM edges WHERE src IN ({q})", chunk):
1385
+ if e["id"] in px:
1386
+ continue
1387
+ if e["kind"] in kset and e["conf_rank"] >= rank and e["gate"] != gate and e["dst"] in depth and e["dst"] not in live:
1388
+ live.add(e["dst"]); changed = True
1389
+ out = defaultdict(list)
1390
+ nodes = {}
1391
+ ids = [n for n in depth if n.split(":", 1)[0] in sinks]
1392
+ for i in range(0, len(ids), 500):
1393
+ chunk = ids[i:i + 500]
1394
+ q = ",".join("?" * len(chunk))
1395
+ for r in st.q(f"SELECT id,kind,name,file,line FROM nodes WHERE id IN ({q})", chunk):
1396
+ nodes[r["id"]] = dict(r)
1397
+ for nid in ids:
1398
+ path, x = [], nid
1399
+ while x in prev:
1400
+ e = prev[x]
1401
+ path.append({"from": e["src"], "kind": e["kind"], "to": e["dst"], "at": f"{e['file']}:{e['line']}", "confidence": e["confidence"],
1402
+ **({"gated": e["gate"]} if e["gate"] else {})})
1403
+ x = e["src"]
1404
+ path.reverse()
1405
+ n = nodes.get(nid, {"id": nid, "kind": nid.split(":", 1)[0], "name": nid})
1406
+ out[n["kind"]].append({**n, "depth": depth[nid], "path": path, "path_confidence": path_confidence(path),
1407
+ "live": (nid in live) if gate else True})
1408
+ for k in out:
1409
+ out[k].sort(key=lambda x: (x["depth"], x["id"]))
1410
+ # tables touched directly (READS/WRITES_TABLE) or through any of their columns
1411
+ touched = {}
1412
+ for c in out.get("column", []) + out.get("table", []):
1413
+ t = c["id"].split(":", 1)[1].split(".")[0] if c["kind"] == "column" else c["id"].split(":", 1)[1]
1414
+ cur = touched.get(t)
1415
+ if cur is None or c["depth"] < cur["depth"]:
1416
+ touched[t] = {"table": t, "depth": c["depth"], "via": c["id"], "live": c["live"], "path_confidence": c["path_confidence"]}
1417
+ elif c["live"]:
1418
+ cur["live"] = True
1419
+ res = {"targets": targets, "gate": gate, "reached": len(depth), "sinks": dict(out),
1420
+ "tables_touched": sorted(touched.values(), key=lambda x: (x["depth"], x["table"]))}
1421
+ if platform:
1422
+ from .platforms import filter_info
1423
+ res["platform"] = filter_info(st, platform)
1424
+ res["platform"]["targets_not_built"] = _not_built(st, targets, platform)
1425
+ return res
1426
+
1427
+
1428
+ def path_between(st: GraphStore, src_spec: str, dst_spec: str, min_conf="heuristic", max_depth=30,
1429
+ platform: str | None = None) -> list[dict]:
1430
+ """Shortest forward dependency path from any node of src_spec to any node of dst_spec.
1431
+ A table target with no direct path falls back to its columns (code mostly reaches a table through column
1432
+ reads/writes), the same rule the visual view uses."""
1433
+ srcs, dsts = resolve_targets(st, src_spec), set(resolve_targets(st, dst_spec))
1434
+ if platform:
1435
+ from .platforms import exclusions
1436
+ xn = exclusions(st, platform)["nodes"]
1437
+ srcs, dsts = [s for s in srcs if s not in xn], {d for d in dsts if d not in xn}
1438
+ p = _bfs_path(st, srcs, dsts, min_conf, max_depth, platform)
1439
+ if not p:
1440
+ cols = set()
1441
+ for t in [d for d in dsts if d.startswith("table:")]:
1442
+ pre = "column:" + t[6:] + "."
1443
+ cols.update(r["id"] for r in st.q("SELECT id FROM nodes WHERE kind='column' AND substr(id, 1, ?) = ?", (len(pre), pre)))
1444
+ if cols:
1445
+ p = _bfs_path(st, srcs, cols, min_conf, max_depth, platform)
1446
+ return p
1447
+
1448
+
1449
+ def _bfs_path(st: GraphStore, srcs: list[str], dsts: set[str], min_conf: str, max_depth: int,
1450
+ platform: str | None = None) -> list[dict]:
1451
+ kset, rank = set(PROPAGATING), CONFIDENCE_RANK[min_conf]
1452
+ px = _px(st, platform)
1453
+ prev, seen, frontier = {}, set(srcs), list(srcs)
1454
+ for _ in range(max_depth):
1455
+ nxt = []
1456
+ for i in range(0, len(frontier), 500):
1457
+ chunk = frontier[i:i + 500]
1458
+ q = ",".join("?" * len(chunk))
1459
+ for e in st.q(f"SELECT id,src,dst,kind,file,line,confidence,conf_rank,gate,attrs FROM edges WHERE src IN ({q})", chunk):
1460
+ if e["kind"] in kset and e["conf_rank"] >= rank and e["dst"] not in seen and not (px and e["id"] in px):
1461
+ seen.add(e["dst"]); prev[e["dst"]] = dict(e); nxt.append(e["dst"])
1462
+ if e["dst"] in dsts:
1463
+ path, x = [], e["dst"]
1464
+ while x in prev:
1465
+ p = prev[x]
1466
+ pl = json.loads(p["attrs"]).get("platforms") if p["attrs"] and '"platforms"' in p["attrs"] else None
1467
+ path.append({"from": p["src"], "kind": p["kind"], "to": p["dst"], "at": f"{p['file']}:{p['line']}",
1468
+ "confidence": p["confidence"], **({"gated": p["gate"]} if p["gate"] else {}),
1469
+ **({"platforms": pl} if pl is not None else {})})
1470
+ x = p["src"]
1471
+ return path[::-1]
1472
+ frontier = nxt
1473
+ if not frontier:
1474
+ break
1475
+ return []
1476
+
1477
+
1478
+ def render_downstream(res: dict, show_paths=True, max_per_kind=60, kinds_order=("route", "table", "column", "connection", "config", "env", "job", "command", "http",
1479
+ "unsafe", "ffi", "feature", "cfg", "define", "endpoint")) -> str:
1480
+ out = [f"targets: {', '.join(res['targets'][:6])}", f"reached nodes: {res['reached']}"]
1481
+ if res.get("platform"):
1482
+ from .platforms import render_filter
1483
+ out.insert(0, render_filter(res["platform"]))
1484
+ tt = res.get("tables_touched") or []
1485
+ if tt:
1486
+ out.append("")
1487
+ out.append(f"== TABLES TOUCHED (directly or via columns): {len(tt)}")
1488
+ out.append(" " + ", ".join(f"{t['table']}{'' if t['live'] else '[GATED-ONLY]'}" for t in tt))
1489
+ for k in kinds_order:
1490
+ items = res["sinks"].get(k) or []
1491
+ if not items:
1492
+ continue
1493
+ out.append("")
1494
+ out.append(f"== {k.upper()}: {len(items)}" + (f" (gated-only for '{res['gate']}': {sum(1 for i in items if not i['live'])})" if res.get("gate") else ""))
1495
+ for i in items[:max_per_kind]:
1496
+ g = "" if i["live"] else " [GATED-ONLY]"
1497
+ out.append(f" {i['id']} depth={i['depth']} conf={i['path_confidence']}{g}")
1498
+ if show_paths:
1499
+ s = i["path"][0]["from"] if i["path"] else i["id"]
1500
+ for p in i["path"]:
1501
+ s += f"\n -{p['kind']}[{p['confidence']} @ {p['at']}{' GATED' if p.get('gated') else ''}]-> {p['to']}"
1502
+ out.append(f" path: {s}")
1503
+ if len(items) > max_per_kind:
1504
+ out.append(f" ... {len(items) - max_per_kind} more")
1505
+ return "\n".join(out)
1506
+
1507
+
1508
+ def api_calls(st: GraphStore, flt: str = "all") -> list[dict]:
1509
+ """Client HTTP endpoints with call sites and matched backend routes (combined DB).
1510
+ flt: 'all' | 'unmatched' | substring of the endpoint/route/caller/file | a glob with `*` (any characters, `/`
1511
+ included) matched against the endpoint (`GET /v1/*/orders*`), its path, a matched route, controller or caller,
1512
+ or a call-site file (`*useOrders*`); a glob without a verb matches every verb. Case-insensitive."""
1513
+ eps = {r["id"]: dict(r) for r in st.q("SELECT id, name, attrs FROM nodes WHERE kind='http'")}
1514
+ calls = defaultdict(list)
1515
+ for e in st.q("SELECT src, dst, file, line, confidence, attrs FROM edges WHERE kind='HTTP_CALLS'"):
1516
+ a = json.loads(e["attrs"] or "{}")
1517
+ calls[e["dst"]].append({"caller": e["src"], "at": f"{e['file']}:{e['line']}", "confidence": e["confidence"],
1518
+ "url": a.get("url"), "via_helper": a.get("via_helper")})
1519
+ routes = defaultdict(list)
1520
+ for e in st.q("SELECT src, dst, confidence, attrs FROM edges WHERE kind='MATCHES_ROUTE'"):
1521
+ ctl = [r["dst"] for r in st.q("SELECT dst FROM edges WHERE src=? AND kind='ROUTES_TO'", (e["dst"],))]
1522
+ routes[e["src"]].append({"route": e["dst"], "confidence": e["confidence"], "controller": ctl,
1523
+ "uri_variant": json.loads(e["attrs"] or "{}").get("uri_variant")})
1524
+ out = []
1525
+ for nid, n in eps.items():
1526
+ row = {"endpoint": nid, "attrs": json.loads(n["attrs"] or "{}"), "calls": calls.get(nid, []), "routes": routes.get(nid, [])}
1527
+ if flt == "unmatched" and row["routes"]:
1528
+ continue
1529
+ if flt not in ("all", "unmatched") and not _api_row_matches(flt, row, n["name"]):
1530
+ continue
1531
+ out.append(row)
1532
+ return sorted(out, key=lambda r: r["endpoint"])
1533
+
1534
+
1535
+ def _api_row_matches(flt: str, row: dict, name: str | None) -> bool:
1536
+ if "*" not in flt:
1537
+ return flt in json.dumps(row) or flt in (name or "")
1538
+ rx = re.compile("^" + ".*".join(re.escape(x) for x in flt.strip().split("*")) + "$", re.I)
1539
+ a = row["attrs"]
1540
+ fields = [name or "", row["endpoint"], row["endpoint"].split(":", 1)[-1], a.get("path") or ""]
1541
+ if re.match(r"^[A-Za-z]+\s", flt.strip()) is None: # no verb: the path part alone also counts
1542
+ fields += [f"{a.get('method') or ''} {a.get('path') or ''}"]
1543
+ for r in row["routes"]:
1544
+ fields += [r["route"], r["route"].split(":", 1)[-1], *r["controller"]]
1545
+ for c in row["calls"]:
1546
+ fields += [c["caller"], c["at"], (c["at"] or "").rsplit(":", 1)[0], c.get("url") or ""]
1547
+ if c.get("via_helper"):
1548
+ fields += [c["via_helper"].get("fn") or "", (c["via_helper"].get("at") or "").rsplit(":", 1)[0]]
1549
+ return any(rx.match(f or "") for f in fields)
1550
+
1551
+
1552
+ def render_api_calls(rows: list[dict], max_calls=4) -> str:
1553
+ out = [f"{len(rows)} client endpoints ({sum(1 for r in rows if r['routes'])} matched)"]
1554
+ for r in rows:
1555
+ a = r.get("attrs") or {}
1556
+ tag = " (called from tests only)" if a.get("test_only") else ""
1557
+ if a.get("base"):
1558
+ tag += f" (base {a['base']['placeholder']} = {a['base']['value']}, {a['base']['from']})"
1559
+ out.append(f"{r['endpoint']}{tag}")
1560
+ for m in r["routes"]:
1561
+ out.append(f" => {m['route']} [{m['confidence']}] -> {', '.join(m['controller']) or '?'}")
1562
+ if not r["routes"]:
1563
+ out.append(" => (unmatched)")
1564
+ for c in r["calls"][:max_calls]:
1565
+ h = f" via {c['via_helper']['fn']}" if c.get("via_helper") else ""
1566
+ out.append(f" <- {c['caller']} @ {c['at']} [{c['confidence']}]{h}")
1567
+ if len(r["calls"]) > max_calls:
1568
+ out.append(f" <- ... {len(r['calls']) - max_calls} more")
1569
+ return "\n".join(out)
1570
+
1571
+
1572
+ # ----------------------------------------------------------------------------------------------- display helpers
1573
+ SHORT_CODE_PREFIXES = ("method:", "class:", "function:", "interface:", "trait:", "enum:", "struct:", "union:", "typedef:",
1574
+ "type_alias:", "macro:", "global:", "ffi:", "field:", "const:", "static:", "composable:", "store:")
1575
+
1576
+
1577
+ def short_id(x: str | None) -> str:
1578
+ """Node id without the code-kind prefix and the root `App\\` namespace (routes, tables, pages keep their prefix)."""
1579
+ if not x:
1580
+ return "?"
1581
+ if x.startswith(SHORT_CODE_PREFIXES):
1582
+ x = x.split(":", 1)[1]
1583
+ if "#" in x:
1584
+ f, _, q = x.partition("#")
1585
+ return f"{q} ({os.path.basename(f)})"
1586
+ return x[4:] if x.startswith("App\\") else x
1587
+
1588
+
1589
+ # ----------------------------------------------------------------------------------------------- search
1590
+ GUARD_KEYS = ("middleware", "guards", "interceptors", "pipes", "auth", "access", "wrapped_by")
1591
+
1592
+
1593
+ def search(st: GraphStore, name: str, kind: str | None = None, limit: int = 20, platform: str | None = None) -> dict:
1594
+ """Nodes whose name / FQN / id contains `name` (case-insensitive), plus routes whose middleware, guards, auth or
1595
+ access checks contain it (those are route attributes and USES_MIDDLEWARE edges, not nodes of their own)."""
1596
+ q = "SELECT id, kind, file, line, attrs FROM nodes WHERE (name LIKE ? OR fqn LIKE ? OR id LIKE ?)"
1597
+ p: list = [f"%{name}%"] * 3
1598
+ if kind:
1599
+ q += " AND kind=?"
1600
+ p.append(kind)
1601
+ xn = set()
1602
+ if platform:
1603
+ from .platforms import exclusions
1604
+ xn = exclusions(st, platform)["nodes"]
1605
+ q += " ORDER BY length(id) LIMIT ?"
1606
+ p.append(limit + len(xn))
1607
+ rows = st.q(q, p)
1608
+ hidden = sum(1 for r in rows if r["id"] in xn)
1609
+ nodes = [with_generated(dict(r)) for r in rows if r["id"] not in xn][:limit]
1610
+ guards: dict[str, list[dict]] = defaultdict(list)
1611
+ if kind in (None, "route", "middleware", "guard"):
1612
+ low = name.lower()
1613
+ for r in st.q("SELECT id, file, line, attrs FROM nodes WHERE kind='route' AND attrs LIKE ?", (f"%{name}%",)):
1614
+ if r["id"] in xn:
1615
+ continue
1616
+ a = json.loads(r["attrs"] or "{}")
1617
+ for k in GUARD_KEYS:
1618
+ v = a.get(k)
1619
+ vals = v if isinstance(v, list) else [v]
1620
+ for x in vals:
1621
+ nm = x.get("name") if isinstance(x, dict) else x
1622
+ if nm and low in str(nm).lower():
1623
+ guards[str(nm)].append({"route": r["id"], "file": r["file"], "line": r["line"], "via": k})
1624
+ for c in a.get("conditions") or []:
1625
+ if isinstance(c, str) and c.startswith("wrapped:") and low in c.lower():
1626
+ guards[c[8:]].append({"route": r["id"], "file": r["file"], "line": r["line"], "via": "urlconf wrapper"})
1627
+ for e in st.q("SELECT src, dst, file, line, attrs FROM edges WHERE kind='USES_MIDDLEWARE' AND src LIKE 'route:%' "
1628
+ "AND (attrs LIKE ? OR dst LIKE ?)", (f"%{name}%", f"%{name}%")):
1629
+ nm = json.loads(e["attrs"] or "{}").get("name") or short_id(e["dst"])
1630
+ if low in nm.lower() or low in e["dst"].lower():
1631
+ if not any(g["route"] == e["src"] for g in guards.get(nm, [])):
1632
+ guards[nm].append({"route": e["src"], "file": e["file"], "line": e["line"], "via": "USES_MIDDLEWARE"})
1633
+ out = {"query": name, "kind": kind, "nodes": nodes, "guards": dict(sorted(guards.items()))}
1634
+ if platform:
1635
+ from .platforms import filter_info
1636
+ out["platform"] = {**filter_info(st, platform), "matches_not_built": hidden}
1637
+ return out
1638
+
1639
+
1640
+ def render_search(res: dict, limit_routes: int = 8) -> str:
1641
+ out = []
1642
+ if res.get("platform"):
1643
+ from .platforms import render_filter
1644
+ out.append(render_filter(res["platform"]))
1645
+ if res["platform"].get("matches_not_built"):
1646
+ out.append(f"{res['platform']['matches_not_built']} matching symbols are not built for {res['platform']['platform']} "
1647
+ f"(search without platform lists them)")
1648
+ for r in res["nodes"]:
1649
+ out.append(f"{r['kind']:10} {r['id']} {r['file'] or '?'}:{r['line']}{platform_label(r)}")
1650
+ if res["guards"]:
1651
+ if out:
1652
+ out.append("")
1653
+ n = sum(len(v) for v in res["guards"].values())
1654
+ out.append(f"middleware / guards / auth matching '{res['query']}' (route attributes): {len(res['guards'])} name(s) on {n} route(s)")
1655
+ for nm, rs in res["guards"].items():
1656
+ shown = ", ".join(x["route"].split(":", 1)[1] for x in rs[:limit_routes])
1657
+ out.append(f" {nm} ({rs[0]['via']}) on {len(rs)} route(s): {shown}{' …' if len(rs) > limit_routes else ''}")
1658
+ if not res["nodes"] and not res["guards"]:
1659
+ out.append(f"no matches for {res['query']!r}" + (f" with kind={res['kind']}" if res.get("kind") else "") +
1660
+ " in node names, FQNs, ids or route middleware / guard / auth names. Try a shorter substring, drop the "
1661
+ "kind filter, or use `routes` to list every route with its guards.")
1662
+ return "\n".join(out)
1663
+
1664
+
1665
+ # ----------------------------------------------------------------------------------------------- empty-result help
1666
+ def explain_siblings(st: GraphStore, spec: str, res: dict) -> str:
1667
+ """Why siblings() found nothing for a resolved symbol, with the queries that answer the likely question."""
1668
+ main = res["targets"][0]
1669
+ n = dict(st.node(main) or {})
1670
+ s = short_id(main)
1671
+ why, tips = [], []
1672
+ fq = (n.get("fqn") or "").split("::")[0]
1673
+ if not st.q("SELECT 1 FROM edges e JOIN nodes c ON c.id=e.src WHERE c.fqn=? AND e.kind IN ('EXTENDS','IMPLEMENTS','USES_TRAIT') LIMIT 1", (fq,)):
1674
+ why.append("its class has no parent class, interface or trait shared with other classes")
1675
+ res_kinds = ("READS_COLUMN", "WRITES_COLUMN", "MENTIONS_COLUMN", "READS_TABLE", "WRITES_TABLE", "READS_CONFIG",
1676
+ "WRITES_CONFIG", "READS_ENV", "USES_CONNECTION", "REGISTERS_CONNECTION")
1677
+ kq = ",".join("?" * len(res_kinds))
1678
+ if not st.q(f"SELECT 1 FROM edges WHERE src=? AND kind IN ({kq}) LIMIT 1", (main, *res_kinds)):
1679
+ why.append("it touches no table, column, config, env key or connection directly")
1680
+ callees = [r["dst"] for r in st.q("SELECT DISTINCT dst FROM edges WHERE src=? AND kind='CALLS'", (main,))]
1681
+ own = [c for c in callees if fq and c.startswith(f"method:{fq}::")]
1682
+ if callees and len(own) == len(callees):
1683
+ why.append(f"all {len(callees)} of its callees are in its own class, which co-caller matching skips")
1684
+ elif not callees:
1685
+ why.append("it calls no other indexed method")
1686
+ data_callees = [c for c in callees if st.q(f"SELECT 1 FROM edges WHERE src=? AND kind IN ({kq}) LIMIT 1", (c, *res_kinds))]
1687
+ for c in data_callees[:3]:
1688
+ tips.append(f"siblings('{short_id(c)}') (a callee that touches data)")
1689
+ tips.append(f"impact('{s}') for its callers and entry points")
1690
+ tips.append(f"downstream('{s}') for the tables, config and connections it reaches")
1691
+ return (f"no siblings found for {s}: " + ("; ".join(why) or "no shared parents, resources or callees") +
1692
+ ".\ntry: " + "; ".join(tips))
1693
+
1694
+
1695
+ def explain_no_callers(st: GraphStore, spec: str, targets: list[str], min_conf: str = "heuristic") -> str:
1696
+ if not targets:
1697
+ return f"no method matches {spec!r}; try search() with part of the name."
1698
+ t = targets[0]
1699
+ n = dict(st.node(t) or {})
1700
+ s = short_id(t)
1701
+ if CONFIDENCE_RANK.get(min_conf, 1) > CONFIDENCE_RANK["heuristic"]:
1702
+ # callers exist, below the confidence threshold: name the filter, not "no callers" (#75)
1703
+ q = ",".join("?" * len(targets))
1704
+ low = st.q(f"SELECT kind, confidence, count(*) c FROM edges WHERE dst IN ({q}) AND kind IN ({','.join('?' * len(PROPAGATING))}) "
1705
+ f"AND conf_rank < ? GROUP BY kind, confidence ORDER BY c DESC", (*targets, *PROPAGATING, CONFIDENCE_RANK[min_conf]))
1706
+ if low:
1707
+ langs = {r["lang"] for r in st.q(f"SELECT DISTINCT lang FROM nodes WHERE id IN ({q})", tuple(targets))}
1708
+ why = {"swift": "Swift resolved / exact edges need the compiler index store (docs/swift.md#exact-mode)",
1709
+ "kotlin": "Kotlin resolved / exact edges need scip-java (docs/kotlin.md#exact-mode)",
1710
+ "rust": "Rust exact edges need rust-analyzer (docs/native.md)",
1711
+ "c": "C / C++ exact edges need scip-clang and a compile_commands.json (docs/native.md)",
1712
+ "cpp": "C / C++ exact edges need scip-clang and a compile_commands.json (docs/native.md)"}
1713
+ notes = [why[x] for x in sorted(x for x in langs if x in why)]
1714
+ return (f"{s}: no callers at --min-confidence {min_conf}; "
1715
+ + ", ".join(f"{r['c']} {r['kind']} edge{'s' if r['c'] != 1 else ''} ({r['confidence']})" for r in low)
1716
+ + f" are below the threshold. Rerun with --min-confidence heuristic (min_confidence='heuristic') to see them"
1717
+ + (f"; {'; '.join(notes)}" if notes else "") + ".")
1718
+ if n.get("entry_kind"):
1719
+ return f"{s} has no recorded callers; it is itself an entry point ({n['entry_kind']}). try: downstream('{s}') for what it reaches."
1720
+ refs = st.q("SELECT kind, count(*) c FROM edges WHERE dst=? GROUP BY kind", (t,))
1721
+ other = ", ".join(f"{r['kind']}×{r['c']}" for r in refs)
1722
+ from .coverage import answer_note, completeness_for, possibly_more
1723
+ comp = completeness_for(st, targets)
1724
+ more = possibly_more(comp)
1725
+ if more:
1726
+ return (f"{s}: no callers found in indexed code (blind spots: {more})" + (f"; other incoming edges: {other}" if other else "")
1727
+ + f". try: reaches('{s}') for every dependent over all edge kinds; search('{n.get('name') or spec}') for "
1728
+ f"similarly named code.\n" + answer_note(comp))
1729
+ tested = sum(r["c"] for r in refs if r["kind"] in ("TEST_CALLS", "TEST_USES", "TEST_HTTP"))
1730
+ return (f"{s} has no recorded callers" + (f" (other incoming edges: {other})" if other else "") +
1731
+ (f". Only test code uses it: tests('{s}') lists the tests" if tested else "") +
1732
+ ". It may be called dynamically (string callables, container lookups, framework hooks) or be unused. "
1733
+ f"try: reaches('{s}') for every dependent over all edge kinds; search('{n.get('name') or spec}') for similarly named code.")
1734
+
1735
+
1736
+ def explain_no_writers(st: GraphStore, table: str, what: str = "writers") -> str:
1737
+ if prop_fields(st, table):
1738
+ return (f"no {what} recorded for property {table!r}. Recorded: `self.x` / bare `x` inside its type and `v.x` with "
1739
+ "a known type of `v`; access through an unknown receiver, a `$binding` or a key path is not modelled.")
1740
+ if what == "readers":
1741
+ return f"no stored property {table!r} in the graph (Swift stored properties are `field:` nodes; try `cg search {table}`)."
1742
+ t = table.split(":", 1)[1] if table.startswith("table:") else table
1743
+ if not st.q("SELECT 1 FROM nodes WHERE id=?", (f"table:{t}",)):
1744
+ near = [r["id"][6:] for r in st.q("SELECT id FROM nodes WHERE kind='table' AND id LIKE ? ORDER BY id LIMIT 6", (f"%{t}%",))]
1745
+ allt = [r["id"][6:] for r in st.q("SELECT id FROM nodes WHERE kind='table' ORDER BY id LIMIT 12")]
1746
+ return (f"no table {t!r} in the graph. " + (f"similar: {', '.join(near)}" if near else f"tables: {', '.join(allt) or '(none)'}")
1747
+ + ". Use the DB table name (Django: `<app>_<model>` or Meta.db_table).")
1748
+ reads = st.q("SELECT count(*) c FROM edges WHERE kind IN ('READS_TABLE','READS_COLUMN','MENTIONS_COLUMN') AND (dst=? OR dst LIKE ?)",
1749
+ (f"table:{t}", f"column:{t}.%"))[0]["c"]
1750
+ return (f"no writers recorded for table {t!r} ({reads} read/mention edges). Writes through raw SQL, bulk helpers or admin "
1751
+ f"form saves may not be modelled. try: reaches(['table:{t}']) for every dependent; routes(writes='{t}') after "
1752
+ "indexing the code that writes it.")
1753
+
1754
+
1755
+ def explain_no_path(st: GraphStore, src: str, dst: str, min_conf: str = "heuristic") -> str:
1756
+ a, b = resolve_targets(st, src), resolve_targets(st, dst)
1757
+ if not a:
1758
+ return f"no path: source {src!r} matches no node; try search()."
1759
+ if not b:
1760
+ return f"no path: target {dst!r} matches no node; try search()."
1761
+ rev = _bfs_path(st, b, set(a), min_conf, 30)
1762
+ if rev:
1763
+ return (f"no path from {src} to {dst}, but there is one in the other direction ({len(rev)} hops): "
1764
+ f"try path('{dst}', '{src}').")
1765
+ return (f"no forward path from {src} to {dst} over dependency edges (min_confidence={min_conf}). "
1766
+ f"try: downstream('{src}') for everything it reaches; reaches(['{dst}']) for everything that depends on the target"
1767
+ + ("; a lower min_confidence" if min_conf != "heuristic" else "") + ".")
1768
+
1769
+
1770
+ # ----------------------------------------------------------------------------------------------- client keys dropped by a helper
1771
+ def _known_keys(ha: dict) -> set[str] | None:
1772
+ """Keys a client request can send (always + conditional), or None when the request params are not statically known."""
1773
+ ks = [x for x in (ha.get("query_keys"), ha.get("body_keys")) if x]
1774
+ if not ks or any(k.get("opaque") for k in ks):
1775
+ return None
1776
+ return {x for k in ks for x in (k.get("keys") or []) + (k.get("conditional") or [])}
1777
+
1778
+
1779
+ def forwarding_gaps(st: GraphStore, issuers: list[str] | None = None) -> list[dict]:
1780
+ """Call sites that pass keys to a request-issuing function which never puts them on the request ("sent but not
1781
+ forwarded"), e.g. a page passes {category_id, date_from} to fetchTop, whose params builder only sets category_id.
1782
+ Uses the HTTP_CALLS query/body key facts and the call-site argument keys (CALLS attrs.arg_keys)."""
1783
+ out = []
1784
+ sql = "SELECT src, dst, file, line, attrs FROM edges WHERE kind='HTTP_CALLS'"
1785
+ rows = st.q(sql) if issuers is None else [r for i in issuers for r in st.q(sql + " AND src=?", (i,))]
1786
+ for h in rows:
1787
+ ha = json.loads(h["attrs"] or "{}")
1788
+ known = _known_keys(ha)
1789
+ if known is None:
1790
+ continue
1791
+ fwd = next((x for x in (ha.get("query_keys"), ha.get("body_keys")) if x and x.get("forwarded_index") is not None), None)
1792
+ fi = fwd.get("forwarded_index") if fwd else None
1793
+ for c in st.q("SELECT src, file, line, attrs FROM edges WHERE kind='CALLS' AND dst=?", (h["src"],)):
1794
+ ca = json.loads(c["attrs"] or "{}")
1795
+ ak = ca.get("arg_keys")
1796
+ if not ak:
1797
+ continue
1798
+ if isinstance(fi, int):
1799
+ ak = [ak[fi]] if fi < len(ak) else []
1800
+ elif len([x for x in ak if x]) != 1:
1801
+ continue # several object arguments and no recorded forwarded parameter: cannot tell which one is sent
1802
+ passed = sorted({k for ks in ak for k in (ks or [])})
1803
+ cond = sorted({k for ks in (ca.get("arg_keys_conditional") or []) for k in (ks or [])})
1804
+ dropped = [k for k in passed + cond if k not in known]
1805
+ if dropped:
1806
+ out.append({"endpoint": h["dst"], "issuer": h["src"], "request_at": f"{h['file']}:{h['line']}",
1807
+ "caller": c["src"], "call_at": f"{c['file']}:{c['line']}", "passed": passed + cond,
1808
+ "request_keys": sorted(known), "dropped": sorted(set(dropped))})
1809
+ return out
1810
+
1811
+
1812
+ def path_notes(st: GraphStore, path: list[dict]) -> list[str]:
1813
+ """Notes for a path: keys a call site passes that the next hop's request never sends."""
1814
+ notes = []
1815
+ for a, b in zip(path, path[1:]):
1816
+ if a["kind"] == "CALLS" and b["kind"] == "HTTP_CALLS" and a["to"] == b["from"]:
1817
+ for g in forwarding_gaps(st, [b["from"]]):
1818
+ if g["caller"] == a["from"] and g["endpoint"] == b["to"]:
1819
+ notes.append(f"sent but not forwarded: {', '.join(g['dropped'])} (passed @ {g['call_at']}; the request built "
1820
+ f"@ {g['request_at']} sends only {', '.join(g['request_keys'])})")
1821
+ return notes
1822
+
1823
+
1824
+ def render_siblings(st: GraphStore, spec: str, res: dict, limit: int = 15) -> str:
1825
+ if not res["targets"]:
1826
+ return f"no symbol matches {spec!r}; try `search` with part of the name"
1827
+ out = [f"target: {short_id(res['targets'][0])}"]
1828
+ if not any(res[k] for k in ("hierarchy", "same_method_in_siblings", "shared_resources", "co_callers")):
1829
+ return "\n".join(out + [explain_siblings(st, spec, res)])
1830
+ for h in res["hierarchy"][:limit]:
1831
+ out.append(f"hierarchy {h['kind']} {short_id(h['parent'])}: {short_id(h['sibling'])}")
1832
+ for m in res["same_method_in_siblings"][:limit]:
1833
+ out.append(f"same method {short_id(m['id'])} @{m['file']}:{m['line']}")
1834
+ for s in res["shared_resources"][:limit]:
1835
+ out.append(f"shares {short_id(s['node'])}: {', '.join(short_id(x) for x in s['shared'][:6])}{' …' if len(s['shared']) > 6 else ''}")
1836
+ for s in res["co_callers"][:limit]:
1837
+ out.append(f"co-caller {short_id(s['node'])} J={s['jaccard']}: {', '.join(short_id(x) for x in s['shared_callees'][:5])}")
1838
+ return "\n".join(out)
1839
+
1840
+
1841
+ # ----------------------------------------------------------------------------------------------- tests covering
1842
+ HTTP_SPEC_RE = re.compile(r"^(?:(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|ANY)\s+)?(/\S*)$", re.I)
1843
+
1844
+
1845
+ def route_targets(st: GraphStore, spec: str) -> list[str]:
1846
+ """`/path` or `VERB /path` (concrete or with {params}) -> backend route nodes it matches."""
1847
+ m = HTTP_SPEC_RE.match(spec.strip())
1848
+ if not m:
1849
+ return []
1850
+ from .link import _method_ok, match_path
1851
+ verb, path = (m.group(1) or "").upper(), m.group(2)
1852
+ out = []
1853
+ for r in st.q("SELECT id, file, attrs FROM nodes WHERE kind='route'"):
1854
+ a = json.loads(r["attrs"] or "{}")
1855
+ uri, meth = a.get("uri"), a.get("method")
1856
+ if not uri:
1857
+ continue
1858
+ if verb and meth and not _method_ok(verb, meth):
1859
+ continue
1860
+ uris = [uri] + (["/api" + uri] if re.search(r"(^|/)routes/api(\.php|/)", r["file"] or "") else [])
1861
+ if any(match_path(path, u)[0] and match_path(path, u)[1]["lit"] == len([s for s in u.split("/") if s and not s.startswith("{")])
1862
+ for u in uris):
1863
+ out.append(r["id"])
1864
+ return out
1865
+
1866
+
1867
+ UI_TEST_PATH = re.compile(r"(^|/)(\w*UITests?|androidTest|\w*[Ss]napshot\w*|\w*[Ss]creenshot\w*|uiTest|"
1868
+ r"integration_test|cypress|playwright)(/|$)", re.I)
1869
+ UI_TEST_FRAMEWORKS = {"playwright", "cypress", "xcuitest", "espresso", "compose-ui", "snapshot"}
1870
+
1871
+
1872
+ def test_class(t: dict) -> str:
1873
+ """"ui" for UI, snapshot and screenshot tests (XCUITest, Compose UI / Espresso, Playwright / Cypress, snapshot
1874
+ libraries; a test target or folder named *UITests, androidTest, e2e, *Snapshot*, *Screenshot*), else "unit"."""
1875
+ if (t.get("framework") or "") in UI_TEST_FRAMEWORKS or UI_TEST_PATH.search(t.get("file") or ""):
1876
+ return "ui"
1877
+ if re.search(r"(snapshot|screenshot)", f"{t.get('name') or ''} {(t.get('file') or '').rsplit('/', 1)[-1]}", re.I):
1878
+ return "ui"
1879
+ return "unit"
1880
+
1881
+
1882
+ def app_roots(st: GraphStore, extra: list[str] | None = None, defaults: bool = True) -> set[str]:
1883
+ """App entry points a transitive test path should not run through: `@main` types (Swift), `*Activity` classes
1884
+ (Android) and their members (`App.body`) unless `defaults` is off, plus the `--exclude-root` symbols."""
1885
+ ids: set[str] = set()
1886
+ if defaults:
1887
+ ids |= {r["id"] for r in st.q("SELECT id FROM nodes WHERE entry_kind='main' AND kind IN ('class','struct')")}
1888
+ ids |= {r["id"] for r in st.q("SELECT id FROM nodes WHERE kind='class' AND lang='kotlin' AND name LIKE "
1889
+ "'%Activity' AND json_extract(attrs, '$.test') IS NULL")}
1890
+ for x in extra or ():
1891
+ ids |= set(resolve_targets(st, x))
1892
+ fqns = [r["fqn"] for r in st.q(f"SELECT fqn FROM nodes WHERE id IN ({','.join('?' * len(ids))})", list(ids))
1893
+ if r["fqn"]] if ids else []
1894
+ for fq in fqns:
1895
+ ids |= {r["id"] for r in st.q("SELECT id FROM nodes WHERE fqn LIKE ? ESCAPE '\\' AND kind IN ('method','function')",
1896
+ (fq.replace("_", "\\_").replace("%", "\\%") + ".%",))}
1897
+ return ids
1898
+
1899
+
1900
+ def tests_covering(st: GraphStore, spec: str, min_conf="heuristic", max_depth=30, near_depth: int | None = 3,
1901
+ unit_only=False, exclude_roots: list[str] | None = None, through_roots=False) -> dict:
1902
+ """Tests that exercise a symbol / route / table...: direct (the test code itself calls / requests it) and
1903
+ transitive (through application code: test -> route -> controller -> service -> target). An inherited
1904
+ `Sub.method` spec leaves out the calls whose receiver cannot be a Sub (narrow_inherited).
1905
+ Transitive results are kept near the target (#87): at most `near_depth` hops (None: any), not through an app
1906
+ root (`@main`, `App.body`, `MainActivity`, `exclude_roots`; `through_roots` keeps those), and UI / snapshot tests
1907
+ in their own `ui` list (`unit_only` drops it). What is left out is counted in `omitted`."""
1908
+ ob = override_bases(st, spec)
1909
+ with narrowed(st, inherited_targets(st, spec) + ob):
1910
+ res = _tests_covering(st, spec, min_conf, max_depth)
1911
+ out = _split_tests(st, res, near_depth, unit_only, exclude_roots, through_roots)
1912
+ if _narrowed_bases(ob):
1913
+ out["override_narrowed"] = _narrowed_bases(ob)
1914
+ return out
1915
+
1916
+
1917
+ # hops that are wiring, not application code between the test and the target: the test's own calls and requests,
1918
+ # route -> handler, cross-repo http -> route, command -> handle, middleware
1919
+ WIRING_EDGES = {"ROUTES_TO", "MATCHES_ROUTE", "MATCHES_ENDPOINT", "MATCHES_CHANNEL", "HANDLED_BY", "USES_MIDDLEWARE"}
1920
+
1921
+
1922
+ def app_depth(path: list[dict]) -> int:
1923
+ """Hops through application code on a test's path (#87): test edges (TEST_CALLS, TEST_HTTP...) and wiring
1924
+ (route -> controller) do not count, so `test -> route -> controller -> service` is 1."""
1925
+ return sum(1 for p in path or () if not p["kind"].startswith("TEST_") and p["kind"] not in WIRING_EDGES)
1926
+
1927
+
1928
+ def _split_tests(st, res, near_depth, unit_only, exclude_roots, through_roots) -> dict:
1929
+ roots = app_roots(st, exclude_roots, defaults=not through_roots)
1930
+ targets = set(res.get("targets") or ())
1931
+ omitted = {"deeper": 0, "through_roots": 0, "ui": 0}
1932
+ out_direct, near, ui = [], [], []
1933
+ for t in res["direct"]:
1934
+ t["class"] = test_class(t)
1935
+ out_direct.append(t)
1936
+ for t in res["transitive"]:
1937
+ t["class"] = test_class(t)
1938
+ hops = {p["to"] for p in t.get("path") or ()} | {p["from"] for p in t.get("path") or ()}
1939
+ root = next((h for h in hops if h in roots and h not in targets and h != t["test"]), None)
1940
+ if root is not None:
1941
+ omitted["through_roots"] += 1
1942
+ continue
1943
+ t["app_depth"] = app_depth(t.get("path"))
1944
+ if near_depth is not None and t["app_depth"] > near_depth:
1945
+ omitted["deeper"] += 1
1946
+ continue
1947
+ if t["class"] == "ui":
1948
+ if unit_only:
1949
+ omitted["ui"] += 1
1950
+ else:
1951
+ ui.append(t)
1952
+ continue
1953
+ near.append(t)
1954
+ if unit_only:
1955
+ omitted["ui"] += sum(1 for t in out_direct if t["class"] == "ui")
1956
+ out_direct = [t for t in out_direct if t["class"] != "ui"]
1957
+ res.update(direct=out_direct, transitive=near, ui=ui, omitted=omitted,
1958
+ limits={"near_depth": near_depth, "unit_only": unit_only, "through_roots": through_roots,
1959
+ "exclude_roots": exclude_roots or []})
1960
+ res["stats"].update(direct=len(out_direct), transitive=len(near), ui=len(ui), omitted=sum(omitted.values()))
1961
+ return res
1962
+
1963
+
1964
+ def _tests_covering(st: GraphStore, spec: str, min_conf="heuristic", max_depth=30) -> dict:
1965
+ from .core.model import TEST_EDGE_KINDS
1966
+ targets = route_targets(st, spec) or resolve_targets(st, spec)
1967
+ # a cross-repo hop (frontend http call -> backend route) is still the test's own request
1968
+ tk = list(TEST_EDGE_KINDS) + ["MATCHES_ROUTE"]
1969
+ si = _has_class_target(targets)
1970
+ # a base / interface method: the tests of its overrides exercise it too (calls through a collection or a
1971
+ # base-typed value land on the overrides), as in impact; they are marked via_override
1972
+ below = override_seeds(st, spec, targets, min_conf)
1973
+ seeds = targets + below
1974
+ direct = reverse_closure(st, seeds, kinds=tk, min_conf=min_conf, max_depth=max_depth, seed_inst=si)
1975
+ allk = CALL_LIKE + tk
1976
+ trans = reverse_closure(st, seeds, kinds=allk, min_conf=min_conf, max_depth=max_depth, seed_inst=si)
1977
+ if below:
1978
+ own = (set(reverse_closure(st, targets, kinds=tk, min_conf=min_conf, max_depth=max_depth, seed_inst=si))
1979
+ | set(reverse_closure(st, targets, kinds=allk, min_conf=min_conf, max_depth=max_depth, seed_inst=si)))
1980
+ bfq = _fqn_of(st, below)
1981
+ else:
1982
+ own, bfq = None, {}
1983
+ ids = list(set(direct) | set(trans))
1984
+ tests = {}
1985
+ for i in range(0, len(ids), 500):
1986
+ chunk = ids[i:i + 500]
1987
+ q = ",".join("?" * len(chunk))
1988
+ for r in st.q(f"SELECT id, kind, name, fqn, file, line, attrs, entry_kind FROM nodes WHERE id IN ({q}) "
1989
+ f"AND (kind='test' OR entry_kind='test')", chunk):
1990
+ tests[r["id"]] = dict(r)
1991
+ dpaths = shortest_paths(st, {k: v for k, v in direct.items()}, kinds=tk, min_conf=min_conf, seed_inst=si)
1992
+ tpaths = shortest_paths(st, trans, kinds=allk, min_conf=min_conf, seed_inst=si)
1993
+ out = {"targets": targets, "direct": [], "transitive": []}
1994
+ inh = inherited_targets(st, spec)
1995
+ if inh:
1996
+ out["inherited"] = inh
1997
+ for tid, t in sorted(tests.items(), key=lambda x: (x[1]["file"] or "", x[1]["line"] or 0)):
1998
+ a = json.loads(t["attrs"] or "{}")
1999
+ it = {"test": tid, "name": t["name"], "framework": a.get("framework"), "file": t["file"], "line": t["line"]}
2000
+ for k in ("display_name", "parameterized"):
2001
+ if a.get(k):
2002
+ it[k] = a[k]
2003
+ if tid in direct:
2004
+ it.update(depth=direct[tid], path=dpaths.get(tid, []))
2005
+ key = "direct"
2006
+ else:
2007
+ it.update(depth=trans[tid], path=tpaths.get(tid, []))
2008
+ key = "transitive"
2009
+ it["path_confidence"] = path_confidence(it["path"])
2010
+ if path_candidate(it["path"]):
2011
+ it["candidate"] = True
2012
+ if own is not None and tid not in own: # reaches the target only through an override
2013
+ end = it["path"][-1]["to"] if it["path"] else None
2014
+ it["via_override"] = [bfq.get(end, end)] if end in bfq else sorted(set(bfq.values()))[:3]
2015
+ out[key].append(it)
2016
+ for k in ("direct", "transitive"): # closest tests first
2017
+ out[k].sort(key=lambda t: (t["depth"], CONFIDENCE_RANK.get(t["path_confidence"], 0) * -1, t["file"] or "", t["line"] or 0))
2018
+ # test cases: `test` nodes (pytest / PHPUnit / Jest ...) and functions marked entry_kind=test (Swift XCTest and
2019
+ # Swift Testing, Kotlin JUnit / kotlin.test)
2020
+ by_fw = {(r["fw"] or "test"): r["c"] for r in
2021
+ st.q("SELECT json_extract(attrs, '$.framework') fw, count(*) c FROM nodes "
2022
+ "WHERE kind='test' OR entry_kind='test' GROUP BY fw ORDER BY c DESC, fw")}
2023
+ test_files = 0
2024
+ if not by_fw:
2025
+ test_files = st.q("SELECT count(*) c FROM nodes WHERE kind='file' AND json_extract(attrs, '$.test')")[0]["c"]
2026
+ out["stats"] = {"direct": len(out["direct"]), "transitive": len(out["transitive"]),
2027
+ "tests_in_graph": sum(by_fw.values()), "tests_by_framework": by_fw, "test_files": test_files}
2028
+ return out
2029
+
2030
+
2031
+ def _path_short(path: list[dict]) -> str:
2032
+ if not path:
2033
+ return ""
2034
+ s = short_id(path[0]["from"])
2035
+ for p in path:
2036
+ s += f" -{p['kind']}-> {short_id(p['to'])}"
2037
+ return s
2038
+
2039
+
2040
+ def render_tests_covering(res: dict, show_paths=True, limit=60) -> str:
2041
+ L = [f"targets: {len(res['targets'])} node(s): {', '.join(short_id(t) for t in res['targets'][:6])}"
2042
+ + (" ..." if len(res["targets"]) > 6 else "")]
2043
+ if not res["targets"]:
2044
+ return L[0] + "\n(nothing matched the spec)"
2045
+ st = res["stats"]
2046
+ fws = st.get("tests_by_framework") or {}
2047
+ per = (": " + ", ".join(f"{k} {v}" for k, v in fws.items())) if fws else ""
2048
+ L += inherited_lines(res)
2049
+ om = res.get("omitted") or {}
2050
+ lim = res.get("limits") or {}
2051
+ more = ", ".join(f"{v} {k.replace('_', ' ')}" for k, v in om.items() if v)
2052
+ nd = lim.get("near_depth")
2053
+ L.append(f"tests: {st['direct']} direct, {st['transitive']} nearby transitive"
2054
+ + (f" (app depth <= {nd})" if nd else "") + (f", {st['ui']} UI / snapshot" if st.get("ui") else "")
2055
+ + (f"; {sum(om.values())} more not listed ({more})" if more else "")
2056
+ + f" (of {st['tests_in_graph']} test cases in the graph{per})")
2057
+ for label, key in (("DIRECT (the test code itself calls / requests the target)", "direct"),
2058
+ ("TRANSITIVE (through application code)", "transitive"),
2059
+ ("UI / SNAPSHOT (through application code)", "ui")):
2060
+ group = res[key]
2061
+ if not group:
2062
+ continue
2063
+ L += ["", f"== {label}: {len(group)}"]
2064
+ for t in group[:limit]:
2065
+ vo = t.get("via_override")
2066
+ dn = (f' "{t["display_name"]}"' if t.get("display_name") else "") + (" (parameterized)" if t.get("parameterized") else "")
2067
+ L.append(f" {t['name']}{dn} [{t.get('framework') or 'test'}] {t['file']}:{t['line']} depth={t['depth']} conf={t['path_confidence']}"
2068
+ + (CANDIDATE_LABEL if t.get("candidate") else "")
2069
+ + (f" (via override {vo[0]}" + (f" +{len(vo) - 1}" if len(vo) > 1 else "") + ")" if vo else "")
2070
+ + (f" app_depth={t['app_depth']}" if t.get("app_depth", t["depth"]) != t["depth"] else ""))
2071
+ if show_paths and t["path"]:
2072
+ L.append(f" {_path_short(t['path'][:8])}{' ...' if len(t['path']) > 8 else ''}")
2073
+ if len(group) > limit:
2074
+ L.append(f" ... {len(group) - limit} more")
2075
+ if any(t.get("candidate") for k in ("direct", "transitive") for t in res[k]):
2076
+ L += ["", CANDIDATE_NOTE]
2077
+ if more:
2078
+ L += ["", "not listed: " + more + " (widen with --max-depth N or 0 for any depth, --through-roots, no --unit-only)"]
2079
+ if not res["direct"] and not res["transitive"] and not res.get("ui"):
2080
+ if st["tests_in_graph"]:
2081
+ why = (f" ({st['tests_in_graph']} test cases are indexed; none calls the target, directly or through"
2082
+ " application code)")
2083
+ elif st.get("test_files"):
2084
+ why = (f" (the graph has {st['test_files']} test files but no recognised test cases; the supported"
2085
+ " frameworks are listed in docs/channels-and-tests.md)")
2086
+ else:
2087
+ why = " (the graph has no test code: no test files or test cases were indexed)"
2088
+ L.append("no indexed test reaches the target" + why)
2089
+ return "\n".join(L)