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,863 @@
|
|
|
1
|
+
"""Laravel framework plugin (on top of the PHP plugin). Purely static: never boots the app.
|
|
2
|
+
|
|
3
|
+
register_hooks(prog) (before PHP reference resolution)
|
|
4
|
+
* parses config/*.php (config keys, env reads, DB connections), migrations (tables/columns),
|
|
5
|
+
Eloquent models (table, $connection, relations) and middleware aliases
|
|
6
|
+
* registers type rules: Eloquent static forwarding -> builder:<Model>, builder/collection
|
|
7
|
+
chains, relation properties, DB::table() -> qb:<table>, app()/resolve() container lookups
|
|
8
|
+
* registers fact handlers: column/table reads+writes, DB connections, config()/env(),
|
|
9
|
+
Config::set('database.connections.*'), dispatch/event, container bindings, Artisan::call
|
|
10
|
+
contribute(...) (after)
|
|
11
|
+
* routes, artisan commands + scheduler, jobs, listeners, Filament admin, observers, bindings
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
|
|
18
|
+
from ...core.model import EXACT, HEURISTIC, RESOLVED
|
|
19
|
+
from ...core.plugin import FrameworkPlugin, GraphBuilder, Project
|
|
20
|
+
from ..php.plugin import PhpFunc, PhpProgram, ResolveCtx, module_of
|
|
21
|
+
|
|
22
|
+
MODEL_BASES = ("Illuminate\\Database\\Eloquent\\Model", "Illuminate\\Foundation\\Auth\\User",
|
|
23
|
+
"Illuminate\\Database\\Eloquent\\Relations\\Pivot", "Illuminate\\Database\\Eloquent\\Relations\\MorphPivot")
|
|
24
|
+
RELATIONS = {"hasone", "hasmany", "belongsto", "belongstomany", "morphto", "morphone", "morphmany", "morphtomany",
|
|
25
|
+
"morphedbymany", "hasonethrough", "hasmanythrough"}
|
|
26
|
+
MANY_RELATIONS = {"hasmany", "belongstomany", "morphmany", "morphtomany", "morphedbymany", "hasmanythrough"}
|
|
27
|
+
RETRIEVE_ONE = {"find", "findorfail", "first", "firstorfail", "firstwhere", "sole", "create", "forcecreate", "make",
|
|
28
|
+
"firstorcreate", "firstornew", "updateorcreate", "findornew", "findorcreate", "newmodelinstance", "replicate", "fresh"}
|
|
29
|
+
RETRIEVE_MANY = {"get", "all", "paginate", "simplepaginate", "cursorpaginate", "cursor", "lazy", "lazybyid", "findmany"}
|
|
30
|
+
SCALAR_TERMINALS = {"count", "exists", "doesntexist", "sum", "avg", "max", "min", "pluck", "value", "delete", "update",
|
|
31
|
+
"insert", "insertgetid", "increment", "decrement", "chunk", "chunkbyid", "each", "toarray", "tosql",
|
|
32
|
+
"upsert", "forcedelete", "restore", "truncate", "implode"}
|
|
33
|
+
COL_ARG0 = {"where", "orwhere", "wherein", "wherenotin", "orwherein", "orwherenotin", "wherenull", "wherenotnull",
|
|
34
|
+
"orwherenull", "orwherenotnull", "wherebetween", "wherenotbetween", "wheredate", "wheremonth", "whereyear",
|
|
35
|
+
"whereday", "wheretime", "wherecolumn", "orderby", "orderbydesc", "groupby", "pluck", "value", "sum", "avg",
|
|
36
|
+
"max", "min", "increment", "decrement", "firstwhere", "latest", "oldest", "wherejsoncontains", "wherenot",
|
|
37
|
+
"wherelike", "having", "keyby", "sortby", "sortbydesc", "wherejsonlength"}
|
|
38
|
+
COL_ALL_ARGS = {"select", "addselect", "groupby", "only", "except"}
|
|
39
|
+
WRITE_ARRAY0 = {"create", "forcecreate", "insert", "insertgetid", "insertorignore", "update", "upsert", "fill", "forcefill"}
|
|
40
|
+
WRITE_ARRAY1 = {"firstorcreate", "updateorcreate", "updateorinsert", "firstornew"}
|
|
41
|
+
WRITE_TABLE = {"save", "update", "delete", "forcedelete", "insert", "insertgetid", "insertorignore", "upsert", "create",
|
|
42
|
+
"forcecreate", "increment", "decrement", "truncate", "updateorcreate", "firstorcreate", "updateorinsert",
|
|
43
|
+
"restore", "push", "touch", "savequietly", "updatequietly", "deletequietly"}
|
|
44
|
+
COLLECTION_KEEP = {"filter", "where", "wherein", "sortby", "sortbydesc", "values", "keyby", "unique", "reject", "take",
|
|
45
|
+
"slice", "merge", "concat", "reverse", "wherenotnull", "wherenotin", "groupby"}
|
|
46
|
+
COLUMN_METHODS = set("""bigincrements biginteger binary boolean char date datetime datetimetz decimal double enum float
|
|
47
|
+
foreignid foreignuuid foreignulid geometry increments integer ipaddress json jsonb longtext macaddress mediumincrements
|
|
48
|
+
mediuminteger mediumtext point set smallincrements smallinteger string text time timetz timestamp timestamptz tinyincrements
|
|
49
|
+
tinyinteger tinytext unsignedbiginteger unsigneddecimal unsignedinteger unsignedmediuminteger unsignedsmallinteger
|
|
50
|
+
unsignedtinyinteger uuid ulid year vector""".split())
|
|
51
|
+
FACADES = {"db": "Illuminate\\Support\\Facades\\DB", "config": "Illuminate\\Support\\Facades\\Config",
|
|
52
|
+
"schema": "Illuminate\\Support\\Facades\\Schema", "schedule": "Illuminate\\Support\\Facades\\Schedule",
|
|
53
|
+
"event": "Illuminate\\Support\\Facades\\Event", "bus": "Illuminate\\Support\\Facades\\Bus",
|
|
54
|
+
"artisan": "Illuminate\\Support\\Facades\\Artisan", "queue": "Illuminate\\Support\\Facades\\Queue"}
|
|
55
|
+
GENERIC_COLUMNS = {"created_at", "updated_at", "deleted_at", "id", "name", "status", "type", "code", "notes"}
|
|
56
|
+
IRREGULAR = {"person": "people", "child": "children", "man": "men", "woman": "women", "tooth": "teeth", "foot": "feet",
|
|
57
|
+
"mouse": "mice", "goose": "geese", "datum": "data", "criterion": "criteria", "analysis": "analyses"}
|
|
58
|
+
UNCOUNTABLE = {"equipment", "information", "rice", "money", "species", "series", "fish", "sheep", "feedback", "metadata",
|
|
59
|
+
"staff", "data", "audio", "media", "news", "knowledge", "traffic", "evidence"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def snake(s: str) -> str:
|
|
63
|
+
s = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", s)
|
|
64
|
+
return s.lower()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def plural(word: str) -> str:
|
|
68
|
+
w = word.lower()
|
|
69
|
+
if w in UNCOUNTABLE:
|
|
70
|
+
return word
|
|
71
|
+
if w in IRREGULAR:
|
|
72
|
+
return IRREGULAR[w]
|
|
73
|
+
if re.search(r"(s|x|z|ch|sh)$", w):
|
|
74
|
+
return word + "es"
|
|
75
|
+
if re.search(r"[^aeiou]y$", w):
|
|
76
|
+
return word[:-1] + "ies"
|
|
77
|
+
return word + "s"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def model_table_by_convention(fqcn: str) -> str:
|
|
81
|
+
base = fqcn.split("\\")[-1]
|
|
82
|
+
words = re.findall(r"[A-Z][a-z0-9]*|[a-z0-9]+", base) or [base]
|
|
83
|
+
words[-1] = plural(words[-1])
|
|
84
|
+
return snake("".join(w[0].upper() + w[1:] for w in words))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def classconst(d) -> str | None:
|
|
88
|
+
return d.get("class") if d and d.get("k") == "classconst" else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class LaravelPlugin(FrameworkPlugin):
|
|
92
|
+
name = "laravel"
|
|
93
|
+
language = "php"
|
|
94
|
+
|
|
95
|
+
def detect(self, project: Project) -> bool:
|
|
96
|
+
cj = project.read_json("composer.json") or {}
|
|
97
|
+
return "laravel/framework" in (cj.get("require") or {}) or project.exists("artisan")
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------ setup
|
|
100
|
+
def register_hooks(self, prog: PhpProgram) -> None:
|
|
101
|
+
self.prog, self.b = prog, prog.b
|
|
102
|
+
self.stats = defaultdict(int)
|
|
103
|
+
self.observers, self.listeners, self.schedules, self.bindings = [], [], [], []
|
|
104
|
+
self.seen_cols = set()
|
|
105
|
+
self.records = {r["file"]: r for r in prog.records}
|
|
106
|
+
self._parse_config()
|
|
107
|
+
self._parse_migrations()
|
|
108
|
+
self._parse_models()
|
|
109
|
+
self._seed_connection_providers()
|
|
110
|
+
prog.static_call_rules.append(self._rule_static)
|
|
111
|
+
prog.method_call_rules.append(self._rule_method)
|
|
112
|
+
prog.prop_rules.append(self._rule_prop)
|
|
113
|
+
prog.func_rules.append(self._rule_func)
|
|
114
|
+
prog.fact_handlers.append(self._handle_fact)
|
|
115
|
+
from .broadcast import BroadcastAnalysis
|
|
116
|
+
from .tests import LaravelTests
|
|
117
|
+
self.broadcast = BroadcastAnalysis(self, prog)
|
|
118
|
+
self.broadcast.register() # types channel-callback users before inference
|
|
119
|
+
self.tests = LaravelTests(self, prog)
|
|
120
|
+
prog.test_fact_handlers.append(self.tests.handle_fact)
|
|
121
|
+
|
|
122
|
+
# ---- config/*.php
|
|
123
|
+
def _parse_config(self):
|
|
124
|
+
b = self.b
|
|
125
|
+
self.config_keys: dict[str, dict] = {}
|
|
126
|
+
self.connections: dict[str, str] = {} # name -> node id
|
|
127
|
+
self.config_conn_refs: dict[str, str] = {} # config key -> connection node id
|
|
128
|
+
for f, r in self.records.items():
|
|
129
|
+
if not f.startswith("config/") or "config" not in r:
|
|
130
|
+
continue
|
|
131
|
+
root = f[len("config/"):-len(".php")].replace("/", ".")
|
|
132
|
+
rid = b.add_node("config", root, file=f, line=1, module="config", lang="php")
|
|
133
|
+
|
|
134
|
+
def walk(nodes, parent_id):
|
|
135
|
+
for n in nodes:
|
|
136
|
+
key = f"{root}.{n['key']}"
|
|
137
|
+
attrs = {}
|
|
138
|
+
if n.get("literal") is not None and not isinstance(n["literal"], (dict, list)):
|
|
139
|
+
attrs["value"] = n["literal"]
|
|
140
|
+
if n.get("envs"):
|
|
141
|
+
attrs["env"] = [e["env"] for e in n["envs"]]
|
|
142
|
+
attrs["env_default"] = [e["default"] for e in n["envs"]]
|
|
143
|
+
cid = b.add_node("config", key, file=f, line=n["line"], module="config", lang="php", attrs=attrs)
|
|
144
|
+
self.config_keys[key] = n
|
|
145
|
+
b.add_edge(parent_id, cid, "CONFIG_CONTAINS", f, n["line"], EXACT)
|
|
146
|
+
for e in n.get("envs", []):
|
|
147
|
+
eid = b.add_node("env", e["env"], lang="env")
|
|
148
|
+
b.add_edge(cid, eid, "READS_ENV", f, e["line"], EXACT)
|
|
149
|
+
for ref in n.get("config_refs", []):
|
|
150
|
+
b.add_edge(cid, b.add_node("config", ref, lang="php"), "REFERS_TO", f, n["line"], EXACT)
|
|
151
|
+
walk(n.get("children", []), cid)
|
|
152
|
+
walk(r["config"], rid)
|
|
153
|
+
# DB connections
|
|
154
|
+
for key, n in self.config_keys.items():
|
|
155
|
+
m = re.match(r"^database\.connections\.([^.]+)$", key)
|
|
156
|
+
if m:
|
|
157
|
+
name = m.group(1)
|
|
158
|
+
cid = self.b.add_node("connection", name, file="config/database.php", line=n["line"], module="config",
|
|
159
|
+
lang="php", attrs={"defined_in": "config/database.php"})
|
|
160
|
+
self.connections[name] = cid
|
|
161
|
+
self.b.add_edge(cid, f"config:{key}", "CONFIGURED_BY", "config/database.php", n["line"], EXACT)
|
|
162
|
+
# config values that name a connection (literal value or env() default)
|
|
163
|
+
for key, n in self.config_keys.items():
|
|
164
|
+
vals = []
|
|
165
|
+
if isinstance(n.get("literal"), str):
|
|
166
|
+
vals.append(n["literal"])
|
|
167
|
+
vals += [e["default"] for e in n.get("envs", []) if isinstance(e.get("default"), str)]
|
|
168
|
+
for v in vals:
|
|
169
|
+
if v in self.connections and (key.endswith("connection") or key == "database.default" or key.endswith(".connection")):
|
|
170
|
+
self.config_conn_refs[key] = self.connections[v]
|
|
171
|
+
via = "literal" if v == n.get("literal") else "env-default"
|
|
172
|
+
self.b.add_edge(f"config:{key}", self.connections[v], "REFERS_TO", f"config/{key.split('.')[0]}.php",
|
|
173
|
+
n["line"], RESOLVED, via=via)
|
|
174
|
+
|
|
175
|
+
# ---- database/migrations
|
|
176
|
+
def _parse_migrations(self):
|
|
177
|
+
b = self.b
|
|
178
|
+
self.tables: dict[str, dict] = {} # name -> {"columns": {col: line}, "file":..}
|
|
179
|
+
mig = sorted((c for c in self.prog.classes.values() if c.file.startswith("database/migrations/")), key=lambda c: c.file)
|
|
180
|
+
for c in mig:
|
|
181
|
+
up = c.methods.get("up")
|
|
182
|
+
if not up:
|
|
183
|
+
continue
|
|
184
|
+
for f in up.facts:
|
|
185
|
+
if f["t"] != "call":
|
|
186
|
+
continue
|
|
187
|
+
ctx, m = f.get("ctx") or {}, (f.get("m") or "").lower()
|
|
188
|
+
args = f.get("args") or []
|
|
189
|
+
a0 = args[0] if args else None
|
|
190
|
+
if f["kind"] == "static" and (f.get("class") or "").endswith("Schema"):
|
|
191
|
+
if m == "create" and a0 and a0.get("k") == "str":
|
|
192
|
+
self._table(a0["v"], c.file, f["line"], created=True)
|
|
193
|
+
elif m in ("drop", "dropifexists") and a0 and a0.get("k") == "str":
|
|
194
|
+
self.tables.pop(a0["v"], None)
|
|
195
|
+
elif m == "rename" and len(args) > 1 and a0.get("k") == "str" and args[1].get("k") == "str":
|
|
196
|
+
t = self.tables.pop(a0["v"], None)
|
|
197
|
+
if t:
|
|
198
|
+
self.tables[args[1]["v"]] = t
|
|
199
|
+
continue
|
|
200
|
+
if f["kind"] != "method" or (f.get("recv") or {}).get("k") != "var":
|
|
201
|
+
continue
|
|
202
|
+
if not ((ctx.get("class") or "").endswith("Schema") and ctx.get("m", "").lower() in ("create", "table") and ctx.get("arg0")):
|
|
203
|
+
continue
|
|
204
|
+
t = self._table(ctx["arg0"], c.file, f["line"])
|
|
205
|
+
cols = []
|
|
206
|
+
if m in COLUMN_METHODS and a0 and a0.get("k") == "str":
|
|
207
|
+
cols = [a0["v"]]
|
|
208
|
+
elif m == "id":
|
|
209
|
+
cols = [a0["v"]] if a0 and a0.get("k") == "str" else ["id"]
|
|
210
|
+
elif m in ("timestamps", "nullabletimestamps", "timestampstz"):
|
|
211
|
+
cols = ["created_at", "updated_at"]
|
|
212
|
+
elif m in ("softdeletes", "softdeletestz"):
|
|
213
|
+
cols = [a0["v"]] if a0 and a0.get("k") == "str" else ["deleted_at"]
|
|
214
|
+
elif m == "remembertoken":
|
|
215
|
+
cols = ["remember_token"]
|
|
216
|
+
elif m in ("morphs", "nullablemorphs", "uuidmorphs", "nullableuuidmorphs", "ulidmorphs") and a0 and a0.get("k") == "str":
|
|
217
|
+
cols = [a0["v"] + "_id", a0["v"] + "_type"]
|
|
218
|
+
elif m == "foreignidfor" and a0 and a0.get("k") == "classconst":
|
|
219
|
+
cols = [args[1]["v"]] if len(args) > 1 and args[1].get("k") == "str" else [snake(a0["class"].split("\\")[-1]) + "_id"]
|
|
220
|
+
elif m == "dropcolumn":
|
|
221
|
+
names = [a0["v"]] if a0 and a0.get("k") == "str" else [i["v"]["v"] for i in (a0 or {}).get("items", []) if (i.get("v") or {}).get("k") == "str"]
|
|
222
|
+
for n in names:
|
|
223
|
+
t["columns"].pop(n, None)
|
|
224
|
+
elif m == "renamecolumn" and len(args) > 1 and a0.get("k") == "str" and args[1].get("k") == "str":
|
|
225
|
+
old = t["columns"].pop(a0["v"], None)
|
|
226
|
+
t["columns"][args[1]["v"]] = old or (c.file, f["line"])
|
|
227
|
+
for col in cols:
|
|
228
|
+
t["columns"].setdefault(col, (c.file, f["line"]))
|
|
229
|
+
col_tables = defaultdict(set)
|
|
230
|
+
for tn, t in self.tables.items():
|
|
231
|
+
tid = b.add_node("table", tn, file=t["file"], line=t["line"], module="database/migrations", lang="sql",
|
|
232
|
+
attrs={"migrations": sorted(t["files"])})
|
|
233
|
+
for col, (cf, cl) in t["columns"].items():
|
|
234
|
+
cid = b.add_node("column", f"{tn}.{col}", name=col, fqn=f"{tn}.{col}", file=cf, line=cl,
|
|
235
|
+
module="database/migrations", lang="sql")
|
|
236
|
+
b.add_edge(tid, cid, "CONTAINS", cf, cl, EXACT)
|
|
237
|
+
col_tables[col].add(tn)
|
|
238
|
+
self.col_tables = col_tables
|
|
239
|
+
self.distinctive_cols = {c: next(iter(ts)) for c, ts in col_tables.items()
|
|
240
|
+
if len(ts) == 1 and "_" in c and len(c) >= 10 and c not in GENERIC_COLUMNS}
|
|
241
|
+
self.stats["tables"] = len(self.tables)
|
|
242
|
+
|
|
243
|
+
def _table(self, name, file, line, created=False):
|
|
244
|
+
t = self.tables.get(name)
|
|
245
|
+
if t is None:
|
|
246
|
+
t = self.tables[name] = {"columns": {}, "file": file, "line": line, "files": set()}
|
|
247
|
+
t["files"].add(file)
|
|
248
|
+
return t
|
|
249
|
+
|
|
250
|
+
# ---- Eloquent models
|
|
251
|
+
def _parse_models(self):
|
|
252
|
+
prog, b = self.prog, self.b
|
|
253
|
+
self.models: dict[str, dict] = {}
|
|
254
|
+
for c in prog.classes.values():
|
|
255
|
+
if c.kind != "class" or c.fqcn.startswith("class@anonymous") or prog.is_test_class(c.fqcn):
|
|
256
|
+
continue
|
|
257
|
+
if not any(prog.is_a(c.fqcn, base) for base in MODEL_BASES):
|
|
258
|
+
continue
|
|
259
|
+
tp = prog.find_prop(c.fqcn, "table")
|
|
260
|
+
explicit = tp and isinstance(tp.get("default"), str)
|
|
261
|
+
table = tp["default"] if explicit else model_table_by_convention(c.fqcn)
|
|
262
|
+
cp = prog.find_prop(c.fqcn, "connection")
|
|
263
|
+
conn = cp["default"] if cp and isinstance(cp.get("default"), str) else None
|
|
264
|
+
self.models[c.fqcn] = {"table": table, "explicit": bool(explicit), "connection": conn, "relations": {}}
|
|
265
|
+
for fq, info in self.models.items():
|
|
266
|
+
c = prog.classes[fq]
|
|
267
|
+
for ln, m in c.methods.items():
|
|
268
|
+
for f in m.facts:
|
|
269
|
+
if f["t"] != "return":
|
|
270
|
+
continue
|
|
271
|
+
d = f["expr"]
|
|
272
|
+
while d and d.get("k") == "mcall" and not (d.get("of", {}).get("k") == "this" and (d.get("m") or "").lower() in RELATIONS):
|
|
273
|
+
d = d.get("of")
|
|
274
|
+
if d and d.get("k") == "mcall" and (d.get("m") or "").lower() in RELATIONS:
|
|
275
|
+
target = classconst((d.get("args") or [None])[0]) if d.get("args") else None
|
|
276
|
+
info["relations"][ln] = {"kind": d["m"], "target": target, "method": m, "line": f["line"]}
|
|
277
|
+
break
|
|
278
|
+
self.stats["models"] = len(self.models)
|
|
279
|
+
|
|
280
|
+
def model_of(self, t: str) -> str | None:
|
|
281
|
+
return t if t in self.models else None
|
|
282
|
+
|
|
283
|
+
def table_of(self, model: str) -> str | None:
|
|
284
|
+
return self.models.get(model, {}).get("table")
|
|
285
|
+
|
|
286
|
+
# ---- dynamic connections registered via Config::set('database.connections.{...}')
|
|
287
|
+
def render(self, fn: PhpFunc, d, depth=0) -> str | None:
|
|
288
|
+
if not d or depth > 5:
|
|
289
|
+
return None
|
|
290
|
+
k = d.get("k")
|
|
291
|
+
if k == "str":
|
|
292
|
+
return d["v"]
|
|
293
|
+
if k == "interp":
|
|
294
|
+
return "".join(self.render(fn, p, depth + 1) or "{?}" for p in d["parts"])
|
|
295
|
+
if k == "concat":
|
|
296
|
+
return (self.render(fn, d["l"], depth + 1) or "{?}") + (self.render(fn, d["r"], depth + 1) or "{?}")
|
|
297
|
+
if k == "var":
|
|
298
|
+
for f in fn.facts:
|
|
299
|
+
if f["t"] == "assign" and f["var"] == d["n"] and f["expr"].get("k") in ("str", "interp", "concat"):
|
|
300
|
+
return self.render(fn, f["expr"], depth + 1)
|
|
301
|
+
return "{" + d["n"] + "}"
|
|
302
|
+
if k == "prop" and d.get("of", {}).get("k") == "var":
|
|
303
|
+
return "{" + d["of"]["n"] + "." + (d.get("n") or "?") + "}"
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
def _config_key_writes(self, fn: PhpFunc, f: dict) -> list[tuple[str, int]]:
|
|
307
|
+
"""Keys written by Config::set(k, v) / config([k => v])."""
|
|
308
|
+
out = []
|
|
309
|
+
kind, m = f["kind"], (f.get("m") or "").lower()
|
|
310
|
+
args = f.get("args") or []
|
|
311
|
+
if kind == "static" and (f.get("class") or "") == FACADES["config"] and m == "set" and args:
|
|
312
|
+
out.append(self.render(fn, args[0]))
|
|
313
|
+
if kind == "func" and (f.get("n") or "").lower() == "config" and args and args[0].get("k") == "arr":
|
|
314
|
+
for it in args[0]["items"]:
|
|
315
|
+
out.append(self.render(fn, it.get("key")))
|
|
316
|
+
return [k for k in out if k]
|
|
317
|
+
|
|
318
|
+
def _seed_connection_providers(self):
|
|
319
|
+
self.registered: dict[str, list] = defaultdict(list)
|
|
320
|
+
for fn in self.prog.all_funcs:
|
|
321
|
+
for f in fn.facts:
|
|
322
|
+
if f["t"] != "call":
|
|
323
|
+
continue
|
|
324
|
+
for key in self._config_key_writes(fn, f):
|
|
325
|
+
m = re.match(r"^database\.connections\.(.+)$", key)
|
|
326
|
+
if m:
|
|
327
|
+
name = m.group(1)
|
|
328
|
+
cid = self.b.add_node("connection", name, lang="php", module=module_of(fn.file),
|
|
329
|
+
attrs={"dynamic": "{" in name, "registered_by": fn.id})
|
|
330
|
+
self.connections.setdefault(name, cid)
|
|
331
|
+
self.registered[fn.id].append(cid)
|
|
332
|
+
# the function is a "connection provider": its return value names this connection
|
|
333
|
+
fn.inferred.add(f"connname:{cid}")
|
|
334
|
+
|
|
335
|
+
# ------------------------------------------------------------------ type rules
|
|
336
|
+
def _rule_static(self, prog, cls, m, args):
|
|
337
|
+
if not cls or not m:
|
|
338
|
+
return None
|
|
339
|
+
ml = m.lower()
|
|
340
|
+
if cls == FACADES["db"]:
|
|
341
|
+
if ml == "table" and args and args[0].get("k") == "str":
|
|
342
|
+
return {f"qb:{args[0]['v'].split(' ')[0]}"}
|
|
343
|
+
if ml == "connection":
|
|
344
|
+
return {"conn:?"}
|
|
345
|
+
return None
|
|
346
|
+
if cls in self.models:
|
|
347
|
+
meth = prog.find_method(cls, m)
|
|
348
|
+
base = prog.classes.get(meth.cls) if meth else None
|
|
349
|
+
if meth and not (base and base.abstract) and meth.returns and ml not in ("on", "query"):
|
|
350
|
+
return None # project-defined static with declared return type wins
|
|
351
|
+
if ml in RETRIEVE_ONE:
|
|
352
|
+
return {cls}
|
|
353
|
+
if ml in RETRIEVE_MANY:
|
|
354
|
+
return {f"collection:{cls}"}
|
|
355
|
+
if ml in SCALAR_TERMINALS:
|
|
356
|
+
return None
|
|
357
|
+
return {f"builder:{cls}"}
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
def _rule_method(self, prog, t, m, args):
|
|
361
|
+
if not m:
|
|
362
|
+
return None
|
|
363
|
+
ml = m.lower()
|
|
364
|
+
if t.startswith("builder:"):
|
|
365
|
+
mdl = t.split(":", 1)[1]
|
|
366
|
+
if ml in RETRIEVE_ONE:
|
|
367
|
+
return {mdl}
|
|
368
|
+
if ml in RETRIEVE_MANY:
|
|
369
|
+
return {f"collection:{mdl}"}
|
|
370
|
+
if ml in SCALAR_TERMINALS:
|
|
371
|
+
return None
|
|
372
|
+
return {t}
|
|
373
|
+
if t.startswith("collection:"):
|
|
374
|
+
if ml in ("first", "last", "find", "firstwhere", "sole", "pop", "shift", "get"):
|
|
375
|
+
return {t.split(":", 1)[1]}
|
|
376
|
+
if ml in COLLECTION_KEEP:
|
|
377
|
+
return {t}
|
|
378
|
+
return None
|
|
379
|
+
if t.startswith("qb:"):
|
|
380
|
+
if ml in ("get", "first", "value", "pluck", "count", "exists") or ml in SCALAR_TERMINALS:
|
|
381
|
+
return None
|
|
382
|
+
return {t}
|
|
383
|
+
if t.startswith("conn:") and ml == "table" and args and args[0].get("k") == "str":
|
|
384
|
+
return {f"qb:{args[0]['v'].split(' ')[0]}"}
|
|
385
|
+
if t in self.models:
|
|
386
|
+
rel = self.models[t]["relations"].get(ml)
|
|
387
|
+
if rel and rel["target"]:
|
|
388
|
+
return {f"builder:{rel['target']}"}
|
|
389
|
+
if ml in ("newquery", "query", "newmodelquery"):
|
|
390
|
+
return {f"builder:{t}"}
|
|
391
|
+
if ml in ("fresh", "replicate", "refresh"):
|
|
392
|
+
return {t}
|
|
393
|
+
if ml in ("make", "makewith") and args and classconst(args[0]):
|
|
394
|
+
return {classconst(args[0])}
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
def _rule_prop(self, prog, t, prop):
|
|
398
|
+
if t in self.models and prop:
|
|
399
|
+
rel = self.models[t]["relations"].get(prop.lower())
|
|
400
|
+
if rel and rel["target"]:
|
|
401
|
+
return {f"collection:{rel['target']}"} if rel["kind"].lower() in MANY_RELATIONS else {rel["target"]}
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
def _rule_func(self, prog, name, args):
|
|
405
|
+
n = (name or "").lower()
|
|
406
|
+
if n in ("app", "resolve") and args and classconst(args[0]):
|
|
407
|
+
return {classconst(args[0])}
|
|
408
|
+
if n == "app" and not args:
|
|
409
|
+
return {"Illuminate\\Foundation\\Application"}
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
# ------------------------------------------------------------------ fact handlers
|
|
413
|
+
def _col_edge(self, fn, table, col, kind, line, conf, **attrs):
|
|
414
|
+
if not table or not col or col == "*" or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", col):
|
|
415
|
+
return
|
|
416
|
+
known = table in self.tables and col in self.tables[table]["columns"]
|
|
417
|
+
if not known:
|
|
418
|
+
if table in self.tables:
|
|
419
|
+
return # table known but column isn't (likely alias/computed/json path)
|
|
420
|
+
# table not in migrations (e.g. tables owned by another DB): infer the column
|
|
421
|
+
self.b.add_node("table", table, lang="sql", attrs={"inferred": True})
|
|
422
|
+
cid = self.b.add_node("column", f"{table}.{col}", name=col, fqn=f"{table}.{col}", lang="sql", attrs={"inferred": True})
|
|
423
|
+
self.b.add_edge(f"table:{table}", cid, "CONTAINS", None, None, HEURISTIC)
|
|
424
|
+
conf = HEURISTIC
|
|
425
|
+
self.b.add_edge(fn.id, f"column:{table}.{col}", kind, fn.file, line, conf, **attrs)
|
|
426
|
+
self.seen_cols.add((fn.id, line, table, col))
|
|
427
|
+
|
|
428
|
+
def _split_col(self, s: str, default_table: str | None):
|
|
429
|
+
s = s.strip()
|
|
430
|
+
s = re.split(r"\s+as\s+", s, flags=re.I)[0].strip()
|
|
431
|
+
if "." in s:
|
|
432
|
+
t, c = s.rsplit(".", 1)
|
|
433
|
+
return (t.split(" ")[0], c)
|
|
434
|
+
return (default_table, s)
|
|
435
|
+
|
|
436
|
+
def _col_args(self, fn, table, f, conf, write_methods=True):
|
|
437
|
+
ml = (f.get("m") or "").lower()
|
|
438
|
+
args = f.get("args") or []
|
|
439
|
+
line = f.get("line")
|
|
440
|
+
if ml in COL_ARG0 and args:
|
|
441
|
+
a0 = args[0]
|
|
442
|
+
if a0.get("k") == "str":
|
|
443
|
+
t, c = self._split_col(a0["v"], table)
|
|
444
|
+
kind = "WRITES_COLUMN" if ml in ("increment", "decrement") else "READS_COLUMN"
|
|
445
|
+
self._col_edge(fn, t, c, kind, line, conf, via=f.get("m"))
|
|
446
|
+
elif a0.get("k") == "arr" and ml in ("where", "orwhere"):
|
|
447
|
+
for it in a0["items"]:
|
|
448
|
+
k = it.get("key")
|
|
449
|
+
if k and k.get("k") == "str":
|
|
450
|
+
t, c = self._split_col(k["v"], table)
|
|
451
|
+
self._col_edge(fn, t, c, "READS_COLUMN", line, conf, via=f.get("m"))
|
|
452
|
+
if ml in COL_ALL_ARGS:
|
|
453
|
+
for a in args:
|
|
454
|
+
vals = [a["v"]] if a.get("k") == "str" else [i["v"]["v"] for i in a.get("items", []) if (i.get("v") or {}).get("k") == "str"]
|
|
455
|
+
for v in vals:
|
|
456
|
+
t, c = self._split_col(v, table)
|
|
457
|
+
self._col_edge(fn, t, c, "READS_COLUMN", line, conf, via=f.get("m"))
|
|
458
|
+
if write_methods:
|
|
459
|
+
for idx, names in ((0, WRITE_ARRAY0), (1, WRITE_ARRAY1)):
|
|
460
|
+
if ml in names and len(args) > idx and args[idx].get("k") == "arr":
|
|
461
|
+
for it in args[idx]["items"]:
|
|
462
|
+
k = it.get("key")
|
|
463
|
+
if k and k.get("k") == "str":
|
|
464
|
+
t, c = self._split_col(k["v"], table)
|
|
465
|
+
self._col_edge(fn, t, c, "WRITES_COLUMN", line, conf, via=f.get("m"))
|
|
466
|
+
if ml in WRITE_ARRAY1 and args and args[0].get("k") == "arr":
|
|
467
|
+
for it in args[0]["items"]:
|
|
468
|
+
k = it.get("key")
|
|
469
|
+
if k and k.get("k") == "str":
|
|
470
|
+
t, c = self._split_col(k["v"], table)
|
|
471
|
+
self._col_edge(fn, t, c, "READS_COLUMN", line, conf, via=f.get("m"))
|
|
472
|
+
if ml in WRITE_TABLE and table:
|
|
473
|
+
self.b.add_edge(fn.id, self._table_node(table), "WRITES_TABLE", fn.file, line, conf, via=f.get("m"))
|
|
474
|
+
|
|
475
|
+
def _table_node(self, t):
|
|
476
|
+
return self.b.add_node("table", t, lang="sql", attrs={} if t in self.tables else {"inferred": True})
|
|
477
|
+
|
|
478
|
+
def _conn_targets(self, ctx: ResolveCtx, arg) -> list[tuple[str, str, str]]:
|
|
479
|
+
"""-> [(connection node id, confidence, via)] for a connection-name argument."""
|
|
480
|
+
out = []
|
|
481
|
+
if not arg:
|
|
482
|
+
return out
|
|
483
|
+
for s in ctx.strings_of(arg):
|
|
484
|
+
if s in self.connections:
|
|
485
|
+
out.append((self.connections[s], EXACT, "literal"))
|
|
486
|
+
if arg.get("k") == "func" and (arg.get("n") or "").lower() == "config" and arg.get("args"):
|
|
487
|
+
key = (arg["args"][0] or {}).get("v")
|
|
488
|
+
if key in self.config_conn_refs:
|
|
489
|
+
out.append((self.config_conn_refs[key], RESOLVED, f"config('{key}')"))
|
|
490
|
+
for t in ctx.type_of(arg):
|
|
491
|
+
if t.startswith("connname:"):
|
|
492
|
+
out.append((t.split(":", 1)[1], RESOLVED, "returned-connection-name"))
|
|
493
|
+
return out
|
|
494
|
+
|
|
495
|
+
def _handle_fact(self, prog: PhpProgram, fn: PhpFunc, f: dict, ctx: ResolveCtx):
|
|
496
|
+
b, t, line = self.b, f["t"], f.get("line")
|
|
497
|
+
if t == "fetch":
|
|
498
|
+
prop = f.get("prop")
|
|
499
|
+
for rt in ctx.type_of(f.get("recv")):
|
|
500
|
+
if rt in self.models and prop:
|
|
501
|
+
rel = self.models[rt]["relations"].get(prop.lower())
|
|
502
|
+
if rel:
|
|
503
|
+
b.add_edge(fn.id, rel["method"].id, "CALLS", fn.file, line, RESOLVED, via="relation-property")
|
|
504
|
+
elif not prog.find_prop(rt, prop) or prog.find_prop(rt, prop).get("src") == "doc":
|
|
505
|
+
self._col_edge(fn, self.table_of(rt), prop, "WRITES_COLUMN" if f.get("write") else "READS_COLUMN",
|
|
506
|
+
line, RESOLVED, via="model-attribute", model=rt)
|
|
507
|
+
return
|
|
508
|
+
if t == "new" and f.get("class") in self.models and self.models[f["class"]]["connection"]:
|
|
509
|
+
self._model_conn(fn, f["class"], line)
|
|
510
|
+
if t != "call":
|
|
511
|
+
return
|
|
512
|
+
kind, m = f["kind"], (f.get("m") or "")
|
|
513
|
+
ml = m.lower()
|
|
514
|
+
args = f.get("args") or []
|
|
515
|
+
if kind == "static":
|
|
516
|
+
cls = f.get("class") or ""
|
|
517
|
+
if cls == FACADES["db"]:
|
|
518
|
+
if ml == "table" and args and args[0].get("k") == "str":
|
|
519
|
+
b.add_edge(fn.id, self._table_node(args[0]["v"].split(" ")[0]), "READS_TABLE", fn.file, line, EXACT, via="DB::table")
|
|
520
|
+
if ml == "connection":
|
|
521
|
+
for cid, conf, via in self._conn_targets(ctx, args[0] if args else None):
|
|
522
|
+
b.add_edge(fn.id, cid, "USES_CONNECTION", fn.file, line, conf, via=f"DB::connection {via}")
|
|
523
|
+
elif cls == FACADES["schema"] and ml == "connection":
|
|
524
|
+
for cid, conf, via in self._conn_targets(ctx, args[0] if args else None):
|
|
525
|
+
b.add_edge(fn.id, cid, "USES_CONNECTION", fn.file, line, conf, via=f"Schema::connection {via}")
|
|
526
|
+
elif cls == FACADES["config"]:
|
|
527
|
+
if ml in ("get", "string", "integer", "boolean", "array", "float", "has") and args and args[0].get("k") == "str":
|
|
528
|
+
self._config_read(fn, args[0]["v"], line, EXACT)
|
|
529
|
+
elif cls in self.models:
|
|
530
|
+
tbl = self.table_of(cls)
|
|
531
|
+
self._col_args(fn, tbl, f, RESOLVED)
|
|
532
|
+
if ml == "on" and args:
|
|
533
|
+
for cid, conf, via in self._conn_targets(ctx, args[0]):
|
|
534
|
+
b.add_edge(fn.id, cid, "USES_CONNECTION", fn.file, line, conf, via=f"{cls.split(chr(92))[-1]}::on {via}")
|
|
535
|
+
if self.models[cls]["connection"]:
|
|
536
|
+
self._model_conn(fn, cls, line)
|
|
537
|
+
if ml in ("dispatch", "dispatchsync", "dispatchnow", "dispatchif", "dispatchunless", "dispatchafterresponse") and cls and prog.cls(cls):
|
|
538
|
+
self._dispatch(fn, cls, line, EXACT)
|
|
539
|
+
if cls == FACADES["artisan"] and ml in ("call", "queue") and args and args[0].get("k") == "str":
|
|
540
|
+
b.add_edge(fn.id, b.add_node("command", args[0]["v"].split(" ")[0]), "DISPATCHES", fn.file, line, EXACT, via="Artisan::call")
|
|
541
|
+
if cls in (FACADES["event"],) and ml == "listen" and args:
|
|
542
|
+
self._listen(fn, args, line)
|
|
543
|
+
if cls in (FACADES["event"], FACADES["bus"]) and ml in ("dispatch", "dispatchsync", "dispatchnow") and args and args[0].get("k") == "new":
|
|
544
|
+
self._dispatch(fn, args[0].get("class"), line, EXACT)
|
|
545
|
+
if ml == "observe" and cls in self.models and args and classconst(args[0]):
|
|
546
|
+
self.observers.append((cls, classconst(args[0]), fn, line))
|
|
547
|
+
elif kind == "func":
|
|
548
|
+
n = (f.get("n") or "").lower()
|
|
549
|
+
if n == "config" and args:
|
|
550
|
+
a0 = args[0]
|
|
551
|
+
if a0.get("k") == "str":
|
|
552
|
+
self._config_read(fn, a0["v"], line, EXACT)
|
|
553
|
+
elif n == "env" and args and args[0].get("k") == "str":
|
|
554
|
+
b.add_edge(fn.id, b.add_node("env", args[0]["v"], lang="env"), "READS_ENV", fn.file, line, EXACT)
|
|
555
|
+
elif n in ("dispatch", "dispatch_sync", "dispatch_now", "event", "broadcast") and args and args[0].get("k") == "new":
|
|
556
|
+
self._dispatch(fn, args[0].get("class"), line, EXACT)
|
|
557
|
+
else: # method call
|
|
558
|
+
recv = f.get("recv") or {}
|
|
559
|
+
types = ctx.type_of(recv)
|
|
560
|
+
for rt in types:
|
|
561
|
+
if rt.startswith("builder:"):
|
|
562
|
+
self._col_args(fn, self.table_of(rt.split(":", 1)[1]), f, RESOLVED)
|
|
563
|
+
elif rt.startswith("qb:"):
|
|
564
|
+
tbl = rt.split(":", 1)[1]
|
|
565
|
+
self._col_args(fn, tbl, f, RESOLVED)
|
|
566
|
+
elif rt in self.models:
|
|
567
|
+
tbl = self.table_of(rt)
|
|
568
|
+
if ml in WRITE_TABLE or ml in WRITE_ARRAY0:
|
|
569
|
+
self._col_args(fn, tbl, {**f, "m": m}, RESOLVED)
|
|
570
|
+
elif rt.startswith("conn:") and ml == "table" and args and args[0].get("k") == "str":
|
|
571
|
+
b.add_edge(fn.id, self._table_node(args[0]["v"].split(" ")[0]), "READS_TABLE", fn.file, line, RESOLVED, via="connection()->table")
|
|
572
|
+
if ml == "setconnection" and args:
|
|
573
|
+
for cid, conf, via in self._conn_targets(ctx, args[0]):
|
|
574
|
+
b.add_edge(fn.id, cid, "USES_CONNECTION", fn.file, line, conf, via=f"setConnection {via}")
|
|
575
|
+
if ml == "connection" and args and recv.get("k") in ("func", "scall"):
|
|
576
|
+
for cid, conf, via in self._conn_targets(ctx, args[0]):
|
|
577
|
+
b.add_edge(fn.id, cid, "USES_CONNECTION", fn.file, line, conf, via=f"->connection {via}")
|
|
578
|
+
if ml == "get" and recv.get("k") == "func" and (recv.get("n") or "").lower() == "config" and args and args[0].get("k") == "str":
|
|
579
|
+
self._config_read(fn, args[0]["v"], line, EXACT)
|
|
580
|
+
if ml in ("bind", "singleton", "scoped", "instance", "bindif", "singletonif") and args and classconst(args[0]):
|
|
581
|
+
self.bindings.append((fn, f, ctx))
|
|
582
|
+
if ml == "artisan" and recv.get("k") == "this" and args and args[0].get("k") == "str":
|
|
583
|
+
# feature tests: $this->artisan('app:prune') runs the command in process (#60)
|
|
584
|
+
b.add_edge(fn.id, b.add_node("command", args[0]["v"].split(" ")[0]), "DISPATCHES", fn.file, line, EXACT, via="$this->artisan")
|
|
585
|
+
self.stats["test_artisan_calls"] += 1
|
|
586
|
+
if ml in ("call", "callsilently", "callsilent") and recv.get("k") == "this" and args and args[0].get("k") == "str" and ":" in args[0]["v"]:
|
|
587
|
+
b.add_edge(fn.id, b.add_node("command", args[0]["v"].split(" ")[0]), "DISPATCHES", fn.file, line, EXACT, via="$this->call")
|
|
588
|
+
if ml in ("command", "job") and (recv.get("k") == "var" and "Illuminate\\Console\\Scheduling\\Schedule" in types):
|
|
589
|
+
self.schedules.append((fn, f))
|
|
590
|
+
if ml == "dispatch" and args and args[0].get("k") == "new":
|
|
591
|
+
self._dispatch(fn, args[0].get("class"), line, RESOLVED)
|
|
592
|
+
# config writes / dynamic connection registration
|
|
593
|
+
for key in self._config_key_writes(fn, f):
|
|
594
|
+
cid = b.add_node("config", key, lang="php", attrs={"runtime_written": True})
|
|
595
|
+
b.add_edge(fn.id, cid, "WRITES_CONFIG", fn.file, line, EXACT if "{" not in key else RESOLVED)
|
|
596
|
+
mm = re.match(r"^database\.connections\.(.+)$", key)
|
|
597
|
+
if mm and mm.group(1) in self.connections:
|
|
598
|
+
b.add_edge(fn.id, self.connections[mm.group(1)], "REGISTERS_CONNECTION", fn.file, line, RESOLVED)
|
|
599
|
+
# schedules in routes/console.php (facade form)
|
|
600
|
+
if kind == "static" and (f.get("class") or "") == FACADES["schedule"] and ml in ("command", "job"):
|
|
601
|
+
self.schedules.append((fn, f))
|
|
602
|
+
# distinctive column-name literals anywhere in the call's arguments (migrations define, not use)
|
|
603
|
+
for s in ([] if fn.file.startswith("database/migrations/") else self._literals(args)):
|
|
604
|
+
tbl = self.distinctive_cols.get(s)
|
|
605
|
+
if tbl and (fn.id, line, tbl, s) not in self.seen_cols:
|
|
606
|
+
b.add_edge(fn.id, f"column:{tbl}.{s}", "MENTIONS_COLUMN", fn.file, line, HEURISTIC, via="string-literal")
|
|
607
|
+
self.seen_cols.add((fn.id, line, tbl, s))
|
|
608
|
+
|
|
609
|
+
def _literals(self, ds, depth=0):
|
|
610
|
+
if depth > 4:
|
|
611
|
+
return
|
|
612
|
+
for d in ds or []:
|
|
613
|
+
if not d:
|
|
614
|
+
continue
|
|
615
|
+
k = d.get("k")
|
|
616
|
+
if k == "str":
|
|
617
|
+
yield d["v"]
|
|
618
|
+
elif k == "arr":
|
|
619
|
+
for it in d["items"]:
|
|
620
|
+
if it.get("key") and it["key"].get("k") == "str":
|
|
621
|
+
yield it["key"]["v"]
|
|
622
|
+
yield from self._literals([it.get("v")], depth + 1)
|
|
623
|
+
elif k == "alt":
|
|
624
|
+
yield from self._literals(d["opts"], depth + 1)
|
|
625
|
+
|
|
626
|
+
def _model_conn(self, fn, cls, line):
|
|
627
|
+
name = self.models[cls]["connection"]
|
|
628
|
+
cid = self.connections.get(name) or self.b.add_node("connection", name, lang="php", attrs={"undefined": True})
|
|
629
|
+
self.b.add_edge(fn.id, cid, "USES_CONNECTION", fn.file, line, RESOLVED, via=f"{cls.split(chr(92))[-1]}::$connection")
|
|
630
|
+
|
|
631
|
+
def _config_read(self, fn, key, line, conf):
|
|
632
|
+
cid = self.b.add_node("config", key, lang="php")
|
|
633
|
+
self.b.add_edge(fn.id, cid, "READS_CONFIG", fn.file, line, conf)
|
|
634
|
+
|
|
635
|
+
def _dispatch(self, fn, cls, line, conf):
|
|
636
|
+
c = self.prog.cls(cls)
|
|
637
|
+
if not c:
|
|
638
|
+
return
|
|
639
|
+
h = self.prog.find_method(c.fqcn, "handle") or self.prog.find_method(c.fqcn, "__invoke")
|
|
640
|
+
if self._is_job(c.fqcn) and h:
|
|
641
|
+
self.b.add_edge(fn.id, h.id, "DISPATCHES", fn.file, line, conf, via="job")
|
|
642
|
+
else:
|
|
643
|
+
eid = self.b.add_node("event", c.fqcn, name=c.fqcn.split("\\")[-1], fqn=c.fqcn, file=c.file, line=c.line,
|
|
644
|
+
module=module_of(c.file), lang="php")
|
|
645
|
+
self.b.add_edge(fn.id, eid, "DISPATCHES", fn.file, line, conf, via="event")
|
|
646
|
+
|
|
647
|
+
def _listen(self, fn, args, line):
|
|
648
|
+
ev = classconst(args[0])
|
|
649
|
+
if not ev or len(args) < 2:
|
|
650
|
+
return
|
|
651
|
+
l = args[1]
|
|
652
|
+
target = None
|
|
653
|
+
if classconst(l):
|
|
654
|
+
target = self.prog.find_method(classconst(l), "handle") if self.prog.cls(classconst(l)) else None
|
|
655
|
+
elif l.get("k") == "arr" and len(l["items"]) == 2:
|
|
656
|
+
c, m = classconst(l["items"][0].get("v")), (l["items"][1].get("v") or {}).get("v")
|
|
657
|
+
target = self.prog.find_method(c, m) if c and self.prog.cls(c) else None
|
|
658
|
+
eid = self.b.add_node("event", ev, name=ev.split("\\")[-1], fqn=ev, lang="php")
|
|
659
|
+
if target:
|
|
660
|
+
self.listeners.append((eid, target, fn, line))
|
|
661
|
+
|
|
662
|
+
def _is_job(self, fqcn):
|
|
663
|
+
return self.prog.is_a(fqcn, "Illuminate\\Contracts\\Queue\\ShouldQueue") or fqcn.startswith("App\\Jobs\\")
|
|
664
|
+
|
|
665
|
+
# ------------------------------------------------------------------ contribute
|
|
666
|
+
def contribute(self, project: Project, builder: GraphBuilder, prog: PhpProgram) -> dict:
|
|
667
|
+
b = builder
|
|
668
|
+
self._models_out()
|
|
669
|
+
self._routes_out()
|
|
670
|
+
self._commands_out()
|
|
671
|
+
self._schedules_out()
|
|
672
|
+
self._jobs_listeners_admin_out()
|
|
673
|
+
self._bindings_out()
|
|
674
|
+
from .values import ValueAnalysis
|
|
675
|
+
import time as _t
|
|
676
|
+
t0 = _t.time()
|
|
677
|
+
vst = ValueAnalysis(self, prog, b).run()
|
|
678
|
+
vst["seconds"] = round(_t.time() - t0, 2)
|
|
679
|
+
# after every edge of the files is emitted: channel callbacks / Pest tests re-attribute edges by line range
|
|
680
|
+
self.stats["broadcast"] = self.broadcast.contribute()
|
|
681
|
+
self.stats["tests"] = self.tests.contribute()
|
|
682
|
+
st = dict(self.stats)
|
|
683
|
+
st["values"] = vst
|
|
684
|
+
st.update({"connections": len(self.connections), "config_keys": len(self.config_keys)})
|
|
685
|
+
return st
|
|
686
|
+
|
|
687
|
+
def _models_out(self):
|
|
688
|
+
b = self.b
|
|
689
|
+
for fq, info in self.models.items():
|
|
690
|
+
c = self.prog.classes[fq]
|
|
691
|
+
cid = f"class:{fq}"
|
|
692
|
+
t = info["table"]
|
|
693
|
+
tid = self._table_node(t)
|
|
694
|
+
b.add_edge(cid, tid, "MAPS_TO_TABLE", c.file, c.line, EXACT if info["explicit"] else RESOLVED,
|
|
695
|
+
via="$table" if info["explicit"] else "naming-convention")
|
|
696
|
+
if t not in self.tables:
|
|
697
|
+
self.stats["models_table_not_in_migrations"] += 1
|
|
698
|
+
if info["connection"]:
|
|
699
|
+
name = info["connection"]
|
|
700
|
+
conn = self.connections.get(name) or b.add_node("connection", name, lang="php", attrs={"undefined": True})
|
|
701
|
+
b.add_edge(cid, conn, "USES_CONNECTION", c.file, c.line, EXACT, via="$connection")
|
|
702
|
+
for rn, rel in info["relations"].items():
|
|
703
|
+
if rel["target"]:
|
|
704
|
+
tc = self.prog.cls(rel["target"])
|
|
705
|
+
if tc:
|
|
706
|
+
b.add_edge(cid, f"class:{tc.fqcn}", "HAS_RELATION", c.file, rel["line"], EXACT, relation=rel["kind"], method=rel["method"].name)
|
|
707
|
+
b.add_node("method", rel["method"].id.split(":", 1)[1], attrs={"relation": rel["kind"], "related": rel["target"]})
|
|
708
|
+
for fn_id, cids in self.registered.items():
|
|
709
|
+
for cid in cids:
|
|
710
|
+
b.add_edge(fn_id, cid, "REGISTERS_CONNECTION", None, None, RESOLVED)
|
|
711
|
+
|
|
712
|
+
def _middleware_aliases(self):
|
|
713
|
+
aliases = {}
|
|
714
|
+
r = self.prog.scripts.get("bootstrap/app.php")
|
|
715
|
+
facts = r.facts if r else []
|
|
716
|
+
for f in facts:
|
|
717
|
+
if f["t"] == "call" and (f.get("m") or "").lower() == "alias" and f.get("args") and f["args"][0].get("k") == "arr":
|
|
718
|
+
for it in f["args"][0]["items"]:
|
|
719
|
+
k, v = it.get("key"), it.get("v")
|
|
720
|
+
if k and k.get("k") == "str" and classconst(v):
|
|
721
|
+
aliases[k["v"]] = classconst(v)
|
|
722
|
+
return aliases
|
|
723
|
+
|
|
724
|
+
def _routes_out(self):
|
|
725
|
+
b, prog = self.b, self.prog
|
|
726
|
+
aliases = self._middleware_aliases()
|
|
727
|
+
n = 0
|
|
728
|
+
for f, r in self.records.items():
|
|
729
|
+
for rt in r.get("routes") or []:
|
|
730
|
+
for verb in rt["methods"]:
|
|
731
|
+
key = f"{verb.upper()} {rt['full_uri']}"
|
|
732
|
+
# middleware given as a non-literal expression (a constant, a method call) comes back as a dict: kept
|
|
733
|
+
# out of the name list, counted
|
|
734
|
+
mws = [m for m in rt.get("middleware") or [] if isinstance(m, str)]
|
|
735
|
+
if len(mws) != len(rt.get("middleware") or []):
|
|
736
|
+
self.stats["route_middleware_unresolved"] = self.stats.get("route_middleware_unresolved", 0) + 1
|
|
737
|
+
rid = b.add_node("route", key, name=key, file=f, line=rt["line"], module=module_of(f), lang="php",
|
|
738
|
+
entry_kind="http_route",
|
|
739
|
+
attrs={"name": rt.get("name") or None, "middleware": mws if rt.get("middleware") is not None else None,
|
|
740
|
+
"uri": rt["full_uri"], "method": verb.upper()})
|
|
741
|
+
n += 1
|
|
742
|
+
act = rt.get("action") or {}
|
|
743
|
+
if act.get("class"):
|
|
744
|
+
c = prog.cls(act["class"])
|
|
745
|
+
meth = prog.find_method(c.fqcn, act.get("method")) if c else None
|
|
746
|
+
if meth:
|
|
747
|
+
b.add_edge(rid, meth.id, "ROUTES_TO", f, rt["line"], EXACT)
|
|
748
|
+
# controller module for grouping
|
|
749
|
+
b.nodes[rid].module = module_of(c.file)
|
|
750
|
+
else:
|
|
751
|
+
self.stats["routes_unresolved_action"] += 1
|
|
752
|
+
for mw in mws:
|
|
753
|
+
alias = mw.split(":")[0]
|
|
754
|
+
cls = aliases.get(alias) or (mw if prog.cls(mw) else None)
|
|
755
|
+
if cls and prog.cls(cls):
|
|
756
|
+
h = prog.find_method(cls, "handle")
|
|
757
|
+
if h:
|
|
758
|
+
b.add_edge(rid, h.id, "USES_MIDDLEWARE", f, rt["line"], RESOLVED, alias=alias)
|
|
759
|
+
self.stats["routes"] = n
|
|
760
|
+
|
|
761
|
+
def _commands_out(self):
|
|
762
|
+
b, prog = self.b, self.prog
|
|
763
|
+
self.command_by_class = {}
|
|
764
|
+
for c in prog.classes.values():
|
|
765
|
+
if c.kind != "class" or prog.is_test_class(c.fqcn) or not prog.is_a(c.fqcn, "Illuminate\\Console\\Command"):
|
|
766
|
+
continue
|
|
767
|
+
sig = prog.find_prop(c.fqcn, "signature") or prog.find_prop(c.fqcn, "name")
|
|
768
|
+
if not sig or not isinstance(sig.get("default"), str):
|
|
769
|
+
continue
|
|
770
|
+
name = sig["default"].strip().split()[0] if sig["default"].strip() else None
|
|
771
|
+
if not name:
|
|
772
|
+
continue
|
|
773
|
+
h = prog.find_method(c.fqcn, "handle") or prog.find_method(c.fqcn, "__invoke")
|
|
774
|
+
cid = b.add_node("command", name, name=name, file=c.file, line=c.line, module=module_of(c.file), lang="php",
|
|
775
|
+
entry_kind="artisan_command", doc=c.doc,
|
|
776
|
+
attrs={"class": c.fqcn, "signature": sig["default"].strip()})
|
|
777
|
+
self.command_by_class[c.fqcn] = name
|
|
778
|
+
if h:
|
|
779
|
+
b.add_edge(cid, h.id, "HANDLED_BY", c.file, h.line, EXACT)
|
|
780
|
+
self.stats["commands"] += 1
|
|
781
|
+
|
|
782
|
+
def _schedules_out(self):
|
|
783
|
+
b = self.b
|
|
784
|
+
for fn, f in self.schedules:
|
|
785
|
+
a0 = (f.get("args") or [None])[0]
|
|
786
|
+
target = None
|
|
787
|
+
if (f.get("m") or "").lower() == "command" and a0:
|
|
788
|
+
if a0.get("k") == "str":
|
|
789
|
+
target = b.add_node("command", a0["v"].split(" ")[0])
|
|
790
|
+
elif classconst(a0) and classconst(a0) in self.command_by_class:
|
|
791
|
+
target = f"command:{self.command_by_class[classconst(a0)]}"
|
|
792
|
+
elif (f.get("m") or "").lower() == "job" and a0 and a0.get("k") == "new" and self.prog.cls(a0.get("class")):
|
|
793
|
+
h = self.prog.find_method(a0["class"], "handle")
|
|
794
|
+
target = h.id if h else None
|
|
795
|
+
if not target:
|
|
796
|
+
continue
|
|
797
|
+
sid = b.add_node("schedule", f"{target.split(':', 1)[1]}@{fn.file}:{f['line']}", name=f"schedule {target.split(':', 1)[1]}",
|
|
798
|
+
file=fn.file, line=f["line"], module=module_of(fn.file), lang="php", entry_kind="scheduled")
|
|
799
|
+
b.add_edge(sid, target, "SCHEDULES", fn.file, f["line"], EXACT)
|
|
800
|
+
self.stats["schedules"] += 1
|
|
801
|
+
|
|
802
|
+
def _jobs_listeners_admin_out(self):
|
|
803
|
+
b, prog = self.b, self.prog
|
|
804
|
+
for c in prog.classes.values():
|
|
805
|
+
if c.kind != "class" or prog.is_test_class(c.fqcn):
|
|
806
|
+
continue
|
|
807
|
+
if self._is_job(c.fqcn):
|
|
808
|
+
h = prog.find_method(c.fqcn, "handle")
|
|
809
|
+
if h:
|
|
810
|
+
jid = b.add_node("job", c.fqcn, name=c.fqcn.split("\\")[-1], fqn=c.fqcn, file=c.file, line=c.line,
|
|
811
|
+
module=module_of(c.file), lang="php", entry_kind="queue_job")
|
|
812
|
+
b.add_edge(jid, h.id, "HANDLED_BY", c.file, h.line, EXACT)
|
|
813
|
+
self.stats["jobs"] += 1
|
|
814
|
+
if c.fqcn.startswith("App\\Listeners\\"):
|
|
815
|
+
h = prog.find_method(c.fqcn, "handle")
|
|
816
|
+
if h:
|
|
817
|
+
for p in h.params[:1]:
|
|
818
|
+
for et in prog.norm(p.get("types"), c.fqcn):
|
|
819
|
+
self.listeners.append((b.add_node("event", et, name=et.split("\\")[-1], fqn=et, lang="php"), h, None, h.line))
|
|
820
|
+
if c.fqcn.startswith("App\\Filament\\"):
|
|
821
|
+
aid = b.add_node("admin", c.fqcn, name=c.fqcn.split("\\")[-1], fqn=c.fqcn, file=c.file, line=c.line,
|
|
822
|
+
module=module_of(c.file), lang="php", entry_kind="admin_panel")
|
|
823
|
+
for m in c.methods.values():
|
|
824
|
+
b.add_edge(aid, m.id, "HANDLED_BY", c.file, m.line, RESOLVED, via="filament-surface")
|
|
825
|
+
self.stats["admin_surfaces"] += 1
|
|
826
|
+
for eid, target, fn, line in self.listeners:
|
|
827
|
+
lid = b.add_node("listener", target.id.split(":", 1)[1], name=target.id.split("::")[-1], file=target.file, line=target.line,
|
|
828
|
+
module=module_of(target.file), lang="php", entry_kind="listener")
|
|
829
|
+
b.add_edge(lid, target.id, "HANDLED_BY", target.file, target.line, EXACT)
|
|
830
|
+
b.add_edge(eid, target.id, "LISTENED_BY", fn.file if fn else target.file, line, EXACT if fn else RESOLVED)
|
|
831
|
+
self.stats["listeners"] += 1
|
|
832
|
+
for model, obs, fn, line in self.observers:
|
|
833
|
+
oc = prog.cls(obs)
|
|
834
|
+
if not oc:
|
|
835
|
+
continue
|
|
836
|
+
b.add_edge(f"class:{model}", f"class:{oc.fqcn}", "OBSERVED_BY", fn.file, line, EXACT)
|
|
837
|
+
oid = b.add_node("observer", oc.fqcn, name=oc.fqcn.split("\\")[-1], fqn=oc.fqcn, file=oc.file, line=oc.line,
|
|
838
|
+
module=module_of(oc.file), lang="php", entry_kind="observer")
|
|
839
|
+
for m in oc.methods.values():
|
|
840
|
+
b.add_edge(oid, m.id, "HANDLED_BY", oc.file, m.line, RESOLVED, via=f"observes {model.split(chr(92))[-1]}")
|
|
841
|
+
|
|
842
|
+
def _bindings_out(self):
|
|
843
|
+
b, prog = self.b, self.prog
|
|
844
|
+
for fn, f, ctx in getattr(self, "bindings", []):
|
|
845
|
+
args = f["args"]
|
|
846
|
+
abstract = classconst(args[0])
|
|
847
|
+
concrete = classconst(args[1]) if len(args) > 1 else None
|
|
848
|
+
if not concrete and len(args) > 1 and args[1].get("k") == "closure":
|
|
849
|
+
news = [g for g in fn.facts if g["t"] == "new" and (g.get("ctx") or {}).get("line") == f["line"] and g.get("class")]
|
|
850
|
+
concrete = news[0]["class"] if news else None
|
|
851
|
+
ac = prog.cls(abstract)
|
|
852
|
+
if not ac:
|
|
853
|
+
continue
|
|
854
|
+
aid = f"{ac.kind}:{ac.fqcn}"
|
|
855
|
+
b.add_edge(fn.id, aid, "BINDS", fn.file, f["line"], EXACT, method=f["m"])
|
|
856
|
+
cc = prog.cls(concrete) if concrete else None
|
|
857
|
+
if cc and cc.fqcn != ac.fqcn:
|
|
858
|
+
b.add_edge(aid, f"{cc.kind}:{cc.fqcn}", "BOUND_TO", fn.file, f["line"], RESOLVED)
|
|
859
|
+
for ln, m in ac.methods.items():
|
|
860
|
+
impl = prog.find_method(cc.fqcn, ln)
|
|
861
|
+
if impl and impl.id != m.id:
|
|
862
|
+
b.add_edge(m.id, impl.id, "BOUND_TO", fn.file, f["line"], RESOLVED)
|
|
863
|
+
self.stats["bindings"] += 1
|