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/realtime.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Realtime channel queries: who can join a channel, which events publish on it, which client code listens."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fnmatch
|
|
5
|
+
import json
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .core.store import GraphStore
|
|
10
|
+
from .query import short_id
|
|
11
|
+
from .plugins.php.strings import channel_match
|
|
12
|
+
|
|
13
|
+
CHECK_KINDS = ("CALLS", "HANDLED_BY", "READS_COLUMN", "READS_TABLE", "READS_CONFIG", "READS_ENV", "READS_SETTING",
|
|
14
|
+
"MENTIONS_COLUMN", "USES_CONNECTION")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _attrs(r) -> dict:
|
|
18
|
+
return json.loads(r["attrs"] or "{}") if r["attrs"] else {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def source_path(st: GraphStore, file: str | None) -> Path | None:
|
|
22
|
+
"""Absolute path of a graph file (single-repo DB: meta root; combined DB: the repo's recorded root)."""
|
|
23
|
+
if not file:
|
|
24
|
+
return None
|
|
25
|
+
m = st.meta()
|
|
26
|
+
if m.get("root"):
|
|
27
|
+
p = Path(m["root"]) / file
|
|
28
|
+
return p if p.exists() else None
|
|
29
|
+
repo, _, rel = file.partition("/")
|
|
30
|
+
src = (m.get("sources") or {}).get(repo)
|
|
31
|
+
if src and Path(src).exists():
|
|
32
|
+
root = GraphStore(src).meta().get("root")
|
|
33
|
+
if root and (Path(root) / rel).exists():
|
|
34
|
+
return Path(root) / rel
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def excerpt(st: GraphStore, file: str | None, lo: int | None, hi: int | None, max_lines=14) -> list[str]:
|
|
39
|
+
p = source_path(st, file)
|
|
40
|
+
if not p or not lo:
|
|
41
|
+
return []
|
|
42
|
+
try:
|
|
43
|
+
lines = p.read_text(errors="replace").splitlines()
|
|
44
|
+
except OSError:
|
|
45
|
+
return []
|
|
46
|
+
hi = min(hi or lo, lo + max_lines - 1)
|
|
47
|
+
return [f"{i:5} {lines[i - 1]}" for i in range(lo, min(hi, len(lines)) + 1)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _select(rows, pattern: str | None):
|
|
51
|
+
if not pattern:
|
|
52
|
+
return rows
|
|
53
|
+
p = pattern[len("channel:"):] if pattern.startswith("channel:") else pattern
|
|
54
|
+
out = []
|
|
55
|
+
for r in rows:
|
|
56
|
+
name = r["name"]
|
|
57
|
+
if name == p or fnmatch.fnmatchcase(name, p) or (("*" not in p) and (channel_match(p, name) or channel_match(name, p))) \
|
|
58
|
+
or (len(p) > 2 and "*" not in p and "{" not in p and p.lower() in name.lower()):
|
|
59
|
+
out.append(r)
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def channels(st: GraphStore, pattern: str | None = None, with_source: bool = True) -> dict:
|
|
64
|
+
"""Backend channels (declared + published-only) and client subscriptions, each with:
|
|
65
|
+
auth (auth routes + middleware, callback location, checks it calls), publishers (events, dispatch sites, entry
|
|
66
|
+
points), subscribers (client code / pages and the events they listen for) and flags."""
|
|
67
|
+
rows = [dict(r) for r in st.q("SELECT * FROM nodes WHERE kind='channel' ORDER BY id")]
|
|
68
|
+
subs_all = [dict(r) for r in st.q("SELECT * FROM nodes WHERE kind='channel_sub' ORDER BY id")]
|
|
69
|
+
has_frontend = bool(subs_all)
|
|
70
|
+
has_backend = bool(rows)
|
|
71
|
+
out = []
|
|
72
|
+
for n in _select(rows, pattern):
|
|
73
|
+
a = _attrs(n)
|
|
74
|
+
nid = n["id"]
|
|
75
|
+
item = {"id": nid, "pattern": a.get("pattern") or n["name"], "declared": bool(a.get("declared")),
|
|
76
|
+
"visibility": a.get("visibility"), "at": f"{n['file']}:{n['line']}" if n["file"] else None, "flags": []}
|
|
77
|
+
# ---- who can join
|
|
78
|
+
auth = {"callback": a.get("callback"), "handler": a.get("handler"), "guards": a.get("guards"), "routes": [], "checks": []}
|
|
79
|
+
for r in st.q("""SELECT e.src, n.attrs, n.file, n.line FROM edges e JOIN nodes n ON n.id=e.src
|
|
80
|
+
WHERE e.dst=? AND e.kind='AUTHORIZES_CHANNEL'""", (nid,)):
|
|
81
|
+
ra = _attrs(r)
|
|
82
|
+
auth["routes"].append({"route": r["src"], "middleware": ra.get("middleware"), "at": f"{r['file']}:{r['line']}",
|
|
83
|
+
"synthesized": ra.get("synthesized")})
|
|
84
|
+
kq = ",".join("?" * len(CHECK_KINDS))
|
|
85
|
+
srcs = [nid] + [r["dst"] for r in st.q("SELECT dst FROM edges WHERE src=? AND kind='HANDLED_BY'", (nid,))]
|
|
86
|
+
for s in srcs:
|
|
87
|
+
for r in st.q(f"SELECT dst, kind, file, line, confidence FROM edges WHERE src=? AND kind IN ({kq}) ORDER BY line", (s, *CHECK_KINDS)):
|
|
88
|
+
auth["checks"].append({"via": s, "kind": r["kind"], "target": r["dst"], "at": f"{r['file']}:{r['line']}",
|
|
89
|
+
"confidence": r["confidence"]})
|
|
90
|
+
if with_source and n["file"]:
|
|
91
|
+
auth["source"] = excerpt(st, n["file"], n["line"], n["end_line"])
|
|
92
|
+
for s in srcs[1:]:
|
|
93
|
+
m = st.node(s)
|
|
94
|
+
if m:
|
|
95
|
+
auth["source"] += [""] + excerpt(st, m["file"], m["line"], m["end_line"])
|
|
96
|
+
item["auth"] = auth
|
|
97
|
+
# ---- who publishes
|
|
98
|
+
pubs = []
|
|
99
|
+
for r in st.q("""SELECT e.src, e.file, e.line, e.attrs, e.confidence FROM edges e WHERE e.dst=? AND e.kind='BROADCASTS_ON'
|
|
100
|
+
ORDER BY e.src, e.file, e.line""", (nid,)):
|
|
101
|
+
ea = _attrs(r)
|
|
102
|
+
p = {"event": r["src"], "name": ea.get("name"), "visibility": ea.get("visibility"), "at": ea.get("at"),
|
|
103
|
+
"site": ea.get("site"), "confidence": r["confidence"], "dispatched_by": []}
|
|
104
|
+
q = "SELECT src, file, line FROM edges WHERE dst=? AND kind='DISPATCHES'"
|
|
105
|
+
for d in st.q(q, (r["src"],)):
|
|
106
|
+
if ea.get("site_fn") and d["src"] != ea["site_fn"]:
|
|
107
|
+
continue
|
|
108
|
+
ents = {e["entry_kind"]: (e["entry_count"], e["sample_entry"]) for e in
|
|
109
|
+
st.q("SELECT entry_kind, entry_count, sample_entry FROM node_entry WHERE node_id=?", (d["src"],))}
|
|
110
|
+
p["dispatched_by"].append({"fn": d["src"], "at": f"{d['file']}:{d['line']}",
|
|
111
|
+
"entry_kinds": {k: v[0] for k, v in ents.items()},
|
|
112
|
+
"sample_entry": next((v[1] for v in ents.values()), None)})
|
|
113
|
+
pubs.append(p)
|
|
114
|
+
item["publishers"] = pubs
|
|
115
|
+
# ---- who listens (linked frontend)
|
|
116
|
+
subs = []
|
|
117
|
+
for r in st.q("""SELECT e.src, e.confidence, e.attrs AS eattrs, n.attrs, n.name FROM edges e JOIN nodes n ON n.id=e.src
|
|
118
|
+
WHERE e.dst=? AND e.kind='MATCHES_CHANNEL'""", (nid,)):
|
|
119
|
+
sub = _subscription(st, r["src"], _attrs(r), r["confidence"])
|
|
120
|
+
mm = json.loads(r["eattrs"] or "{}").get("visibility_mismatch")
|
|
121
|
+
if mm:
|
|
122
|
+
sub["flag"] = f"VISIBILITY MISMATCH: {mm}"
|
|
123
|
+
subs.append(sub)
|
|
124
|
+
item["subscribers"] = subs
|
|
125
|
+
# ---- flags
|
|
126
|
+
if not item["declared"] and (item["visibility"] or "private") != "public":
|
|
127
|
+
item["flags"].append("UNDECLARED: published on a private/presence channel with no Broadcast::channel "
|
|
128
|
+
"callback, so every client subscription is rejected")
|
|
129
|
+
pub_public = sorted({p["event"] for p in pubs if p.get("visibility") == "public"})
|
|
130
|
+
if item["declared"] and pub_public:
|
|
131
|
+
item["flags"].append(f"PUBLIC PUBLISH: {len(pub_public)} event(s) publish on a public Channel with this name "
|
|
132
|
+
f"(e.g. {short_id(pub_public[0])}); the authorization callback only guards private-/presence- "
|
|
133
|
+
"subscriptions, so anyone can listen to these")
|
|
134
|
+
if item["declared"] and not auth["routes"]:
|
|
135
|
+
item["flags"].append("no broadcasting auth route found (Broadcast::routes / withBroadcasting / explicit route)")
|
|
136
|
+
if not pubs:
|
|
137
|
+
item["flags"].append("no indexed event publishes on this channel")
|
|
138
|
+
if has_frontend and not subs:
|
|
139
|
+
item["flags"].append("no client subscription matches this channel")
|
|
140
|
+
out.append(item)
|
|
141
|
+
orphans = []
|
|
142
|
+
if has_frontend:
|
|
143
|
+
linked = {r["src"] for r in st.q("SELECT src FROM edges WHERE kind='MATCHES_CHANNEL'")}
|
|
144
|
+
for s in _select(subs_all, pattern) if pattern else subs_all:
|
|
145
|
+
if s["id"] in linked:
|
|
146
|
+
continue
|
|
147
|
+
o = _subscription(st, s["id"], _attrs(s), None)
|
|
148
|
+
if has_backend:
|
|
149
|
+
o["flag"] = "no backend channel matches this subscription"
|
|
150
|
+
orphans.append(o)
|
|
151
|
+
return {"pattern": pattern, "channels": out, "client_subscriptions_unmatched": orphans,
|
|
152
|
+
"has_backend": has_backend, "has_frontend": has_frontend}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _subscription(st, sid, a, conf):
|
|
156
|
+
sub = {"subscription": sid, "name": a.get("name") or sid.split(":", 1)[1], "visibility": a.get("visibility"),
|
|
157
|
+
"events": a.get("events") or [], "confidence": conf, "subscribed_in": [], "pages": [], "listens_for": []}
|
|
158
|
+
for r in st.q("SELECT src, file, line, confidence FROM edges WHERE dst=? AND kind='SUBSCRIBES_CHANNEL' ORDER BY file, line", (sid,)):
|
|
159
|
+
sub["subscribed_in"].append({"fn": r["src"], "at": f"{r['file']}:{r['line']}", "confidence": r["confidence"]})
|
|
160
|
+
for r in st.q("SELECT dst, confidence FROM edges WHERE src=? AND kind='LISTENS_FOR'", (sid,)):
|
|
161
|
+
sub["listens_for"].append(r["dst"])
|
|
162
|
+
pages = set()
|
|
163
|
+
for s in sub["subscribed_in"]:
|
|
164
|
+
for r in st.q("SELECT sample_entry, entry_kind FROM node_entry WHERE node_id=? AND entry_kind IN ('ui_page','ui_global')", (s["fn"],)):
|
|
165
|
+
pages.add(r["sample_entry"])
|
|
166
|
+
n = st.node(s["fn"])
|
|
167
|
+
if n and n["entry_kind"] in ("ui_page", "ui_global"):
|
|
168
|
+
pages.add(s["fn"])
|
|
169
|
+
if sub["subscribed_in"]:
|
|
170
|
+
from .query import reverse_closure
|
|
171
|
+
dep = reverse_closure(st, [s["fn"] for s in sub["subscribed_in"]], max_depth=12)
|
|
172
|
+
if dep:
|
|
173
|
+
q = ",".join("?" * len(dep))
|
|
174
|
+
for r in st.q(f"SELECT id FROM nodes WHERE id IN ({q}) AND kind IN ('page','layout','app')", list(dep)):
|
|
175
|
+
pages.add(r["id"])
|
|
176
|
+
sub["pages"] = sorted(pages)
|
|
177
|
+
return sub
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def render_channels(res: dict, max_items=40) -> str:
|
|
181
|
+
from .query import short_id
|
|
182
|
+
L = []
|
|
183
|
+
chans = res["channels"]
|
|
184
|
+
if not chans and not res["client_subscriptions_unmatched"]:
|
|
185
|
+
return f"no channel matches {res['pattern']!r}" if res["pattern"] else "no broadcast channels in this graph"
|
|
186
|
+
detail = len(chans) <= 3 and res["pattern"]
|
|
187
|
+
if not detail:
|
|
188
|
+
L.append(f"channels: {len(chans)}" + (f" matching {res['pattern']!r}" if res["pattern"] else ""))
|
|
189
|
+
for c in chans[:max_items]:
|
|
190
|
+
au = c["auth"]
|
|
191
|
+
who = au.get("handler") or au.get("callback") or ("public: no auth" if c["visibility"] == "public" else "NO CALLBACK")
|
|
192
|
+
checks = sorted({short_id(x["target"]) for x in au["checks"] if x["kind"] in ("CALLS", "HANDLED_BY")})
|
|
193
|
+
L.append(f" {c['pattern']} [{c['visibility'] or '?'}{'' if c['declared'] else ', undeclared'}] auth: {who}"
|
|
194
|
+
+ (f" checks: {', '.join(checks[:4])}" if checks else ""))
|
|
195
|
+
L.append(f" publishers: {len(c['publishers'])} ({', '.join(sorted({short_id(p['event']) for p in c['publishers']})[:4])})"
|
|
196
|
+
+ (f" subscribers: {len(c['subscribers'])}" if res["has_frontend"] else ""))
|
|
197
|
+
for fl in c["flags"]:
|
|
198
|
+
if not fl.startswith("no indexed event") and not fl.startswith("no client"):
|
|
199
|
+
L.append(f" ! {fl}")
|
|
200
|
+
if len(chans) > max_items:
|
|
201
|
+
L.append(f" ... {len(chans) - max_items} more")
|
|
202
|
+
else:
|
|
203
|
+
for c in chans:
|
|
204
|
+
au = c["auth"]
|
|
205
|
+
L.append(f"== channel {c['pattern']} [{c['visibility'] or '?'}{'' if c['declared'] else ', UNDECLARED'}]"
|
|
206
|
+
+ (f" @ {c['at']}" if c["at"] else ""))
|
|
207
|
+
L.append("WHO CAN JOIN")
|
|
208
|
+
if c["visibility"] == "public":
|
|
209
|
+
L.append(" public channel: anyone can subscribe (no authorization)")
|
|
210
|
+
for r in au["routes"]:
|
|
211
|
+
L.append(f" auth route {short_id(r['route'])} middleware={r['middleware']}"
|
|
212
|
+
+ (f" (from {r['synthesized']})" if r.get("synthesized") else f" @ {r['at']}"))
|
|
213
|
+
if au.get("guards"):
|
|
214
|
+
L.append(f" guards: {au['guards']}")
|
|
215
|
+
if au.get("handler"):
|
|
216
|
+
L.append(f" channel class {au['handler']} (join())")
|
|
217
|
+
elif au.get("callback"):
|
|
218
|
+
L.append(f" callback {au['callback']}")
|
|
219
|
+
elif c["declared"] is False and c["visibility"] != "public":
|
|
220
|
+
L.append(" no Broadcast::channel callback: private subscriptions are rejected")
|
|
221
|
+
for x in au["checks"]:
|
|
222
|
+
L.append(f" {x['kind']} {short_id(x['target'])} @ {x['at']} [{x['confidence']}]")
|
|
223
|
+
if au.get("source"):
|
|
224
|
+
L += [" source:"] + [" " + s for s in au["source"]]
|
|
225
|
+
L.append(f"PUBLISHED BY ({len(c['publishers'])})")
|
|
226
|
+
for p in c["publishers"]:
|
|
227
|
+
L.append(f" {short_id(p['event'])} name={p['name']} [{p['visibility']}] broadcastOn @ {p['at']}"
|
|
228
|
+
+ (f" value from {p['site']}" if p.get("site") else ""))
|
|
229
|
+
for d in p["dispatched_by"][:8]:
|
|
230
|
+
ek = ", ".join(f"{k}({v})" for k, v in sorted(d["entry_kinds"].items()))
|
|
231
|
+
L.append(f" dispatched by {short_id(d['fn'])} @ {d['at']} {ek}"
|
|
232
|
+
+ (f" e.g. {d['sample_entry']}" if d.get("sample_entry") else ""))
|
|
233
|
+
if res["has_frontend"]:
|
|
234
|
+
L.append(f"LISTENED TO BY ({len(c['subscribers'])})")
|
|
235
|
+
for s in c["subscribers"]:
|
|
236
|
+
L += _render_sub(s)
|
|
237
|
+
for fl in c["flags"]:
|
|
238
|
+
L.append(f"! {fl}")
|
|
239
|
+
L.append("")
|
|
240
|
+
if res["client_subscriptions_unmatched"]:
|
|
241
|
+
L.append(f"client subscriptions without a backend channel: {len(res['client_subscriptions_unmatched'])}"
|
|
242
|
+
if res["has_backend"] else f"client subscriptions: {len(res['client_subscriptions_unmatched'])}")
|
|
243
|
+
for s in res["client_subscriptions_unmatched"][:max_items]:
|
|
244
|
+
L += _render_sub(s)
|
|
245
|
+
return "\n".join(L).rstrip()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _render_sub(s) -> list[str]:
|
|
249
|
+
from .query import short_id
|
|
250
|
+
L = [f" {s['name']} [{s['visibility']}]" + (f" events: {', '.join(s['events'][:6])}" if s["events"] else "")
|
|
251
|
+
+ (f" ({s['confidence']})" if s.get("confidence") else "")]
|
|
252
|
+
for x in s["subscribed_in"][:6]:
|
|
253
|
+
L.append(f" subscribed in {short_id(x['fn'])} @ {x['at']}")
|
|
254
|
+
if s["pages"]:
|
|
255
|
+
L.append(f" pages: {', '.join(short_id(p) for p in s['pages'][:8])}" + (" ..." if len(s["pages"]) > 8 else ""))
|
|
256
|
+
if s["listens_for"]:
|
|
257
|
+
L.append(f" listens for backend events: {', '.join(short_id(e) for e in s['listens_for'][:6])}")
|
|
258
|
+
if s.get("flag"):
|
|
259
|
+
L.append(f" ! {s['flag']}")
|
|
260
|
+
return L
|
codegraph/roundtrip.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""`cg roundtrip Type.prop` (#88 phase 2): state that round-trips through a lossy transform.
|
|
2
|
+
|
|
3
|
+
For each write site of a stored property (WRITES_PROP), report whether the written expression passes through a lossy
|
|
4
|
+
call (clamp, min / max, round / floor / ceil, truncating casts, quantize, `fit*` / gamut-map names, the project's
|
|
5
|
+
`.cg.yaml` `lossy:` names, functions tagged `@cg-lossy` in their doc comment). The data flow followed is the
|
|
6
|
+
statement itself, a local assigned earlier in the same function, and one hop through direct callers when the written
|
|
7
|
+
value is a parameter. For each read site (READS_PROP), report whether it seeds UI state: an initializer /
|
|
8
|
+
constructor, `onAppear` / `.task`, `remember` / `mutableStateOf`, `useState(initial)` / `useRef`, `mounted` /
|
|
9
|
+
`onMounted`, `State(initialValue:)`, or a view constructed on the same line. A read in a file that also draws a range
|
|
10
|
+
wider than the lossy bounds (`0...100` against `clamp(v, 0, 10)`) is flagged.
|
|
11
|
+
|
|
12
|
+
Everything here is a heuristic finding with file:line evidence, read from the indexed source text; it never adds
|
|
13
|
+
edges or changes counts. Proving the bug is a non-goal."""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import fnmatch
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import query as Q
|
|
23
|
+
from .core.store import GraphStore
|
|
24
|
+
|
|
25
|
+
# call names (or name globs) that narrow a value
|
|
26
|
+
DEFAULT_LOSSY = ("clamp*", "clamped", "coerceIn", "coerceAtMost", "coerceAtLeast", "min", "max", "fmin", "fmax",
|
|
27
|
+
"round*", "floor", "ceil", "trunc*", "toInt", "toLong", "toShort", "toByte", "roundToInt",
|
|
28
|
+
"roundToLong", "Int", "Int8", "Int16", "Int32", "UInt8", "UInt16", "int", "quantiz*", "fit*",
|
|
29
|
+
"gamut*", "narrow*", "snap*", "limit*", "bound*", "constrain*", "saturate*")
|
|
30
|
+
CALL_RE = re.compile(r"(?<![\w$.])([A-Za-z_$][\w$]*)\s*(?:<[^<>()]*>)?\s*\(|\.([A-Za-z_]\w*)\s*\(|\.(toInt|roundToInt|toLong)\b")
|
|
31
|
+
NUM_RE = re.compile(r"(?<![\w])(-?\d+(?:\.\d+)?)[fFdDL]?(?![\w])")
|
|
32
|
+
# a drawn / accepted range: Swift `0...100` / `0..<100`, Kotlin `0f..100f` / `valueRange = 0..100`, JSX / HTML
|
|
33
|
+
# `min={0} max={100}`, Python `range(0, 100)` is not a UI range and is skipped
|
|
34
|
+
RANGE_RES = (re.compile(r"(-?\d+(?:\.\d+)?)[fFdD]?\s*\.\.(?:\.|<)?\s*(-?\d+(?:\.\d+)?)"),
|
|
35
|
+
re.compile(r"\bmin\s*[:=]\s*\{?\s*(-?\d+(?:\.\d+)?)\s*\}?[\s,]+max\s*[:=]\s*\{?\s*(-?\d+(?:\.\d+)?)"),
|
|
36
|
+
re.compile(r"\b(?:from|minimum|minValue|valueFrom)\s*[:=]\s*(-?\d+(?:\.\d+)?)[^\n]{0,40}?\b(?:to|through|maximum|maxValue|valueTo)\s*[:=]\s*(-?\d+(?:\.\d+)?)"))
|
|
37
|
+
SEED_NAMES = {"init", "__init__", "constructor", "<init>", "mounted", "created", "setup", "beforeMount", "onCreate",
|
|
38
|
+
"viewDidLoad", "awakeFromNib", "initState"}
|
|
39
|
+
SEED_TEXT = (("onAppear", re.compile(r"\.onAppear\b|\bonAppear\s*\(")), ("task", re.compile(r"\.task\s*[({]")),
|
|
40
|
+
("remember", re.compile(r"\bremember(?:Saveable)?\s*[({]|\bmutableStateOf\s*\(")),
|
|
41
|
+
("useState(initial)", re.compile(r"\buse(?:State|Ref|Reducer)\s*\(")),
|
|
42
|
+
("mounted", re.compile(r"\bonMounted\s*\(|\bmounted\s*\(")),
|
|
43
|
+
("State(initialValue:)", re.compile(r"\b(?:State|StateObject|Binding)\s*\(\s*(?:initialValue|wrappedValue)\s*:")),
|
|
44
|
+
("ref(initial)", re.compile(r"\b(?:ref|shallowRef|reactive)\s*\(")),
|
|
45
|
+
("LaunchedEffect", re.compile(r"\bLaunchedEffect\s*\(")))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def lossy_names(st: GraphStore, root: str | None) -> tuple[list[str], list[str]]:
|
|
49
|
+
"""(name globs, sources): built-in defaults, `.cg.yaml` `lossy:`, functions tagged `@cg-lossy`."""
|
|
50
|
+
names, src = list(DEFAULT_LOSSY), ["built-in"]
|
|
51
|
+
if root:
|
|
52
|
+
try:
|
|
53
|
+
from . import config
|
|
54
|
+
extra = config.load(root).get("lossy") or []
|
|
55
|
+
except Exception: # noqa: BLE001 - a broken config is reported by `cg index`, not here
|
|
56
|
+
extra = []
|
|
57
|
+
if extra:
|
|
58
|
+
names += extra
|
|
59
|
+
src.append(".cg.yaml lossy")
|
|
60
|
+
tagged = [r["name"] for r in st.q("SELECT DISTINCT name FROM nodes WHERE kind IN ('function','method') AND doc LIKE '%@cg-lossy%'")]
|
|
61
|
+
if tagged:
|
|
62
|
+
names += tagged
|
|
63
|
+
src.append("@cg-lossy")
|
|
64
|
+
return names, src
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Src:
|
|
68
|
+
def __init__(self, root: str | None):
|
|
69
|
+
self.root, self.cache = root, {}
|
|
70
|
+
|
|
71
|
+
def lines(self, rel: str | None) -> list[str]:
|
|
72
|
+
if not rel or not self.root:
|
|
73
|
+
return []
|
|
74
|
+
if rel not in self.cache:
|
|
75
|
+
try:
|
|
76
|
+
self.cache[rel] = Path(self.root, rel).read_text("utf-8", "replace").split("\n")
|
|
77
|
+
except OSError:
|
|
78
|
+
self.cache[rel] = []
|
|
79
|
+
return self.cache[rel]
|
|
80
|
+
|
|
81
|
+
def stmt(self, rel: str, line: int, max_lines: int = 6) -> str:
|
|
82
|
+
"""The statement starting at `line`, continued while brackets are open."""
|
|
83
|
+
ls = self.lines(rel)
|
|
84
|
+
out, depth = [], 0
|
|
85
|
+
for i in range(line - 1, min(len(ls), line - 1 + max_lines)):
|
|
86
|
+
t = ls[i]
|
|
87
|
+
out.append(t)
|
|
88
|
+
depth += sum(t.count(c) for c in "([{") - sum(t.count(c) for c in ")]}")
|
|
89
|
+
if depth <= 0:
|
|
90
|
+
break
|
|
91
|
+
return "\n".join(out)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _lossy_calls(text: str, globs: list[str]) -> list[str]:
|
|
95
|
+
out = []
|
|
96
|
+
for m in CALL_RE.finditer(text):
|
|
97
|
+
nm = m.group(1) or m.group(2) or m.group(3)
|
|
98
|
+
if nm and any(fnmatch.fnmatchcase(nm, g) for g in globs) and nm not in out:
|
|
99
|
+
out.append(nm)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _rhs(stmt: str, prop: str) -> str:
|
|
104
|
+
"""The written expression: after `prop =` / `prop +=` (or after the first `=`), else the call arguments."""
|
|
105
|
+
m = re.search(rf"{re.escape(prop)}\s*(?:\[[^\]]*\]\s*)?(?:[-+*/%|&^]|\?\?)?=(?!=)(.*)", stmt, re.S)
|
|
106
|
+
if m:
|
|
107
|
+
return m.group(1)
|
|
108
|
+
m = re.search(r"(?<![=!<>])=(?!=)(.*)", stmt, re.S)
|
|
109
|
+
if m:
|
|
110
|
+
return m.group(1)
|
|
111
|
+
m = re.search(r"\((.*)\)", stmt, re.S)
|
|
112
|
+
return m.group(1) if m else stmt
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _bounds(text: str) -> tuple[float, float] | None:
|
|
116
|
+
nums = [float(x) for x in NUM_RE.findall(text)]
|
|
117
|
+
return (min(nums), max(nums)) if len(nums) >= 2 and min(nums) != max(nums) else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _fn_bounds(st: GraphStore, src: _Src, name: str) -> tuple[float, float] | None:
|
|
121
|
+
"""Bounds inside a project function `name` (`func clamp(_ v: Double) -> Double { min(max(v, 0), 10) }`)."""
|
|
122
|
+
for r in st.q("SELECT file, line, end_line FROM nodes WHERE kind IN ('function','method') AND name=? LIMIT 3", (name,)):
|
|
123
|
+
ls = src.lines(r["file"])
|
|
124
|
+
body = "\n".join(ls[(r["line"] or 1) - 1:(r["end_line"] or r["line"] or 1)])
|
|
125
|
+
b = _bounds(body.split("{", 1)[-1] if "{" in body else body.split(":", 1)[-1])
|
|
126
|
+
if b:
|
|
127
|
+
return b
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _ident(rhs: str) -> list[str]:
|
|
132
|
+
return [w for w in dict.fromkeys(re.findall(r"(?<![\w$.])([A-Za-z_$][\w$]*)(?!\s*\()", rhs))
|
|
133
|
+
if w not in ("self", "this", "true", "false", "nil", "null", "None", "True", "False", "it", "new", "let",
|
|
134
|
+
"var", "val", "const", "await", "try", "return")]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _write_site(st, src, r, prop, globs) -> dict:
|
|
138
|
+
stmt = src.stmt(r["file"], r["line"])
|
|
139
|
+
rhs = _rhs(stmt, prop)
|
|
140
|
+
site = {"at": f"{r['file']}:{r['line']}", "writer": r["fqn"] or r["src"], "code": stmt.strip()[:160],
|
|
141
|
+
"test": bool(r.get("test")), "lossy": None, "_span": (r["src"], r["line"], r["line"] + stmt.count("\n"))}
|
|
142
|
+
hit = _lossy_calls(rhs, globs)
|
|
143
|
+
if hit:
|
|
144
|
+
site["lossy"] = {"calls": hit, "how": "in the written expression", "at": site["at"], "code": rhs.strip()[:120],
|
|
145
|
+
"bounds": _bounds(rhs) or _fn_bounds(st, src, hit[0])}
|
|
146
|
+
return site
|
|
147
|
+
node = st.node(r["src"])
|
|
148
|
+
if node is None or not node["line"]:
|
|
149
|
+
return site
|
|
150
|
+
ls = src.lines(r["file"])
|
|
151
|
+
lo, hi = node["line"], min(r["line"], node["end_line"] or r["line"])
|
|
152
|
+
for v in _ident(rhs):
|
|
153
|
+
# a local assigned earlier in the same function
|
|
154
|
+
for i in range(hi - 1, lo - 1, -1):
|
|
155
|
+
t = ls[i - 1] if 0 < i <= len(ls) else ""
|
|
156
|
+
m = re.search(rf"(?:\b(?:let|var|val|const|auto)\s+)?(?<![\w$.]){re.escape(v)}\s*(?::[^=]+)?=(?!=)(.*)", t)
|
|
157
|
+
if m and i != r["line"]:
|
|
158
|
+
h = _lossy_calls(m.group(1), globs)
|
|
159
|
+
if h:
|
|
160
|
+
site["lossy"] = {"calls": h, "how": f"local `{v}`", "at": f"{r['file']}:{i}", "code": t.strip()[:120],
|
|
161
|
+
"bounds": _bounds(m.group(1)) or _fn_bounds(st, src, h[0])}
|
|
162
|
+
return site
|
|
163
|
+
break
|
|
164
|
+
# a parameter: one hop through the direct callers
|
|
165
|
+
sig = "\n".join(ls[lo - 1:lo + 2])
|
|
166
|
+
sig = sig.split("{", 1)[0] if "{" in sig else sig.split("\n", 1)[0]
|
|
167
|
+
if re.search(rf"(?<![\w$]){re.escape(v)}\b", sig):
|
|
168
|
+
for c in st.q("SELECT src, file, line FROM edges WHERE dst=? AND kind IN ('CALLS','TEST_CALLS') ORDER BY file, line LIMIT 40",
|
|
169
|
+
(r["src"],)):
|
|
170
|
+
ct = src.stmt(c["file"], c["line"])
|
|
171
|
+
h = _lossy_calls(_call_args(st, ct, r["src"], v), globs)
|
|
172
|
+
if h:
|
|
173
|
+
site["lossy"] = {"calls": h, "how": f"parameter `{v}`, from caller {c['src']}", "at": f"{c['file']}:{c['line']}",
|
|
174
|
+
"code": ct.strip()[:120], "bounds": _bounds(ct) or _fn_bounds(st, src, h[0])}
|
|
175
|
+
return site
|
|
176
|
+
return site
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _call_args(st, stmt: str, callee: str, param: str) -> str:
|
|
180
|
+
"""The argument text a caller statement passes for `param` of `callee` (the labelled argument when there is one),
|
|
181
|
+
so a lossy call elsewhere in the statement (`rooms[Int(i)] = Room(r)`) is not blamed on it. Falls back to the
|
|
182
|
+
whole statement when the call is not found."""
|
|
183
|
+
node = st.node(callee)
|
|
184
|
+
nm = re.split(r"[.#:]", (node["fqn"] or node["name"] or "") if node is not None else callee.split(":", 1)[-1])
|
|
185
|
+
short = nm[-1] if nm[-1] not in ("init", "__init__", "constructor", "<init>") else (nm[-2] if len(nm) > 1 else "")
|
|
186
|
+
m = re.search(rf"(?<![\w$]){re.escape(short)}\s*\(", stmt) if short else None
|
|
187
|
+
if not m:
|
|
188
|
+
return stmt
|
|
189
|
+
depth, i = 1, m.end()
|
|
190
|
+
while i < len(stmt) and depth:
|
|
191
|
+
depth += {"(": 1, "[": 1, "{": 1, ")": -1, "]": -1, "}": -1}.get(stmt[i], 0)
|
|
192
|
+
i += 1
|
|
193
|
+
args = stmt[m.end():i - 1]
|
|
194
|
+
lab = re.search(rf"(?<![\w$]){re.escape(param)}\s*[:=](?![:=])\s*", args)
|
|
195
|
+
if lab:
|
|
196
|
+
rest, depth, j = args[lab.end():], 0, 0
|
|
197
|
+
while j < len(rest) and not (rest[j] == "," and depth == 0):
|
|
198
|
+
depth += {"(": 1, "[": 1, "{": 1, ")": -1, "]": -1, "}": -1}.get(rest[j], 0)
|
|
199
|
+
j += 1
|
|
200
|
+
return rest[:j]
|
|
201
|
+
return args
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _read_site(st, src, r) -> dict:
|
|
205
|
+
node = st.node(r["src"])
|
|
206
|
+
ls = src.lines(r["file"])
|
|
207
|
+
t = ls[r["line"] - 1] if 0 < r["line"] <= len(ls) else ""
|
|
208
|
+
seed = None
|
|
209
|
+
nm = (node["name"] if node is not None else "") or ""
|
|
210
|
+
if nm.split(".")[-1] in SEED_NAMES:
|
|
211
|
+
seed = f"initializer ({nm.split('.')[-1]})"
|
|
212
|
+
elif node is not None and node["kind"] == "class":
|
|
213
|
+
seed = _class_level_seed(ls, r["line"])
|
|
214
|
+
if seed is None:
|
|
215
|
+
ctx = "\n".join(ls[max(0, r["line"] - 4):r["line"]])
|
|
216
|
+
for label, rx in SEED_TEXT:
|
|
217
|
+
if rx.search(t) or (label in ("onAppear", "task", "mounted", "LaunchedEffect") and rx.search(ctx)):
|
|
218
|
+
seed = label
|
|
219
|
+
break
|
|
220
|
+
if seed is None:
|
|
221
|
+
ins = st.q("SELECT dst FROM edges WHERE src=? AND line=? AND kind IN ('INSTANTIATES','RENDERS') LIMIT 1", (r["src"], r["line"]))
|
|
222
|
+
if ins:
|
|
223
|
+
seed = f"constructs {ins[0]['dst']}"
|
|
224
|
+
return {"at": f"{r['file']}:{r['line']}", "reader": r["fqn"] or r["src"], "code": t.strip()[:160],
|
|
225
|
+
"test": bool(r.get("test")), "seeds": seed, "_src": (r["src"], r["line"])}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
PROP_INIT_RE = re.compile(r"^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:(?:private|public|protected|internal|readonly|override|static|"
|
|
229
|
+
r"lateinit|final|open)\s+)*(?:val|var|let|const)?\s*[#\w]+\s*(?::[^=()]+)?=(?!=)")
|
|
230
|
+
INIT_BLOCK_RE = re.compile(r"^\s*(?:init\s*\{|(?:public\s+|private\s+)?constructor\s*\(|(?:convenience\s+)?init\s*[(?!])")
|
|
231
|
+
METHOD_RE = re.compile(r"^\s*(?:@\w+\s+)*(?:(?:private|public|protected|internal|override|static|async|open|final)\s+)*"
|
|
232
|
+
r"(?:get|set|fun|func|function|def)?\s*[\w$]+\s*(?:<[^>]*>)?\s*\([^)]*\)\s*(?::\s*[^{=]+)?\s*\{\s*$")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _class_level_seed(ls: list[str], line: int) -> str | None:
|
|
236
|
+
"""A read attributed to the class itself: a property initializer or an `init` / constructor block seeds state;
|
|
237
|
+
a getter / method body (whose code some languages also attribute to the class) does not."""
|
|
238
|
+
for i in range(line, max(0, line - 15), -1):
|
|
239
|
+
t = ls[i - 1] if i <= len(ls) else ""
|
|
240
|
+
if INIT_BLOCK_RE.search(t):
|
|
241
|
+
return "initializer (init block)"
|
|
242
|
+
if METHOD_RE.search(t):
|
|
243
|
+
return None
|
|
244
|
+
if PROP_INIT_RE.search(t):
|
|
245
|
+
return "property initializer"
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _ranges(src, rel: str, b: tuple[float, float]) -> list[dict]:
|
|
250
|
+
out = []
|
|
251
|
+
for i, t in enumerate(src.lines(rel), 1):
|
|
252
|
+
for rx in RANGE_RES:
|
|
253
|
+
for m in rx.finditer(t):
|
|
254
|
+
lo, hi = float(m.group(1)), float(m.group(2))
|
|
255
|
+
if lo < hi and (lo < b[0] or hi > b[1]) and not (lo >= b[1] or hi <= b[0]):
|
|
256
|
+
out.append({"at": f"{rel}:{i}", "range": [lo, hi], "code": t.strip()[:120]})
|
|
257
|
+
return out
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def roundtrip(st: GraphStore, spec: str, include_tests: bool = False) -> dict:
|
|
261
|
+
fids = Q.prop_fields(st, spec)
|
|
262
|
+
root = st.meta().get("root")
|
|
263
|
+
globs, lsrc = lossy_names(st, root)
|
|
264
|
+
src = _Src(root)
|
|
265
|
+
res = {"spec": spec, "fields": fids, "confidence": "heuristic", "lossy_sources": lsrc, "writes": [], "reads": [],
|
|
266
|
+
"findings": []}
|
|
267
|
+
if not fids:
|
|
268
|
+
return res
|
|
269
|
+
prop = re.split(r"[.$:]", spec)[-1]
|
|
270
|
+
for r in Q.prop_access(st, spec, "WRITES_PROP"):
|
|
271
|
+
if include_tests or not r.get("test"):
|
|
272
|
+
res["writes"].append(_write_site(st, src, r, prop, globs))
|
|
273
|
+
for r in Q.prop_access(st, spec, "READS_PROP"):
|
|
274
|
+
if include_tests or not r.get("test"):
|
|
275
|
+
res["reads"].append(_read_site(st, src, r))
|
|
276
|
+
lossy = [w for w in res["writes"] if w["lossy"]]
|
|
277
|
+
seeds = [x for x in res["reads"] if x["seeds"]]
|
|
278
|
+
for w in lossy:
|
|
279
|
+
b = w["lossy"].get("bounds")
|
|
280
|
+
for x in seeds:
|
|
281
|
+
sp, rs = w["_span"], x["_src"]
|
|
282
|
+
if rs[0] == sp[0] and sp[1] <= rs[1] <= sp[2]:
|
|
283
|
+
continue # read inside the write statement itself: not a read-back
|
|
284
|
+
if not _read_back(src, w, x, prop):
|
|
285
|
+
continue
|
|
286
|
+
f = {"kind": "lossy round trip", "confidence": "heuristic", "write": w["at"], "lossy": w["lossy"],
|
|
287
|
+
"read": x["at"], "seeds": x["seeds"], "wider_ranges": []}
|
|
288
|
+
if b:
|
|
289
|
+
f["wider_ranges"] = _ranges(src, x["at"].rsplit(":", 1)[0], tuple(b))[:5]
|
|
290
|
+
res["findings"].append(f)
|
|
291
|
+
for x in res["writes"]:
|
|
292
|
+
x.pop("_span", None)
|
|
293
|
+
for x in res["reads"]:
|
|
294
|
+
x.pop("_src", None)
|
|
295
|
+
return res
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _read_back(src, w, x, prop) -> bool:
|
|
299
|
+
"""Is this read a read-back of the stored value rather than the same setup using what it just built? Not when
|
|
300
|
+
the writer reads it a few lines later (`scrollBar = ScrollBarView(...)` then `addView(scrollBar)` in one init),
|
|
301
|
+
when the property is a callback (`this.onWheel = (e) => ...`), or when it is only used as an index
|
|
302
|
+
(`bands[p.band]`)."""
|
|
303
|
+
wf, wl = w["at"].rsplit(":", 1)
|
|
304
|
+
xf, xl = x["at"].rsplit(":", 1)
|
|
305
|
+
if w.get("writer") and w.get("writer") == x.get("reader") and wf == xf and 0 <= int(xl) - int(wl) <= 40:
|
|
306
|
+
return False
|
|
307
|
+
wt = src.lines(wf)[int(wl) - 1] if 0 < int(wl) <= len(src.lines(wf)) else ""
|
|
308
|
+
if re.search(r"=\s*(?:async\s*)?(?:\([^()]*\)|\w+)\s*=>|=\s*(?:async\s+)?function\b|=\s*\{\s*(?:\[[^\]]*\]\s*)?[\w, ]*\bin\b", wt):
|
|
309
|
+
return False
|
|
310
|
+
xt = src.lines(xf)[int(xl) - 1] if 0 < int(xl) <= len(src.lines(xf)) else ""
|
|
311
|
+
if re.search(rf"\[[^\[\]]*\b{re.escape(prop)}\s*\]", xt) and not re.search(rf"(?<![\[\w.]){re.escape(prop)}\b(?!\s*\])", xt):
|
|
312
|
+
return False
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def render(res: dict) -> str:
|
|
317
|
+
if not res["fields"]:
|
|
318
|
+
return f"no stored property {res['spec']!r} in the graph (try `cg search {res['spec'].split('.')[-1]}`)."
|
|
319
|
+
out = [f"roundtrip {res['spec']} ({', '.join(res['fields'])}) — heuristic; lossy names: {', '.join(res['lossy_sources'])}"]
|
|
320
|
+
out.append(f"writes ({len(res['writes'])}):")
|
|
321
|
+
for w in res["writes"]:
|
|
322
|
+
lz = w["lossy"]
|
|
323
|
+
out.append(f" {w['writer']} @{w['at']}" + (f" LOSSY {'/'.join(lz['calls'])} via {lz['how']} @{lz['at']}"
|
|
324
|
+
+ (f" bounds {_fmt(lz['bounds'])}" if lz.get("bounds") else "")
|
|
325
|
+
if lz else " not narrowed"))
|
|
326
|
+
out.append(f"reads ({len(res['reads'])}):")
|
|
327
|
+
for x in res["reads"]:
|
|
328
|
+
out.append(f" {x['reader']} @{x['at']}" + (f" SEEDS {x['seeds']}" if x["seeds"] else ""))
|
|
329
|
+
if res["findings"]:
|
|
330
|
+
out.append(f"findings ({len(res['findings'])}, heuristic):")
|
|
331
|
+
for f in res["findings"]:
|
|
332
|
+
out.append(f" write @{f['write']} -> {'/'.join(f['lossy']['calls'])} @{f['lossy']['at']} -> {res['spec']} "
|
|
333
|
+
f"-> read @{f['read']} -> seeds {f['seeds']}")
|
|
334
|
+
for g in f["wider_ranges"]:
|
|
335
|
+
out.append(f" un-narrowed range {_fmt(g['range'])} @{g['at']}: {g['code']}")
|
|
336
|
+
else:
|
|
337
|
+
out.append("no lossy round trip found (heuristic: a lossy write and a read that seeds UI state)")
|
|
338
|
+
return "\n".join(out)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _fmt(b) -> str:
|
|
342
|
+
return "…".join(f"{x:g}" for x in b)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def to_json(res: dict) -> str:
|
|
346
|
+
return json.dumps(res, indent=1, default=str)
|