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/link.py ADDED
@@ -0,0 +1,434 @@
1
+ """Cross-repo linking: client HTTP endpoints (frontend graph) -> backend Route nodes.
2
+
3
+ link(backend_db, frontend_db, out_db) builds ONE combined SQLite graph:
4
+ * nodes/edges of both graphs (ids are kept; `file` is prefixed with the repo name and
5
+ attrs.repo is set, so evidence stays unambiguous),
6
+ * MATCHES_ROUTE edges http:<METHOD> <client path> -> route:<METHOD> <uri>,
7
+ * entry tagging recomputed over the union (frontend pages become entry points that reach
8
+ backend tables), plus the live-under-gate closure for the backend's gate scenario.
9
+
10
+ Matching is deterministic (no guessing):
11
+ - the client path is the extractor's URL template with the origin (scheme://host or the
12
+ unknown server-URL placeholder) and query string removed;
13
+ - backend URIs are tried as declared and, for routes from routes/api.php, with the `/api`
14
+ prefix Laravel adds;
15
+ - segments match when literal == literal, client placeholder <-> route {param},
16
+ client literal -> route {param} (resolved), or a client segment with an embedded
17
+ placeholder fully matches a route literal (heuristic, e.g. export.{format} ~ export.csv);
18
+ - method must match (HEAD ~ GET); the most specific route (most literal==literal segments)
19
+ wins; ties are kept and marked heuristic (ambiguous).
20
+
21
+ Realtime: client channel subscriptions (channel_sub:<name>, from Echo / pusher-js / useEcho) get MATCHES_CHANNEL ->
22
+ the backend channel pattern they fit (`orders.{id}` ~ `orders.{orderId}`, a literal fits a {param} segment), and
23
+ LISTENS_FOR -> the backend broadcast event named by `.listen('X')` (`App\\Events\\X` by Echo's default namespace,
24
+ `.listen('.name')` by broadcastAs()). Endpoints called only from tests (attrs.test_only) are matched too, so
25
+ `tests` can follow them to routes, but they do not count in the match statistics or the uncalled-routes list.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import re
31
+ import sqlite3
32
+ import time
33
+ from collections import defaultdict
34
+ from pathlib import Path
35
+ from types import SimpleNamespace
36
+
37
+ from .core.model import CONFIDENCE_RANK
38
+ from .core.store import GraphStore
39
+
40
+ PARAM = re.compile(r"^\{(\w+)(\?)?\}$")
41
+ CATCH_ALL = re.compile(r"^\{(\w+)\*(\?)?\}$") # {rest*} one or more segments, {rest*?} zero or more (TS routers, Next.js)
42
+ PH = re.compile(r"\{[^{}]*\}")
43
+
44
+
45
+ def _segs(p: str) -> list[str]:
46
+ return [s for s in p.split("/") if s != ""]
47
+
48
+
49
+ def match_path(client: str, route: str) -> tuple[bool, dict]:
50
+ c, r = _segs(client), _segs(route)
51
+ info = {"lit": 0, "param": 0, "lit_into_param": 0, "ph_into_lit": 0}
52
+ if len(c) > len(r) and not (r and CATCH_ALL.match(r[-1])):
53
+ return False, info
54
+ for i, rs in enumerate(r):
55
+ cm = CATCH_ALL.match(rs)
56
+ if cm and i == len(r) - 1:
57
+ rest = c[i:]
58
+ if not rest and not cm.group(2):
59
+ return False, info
60
+ info["lit_into_param" if any(not PH.search(x) for x in rest) else "param"] += 1
61
+ return True, info
62
+ pm = PARAM.match(rs)
63
+ if i >= len(c):
64
+ if pm and pm.group(2):
65
+ continue # optional trailing param
66
+ return False, info
67
+ cs = c[i]
68
+ c_is_ph = bool(PARAM.match(cs)) or cs == "{?}"
69
+ if pm:
70
+ if c_is_ph:
71
+ info["param"] += 1
72
+ else:
73
+ info["lit_into_param"] += 1
74
+ continue
75
+ if cs == rs:
76
+ info["lit"] += 1
77
+ continue
78
+ if PH.search(rs): # route segment with embedded params, e.g. export.{format}
79
+ if PH.sub("{}", cs) == PH.sub("{}", rs):
80
+ info["param"] += 1
81
+ continue
82
+ rrx = "^" + "".join("[^/]+" if PH.fullmatch(part) else re.escape(part) for part in re.split(r"(\{[^{}]*\})", rs)) + "$"
83
+ if not PH.search(cs) and re.match(rrx, cs):
84
+ info["lit_into_param"] += 1
85
+ continue
86
+ if PH.search(cs):
87
+ rx = "^" + "".join("[^/]+" if PH.fullmatch(part) else re.escape(part) for part in re.split(r"(\{[^{}]*\})", cs)) + "$"
88
+ if re.match(rx, rs):
89
+ info["ph_into_lit"] += 1
90
+ continue
91
+ return False, info
92
+ return True, info
93
+
94
+
95
+ def _method_ok(cm: str, rm: str) -> bool:
96
+ rms = set(rm.upper().replace("|", ",").split(","))
97
+ return cm in rms or "ANY" in rms or (cm == "HEAD" and "GET" in rms)
98
+
99
+
100
+ def suffix_candidates(path: str, routes: list[dict]):
101
+ """Origin unknown: align the client path with the END of route URIs (prefix assumed to be the base)."""
102
+ cs = _segs(path)
103
+ out = []
104
+ for r in routes:
105
+ us = _segs(r["uri"])
106
+ if len(us) <= len(cs):
107
+ continue
108
+ ok, info = match_path("/" + "/".join(cs), "/" + "/".join(us[len(us) - len(cs):]))
109
+ if ok and info["lit"] > 0:
110
+ out.append((r, "suffix", info))
111
+ return out
112
+
113
+
114
+ def match_endpoint(method: str, path: str, routes: list[dict], origin_kind: str = "api", origin: str | None = None,
115
+ base_prefix: str | None = None) -> dict:
116
+ """base_prefix: path part of a configured base URL already folded into `path` (http node attrs.base); when the
117
+ full path matches nothing, the path without it is tried as an unknown-origin path (heuristic)."""
118
+ if base_prefix and path.startswith(base_prefix + "/"):
119
+ res = match_endpoint(method, path, routes, origin_kind, origin)
120
+ if res["matched"]:
121
+ return res
122
+ alt = match_endpoint(method, path[len(base_prefix):], routes, "unknown", origin)
123
+ if alt["matched"]:
124
+ for m in alt["matched"]:
125
+ m["uri_variant"] = f"{m['uri_variant']}, without configured base {base_prefix}"
126
+ m["confidence"] = "heuristic"
127
+ alt["path"] = path
128
+ return alt
129
+ return res
130
+ if "{?}" in path or path in ("", "/") or re.fullmatch(r"(/\{[^{}]*\})*", path or ""):
131
+ return {"matched": [], "reason": "dynamic URL: path not statically resolvable", "path": path}
132
+ if origin_kind == "other":
133
+ return {"matched": [], "reason": f"not a backend URL: other origin {origin}", "path": path}
134
+ if origin_kind == "same-origin" and not path.startswith("/api/"):
135
+ return {"matched": [], "reason": "not a backend URL: same-origin relative URL (served by the frontend itself)", "path": path}
136
+ hits, method_miss = [], []
137
+ for r in routes:
138
+ for variant, uri in r["uris"]:
139
+ ok, info = match_path(path, uri)
140
+ if not ok or info["lit"] == 0:
141
+ continue # at least one literal segment must agree (no "/x" ~ "/{param}" matches)
142
+ if _method_ok(method, r["method"]):
143
+ hits.append((r, variant, info))
144
+ else:
145
+ method_miss.append(r)
146
+ break
147
+ if not hits and origin_kind in ("unknown", "env"):
148
+ sx = [h for h in suffix_candidates(path, routes) if _method_ok(method, h[0]["method"])]
149
+ if len(sx) > 1: # prefer the most specific alignment (literal over param, param over placeholder-into-literal)
150
+ rank = lambda h: (h[2]["lit"], h[2]["param"], -h[2]["lit_into_param"], -h[2]["ph_into_lit"])
151
+ best = max(rank(h) for h in sx)
152
+ if sum(1 for h in sx if rank(h) == best) == 1:
153
+ sx = [h for h in sx if rank(h) == best]
154
+ if len(sx) == 1:
155
+ r, variant, info = sx[0]
156
+ return {"matched": [{"route": r["id"], "uri_variant": "suffix (origin unknown)", "confidence": "heuristic", "segments": info}], "path": path}
157
+ if sx:
158
+ return {"matched": [], "path": path, "reason": f"origin unknown and suffix match ambiguous ({len(sx)} routes)", "near": [h[0]["id"] for h in sx][:3]}
159
+ if not hits:
160
+ if method_miss:
161
+ return {"matched": [], "path": path, "reason": f"method mismatch: path exists only as {sorted({m['method'] for m in method_miss})}",
162
+ "near": [m["id"] for m in method_miss][:3]}
163
+ first = _segs(path)[:1]
164
+ roots = {_segs(u)[0] for r in routes for _, u in r["uris"] if _segs(u)}
165
+ if not first or first[0] not in roots:
166
+ return {"matched": [], "path": path, "reason": "not a backend URL (no route shares its first segment: local asset / device bridge / external)"}
167
+ best, bl = None, -1
168
+ cs = _segs(path)
169
+ for r in routes:
170
+ us = _segs(r["uri"])
171
+ k = 0
172
+ while k < min(len(cs), len(us)) and (cs[k] == us[k] or PARAM.match(us[k]) or PARAM.match(cs[k])):
173
+ k += 1
174
+ if k > bl:
175
+ best, bl = r, k
176
+ return {"matched": [], "path": path, "reason": "no backend route matches this path",
177
+ "closest": best["id"] if best else None, "common_prefix_segments": bl}
178
+ # most specific: fewest placeholder->literal fits, then most literal==literal segments
179
+ key = lambda h: (-h[2]["ph_into_lit"], h[2]["lit"])
180
+ top = max(key(h) for h in hits)
181
+ best = [h for h in hits if key(h) == top]
182
+ out = []
183
+ for r, variant, info in best:
184
+ conf = "exact"
185
+ if info["lit_into_param"]:
186
+ conf = "resolved"
187
+ if info["ph_into_lit"]:
188
+ conf = "heuristic"
189
+ # several routes equally specific -> ambiguous, unless the placeholder fans out over literals (export.{format})
190
+ if len(best) > 1 and not info["ph_into_lit"]:
191
+ conf = "heuristic"
192
+ if origin_kind == "unknown":
193
+ conf = "heuristic" # base not traced to the API client config
194
+ elif origin_kind == "env" and conf == "exact":
195
+ conf = "resolved" # base is a configured server URL (env key) whose value is not in the repo
196
+ out.append({"route": r["id"], "uri_variant": variant, "confidence": conf, "segments": info})
197
+ return {"matched": out, "path": path}
198
+
199
+
200
+ def tag_entries_rows(nodes: list, edges: list, prop: set, skip_gate: str | None = None):
201
+ from .indexer import tag_entries
202
+ b = SimpleNamespace(nodes={n.id: n for n in nodes}, edges={i: e for i, e in enumerate(edges) if e.kind in prop})
203
+ return tag_entries(b, skip_gate=skip_gate)
204
+
205
+
206
+ def link(backend_db: str, frontend_db: str, out_db: str, backend_name="backend", frontend_name="frontend") -> dict:
207
+ t0 = time.time()
208
+ store = GraphStore.create(out_db)
209
+ db = store.db
210
+ stats = {"backend": backend_name, "frontend": frontend_name}
211
+ for alias, path, repo in (("b", backend_db, backend_name), ("f", frontend_db, frontend_name)):
212
+ db.execute(f"ATTACH DATABASE ? AS {alias}", (str(path),))
213
+ before = db.execute("SELECT count(*) FROM nodes").fetchone()[0]
214
+ db.execute(f"""INSERT OR IGNORE INTO nodes
215
+ SELECT id, kind, name, fqn, CASE WHEN file IS NULL THEN NULL ELSE ? || '/' || file END, line, end_line, module, doc, lang,
216
+ entry_kind, json_set(coalesce(attrs, '{{}}'), '$.repo', ?) FROM {alias}.nodes""", (repo, repo))
217
+ n_in = db.execute(f"SELECT count(*) FROM {alias}.nodes").fetchone()[0]
218
+ added = db.execute("SELECT count(*) FROM nodes").fetchone()[0] - before
219
+ stats[f"{repo}_nodes"] = n_in
220
+ stats[f"{repo}_id_collisions"] = n_in - added
221
+ db.execute(f"""INSERT INTO edges(src,dst,kind,file,line,confidence,conf_rank,attrs,gate)
222
+ SELECT src, dst, kind, CASE WHEN file IS NULL THEN NULL ELSE ? || '/' || file END, line, confidence, conf_rank, attrs, gate
223
+ FROM {alias}.edges""", (repo,))
224
+ stats[f"{repo}_edges"] = db.execute(f"SELECT count(*) FROM {alias}.edges").fetchone()[0]
225
+ db.execute("INSERT OR REPLACE INTO gate_predicates SELECT * FROM b.gate_predicates")
226
+ db.commit()
227
+ # ---- match client endpoints to routes
228
+ routes = []
229
+ for r in db.execute("SELECT id, file, attrs, line FROM b.nodes WHERE kind='route'"):
230
+ a = json.loads(r[2] or "{}")
231
+ uri, method = a.get("uri"), a.get("method")
232
+ if not uri or not method:
233
+ continue
234
+ uris = [("as-declared", uri)]
235
+ if (r[1] or "").startswith("routes/api.php") or (r[1] or "").startswith("routes/api/"):
236
+ uris.append(("api-prefixed", "/api" + uri))
237
+ routes.append({"id": r[0], "uri": uri, "method": method, "uris": uris, "file": f"{backend_name}/{r[1]}" if r[1] else None, "line": r[3],
238
+ "attrs": dict(a, _file=r[1], _line=r[3])})
239
+ calls_by_ep = defaultdict(list)
240
+ for e in db.execute("SELECT src, dst, file, line, confidence, attrs FROM f.edges WHERE kind='HTTP_CALLS'"):
241
+ calls_by_ep[e[1]].append({"src": e[0], "at": f"{frontend_name}/{e[2]}:{e[3]}", "confidence": e[4], **json.loads(e[5] or "{}")})
242
+ results = []
243
+ new_edges = []
244
+ for ep_id, attrs in db.execute("SELECT id, attrs FROM f.nodes WHERE kind='http'"):
245
+ a = json.loads(attrs or "{}")
246
+ from .plugins.ts.baseurl import base_path
247
+ bp = base_path((a.get("base") or {}).get("value") or "") if a.get("base") else None
248
+ res = match_endpoint(a["method"], a["path"], routes, a.get("origin_kind", "api"), a.get("origin"), base_prefix=bp or None)
249
+ res.update({"endpoint": ep_id, "method": a["method"], "calls": calls_by_ep.get(ep_id, [])})
250
+ results.append(res)
251
+ rmap = {r["id"]: r for r in routes}
252
+ for m in res["matched"]:
253
+ rr = rmap[m["route"]]
254
+ new_edges.append((ep_id, m["route"], "MATCHES_ROUTE", rr["file"], rr["line"], m["confidence"], CONFIDENCE_RANK[m["confidence"]],
255
+ json.dumps({"client_path": a["path"], "uri_variant": m["uri_variant"], "segments": m["segments"]}), None))
256
+ db.executemany("INSERT INTO edges(src,dst,kind,file,line,confidence,conf_rank,attrs,gate) VALUES (?,?,?,?,?,?,?,?,?)", new_edges)
257
+ db.commit()
258
+ stats.update(link_channels(db, backend_name))
259
+ from .protocols import link_db as link_protocols
260
+ if (pr := link_protocols(db)): # only graphs with endpoints of the #31 protocols (MQTT, Socket.IO, ...)
261
+ stats["protocols"] = pr
262
+ test_only = {r[0] for r in db.execute("SELECT id FROM f.nodes WHERE kind='http' AND json_extract(attrs,'$.test_only')=1")}
263
+ test_results = [r for r in results if r["endpoint"] in test_only]
264
+ results = [r for r in results if r["endpoint"] not in test_only]
265
+ # ---- payload / field contract checks + routes without a client caller
266
+ from .payload import Checker
267
+ fe_nodes = {r[0]: json.loads(r[1] or "{}") for r in db.execute(
268
+ "SELECT id, attrs FROM f.nodes WHERE kind='class' AND (attrs LIKE '%json_from%' OR attrs LIKE '%enum_values%' OR attrs LIKE '%json_to%')")}
269
+ be_nodes = {r[0]: json.loads(r[1] or "{}") for r in db.execute("SELECT id, attrs FROM b.nodes WHERE kind='class' AND attrs LIKE '%schema_fields%'")}
270
+ checker = Checker(fe_nodes, be_nodes, frontend_name, backend_name)
271
+ rmap = {r["id"]: r for r in routes}
272
+ issues = []
273
+ called = set()
274
+ for res in results:
275
+ for m in res["matched"]:
276
+ called.add(m["route"])
277
+ if len(res["matched"]) != 1:
278
+ continue
279
+ rr = rmap[res["matched"][0]["route"]]
280
+ for c in res["calls"]:
281
+ try:
282
+ issues += checker.check(res["endpoint"], rr["id"], rr["attrs"], c)
283
+ except Exception as ex: # a malformed fact must not abort linking
284
+ issues.append({"endpoint": res["endpoint"], "route": rr["id"], "kind": "checker_error", "severity": "info", "message": str(ex)[:200]})
285
+ db.execute("""CREATE TABLE IF NOT EXISTS payload_checks (endpoint TEXT, route TEXT, kind TEXT, severity TEXT, message TEXT,
286
+ client_at TEXT, server_at TEXT, details TEXT)""")
287
+ db.executemany("INSERT INTO payload_checks VALUES (?,?,?,?,?,?,?,?)",
288
+ [(i.get("endpoint"), i.get("route"), i["kind"], i["severity"], i["message"], i.get("client_at"), i.get("server_at"),
289
+ json.dumps({k: v for k, v in i.items() if k not in ("endpoint", "route", "kind", "severity", "message", "client_at", "server_at")}))
290
+ for i in issues])
291
+ uncalled = [{"route": r["id"], "at": f"{r['file']}:{r['line']}", "framework": r["attrs"].get("framework"), "auth": r["attrs"].get("auth")}
292
+ for r in routes if r["id"] not in called and r["attrs"].get("mounted", True) is not False
293
+ and r["attrs"].get("framework") not in ("django-admin",)]
294
+ db.commit()
295
+ # ---- entry tagging over the union
296
+ prop = {r[0] for r in db.execute("SELECT kind FROM edge_kinds WHERE propagates=1")}
297
+ nodes = [SimpleNamespace(id=r[0], entry_kind=r[1]) for r in db.execute("SELECT id, entry_kind FROM nodes")]
298
+ edges = [SimpleNamespace(src=r[0], dst=r[1], kind=r[2], gate=r[3]) for r in db.execute("SELECT src, dst, kind, gate FROM edges")]
299
+ db.executemany("INSERT INTO node_entry VALUES (?,?,?,?)", tag_entries_rows(nodes, edges, prop))
300
+ scen = [r[0] for r in db.execute("SELECT DISTINCT scenario FROM b.node_entry_live")]
301
+ for sc in scen[:1]:
302
+ db.executemany("INSERT INTO node_entry_live VALUES (?,?,?,?,?)", [(sc, *r) for r in tag_entries_rows(nodes, edges, prop, skip_gate=sc)])
303
+ db.commit()
304
+ # ---- stats: per call site and per distinct endpoint
305
+ n_ep = len(results)
306
+ m_ep = sum(1 for r in results if r["matched"])
307
+ site_match = {}
308
+ for r in results:
309
+ for c in r["calls"]:
310
+ site_match[c["at"]] = site_match.get(c["at"], False) or bool(r["matched"])
311
+ sites = list(site_match)
312
+ m_sites = sum(1 for v in site_match.values() if v)
313
+ conf_ct = defaultdict(int)
314
+ for r in results:
315
+ if r["matched"]:
316
+ conf_ct[min((m["confidence"] for m in r["matched"]), key=lambda c: CONFIDENCE_RANK[c])] += 1
317
+ reasons = defaultdict(int)
318
+ for r in results:
319
+ if not r["matched"]:
320
+ reasons[r["reason"].split(":")[0].split(" (")[0]] += 1
321
+ stats.update({"test_endpoints": len(test_results), "test_endpoints_matched": sum(1 for r in test_results if r["matched"])})
322
+ stats.update({"endpoints": n_ep, "endpoints_matched": m_ep, "call_sites": len(sites), "call_sites_matched": m_sites,
323
+ "endpoint_match_confidence": dict(conf_ct), "unmatched_reasons": dict(reasons),
324
+ "ambiguous_endpoints": sum(1 for r in results if len(r["matched"]) > 1),
325
+ "payload_issues": dict(sorted({k: sum(1 for i in issues if i["severity"] == k) for k in ("high", "medium", "low", "info")}.items())),
326
+ "routes_without_client_call": len(uncalled),
327
+ "link_seconds": round(time.time() - t0, 2)})
328
+ db.execute("DETACH DATABASE b")
329
+ db.execute("DETACH DATABASE f")
330
+ from .coverage import for_graph
331
+ cov = {}
332
+ for name, path in ((backend_name, backend_db), (frontend_name, frontend_db)):
333
+ got = for_graph(GraphStore(path))
334
+ cov.update({name: next(iter(got.values()))} if len(got) == 1 else got)
335
+ store.set_meta(project=f"{backend_name}+{frontend_name}", stats=stats, repos=[backend_name, frontend_name], coverage=cov,
336
+ sources={backend_name: str(Path(backend_db).resolve()), frontend_name: str(Path(frontend_db).resolve())},
337
+ indexed_at=time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()))
338
+ db.close()
339
+ return {"stats": stats, "results": results, "payload_issues": issues, "uncalled_routes": uncalled}
340
+
341
+
342
+ def channel_links(chans, subs, events, on):
343
+ """Pure matching shared by cg link and single-repo indexing (backend + Echo client in one checkout).
344
+ chans: [(id, attrs, file, line)], subs: [(id, attrs)], events: {event id: attrs}, on: {channel id: {event ids}}.
345
+ Returns (edges [(src, dst, kind, confidence, attrs, file, line)], stats)."""
346
+ from .plugins.php.strings import channel_match, same_shape
347
+ rows, st = [], {"channel_subscriptions": len(subs), "channel_subscriptions_matched": 0, "listened_events": 0,
348
+ "listened_events_matched": 0}
349
+ for sid, a in subs:
350
+ name = a.get("name") or sid.split(":", 1)[1]
351
+ exact = [c for c in chans if same_shape(name, c[1].get("pattern") or c[0][8:])]
352
+ hits = exact or [c for c in chans if channel_match(name, c[1].get("pattern") or c[0][8:])]
353
+ conf = "exact" if exact and len(exact) == 1 else ("resolved" if len(hits) == 1 else "heuristic")
354
+ for cid, ca, cf, cl in hits:
355
+ ea = {"client_name": name}
356
+ cv, sv = ca.get("visibility"), a.get("visibility")
357
+ if cv and sv and cv != sv and not (cv == "private" and sv == "private-encrypted"):
358
+ ea["visibility_mismatch"] = f"client subscribes as {sv}, backend channel is {cv}"
359
+ rows.append((sid, cid, "MATCHES_CHANNEL", conf, ea, cf, cl))
360
+ if hits:
361
+ st["channel_subscriptions_matched"] += 1
362
+ # events: .listen('OrderShipped') -> App\\Events\\OrderShipped; .listen('.custom') -> broadcastAs() == 'custom'
363
+ near = set().union(*(on.get(c[0], set()) for c in hits)) if hits else set()
364
+ for ev in a.get("events") or []:
365
+ if ev == "*" or ev.startswith(("(", "whisper:", "client-", "pusher:")):
366
+ continue
367
+ st["listened_events"] += 1
368
+ if ev.startswith("."):
369
+ cands = [e for e, ea in events.items() if ea.get("broadcast_as") == ev[1:]]
370
+ else:
371
+ fq = ev.replace(".", "\\")
372
+ cands = [e for e in events if e == f"event:{fq}" or e == f"event:App\\Events\\{fq}"]
373
+ if not cands:
374
+ cands = [e for e, ea in events.items() if not ea.get("broadcast_as") and e.rsplit("\\", 1)[-1] == ev.rsplit(".", 1)[-1].rsplit("\\", 1)[-1]]
375
+ if not cands: # broadcastAs() names used without the leading dot (pusher-js bind / useEcho)
376
+ cands = [e for e, ea in events.items() if ea.get("broadcast_as") == ev]
377
+ pref = [e for e in cands if e in near] or cands
378
+ for e in pref:
379
+ c = "exact" if e in near and len(pref) == 1 else "resolved" if len(pref) == 1 else "heuristic"
380
+ rows.append((sid, e, "LISTENS_FOR", c, {"event_name": ev}, None, None))
381
+ if pref:
382
+ st["listened_events_matched"] += 1
383
+ return rows, st
384
+
385
+
386
+ def link_channels(db, backend_name: str) -> dict:
387
+ """MATCHES_CHANNEL (client subscription -> backend channel) and LISTENS_FOR (subscription -> broadcast event)."""
388
+ chans = [(r[0], json.loads(r[1] or "{}"), r[2], r[3]) for r in db.execute("SELECT id, attrs, file, line FROM b.nodes WHERE kind='channel'")]
389
+ subs = [(r[0], json.loads(r[1] or "{}")) for r in db.execute("SELECT id, attrs FROM f.nodes WHERE kind='channel_sub'")]
390
+ if not subs:
391
+ return {}
392
+ events = {}
393
+ for r in db.execute("SELECT id, attrs FROM b.nodes WHERE kind='event'"):
394
+ a = json.loads(r[1] or "{}")
395
+ if a.get("broadcast"):
396
+ events[r[0]] = a
397
+ on = defaultdict(set)
398
+ for r in db.execute("SELECT src, dst FROM b.edges WHERE kind='BROADCASTS_ON'"):
399
+ on[r[1]].add(r[0])
400
+ rows, st = channel_links(chans, subs, events, on)
401
+ db.executemany("INSERT INTO edges(src,dst,kind,file,line,confidence,conf_rank,attrs,gate) VALUES (?,?,?,?,?,?,?,?,?)",
402
+ [(s, d, k, f"{backend_name}/{f}" if f else None, l, c, CONFIDENCE_RANK[c], json.dumps(a), None)
403
+ for s, d, k, c, a, f, l in rows])
404
+ db.commit()
405
+ return st
406
+
407
+
408
+ def write_match_report(res: dict, out_prefix: str) -> None:
409
+ Path(out_prefix + ".json").write_text(json.dumps(res, indent=1, default=str))
410
+ s = res["stats"]
411
+ L = [f"# API-call matching: {s['frontend']} -> {s['backend']}", "",
412
+ f"- call sites: {s['call_sites_matched']}/{s['call_sites']} matched "
413
+ f"({100 * s['call_sites_matched'] / max(1, s['call_sites']):.1f}%)",
414
+ f"- distinct client endpoints: {s['endpoints_matched']}/{s['endpoints']} matched "
415
+ f"({100 * s['endpoints_matched'] / max(1, s['endpoints']):.1f}%); ambiguous: {s['ambiguous_endpoints']}",
416
+ f"- match confidence (per endpoint, weakest route): {s['endpoint_match_confidence']}",
417
+ f"- unmatched by reason: {s['unmatched_reasons']}", "", "## Unmatched endpoints", ""]
418
+ for r in sorted((r for r in res["results"] if not r["matched"]), key=lambda r: (r["reason"], r["endpoint"])):
419
+ at = ", ".join(sorted({c["at"] for c in r["calls"]})[:3])
420
+ extra = f" closest={r['closest']}" if r.get("closest") else (f" near={r['near']}" if r.get("near") else "")
421
+ L.append(f"- `{r['endpoint']}` - {r['reason']}{extra} - at {at}")
422
+ L += ["", "## Ambiguous / heuristic matches", ""]
423
+ for r in res["results"]:
424
+ if r["matched"] and (len(r["matched"]) > 1 or any(m["confidence"] == "heuristic" for m in r["matched"])):
425
+ L.append(f"- `{r['endpoint']}` -> " + ", ".join(f"`{m['route']}` ({m['confidence']})" for m in r["matched"]))
426
+ L += ["", "## Payload / contract issues", ""]
427
+ order = {"high": 0, "medium": 1, "low": 2, "info": 3}
428
+ for i in sorted(res.get("payload_issues") or [], key=lambda i: (order.get(i["severity"], 9), i["kind"], i.get("endpoint") or "")):
429
+ ev = ", ".join(x for x in (i.get("client_field") or i.get("client_at"), i.get("server_field") or i.get("server_at")) if x)
430
+ L.append(f"- **{i['severity']}** `{i['kind']}` `{i.get('endpoint')}` -> `{i.get('route')}`: {i['message']} ({ev})")
431
+ L += ["", "## Backend routes without a client call", ""]
432
+ for u in res.get("uncalled_routes") or []:
433
+ L.append(f"- `{u['route']}` ({u.get('framework')}, auth={u.get('auth')}) at {u['at']}")
434
+ Path(out_prefix + ".md").write_text("\n".join(L) + "\n")