cg-code-graph 0.10.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- codegraph/xcode.py +251 -0
|
@@ -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)
|