cg-code-graph 0.10.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
@@ -0,0 +1,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