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
@@ -0,0 +1,728 @@
1
+ """Value facts for PHP/Laravel: JSON settings, HTTP request keys, argument flow and fallback chains.
2
+
3
+ All facts are deterministic, derived from the PHP extractor's expression descriptors and the resolver's types:
4
+
5
+ * settings `$model->getSetting('a.b', default)` / `setSetting('a.b', v)` on any class that declares the accessor
6
+ -> `setting:a.b` nodes, READS_SETTING / WRITES_SETTING edges (attrs: owner, default literal).
7
+ * request keys `FormRequest::rules()` array keys -> VALIDATES; actions type-hinting a FormRequest -> VALIDATED_BY rules();
8
+ reads `$request->input|query|get|string|integer|boolean|validated('k')`, `request('k')`,
9
+ `$validated['k']` (where `$validated = $request->validated()`), and `$filters['k']` where `$filters`
10
+ receives a request array through calls (argument flow, any depth: fixpoint over the call graph)
11
+ -> READS_INPUT edges to `request_key:k` nodes (attrs: via, flow evidence).
12
+ * resolutions a variable assigned from a fallback expression (`??`, `?:`), a fallback `return`, or a function's
13
+ ordered early returns ("resolver" form) -> `resolution:` node with the expanded chain (request key,
14
+ setting + its default, model attribute / queried column, config/env, literal), parameters bound to
15
+ their call-site arguments (helper levels, depth <= 4), and FALLS_BACK_TO edges with attrs.order.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ from collections import defaultdict
22
+
23
+ from ...core.model import CONFIDENCE_RANK
24
+
25
+ EXACT, RESOLVED, HEURISTIC = "exact", "resolved", "heuristic"
26
+ REQUEST_BASES = {"illuminate\\http\\request", "illuminate\\foundation\\http\\formrequest"}
27
+ FORM_REQUEST = "illuminate\\foundation\\http\\formrequest"
28
+ REQ_SCALAR = {"input", "query", "get", "post", "string", "str", "integer", "boolean", "float", "date", "enum", "validated",
29
+ "has", "filled", "missing", "exists", "json", "array", "collect", "route"}
30
+ REQ_ARRAY = {"validated", "validate", "all", "input", "query", "post", "only", "except", "safe", "toarray", "collect", "json"}
31
+ ARRAY_FUNCS = {"array_merge", "array_replace", "array_filter", "array_intersect_key", "array_diff_key", "collect", "array_map"}
32
+ TRANSPARENT = {"strtoupper", "strtolower", "trim", "ltrim", "rtrim", "mb_strtoupper", "mb_strtolower", "ucfirst", "lcfirst",
33
+ "strval", "intval", "floatval", "boolval", "mb_strtolower", "e", "htmlspecialchars"}
34
+ SETTING_READ, SETTING_WRITE = {"getsetting"}, {"setsetting"}
35
+ SOURCE_KINDS = ("input", "setting", "column", "config", "env")
36
+ MAX_DEPTH = 4
37
+
38
+
39
+ def lit(d):
40
+ """Literal value of a descriptor, or a sentinel."""
41
+ if not d:
42
+ return None
43
+ k = d.get("k")
44
+ if k in ("str", "int"):
45
+ return d["v"]
46
+ if k == "cfetch":
47
+ n = (d.get("n") or "").lower()
48
+ return {"true": True, "false": False, "null": None}.get(n, f"<{d.get('n')}>")
49
+ if k == "arr":
50
+ return [] if not d.get("items") else "<array>"
51
+ if k == "const":
52
+ return f"{(d.get('class') or '').split(chr(92))[-1]}::{d.get('n')}"
53
+ return "<expr>"
54
+
55
+
56
+ def is_literal(d) -> bool:
57
+ return bool(d) and (d.get("k") in ("str", "int", "const") or (d.get("k") == "cfetch" and (d.get("n") or "").lower() in ("true", "false", "null")))
58
+
59
+
60
+ def short(fid: str) -> str:
61
+ x = fid.split(":", 1)[-1]
62
+ return x[4:] if x.startswith("App\\") else x
63
+
64
+
65
+ def head_token(ident: str) -> str:
66
+ """Last word of an identifier/key: customerTimezone -> timezone, reports.default -> default."""
67
+ toks = re.findall(r"[A-Z]?[a-z0-9]+|[A-Z]+(?![a-z])", (ident or "").replace("$", ""))
68
+ return toks[-1].lower() if toks else ""
69
+
70
+
71
+ def sig_of(atoms: list[dict]) -> list[str]:
72
+ """Flattened, deduplicated signature of a chain (nulls / empty strings dropped)."""
73
+ out = []
74
+
75
+ def add(s): # a source repeated later in the chain adds no new fallback: keep the first occurrence
76
+ if s and s not in out:
77
+ out.append(s)
78
+
79
+ def walk(a):
80
+ k = a["kind"]
81
+ if k == "literal":
82
+ v = a.get("value")
83
+ if v not in (None, "", [], False):
84
+ add(repr(v) if isinstance(v, str) else json.dumps(v))
85
+ elif k == "input":
86
+ add(f"input:{a['key']}")
87
+ elif k == "setting":
88
+ add(f"setting:{a['key']}")
89
+ elif k == "column":
90
+ add(f"column:{'|'.join(a.get('candidates') or [a['column']])}")
91
+ elif k in ("config", "env"):
92
+ add(f"{k}:{a['key']}")
93
+ elif k == "call":
94
+ if a.get("atoms"):
95
+ for x in a["atoms"]:
96
+ walk(x)
97
+ else:
98
+ add(f"call:{short(a['target'])}")
99
+ elif k == "param":
100
+ variants = []
101
+ for s in a.get("sources") or []:
102
+ v = sig_of(s["atoms"])
103
+ if v not in variants:
104
+ variants.append(v)
105
+ if len(variants) == 1:
106
+ for x in variants[0]:
107
+ add(x)
108
+ elif variants:
109
+ add(f"param:${a['name']}{{" + " | ".join(" > ".join(v) for v in variants) + "}")
110
+ else:
111
+ add(f"param:${a['name']}")
112
+ elif k == "expr":
113
+ add(f"expr:{a.get('text')}")
114
+ for a in atoms:
115
+ walk(a)
116
+ return out
117
+
118
+
119
+ class ValueAnalysis:
120
+ def __init__(self, lv, prog, builder):
121
+ self.lv, self.prog, self.b = lv, prog, builder
122
+ self.stats = defaultdict(int)
123
+ self.targets = {} # (fn.id, fact idx) -> [(PhpFunc, conf)]
124
+ self.callers = defaultdict(list) # callee id -> [(caller fn, fact, idx, conf)]
125
+ self.req_params = defaultdict(dict) # fn.id -> {param: evidence}
126
+ self.setting_owners = {c.fqcn for c in prog.classes.values() if "getsetting" in c.methods or "setsetting" in c.methods}
127
+ self.fn_by_id = {f.id: f for f in prog.all_funcs}
128
+ self.req_returns = {} # fn.id -> evidence: function returns (an array of) request data
129
+ self._rc = {}
130
+
131
+ # ------------------------------------------------------------------ helpers
132
+ def ctx(self, fn):
133
+ from ..php.plugin import ResolveCtx
134
+ return ResolveCtx(self.prog, fn)
135
+
136
+ def types(self, d, fn):
137
+ return self.prog.type_of(d, fn, fn.env) if d else set()
138
+
139
+ def is_request_type(self, types) -> bool:
140
+ for t in types:
141
+ if any(a.lower() in REQUEST_BASES for a in self.prog.ancestors(t)):
142
+ return True
143
+ return False
144
+
145
+ def is_request_recv(self, d, fn) -> bool:
146
+ if not d:
147
+ return False
148
+ if d.get("k") == "func" and (d.get("n") or "").lower() == "request" and not d.get("args"):
149
+ return True
150
+ return self.is_request_type(self.types(d, fn))
151
+
152
+ def call_fact(self, d):
153
+ k = d.get("k")
154
+ if k == "mcall":
155
+ return {"t": "call", "kind": "method", "recv": d.get("of"), "m": d.get("m"), "args": d.get("args") or []}
156
+ if k == "scall":
157
+ return {"t": "call", "kind": "static", "class": d.get("class"), "m": d.get("m"), "args": d.get("args") or []}
158
+ if k == "func":
159
+ return {"t": "call", "kind": "func", "n": d.get("n"), "args": d.get("args") or []}
160
+ return None
161
+
162
+ def resolve_call(self, d, fn):
163
+ f = self.call_fact(d)
164
+ if not f:
165
+ return []
166
+ try:
167
+ t, _ = self.prog.call_targets(fn, f, self.ctx(fn))
168
+ except Exception:
169
+ return []
170
+ return [(m, c) for m, c in t if c != HEURISTIC]
171
+
172
+ def param_index(self, m, ai, a):
173
+ if a.get("named"):
174
+ for i, p in enumerate(m.params):
175
+ if p["name"] == a["named"]:
176
+ return i
177
+ return None
178
+ return ai if ai < len(m.params) else None
179
+
180
+ # ------------------------------------------------------------------ call index + request-array flow
181
+ def index_calls(self):
182
+ for fn in self.prog.all_funcs:
183
+ ctx = self.ctx(fn)
184
+ for i, f in enumerate(fn.facts):
185
+ if f["t"] != "call":
186
+ continue
187
+ try:
188
+ t, _ = self.prog.call_targets(fn, f, ctx)
189
+ except Exception:
190
+ t = []
191
+ t = [(m, c) for m, c in t if c != HEURISTIC]
192
+ self.targets[(fn.id, i)] = t
193
+ for m, c in t:
194
+ self.callers[m.id].append((fn, f, i, c))
195
+
196
+ def req_array(self, d, fn, rvars, depth=0):
197
+ """Evidence string if descriptor d evaluates to (part of) the request's input array, else None."""
198
+ if not d or depth > 6:
199
+ return None
200
+ k = d.get("k")
201
+ if k == "var":
202
+ return rvars.get(d["n"])
203
+ if k == "mcall":
204
+ m = (d.get("m") or "").lower()
205
+ if m in REQ_ARRAY and (not d.get("args") or m in ("only", "except", "validate")) and self.is_request_recv(d.get("of"), fn):
206
+ return f"$request->{d.get('m')}()"
207
+ if m in ("toarray", "all", "only", "except", "collect") and self.req_array(d.get("of"), fn, rvars, depth + 1):
208
+ return self.req_array(d.get("of"), fn, rvars, depth + 1)
209
+ if k in ("mcall", "scall", "func"):
210
+ for t, _ in self.resolve_call_cached(d, fn):
211
+ if t.id in self.req_returns:
212
+ return f"{short(t.id)}() returns request data"
213
+ return None
214
+ if k == "arr":
215
+ for it in d.get("items") or []:
216
+ if self.contains_input(it.get("v"), fn, rvars):
217
+ return "array built from request keys"
218
+ return None
219
+ if k == "alt":
220
+ for o in d.get("opts") or []:
221
+ e = self.req_array(o, fn, rvars, depth + 1)
222
+ if e:
223
+ return e
224
+ return None
225
+ if k == "func" and (d.get("n") or "").lower() in ARRAY_FUNCS:
226
+ for a in d.get("args") or []:
227
+ e = self.req_array(a, fn, rvars, depth + 1)
228
+ if e:
229
+ return e
230
+ return None
231
+
232
+ def resolve_call_cached(self, d, fn):
233
+ key = (fn.id, id(d))
234
+ r = self._rc.get(key)
235
+ if r is None:
236
+ r = self._rc[key] = (self.resolve_call(d, fn), d)
237
+ return r[0]
238
+
239
+ def contains_input(self, d, fn, rvars, depth=0) -> bool:
240
+ if not isinstance(d, dict) or depth > 6:
241
+ return False
242
+ k = d.get("k")
243
+ if k == "dim" and (d.get("key") or {}).get("k") == "str" and self.req_array(d.get("of"), fn, rvars, depth + 1):
244
+ return True
245
+ if k == "mcall" and (d.get("m") or "").lower() in REQ_SCALAR and (d.get("args") or [{}])[0].get("k") == "str" \
246
+ and self.is_request_recv(d.get("of"), fn):
247
+ return True
248
+ for v in d.values():
249
+ if isinstance(v, dict) and self.contains_input(v, fn, rvars, depth + 1):
250
+ return True
251
+ if isinstance(v, list) and any(self.contains_input(x, fn, rvars, depth + 1) for x in v if isinstance(x, dict)):
252
+ return True
253
+ return False
254
+
255
+ def rvars(self, fn) -> dict:
256
+ rv = {p: f"param ${p}" for p in self.req_params.get(fn.id, {})}
257
+ for _ in range(3):
258
+ changed = False
259
+ for f in fn.facts:
260
+ if f["t"] == "assign" and f["var"] not in rv:
261
+ e = self.req_array(f["expr"], fn, rv)
262
+ if e:
263
+ rv[f["var"]] = e
264
+ changed = True
265
+ if not changed:
266
+ break
267
+ return rv
268
+
269
+ def flow_request_arrays(self, rounds=8):
270
+ for _ in range(rounds):
271
+ changed = 0
272
+ for fn in self.prog.all_funcs:
273
+ rv = None
274
+ if fn.id not in self.req_returns:
275
+ rv = self.rvars(fn)
276
+ for f in fn.facts:
277
+ if f["t"] == "return" and not f.get("ctx"):
278
+ ev = self.req_array(f["expr"], fn, rv)
279
+ if ev:
280
+ self.req_returns[fn.id] = {"via": ev, "file": fn.file, "line": f.get("line")}
281
+ changed += 1
282
+ break
283
+ for i, f in enumerate(fn.facts):
284
+ t = self.targets.get((fn.id, i))
285
+ if not t or not f.get("args"):
286
+ continue
287
+ if rv is None:
288
+ rv = self.rvars(fn)
289
+ for ai, a in enumerate(f["args"]):
290
+ ev = self.req_array(a, fn, rv)
291
+ if not ev:
292
+ continue
293
+ for m, conf in t:
294
+ pi = self.param_index(m, ai, a)
295
+ if pi is None:
296
+ continue
297
+ pn = m.params[pi]["name"]
298
+ if pn not in self.req_params[m.id]:
299
+ self.req_params[m.id][pn] = {"from": fn.id, "file": fn.file, "line": f.get("line"), "via": ev,
300
+ "gated": bool(fn.dead.get(i))}
301
+ changed += 1
302
+ if not changed:
303
+ break
304
+ self.stats["request_array_params"] = sum(len(v) for v in self.req_params.values())
305
+ self.stats["request_array_returns"] = len(self.req_returns)
306
+
307
+ def flow_chain(self, fn_id, pname, limit=6) -> list[str]:
308
+ """Human-readable evidence for a request-array parameter: `$filters <- X::report L33 <- Ctl::index L27 ($request->validated())`."""
309
+ out, seen = [], set()
310
+ while fn_id and (fn_id, pname) not in seen and len(out) < limit:
311
+ seen.add((fn_id, pname))
312
+ ev = self.req_params.get(fn_id, {}).get(pname)
313
+ if not ev:
314
+ break
315
+ out.append(f"{short(ev['from'])} @{ev['file']}:{ev['line']} passes {ev['via']}")
316
+ if ev["via"].startswith("param $"):
317
+ fn_id, pname = ev["from"], ev["via"][len("param $"):]
318
+ else:
319
+ break
320
+ return out
321
+
322
+ # ------------------------------------------------------------------ settings + request keys
323
+ def emit_settings_and_inputs(self):
324
+ b = self.b
325
+ for fn in self.prog.all_funcs:
326
+ if not self.b.has(fn.id):
327
+ continue
328
+ rv = self.rvars(fn)
329
+ for i, f in enumerate(fn.facts):
330
+ b.current_gate = fn.dead.get(i)
331
+ if f["t"] == "call":
332
+ self._call_fact(fn, f, rv)
333
+ for d in self._descs(f):
334
+ self._walk_dims(fn, d, rv, f.get("line"))
335
+ b.current_gate = None
336
+ # FormRequest rules + actions validated by them
337
+ prog = self.prog
338
+ for c in prog.classes.values():
339
+ if c.kind != "class" or prog.is_test_class(c.fqcn) or not any(a.lower() == FORM_REQUEST for a in prog.ancestors(c.fqcn)):
340
+ continue
341
+ rules = c.methods.get("rules")
342
+ if not rules:
343
+ continue
344
+ for f in rules.facts:
345
+ if f["t"] != "return" or (f.get("expr") or {}).get("k") != "arr" or f.get("ctx"):
346
+ continue
347
+ for it in f["expr"].get("items") or []:
348
+ kd = it.get("key") or {}
349
+ if kd.get("k") != "str":
350
+ continue
351
+ rule = it.get("v") or {}
352
+ rtxt = rule.get("v") if rule.get("k") == "str" else (
353
+ [x["v"].get("v") for x in rule.get("items") or [] if (x.get("v") or {}).get("k") == "str"] if rule.get("k") == "arr" else None)
354
+ kid = self._key_node(kd["v"])
355
+ b.add_edge(rules.id, kid, "VALIDATES", rules.file, f.get("line"), EXACT, rule=rtxt)
356
+ self.stats["validated_keys"] += 1
357
+ for fn in prog.all_funcs:
358
+ if not fn.cls:
359
+ continue
360
+ for p in fn.params:
361
+ for t in prog.norm(p.get("types"), fn.cls):
362
+ tc = prog.cls(t)
363
+ if tc and "rules" in tc.methods and any(a.lower() == FORM_REQUEST for a in prog.ancestors(tc.fqcn)):
364
+ b.add_edge(fn.id, tc.methods["rules"].id, "VALIDATED_BY", fn.file, p.get("line"), EXACT, param=p["name"])
365
+ self.stats["validated_by"] += 1
366
+
367
+ def _descs(self, f):
368
+ t = f["t"]
369
+ if t == "assign":
370
+ yield f["expr"]
371
+ elif t == "return":
372
+ yield f["expr"]
373
+ elif t == "call":
374
+ if f.get("recv"):
375
+ yield f["recv"]
376
+ for a in f.get("args") or []:
377
+ yield a
378
+
379
+ def _key_node(self, key: str) -> str:
380
+ return self.b.add_node("request_key", key, name=key, fqn=key, lang="php", module="http")
381
+
382
+ def _call_fact(self, fn, f, rv):
383
+ b, m, args, line = self.b, (f.get("m") or "").lower(), f.get("args") or [], f.get("line")
384
+ a0 = args[0] if args else None
385
+ if f["kind"] == "method" and m in SETTING_READ | SETTING_WRITE and a0:
386
+ owners = sorted({t.split("\\")[-1] for t in self.types(f.get("recv"), fn) if t in self.setting_owners})
387
+ key = self.lv.render(fn, a0)
388
+ if not key:
389
+ return
390
+ sid = self.b.add_node("setting", key, name=key, fqn=key, lang="php", module="settings")
391
+ n = self.b.nodes[sid]
392
+ n.attrs.setdefault("owners", [])
393
+ for o in owners:
394
+ if o not in n.attrs["owners"]:
395
+ n.attrs["owners"].append(o)
396
+ if m in SETTING_READ:
397
+ dflt = lit(args[1]) if len(args) > 1 else None
398
+ b.add_edge(fn.id, sid, "READS_SETTING", fn.file, line, EXACT if a0.get("k") == "str" else RESOLVED,
399
+ owner=owners or None, default=dflt)
400
+ self.stats["setting_reads"] += 1
401
+ else:
402
+ b.add_edge(fn.id, sid, "WRITES_SETTING", fn.file, line, EXACT if a0.get("k") == "str" else RESOLVED, owner=owners or None)
403
+ self.stats["setting_writes"] += 1
404
+ return
405
+ if f["kind"] == "method" and m in REQ_SCALAR and a0 and a0.get("k") == "str" and self.is_request_recv(f.get("recv"), fn):
406
+ b.add_edge(fn.id, self._key_node(a0["v"]), "READS_INPUT", fn.file, line, EXACT, via=f"$request->{f.get('m')}()",
407
+ default=lit(args[1]) if len(args) > 1 else None)
408
+ self.stats["input_reads"] += 1
409
+ elif f["kind"] == "func" and (f.get("n") or "").lower() == "request" and a0 and a0.get("k") == "str":
410
+ b.add_edge(fn.id, self._key_node(a0["v"]), "READS_INPUT", fn.file, line, EXACT, via="request()",
411
+ default=lit(args[1]) if len(args) > 1 else None)
412
+ self.stats["input_reads"] += 1
413
+
414
+ def _walk_dims(self, fn, d, rv, line, depth=0):
415
+ if not isinstance(d, dict) or depth > 10:
416
+ return
417
+ if d.get("k") == "dim" and (d.get("key") or {}).get("k") == "str":
418
+ ev = self.req_array(d.get("of"), fn, rv)
419
+ if ev:
420
+ of = d.get("of") or {}
421
+ flow = self.flow_chain(fn.id, of.get("n")) if of.get("k") == "var" and of.get("n") in self.req_params.get(fn.id, {}) else []
422
+ self.b.add_edge(fn.id, self._key_node(d["key"]["v"]), "READS_INPUT", fn.file, line,
423
+ EXACT if not flow else RESOLVED, via=f"${of.get('n')}[...]" if of.get("k") == "var" else ev,
424
+ flow=flow or None)
425
+ self.stats["input_reads"] += 1
426
+ for v in d.values():
427
+ if isinstance(v, dict):
428
+ self._walk_dims(fn, v, rv, line, depth + 1)
429
+ elif isinstance(v, list):
430
+ for x in v:
431
+ self._walk_dims(fn, x, rv, line, depth + 1)
432
+
433
+ # ------------------------------------------------------------------ fallback chains
434
+ def strip(self, d, norm=()):
435
+ while d and d.get("k") == "func" and (d.get("n") or "").lower() in TRANSPARENT and d.get("args"):
436
+ norm = norm + ((d.get("n") or "").lower(),)
437
+ d = d["args"][0]
438
+ return d, norm
439
+
440
+ def has_fallback(self, d) -> bool:
441
+ d, _ = self.strip(d)
442
+ return bool(d) and d.get("k") == "alt" and d.get("op") in ("coalesce", "elvis")
443
+
444
+ def assign_for(self, fn, var, line):
445
+ cands = [f for f in fn.facts if f["t"] == "assign" and f["var"] == var and not f.get("ctx")]
446
+ before = [f for f in cands if (f.get("line") or 0) <= (line or 10 ** 9)]
447
+ return (before or cands)[-1] if (before or cands) else None
448
+
449
+ def expand(self, d, fn, line, depth=0, bind=None, seen=None, norm=()):
450
+ seen = seen if seen is not None else set()
451
+ d, norm = self.strip(d, norm)
452
+ at = f"{fn.file}:{line}"
453
+ if not d:
454
+ return []
455
+ k = d.get("k")
456
+ if k == "alt":
457
+ out = []
458
+ for o in d.get("opts") or []:
459
+ out += self.expand(o, fn, line, depth, bind, seen, norm)
460
+ return out
461
+ if is_literal(d):
462
+ return [{"kind": "literal", "value": lit(d), "at": at, "norm": list(norm) or None}]
463
+ if k == "var":
464
+ name = d["n"]
465
+ pnames = [p["name"] for p in fn.params]
466
+ asg = self.assign_for(fn, name, line)
467
+ if asg and (fn.id, name) not in seen:
468
+ return self.expand(asg["expr"], fn, asg.get("line"), depth, bind, seen | {(fn.id, name)}, norm)
469
+ if name in pnames:
470
+ return [self.param_atom(fn, name, pnames.index(name), line, depth, bind, seen, norm)]
471
+ return [{"kind": "expr", "text": f"${name}", "at": at}]
472
+ if k == "dim":
473
+ key = (d.get("key") or {}).get("v") if (d.get("key") or {}).get("k") == "str" else None
474
+ rv = self.rvars(fn)
475
+ ev = self.req_array(d.get("of"), fn, rv) if key else None
476
+ if ev:
477
+ of = d.get("of") or {}
478
+ flow = self.flow_chain(fn.id, of.get("n")) if of.get("k") == "var" else []
479
+ if bind and of.get("k") == "var" and of.get("n") in bind:
480
+ flow = [f"bound at {bind[of['n']][2]}"] + flow
481
+ return [{"kind": "input", "key": key, "via": f"${of.get('n')}[...]" if of.get("k") == "var" else ev,
482
+ "flow": flow or None, "at": at, "norm": list(norm) or None}]
483
+ return [{"kind": "expr", "text": f"{self.text(d.get('of'))}[{key!r}]" if key else self.text(d), "at": at}]
484
+ if k == "mcall":
485
+ m = (d.get("m") or "").lower()
486
+ args = d.get("args") or []
487
+ if m in SETTING_READ and args:
488
+ key = self.lv.render(fn, args[0])
489
+ owners = sorted({t.split("\\")[-1] for t in self.types(d.get("of"), fn) if t in self.setting_owners})
490
+ out = [{"kind": "setting", "key": key, "owner": owners or None, "default": lit(args[1]) if len(args) > 1 else None,
491
+ "at": at, "norm": list(norm) or None}]
492
+ if len(args) > 1 and is_literal(args[1]):
493
+ out.append({"kind": "literal", "value": lit(args[1]), "at": at, "from": "getSetting default"})
494
+ return out
495
+ if m in REQ_SCALAR and args and args[0].get("k") == "str" and self.is_request_recv(d.get("of"), fn):
496
+ out = [{"kind": "input", "key": args[0]["v"], "via": f"$request->{d.get('m')}()", "at": at}]
497
+ if len(args) > 1 and is_literal(args[1]):
498
+ out.append({"kind": "literal", "value": lit(args[1]), "at": at, "from": "input default"})
499
+ return out
500
+ if m in ("value", "pluck") and args and args[0].get("k") == "str":
501
+ tables = self.builder_tables(d.get("of"), fn, depth, bind)
502
+ col = args[0]["v"]
503
+ if len(tables) == 1:
504
+ return [{"kind": "column", "column": f"{tables[0][0]}.{col}", "how": f"->{d.get('m')}() ({tables[0][1]})", "at": at}]
505
+ return [{"kind": "expr", "text": f"->{d.get('m')}('{col}')", "at": at}]
506
+ return self.call_atom(d, fn, line, depth, bind, seen, norm)
507
+ if k in ("scall", "func"):
508
+ n = (d.get("n") or d.get("m") or "").lower()
509
+ args = d.get("args") or []
510
+ if k == "func" and n in ("config", "env") and args and args[0].get("k") == "str":
511
+ out = [{"kind": n, "key": args[0]["v"], "default": lit(args[1]) if len(args) > 1 else None, "at": at}]
512
+ if len(args) > 1 and is_literal(args[1]):
513
+ out.append({"kind": "literal", "value": lit(args[1]), "at": at, "from": f"{n}() default"})
514
+ return out
515
+ if k == "func" and n == "request" and args and args[0].get("k") == "str":
516
+ return [{"kind": "input", "key": args[0]["v"], "via": "request()", "at": at}]
517
+ return self.call_atom(d, fn, line, depth, bind, seen, norm)
518
+ if k == "prop":
519
+ prop = d.get("n")
520
+ models = sorted(t for t in self.types(d.get("of"), fn)
521
+ if t in self.lv.models and prop and prop.lower() not in self.lv.models[t]["relations"])
522
+ if models:
523
+ # #79: a receiver that can be several models (`Income|Expense $doc`) reads the column of each, in a
524
+ # stable order; narrowed to the tables whose migrations declare the column, else to the tables the
525
+ # migrations create (not an abstract base model's guessed table)
526
+ cands = [(t, self.lv.table_of(t)) for t in models]
527
+ declared = getattr(self.lv, "col_tables", {}).get(prop, ())
528
+ tables = getattr(self.lv, "tables", {})
529
+ cands = [c for c in cands if c[1] in declared] or [c for c in cands if c[1] in tables] or cands
530
+ cols = list(dict.fromkeys(f"{tb}.{prop}" for _, tb in cands))
531
+ a = {"kind": "column", "column": cols[0], "how": "|".join(t.split(chr(92))[-1] for t, _ in cands) + f"->{prop}",
532
+ "at": at, "norm": list(norm) or None}
533
+ if len(cols) > 1:
534
+ a["candidates"] = cols
535
+ return [a]
536
+ return [{"kind": "expr", "text": self.text(d), "at": at}]
537
+ return [{"kind": "expr", "text": self.text(d), "at": at}]
538
+
539
+ def text(self, d, depth=0) -> str:
540
+ if not d or depth > 4:
541
+ return "…"
542
+ k = d.get("k")
543
+ if k == "var":
544
+ return f"${d['n']}"
545
+ if k == "this":
546
+ return "$this"
547
+ if k == "prop":
548
+ return f"{self.text(d.get('of'), depth + 1)}->{d.get('n')}"
549
+ if k == "mcall":
550
+ return f"{self.text(d.get('of'), depth + 1)}->{d.get('m')}()"
551
+ if k == "scall":
552
+ return f"{(d.get('class') or '?').split(chr(92))[-1]}::{d.get('m')}()"
553
+ if k == "func":
554
+ return f"{d.get('n')}()"
555
+ if k == "dim":
556
+ return f"{self.text(d.get('of'), depth + 1)}[…]"
557
+ if k in ("str", "int"):
558
+ return repr(d["v"])
559
+ return f"<{k}>"
560
+
561
+ def builder_tables(self, d, fn, depth, bind) -> list[tuple[str, str]]:
562
+ """Tables a query-builder expression targets; through bound / caller arguments for untyped Builder params."""
563
+ out = []
564
+ for t in self.types(d, fn):
565
+ if t.startswith("builder:"):
566
+ tb = self.lv.table_of(t.split(":", 1)[1])
567
+ if tb:
568
+ out.append((tb, "builder type"))
569
+ elif t.startswith("qb:"):
570
+ out.append((t.split(":", 1)[1], "DB::table"))
571
+ if out:
572
+ return sorted(set(out))
573
+ root = d
574
+ while root and root.get("k") == "mcall":
575
+ root = root.get("of")
576
+ if root and root.get("k") == "var" and depth < MAX_DEPTH:
577
+ name = root["n"]
578
+ asg = self.assign_for(fn, name, None)
579
+ if asg:
580
+ return self.builder_tables(asg["expr"], fn, depth + 1, bind)
581
+ pnames = [p["name"] for p in fn.params]
582
+ if name in pnames:
583
+ idx = pnames.index(name)
584
+ srcs = [bind[name][:2]] if bind and name in bind else [(a, c) for c, a in self._caller_args(fn, idx)]
585
+ for a, cf in srcs:
586
+ for tb, how in self.builder_tables(a, cf, depth + 1, None):
587
+ out.append((tb, f"argument of {short(cf.id)}"))
588
+ return sorted(set(out))[:3]
589
+
590
+ def _caller_args(self, fn, idx):
591
+ out = []
592
+ for cf, f, i, conf in self.callers.get(fn.id, []):
593
+ args = f.get("args") or []
594
+ for ai, a in enumerate(args):
595
+ fnp = self.fn_by_id.get(fn.id)
596
+ pi = self.param_index(fnp, ai, a) if fnp else None
597
+ if pi == idx:
598
+ out.append((cf, a))
599
+ return out
600
+
601
+ def param_atom(self, fn, name, idx, line, depth, bind, seen, norm):
602
+ atom = {"kind": "param", "name": name, "fn": fn.id, "at": f"{fn.file}:{line}", "norm": list(norm) or None, "sources": []}
603
+ if bind and name in bind:
604
+ a, cf, cat = bind[name]
605
+ atom["sources"].append({"caller": cf.id, "at": cat, "atoms": self.expand(a, cf, int(cat.rsplit(":", 1)[1]), depth + 1, None, seen)})
606
+ return atom
607
+ if depth >= MAX_DEPTH:
608
+ return atom
609
+ for cf, f, i, conf in self.callers.get(fn.id, [])[:40]:
610
+ args = f.get("args") or []
611
+ for ai, a in enumerate(args):
612
+ if self.param_index(fn, ai, a) == idx:
613
+ atom["sources"].append({"caller": cf.id, "at": f"{cf.file}:{f.get('line')}", "gated": bool(cf.dead.get(i)) or None,
614
+ "atoms": self.expand(a, cf, f.get("line"), depth + 1, None, seen)})
615
+ return atom
616
+
617
+ def call_atom(self, d, fn, line, depth, bind, seen, norm):
618
+ """Helper level: a call to an app method whose own value is a fallback chain is inlined (args bound)."""
619
+ targets = self.resolve_call(d, fn)
620
+ at = f"{fn.file}:{line}"
621
+ if len(targets) == 1 and depth < MAX_DEPTH:
622
+ m = targets[0][0]
623
+ chain = self.fn_chain(m)
624
+ if chain and (m.id, "fn") not in seen:
625
+ b2 = {}
626
+ for ai, a in enumerate(d.get("args") or []):
627
+ pi = self.param_index(m, ai, a)
628
+ if pi is not None:
629
+ b2[m.params[pi]["name"]] = (a, fn, at)
630
+ atoms = []
631
+ for e, ln in chain:
632
+ atoms += self.expand(e, m, ln, depth + 1, b2, seen | {(m.id, "fn")})
633
+ return [{"kind": "call", "target": m.id, "at": at, "atoms": atoms, "norm": list(norm) or None}]
634
+ return [{"kind": "call", "target": m.id, "at": at}]
635
+ return [{"kind": "expr", "text": self.text(d), "at": at}]
636
+
637
+ def fn_chain(self, m):
638
+ """[(expr, line)] of a function's value: its single fallback return, or its ordered early returns."""
639
+ rets = [f for f in m.facts if f["t"] == "return" and not f.get("ctx")]
640
+ if not rets or any((f.get("expr") or {}).get("k") in ("arr", "new", "closure") for f in rets):
641
+ return None
642
+ if len(rets) == 1:
643
+ return [(rets[0]["expr"], rets[0].get("line"))] if self.has_fallback(rets[0]["expr"]) else None
644
+ return [(f["expr"], f.get("line")) for f in rets]
645
+
646
+ def emit_resolutions(self):
647
+ b = self.b
648
+ for fn in self.prog.all_funcs:
649
+ if not b.has(fn.id) or fn.file.startswith(("config/", "database/", "routes/", "lang/")):
650
+ continue
651
+ sites = []
652
+ rets = [f for f in fn.facts if f["t"] == "return" and not f.get("ctx")]
653
+ chain = self.fn_chain(fn) if len(rets) >= 2 else None
654
+ if chain:
655
+ sites.append(("return", "returns", fn.line, chain))
656
+ for f in fn.facts:
657
+ if f.get("ctx"):
658
+ continue
659
+ if f["t"] == "assign" and self.has_fallback(f["expr"]):
660
+ sites.append((f"${f['var']}", "expr", f.get("line"), [(f["expr"], f.get("line"))]))
661
+ elif f["t"] == "return" and not chain and self.has_fallback(f["expr"]):
662
+ sites.append(("return", "expr", f.get("line"), [(f["expr"], f.get("line"))]))
663
+ for target, form, line, ch in sites:
664
+ atoms = []
665
+ for e, ln in ch:
666
+ atoms += self.expand(e, fn, ln)
667
+ sig = sig_of(atoms)
668
+ kinds = {s.split(":", 1)[0] for s in sig}
669
+ if not (kinds & set(SOURCE_KINDS)) or len(sig) < 2:
670
+ continue
671
+ tname = fn.name if target == "return" else target
672
+ key = f"{fn.id.split(':', 1)[1]}#{target}@{line}"
673
+ lits = [a for a in self._flat(atoms) if a["kind"] == "literal" and a.get("value") not in (None, "", [], False)]
674
+ rid = b.add_node("resolution", key, name=f"{short(fn.id)} {target}", fqn=f"{fn.id.split(':', 1)[1]}#{target}",
675
+ file=fn.file, line=line, module=self.prog.b.nodes[fn.id].module if fn.id in self.prog.b.nodes else None,
676
+ lang="php", attrs={"fn": fn.id, "target": target, "target_name": tname, "form": form, "chain": atoms,
677
+ "signature": sig, "final_literal": lits[-1]["value"] if lits else None})
678
+ b.add_edge(fn.id, rid, "HAS_RESOLUTION", fn.file, line, EXACT, target=target)
679
+ order = 0
680
+ for a in self._flat(atoms):
681
+ alts = a.get("candidates") if a["kind"] == "column" else None
682
+ dsts = [d for d in ([self._source_node({**a, "column": c}) for c in alts] if alts else [self._source_node(a)]) if d]
683
+ if dsts:
684
+ order += 1
685
+ for dst in dsts: # one fallback step; several columns when the receiver can be several models (#79)
686
+ b.add_edge(rid, dst, "FALLS_BACK_TO", a.get("at", "").rsplit(":", 1)[0] or fn.file,
687
+ int(a["at"].rsplit(":", 1)[1]) if a.get("at") and a["at"].rsplit(":", 1)[1].isdigit() else line,
688
+ EXACT if a["kind"] in ("setting", "input", "config", "env") else RESOLVED,
689
+ order=order, default=a.get("default"), **({"ambiguous": True, "candidates": alts} if alts else {}))
690
+ self.stats["resolutions"] += 1
691
+
692
+ def _flat(self, atoms):
693
+ for a in atoms:
694
+ if a["kind"] == "call" and a.get("atoms"):
695
+ yield from self._flat(a["atoms"])
696
+ elif a["kind"] == "param" and a.get("sources"):
697
+ seen = []
698
+ for s in a["sources"]:
699
+ for x in self._flat(s["atoms"]):
700
+ k = json.dumps({kk: vv for kk, vv in x.items() if kk in ("kind", "key", "column", "value")}, sort_keys=True)
701
+ if k not in seen:
702
+ seen.append(k)
703
+ yield x
704
+ else:
705
+ yield a
706
+
707
+ def _source_node(self, a):
708
+ k = a["kind"]
709
+ if k == "setting" and a.get("key"):
710
+ return self.b.add_node("setting", a["key"], name=a["key"], fqn=a["key"], lang="php", module="settings")
711
+ if k == "input" and a.get("key"):
712
+ return self._key_node(a["key"])
713
+ if k == "column":
714
+ tbl, col = a["column"].split(".", 1)
715
+ if tbl and tbl != "None":
716
+ return self.b.add_node("column", a["column"], name=col, fqn=a["column"], lang="sql")
717
+ if k == "config":
718
+ return self.b.add_node("config", a["key"], lang="php")
719
+ if k == "env":
720
+ return self.b.add_node("env", a["key"], lang="env")
721
+ return None
722
+
723
+ def run(self) -> dict:
724
+ self.index_calls()
725
+ self.flow_request_arrays()
726
+ self.emit_settings_and_inputs()
727
+ self.emit_resolutions()
728
+ return dict(self.stats)