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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
codegraph/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)