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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- 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)
|