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/concepts.py ADDED
@@ -0,0 +1,362 @@
1
+ """Deterministic concept queries over resolution facts.
2
+
3
+ `resolutions(st, concept)` lists every place a value for the concept (e.g. "currency", "timezone", "locale") is *resolved*: a variable,
4
+ fallback return or resolver function whose value comes from an ordered fallback chain (request key, JSON setting
5
+ and its default, model attribute / queried column, config/env, literal). Sites are grouped by their chain
6
+ signature, divergent chains are compared step by step, and on a combined graph the client side is attached:
7
+ which frontend endpoints reach each site, whether their requests can/do send the concept's request key, and the
8
+ client's own literal fallbacks (`report.value?.timezone ?? 'UTC'`).
9
+
10
+ Matching is lexical and deterministic: a site belongs to the concept when the *head word* of its target
11
+ (`$timezone`, `$customerTimezone`, `resolveTimezone()`, `report.value?.timezone`) is the concept (singular/plural),
12
+ or when its chain starts with a request key / setting / column whose head word is the concept.
13
+ The output is the deterministic facts themselves, ready for a reviewer or an agent to summarise.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ from collections import defaultdict
20
+
21
+ from .core.store import GraphStore
22
+
23
+ SOURCE_KINDS = ("input", "setting", "column", "config", "env")
24
+ CALL_LIKE_UP = ["CALLS", "IMPLEMENTED_BY", "OVERRIDDEN_BY", "BOUND_TO", "ROUTES_TO", "HANDLED_BY"]
25
+
26
+
27
+ def head_token(ident: str) -> str:
28
+ toks = re.findall(r"[A-Z]?[a-z0-9]+|[A-Z]+(?![a-z])", (ident or "").replace("$", ""))
29
+ return toks[-1].lower() if toks else ""
30
+
31
+
32
+ def concept_forms(concept: str) -> set[str]:
33
+ c = concept.lower().strip()
34
+ forms = {c}
35
+ if c.endswith("ies"):
36
+ forms.add(c[:-3] + "y")
37
+ elif c.endswith("y"):
38
+ forms.add(c[:-1] + "ies")
39
+ elif c.endswith("s"):
40
+ forms.add(c[:-1])
41
+ else:
42
+ forms.add(c + "s")
43
+ return forms
44
+
45
+
46
+ def short(nid: str | None) -> str:
47
+ if not nid:
48
+ return "?"
49
+ x = nid.split(":", 1)[-1]
50
+ if "#" in x and nid.split(":", 1)[0] in ("page", "component", "layout", "composable", "function", "store", "module"):
51
+ return x
52
+ x = x.split("\\")[-1] if "\\" in x and "::" not in x else x
53
+ if "::" in x:
54
+ cls, m = x.rsplit("::", 1)
55
+ x = f"{cls.split(chr(92))[-1]}::{m}"
56
+ return x
57
+
58
+
59
+ def flat_atoms(atoms):
60
+ for a in atoms or []:
61
+ if a["kind"] == "call" and a.get("atoms"):
62
+ yield from flat_atoms(a["atoms"])
63
+ elif a["kind"] == "param" and a.get("sources"):
64
+ seen = []
65
+ for s in a["sources"]:
66
+ for x in flat_atoms(s["atoms"]):
67
+ k = json.dumps({kk: vv for kk, vv in x.items() if kk in ("kind", "key", "column", "value", "text")}, sort_keys=True)
68
+ if k not in seen:
69
+ seen.append(k)
70
+ yield {**x, "via_param": a["name"], "callers": len(a["sources"])}
71
+ else:
72
+ yield a
73
+
74
+
75
+ def atom_text(a) -> str:
76
+ k = a["kind"]
77
+ norm = f" [{', '.join(a['norm'])}]" if a.get("norm") else ""
78
+ if k == "input":
79
+ flow = f"; flow: {' <- '.join(a['flow'])}" if a.get("flow") else ""
80
+ vp = f" (param ${a['via_param']} from {a['callers']} call site(s))" if a.get("via_param") else ""
81
+ return f"request key '{a['key']}' via {a.get('via')}{vp}{flow}{norm}"
82
+ if k == "setting":
83
+ own = f" ({'/'.join(a['owner'])}::getSetting)" if a.get("owner") else ""
84
+ return f"setting '{a['key']}'{own} default {a.get('default')!r}{norm}"
85
+ if k == "column":
86
+ if a.get("candidates"):
87
+ return f"column {' | '.join(a['candidates'])} ({a.get('how')}, one per model){norm}"
88
+ return f"column {a['column']} ({a.get('how')}){norm}"
89
+ if k in ("config", "env"):
90
+ return f"{k} '{a['key']}' default {a.get('default')!r}"
91
+ if k == "literal":
92
+ src = f" ({a['from']})" if a.get("from") else ""
93
+ return f"literal {a.get('value')!r}{src}"
94
+ if k == "call":
95
+ return f"call {short(a['target'])}()"
96
+ if k == "param":
97
+ return f"param ${a['name']} (no resolvable callers)"
98
+ return f"expr {a.get('text')}"
99
+
100
+
101
+ def _matches(attrs: dict, forms: set[str], lang: str) -> str | None:
102
+ tname = attrs.get("target_name") or attrs.get("target") or ""
103
+ if lang == "ts":
104
+ m = re.findall(r"[A-Za-z_$][\w$]*", tname)
105
+ if m and head_token(m[-1]) in forms:
106
+ return "target"
107
+ return None
108
+ if head_token(tname) in forms:
109
+ return "target"
110
+ sig = attrs.get("signature") or []
111
+ if sig:
112
+ k, _, key = sig[0].partition(":")
113
+ if k in SOURCE_KINDS and head_token(key) in forms:
114
+ return "first-source"
115
+ return None
116
+
117
+
118
+ def _routes_reaching(st: GraphStore, fn: str, max_depth=12) -> list[str]:
119
+ from .query import reverse_closure
120
+ d = reverse_closure(st, [fn], kinds=CALL_LIKE_UP, max_depth=max_depth)
121
+ return sorted(n for n in d if n.startswith("route:"))
122
+
123
+
124
+ def _diff(a: list[str], b: list[str]) -> dict:
125
+ i = 0
126
+ while i < min(len(a), len(b)) and a[i] == b[i]:
127
+ i += 1
128
+ return {"common_prefix": a[:i], "a_next": a[i] if i < len(a) else None, "b_next": b[i] if i < len(b) else None,
129
+ "final_a": a[-1] if a else None, "final_b": b[-1] if b else None}
130
+
131
+
132
+ def resolutions(st: GraphStore, concept: str, within: str | None = None, client: bool = True) -> dict:
133
+ forms = concept_forms(concept)
134
+ w = within.lower() if within else None
135
+ rows = st.q("SELECT id, name, file, line, lang, attrs FROM nodes WHERE kind='resolution'")
136
+ backend, frontend = [], []
137
+ for r in rows:
138
+ a = json.loads(r["attrs"] or "{}")
139
+ how = _matches(a, forms, r["lang"])
140
+ if not how:
141
+ continue
142
+ site = {"id": r["id"], "fn": a.get("fn"), "target": a.get("target"), "form": a.get("form"), "file": r["file"],
143
+ "line": r["line"], "signature": a.get("signature") or [], "chain": a.get("chain") or [],
144
+ "final_literal": a.get("final_literal"), "match": how, "repo": a.get("repo")}
145
+ (frontend if r["lang"] == "ts" else backend).append(site)
146
+ if w:
147
+ backend = [s for s in backend if w in (s["fn"] or "").lower() or w in (s["file"] or "").lower()]
148
+ backend.sort(key=lambda s: (s["file"] or "", s["line"] or 0))
149
+ # chains by signature
150
+ groups: dict[tuple, list] = defaultdict(list)
151
+ for s in backend:
152
+ groups[tuple(s["signature"])].append(s)
153
+ chains = []
154
+ for i, (sig, sites) in enumerate(sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[0]))):
155
+ label = chr(ord("A") + i) if i < 26 else chr(ord("A") + i // 26 - 1) + chr(ord("A") + i % 26)
156
+ for s in sites:
157
+ s["chain_label"] = label
158
+ chains.append({"label": label, "signature": list(sig), "sites": [s["id"] for s in sites]})
159
+ # request-driven sites: chain contains a request key of the concept
160
+ concept_keys = sorted({x.split(":", 1)[1] for s in backend for x in s["signature"]
161
+ if x.startswith("input:") and head_token(x.split(":", 1)[1]) in forms})
162
+ req_sites = [s for s in backend if any(x.startswith("input:") and x.split(":", 1)[1] in concept_keys for x in s["signature"])]
163
+ # routes per site (who can trigger it)
164
+ for s in backend:
165
+ s["routes"] = _routes_reaching(st, s["fn"]) if s["fn"] else []
166
+ divergence = []
167
+ by_label = {c["label"]: c for c in chains}
168
+ req_labels = sorted({s["chain_label"] for s in req_sites}, key=lambda x: (len(x), x))
169
+ for i, la in enumerate(req_labels):
170
+ for lb in req_labels[i + 1:]:
171
+ d = _diff(by_label[la]["signature"], by_label[lb]["signature"])
172
+ divergence.append({"a": la, "b": lb, **d})
173
+ res = {"concept": concept, "forms": sorted(forms), "within": within, "backend_sites": backend, "chains": chains,
174
+ "concept_request_keys": concept_keys, "request_driven_chains": req_labels, "divergence": divergence,
175
+ "client": [], "client_fallbacks": []}
176
+ if client:
177
+ res["client"], res["client_fallbacks"] = _client_side(st, req_sites, concept_keys, frontend, forms)
178
+ res["not_forwarded"] = _not_forwarded(st, res, forms)
179
+ return res
180
+
181
+
182
+ def _not_forwarded(st: GraphStore, res: dict, forms: set[str]) -> list[dict]:
183
+ """Keys a client call site passes that the request it calls never sends: for the requests listed under CLIENT,
184
+ and every gap whose dropped key is a form of the concept (e.g. date_from, which then has no backend site at all)."""
185
+ from .query import forwarding_gaps
186
+ issuers = {rq["issuer"] for it in res["client"] for rq in it["requests"]}
187
+ c = res["concept"].lower()
188
+ out = []
189
+ for g in forwarding_gaps(st):
190
+ concept_hit = [k for k in g["dropped"] if head_token(k) in forms or c in k.lower()]
191
+ if g["issuer"] in issuers or concept_hit:
192
+ out.append({**g, "concept_keys": concept_hit})
193
+ return out
194
+
195
+
196
+ def _key_status(keys: dict | None, k: str) -> str:
197
+ if not keys:
198
+ return "absent"
199
+ if keys.get("opaque") and k not in (keys.get("keys") or []) + (keys.get("conditional") or []):
200
+ return "unknown"
201
+ if k in (keys.get("keys") or []):
202
+ return "always"
203
+ if k in (keys.get("conditional") or []):
204
+ return "conditional"
205
+ return "absent"
206
+
207
+
208
+ def _client_side(st: GraphStore, req_sites: list[dict], concept_keys: list[str], frontend: list[dict], forms: set[str]):
209
+ route_sites = defaultdict(list)
210
+ for s in req_sites:
211
+ for r in s["routes"]:
212
+ route_sites[r].append(s)
213
+ if not route_sites:
214
+ return [], []
215
+ eps = defaultdict(set)
216
+ for r in route_sites:
217
+ for e in st.q("SELECT src, confidence FROM edges WHERE kind='MATCHES_ROUTE' AND dst=?", (r,)):
218
+ eps[e["src"]].add(r)
219
+ fb_by_owner = defaultdict(list)
220
+ for f in frontend:
221
+ fb_by_owner[f["fn"]].append(f)
222
+ out, used_fb = [], {}
223
+ for ep in sorted(eps):
224
+ routes = sorted(eps[ep])
225
+ sites = sorted({(s["chain_label"], s["id"]) for r in routes for s in route_sites[r]})
226
+ item = {"endpoint": ep, "routes": routes, "backend_sites": [{"chain": l, "site": i} for l, i in sites], "requests": []}
227
+ for h in st.q("SELECT src, file, line, attrs FROM edges WHERE kind='HTTP_CALLS' AND dst=?", (ep,)):
228
+ ha = json.loads(h["attrs"] or "{}")
229
+ fwd = next((x for x in (ha.get("query_keys"), ha.get("body_keys")) if x and x.get("forwarded")), None) or {}
230
+ req = {"issuer": h["src"], "at": f"{h['file']}:{h['line']}", "keys": {}, "callers": [],
231
+ "forwarded": fwd.get("forwarded"), "forwarded_index": fwd.get("forwarded_index")}
232
+ for k in concept_keys:
233
+ req["keys"][k] = max(_key_status(ha.get("query_keys"), k), _key_status(ha.get("body_keys"), k),
234
+ key=["absent", "unknown", "conditional", "always"].index)
235
+ owners = [h["src"]]
236
+ for c in st.q("SELECT src, file, line, attrs FROM edges WHERE kind='CALLS' AND dst=?", (h["src"],)):
237
+ ca = json.loads(c["attrs"] or "{}")
238
+ ak = ca.get("arg_keys")
239
+ akc = ca.get("arg_keys_conditional") or []
240
+ akp = ca.get("arg_keys_partial") or []
241
+ if isinstance(akp, bool): # older DBs
242
+ akp = [akp] * len(ak or [])
243
+ fi = req["forwarded_index"]
244
+ if ak is not None and isinstance(fi, int):
245
+ # the issuer forwards one parameter into the request: only that argument position matters
246
+ one = ak[fi] if fi < len(ak) else None
247
+ ak = None if one is None else [one]
248
+ akc = [akc[fi]] if fi < len(akc) and akc[fi] else []
249
+ akp = [akp[fi]] if fi < len(akp) else []
250
+ passed = sorted({x for ks in (ak or []) for x in (ks or [])})
251
+ cond = sorted({x for ks in akc for x in (ks or [])})
252
+ partial = any(akp)
253
+
254
+ def sends(k):
255
+ if ak is None:
256
+ return None
257
+ if k in passed:
258
+ return True
259
+ if k in cond:
260
+ return "conditional"
261
+ return None if partial else False
262
+ req["callers"].append({"caller": c["src"], "at": f"{c['file']}:{c['line']}", "arg_keys": passed if ak is not None else None,
263
+ "arg_keys_conditional": cond or None, "sends": {k: sends(k) for k in concept_keys}})
264
+ owners.append(c["src"])
265
+ fbs = []
266
+ for o in owners:
267
+ for f in fb_by_owner.get(o, []):
268
+ fbs.append(f)
269
+ used_fb[f["id"]] = f
270
+ req["client_fallbacks"] = [{"at": f"{f['file']}:{f['line']}", "expr": " ?? ".join(f["signature"][:-1]) if f["signature"] else None,
271
+ "literal": f["final_literal"], "owner": f["fn"]} for f in fbs]
272
+ # verdict per key: sent / maybe / never
273
+ for k in concept_keys:
274
+ st_ = req["keys"][k]
275
+ if st_ == "always":
276
+ v = "sent"
277
+ elif st_ == "absent":
278
+ v = "never sent (request builder has no such key)"
279
+ elif st_ == "unknown" and not any(c["sends"].get(k) for c in req["callers"]):
280
+ v = "unknown (request params not statically known)"
281
+ else:
282
+ vals = [c["sends"].get(k) for c in req["callers"]]
283
+ unknown = sum(1 for x in vals if x is None)
284
+ if any(x is True for x in vals):
285
+ v = "sent by some call sites"
286
+ elif any(x == "conditional" for x in vals):
287
+ v = "sent conditionally by some call sites"
288
+ elif vals and not unknown:
289
+ v = "never sent (builder key is conditional and no call site passes it)"
290
+ elif vals and unknown < len(vals):
291
+ v = f"not sent by {len(vals) - unknown} call site(s) with known keys; {unknown} unknown"
292
+ else:
293
+ v = "conditional (call-site keys unknown)"
294
+ req.setdefault("verdict", {})[k] = v
295
+ item["requests"].append(req)
296
+ out.append(item)
297
+ return out, sorted(used_fb.values(), key=lambda f: (f["file"], f["line"]))
298
+
299
+
300
+ def render_resolutions(res: dict, max_sites_per_chain=8, show_client=True, compact=False) -> str:
301
+ L = [f"concept: {res['concept']} (head-word forms: {', '.join(res['forms'])})" + (f" within: {res['within']}" if res.get("within") else ""),
302
+ f"backend resolution sites: {len(res['backend_sites'])} in {len(res['chains'])} distinct fallback chains; "
303
+ f"request keys of the concept: {', '.join(res['concept_request_keys']) or '-'}; "
304
+ f"request-driven chains: {', '.join(res['request_driven_chains']) or '-'}"]
305
+ sites = {s["id"]: s for s in res["backend_sites"]}
306
+ L += ["", "== FALLBACK CHAINS (deterministic, grouped by signature)"]
307
+ for c in res["chains"]:
308
+ L.append(f"[{c['label']}] {' > '.join(c['signature'])} ({len(c['sites'])} site{'s' if len(c['sites']) != 1 else ''})")
309
+ for sid in c["sites"][:max_sites_per_chain]:
310
+ s = sites[sid]
311
+ L.append(f" {short(s['fn'])} {s['target']} ({s['form']}) @{s['file']}:{s['line']}")
312
+ i = 0
313
+ seen_atoms = set()
314
+ for a in flat_atoms(s["chain"]):
315
+ if a["kind"] == "literal" and a.get("value") in (None, "", [], False):
316
+ continue
317
+ ak = json.dumps({kk: vv for kk, vv in a.items() if kk in ("kind", "key", "column", "value", "text")}, sort_keys=True)
318
+ if ak in seen_atoms: # repeated source (e.g. `($a ?? $fb) ?: $fb`): already listed
319
+ continue
320
+ seen_atoms.add(ak)
321
+ i += 1
322
+ L.append(f" {i}. {atom_text(a)} @{a.get('at')}")
323
+ if s.get("routes"):
324
+ L.append(f" reached from {len(s['routes'])} route(s): {', '.join(r[6:] for r in s['routes'][:4])}{' …' if len(s['routes']) > 4 else ''}")
325
+ if len(c["sites"]) > max_sites_per_chain:
326
+ L.append(f" … +{len(c['sites']) - max_sites_per_chain} more")
327
+ if res["divergence"]:
328
+ L += ["", f"== DIVERGENCE between request-driven chains ({', '.join(res['request_driven_chains'])})"]
329
+ for d in res["divergence"]:
330
+ cp = " > ".join(d["common_prefix"]) or "(nothing)"
331
+ L.append(f" [{d['a']}] vs [{d['b']}]: same up to {cp}; then [{d['a']}] {d['a_next'] or '(end)'} vs [{d['b']}] {d['b_next'] or '(end)'}"
332
+ + (f"; final fallback {d['final_a']} vs {d['final_b']}" if d["final_a"] != d["final_b"] else f"; same final fallback {d['final_a']}"))
333
+ if show_client and res["client"]:
334
+ L += ["", "== CLIENT (frontend endpoints reaching request-driven sites)"]
335
+ for it in res["client"]:
336
+ labels = sorted({b["chain"] for b in it["backend_sites"]})
337
+ L.append(f" {it['endpoint'][5:]} -> chain {', '.join(labels)} via {', '.join(r[6:] for r in it['routes'][:2])}")
338
+ if compact: # one line per request: issuer, verdict, client fallbacks
339
+ for rq in it["requests"]:
340
+ v = "; ".join(f"'{k}': {x}" for k, x in (rq.get("verdict") or {}).items())
341
+ fb = "".join(f"; client fallback @{f['at']}: {f['expr']} ?? {f['literal']!r}" for f in rq["client_fallbacks"][:2])
342
+ L.append(f" {short(rq['issuer'])} @{rq['at']} ({len(rq['callers'])} caller(s)) => {v}{fb}")
343
+ continue
344
+ for rq in it["requests"]:
345
+ keys = ", ".join(f"{k}: {v}" for k, v in rq["keys"].items()) + (f" (forwards parameter `{rq['forwarded']}`)" if rq.get("forwarded") else "")
346
+ L.append(f" request {short(rq['issuer'])} @{rq['at']} builder keys: {keys}")
347
+ for c in rq["callers"][:6]:
348
+ sends = ", ".join(f"{k}={'yes' if v is True else ('conditional' if v == 'conditional' else ('no' if v is False else '?'))}" for k, v in c["sends"].items())
349
+ L.append(f" called by {short(c['caller'])} @{c['at']} sends {sends}" + (f" (passes: {', '.join(c['arg_keys'])})" if c["arg_keys"] else ""))
350
+ for f in rq["client_fallbacks"][:4]:
351
+ L.append(f" client fallback @{f['at']}: {f['expr']} ?? {f['literal']!r}")
352
+ for k, v in (rq.get("verdict") or {}).items():
353
+ L.append(f" => '{k}': {v}")
354
+ if show_client and res.get("not_forwarded"):
355
+ L += ["", "== SENT BUT NOT FORWARDED (a call site passes the key; the request it calls never sends it)"]
356
+ for g in res["not_forwarded"]:
357
+ L.append(f" {short(g['caller'])} @{g['call_at']} passes {', '.join(g['dropped'])} to {short(g['issuer'])}; "
358
+ f"the request @{g['request_at']} ({g['endpoint'][5:]}) sends only {', '.join(g['request_keys'])}")
359
+ elif show_client and not res["backend_sites"] and not res["client"]:
360
+ L += ["", f"no resolution sites match {res['concept']!r}. try: a head word such as timezone, locale, currency or store; "
361
+ "search() / `cg search` for the name"]
362
+ return "\n".join(L)