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,668 @@
1
+ """PHP language plugin.
2
+
3
+ 1. Runs extractor/extract.php (nikic/php-parser + NameResolver) -> per-file JSON facts.
4
+ 2. Builds symbol tables (classes/interfaces/traits/enums, methods, properties, functions).
5
+ 3. Flow-insensitive local type inference per function body (params, typed/promoted
6
+ properties, `new`, assignments, @var/@param/@return docblocks, instanceof, closure
7
+ params, inferred return types), iterated to a fixed point across the program.
8
+ 4. Emits CALLS / IMPLEMENTED_BY / OVERRIDDEN_BY / INSTANTIATES / INJECTS / EXTENDS ...
9
+ Framework plugins (e.g. Laravel) register type rules and fact handlers on the
10
+ PhpProgram before step 3/4, so they can resolve facades, Eloquent builders, etc.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+ import time
21
+ from collections import defaultdict
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any, Callable
25
+
26
+ from ...core import extractors
27
+ from ...core.model import EXACT, HEURISTIC, RESOLVED
28
+ from ...core.fsutil import keep_file
29
+ from ...core.paths import rel_dir, rules as path_rules
30
+ from ...core.plugin import FrameworkPlugin, GraphBuilder, LanguagePlugin, Project
31
+
32
+ EXTRACTOR = Path(__file__).parent / "extractor" / "extract.php"
33
+ SCALARS = {"int", "string", "bool", "float", "array", "mixed", "void", "null", "callable", "iterable",
34
+ "object", "false", "true", "never", "resource", "list"}
35
+ # Heuristic unique-name fallback never applies to these (framework-common names).
36
+ HEURISTIC_STOP = set("""handle get set first find all create update delete save make toarray render boot register with
37
+ where map filter each count exists value load refresh fresh push pull put send build run execute call apply resolve
38
+ validate authorize rules messages up down definition casts fill getkey tojson jsonserialize offsetget format parse process
39
+ store show index destroy edit label table form schema query toresponse broadcaston broadcastas broadcastwith via tomail
40
+ toarray failed middleware getname getid title description""".split())
41
+ # test code: Laravel/PHPUnit/Pest keep tests under tests/ (also Modules/<x>/Tests/ in modular apps)
42
+ TEST_PATH_RE = re.compile(r"(^|/)(tests|Tests)/")
43
+
44
+
45
+ def is_test_path(path: str | None) -> bool:
46
+ return bool(path and TEST_PATH_RE.search(path))
47
+
48
+
49
+ def module_of(path: str | None) -> str | None:
50
+ if not path:
51
+ return None
52
+ parts = path.split("/")
53
+ if parts[0] == "app":
54
+ segs = parts[1:-1]
55
+ return "/".join(segs[:3]) if segs else "app"
56
+ if parts[0] == "database":
57
+ return "/".join(parts[:2])
58
+ return parts[0] if len(parts) > 1 else "(root)"
59
+
60
+
61
+ @dataclass
62
+ class PhpFunc:
63
+ id: str
64
+ name: str
65
+ cls: str | None
66
+ file: str
67
+ line: int
68
+ end_line: int
69
+ doc: str | None
70
+ params: list
71
+ returns: list
72
+ facts: list
73
+ static: bool = False
74
+ abstract: bool = False
75
+ visibility: str = "public"
76
+ inferred: set = field(default_factory=set)
77
+ env: dict = field(default_factory=dict)
78
+ skel: list = field(default_factory=list)
79
+ returns_nullable: bool = False
80
+ dead: dict = field(default_factory=dict) # fact index -> guard evidence, per gate scenario (see gating.py)
81
+ attributes: list = field(default_factory=list) # PHP 8 attribute names on a method (e.g. PHPUnit\Framework\Attributes\Test)
82
+
83
+
84
+ @dataclass
85
+ class PhpClass:
86
+ fqcn: str
87
+ kind: str
88
+ file: str
89
+ line: int
90
+ end_line: int
91
+ doc: str | None
92
+ extends: list
93
+ implements: list
94
+ traits: list
95
+ abstract: bool
96
+ props: dict
97
+ consts: list
98
+ methods: dict # lower name -> PhpFunc
99
+ cases: list = field(default_factory=list) # PHP 8.1 enum cases (#84)
100
+
101
+
102
+ class ResolveCtx:
103
+ """Per-function resolution context handed to framework fact handlers."""
104
+
105
+ def __init__(self, prog: "PhpProgram", fn: PhpFunc):
106
+ self.prog, self.fn = prog, fn
107
+ self.env = fn.env
108
+
109
+ def type_of(self, d) -> set:
110
+ return self.prog.type_of(d, self.fn, self.env)
111
+
112
+ def strings_of(self, d) -> list[str]:
113
+ """Literal string values an expression can take (str / alternatives)."""
114
+ if not d:
115
+ return []
116
+ if d.get("k") == "str":
117
+ return [d["v"]]
118
+ if d.get("k") == "alt":
119
+ out = []
120
+ for o in d["opts"]:
121
+ out += self.strings_of(o)
122
+ return out
123
+ return []
124
+
125
+
126
+ class PhpProgram:
127
+ def __init__(self, project: Project, records: list[dict], builder: GraphBuilder):
128
+ self.project, self.records, self.b = project, records, builder
129
+ self.classes: dict[str, PhpClass] = {}
130
+ self.lower_classes: dict[str, str] = {}
131
+ self.functions: dict[str, PhpFunc] = {}
132
+ self.scripts: dict[str, PhpFunc] = {}
133
+ self.all_funcs: list[PhpFunc] = [] # application code only
134
+ # test code (tests/): in the symbol tables so tests resolve their calls into the app, but analysed in a
135
+ # separate pass whose edges never feed the application graph (see codegraph/tests_index.py)
136
+ self.test_funcs: list[PhpFunc] = []
137
+ self.test_classes: set[str] = set()
138
+ # framework hooks
139
+ self.static_call_rules: list[Callable] = []
140
+ self.method_call_rules: list[Callable] = []
141
+ self.prop_rules: list[Callable] = []
142
+ self.func_rules: list[Callable] = []
143
+ self.fact_handlers: list[Callable] = []
144
+ self.test_fact_handlers: list[Callable] = [] # handlers run on test code only (HTTP test calls, ...)
145
+ self.stats = defaultdict(int)
146
+ self._load()
147
+
148
+ # ---------------- symbol tables ----------------
149
+ def _load(self):
150
+ for rec in self.records:
151
+ f = rec["file"]
152
+ in_test = is_test_path(f)
153
+ funcs = self.test_funcs if in_test else self.all_funcs
154
+ for c in rec.get("classes", []):
155
+ pc = PhpClass(fqcn=c["fqcn"], kind=c["kind"], file=f, line=c["line"], end_line=c["end_line"], doc=c.get("doc"),
156
+ extends=c["extends"], implements=c["implements"], traits=c["traits"], abstract=c.get("abstract", False),
157
+ props={p["name"]: p for p in c["props"]}, consts=c["consts"], methods={},
158
+ cases=c.get("cases") or [])
159
+ for m in c["methods"]:
160
+ fn = PhpFunc(id=f"method:{pc.fqcn}::{m['name']}", name=m["name"], cls=pc.fqcn, file=f, line=m["line"],
161
+ end_line=m["end_line"], doc=m.get("doc"), params=m["params"], returns=m["returns"], facts=m["facts"],
162
+ static=m["static"], abstract=m["abstract"], visibility=m["visibility"],
163
+ skel=m.get("skel") or [], returns_nullable=m.get("returns_nullable", False))
164
+ fn.attributes = m.get("attributes") or []
165
+ pc.methods[m["name"].lower()] = fn
166
+ funcs.append(fn)
167
+ if in_test:
168
+ self.test_classes.add(pc.fqcn)
169
+ self.classes[pc.fqcn] = pc
170
+ self.lower_classes[pc.fqcn.lower()] = pc.fqcn
171
+ for fn in rec.get("functions", []):
172
+ pf = PhpFunc(id=f"function:{fn['name']}", name=fn["name"], cls=None, file=f, line=fn["line"], end_line=fn["end_line"],
173
+ doc=fn.get("doc"), params=fn["params"], returns=fn["returns"], facts=fn["facts"],
174
+ skel=fn.get("skel") or [], returns_nullable=fn.get("returns_nullable", False))
175
+ if not in_test or fn["name"].lower() not in self.functions:
176
+ self.functions[fn["name"].lower()] = pf
177
+ funcs.append(pf)
178
+ if rec.get("top"):
179
+ sf = PhpFunc(id=f"script:{f}", name=f, cls=None, file=f, line=1, end_line=1, doc=None, params=[], returns=[], facts=rec["top"])
180
+ self.scripts[f] = sf
181
+ funcs.append(sf)
182
+ # method-name index for heuristic fallback (application classes only: a test double must not make an
183
+ # application method name ambiguous)
184
+ self.by_name = defaultdict(list)
185
+ for c in self.classes.values():
186
+ if c.fqcn in self.test_classes:
187
+ continue
188
+ for ln, m in c.methods.items():
189
+ self.by_name[ln].append(m)
190
+ self.children = defaultdict(set)
191
+ for c in self.classes.values():
192
+ for p in c.extends + c.implements + c.traits:
193
+ self.children[p].add(c.fqcn)
194
+
195
+ def cls(self, name: str | None) -> PhpClass | None:
196
+ if not name:
197
+ return None
198
+ c = self.classes.get(name)
199
+ if c is None:
200
+ fq = self.lower_classes.get(name.lower())
201
+ c = self.classes.get(fq) if fq else None
202
+ return c
203
+
204
+ def ancestors(self, fqcn: str, include_self=True) -> list[str]:
205
+ """Linearised ancestors: self, traits, parent chain, interfaces (incl. external names)."""
206
+ out, seen, stack = [], set(), [fqcn]
207
+ while stack:
208
+ x = stack.pop(0)
209
+ if x in seen:
210
+ continue
211
+ seen.add(x)
212
+ out.append(x)
213
+ c = self.cls(x)
214
+ if c:
215
+ stack.extend(c.traits + c.extends + c.implements)
216
+ return out if include_self else out[1:]
217
+
218
+ def is_test_class(self, fqcn: str) -> bool:
219
+ return fqcn in self.test_classes
220
+
221
+ def is_test_fn(self, fn: "PhpFunc") -> bool:
222
+ return is_test_path(fn.file)
223
+
224
+ def is_a(self, fqcn: str, base: str) -> bool:
225
+ b = base.lower()
226
+ return any(a.lower() == b for a in self.ancestors(fqcn))
227
+
228
+ def find_method(self, fqcn: str, name: str | None) -> PhpFunc | None:
229
+ if not name:
230
+ return None
231
+ ln = name.lower()
232
+ for a in self.ancestors(fqcn):
233
+ c = self.cls(a)
234
+ if c and ln in c.methods:
235
+ return c.methods[ln]
236
+ return None
237
+
238
+ def find_prop(self, fqcn: str, name: str | None) -> dict | None:
239
+ for a in self.ancestors(fqcn):
240
+ c = self.cls(a)
241
+ if c and name in c.props:
242
+ return c.props[name]
243
+ return None
244
+
245
+ def descendants(self, fqcn: str) -> set[str]:
246
+ out, stack = set(), [fqcn]
247
+ while stack:
248
+ x = stack.pop()
249
+ for ch in self.children.get(x, ()):
250
+ if ch not in out:
251
+ out.add(ch)
252
+ stack.append(ch)
253
+ return out
254
+
255
+ # ---------------- types ----------------
256
+ def norm(self, types, ctx_cls: str | None) -> set:
257
+ out = set()
258
+ for t in types or []:
259
+ if not t:
260
+ continue
261
+ tl = t.lower()
262
+ if tl in ("self", "static", "$this"):
263
+ if ctx_cls:
264
+ out.add(ctx_cls)
265
+ elif tl in SCALARS:
266
+ continue
267
+ else:
268
+ out.add(t.lstrip("\\"))
269
+ return out
270
+
271
+ def returns_of(self, m: PhpFunc, recv_cls: str | None) -> set:
272
+ # late static binding: `static`/`self` returns map to the receiver class
273
+ return self.norm(m.returns, recv_cls or m.cls) | {t for t in m.inferred if not t.startswith("__")}
274
+
275
+ def type_of(self, d, fn: PhpFunc, env: dict, depth=0) -> set:
276
+ if not d or depth > 8:
277
+ return set()
278
+ k = d.get("k")
279
+ if k == "this":
280
+ return {fn.cls} if fn.cls else set()
281
+ if k == "var":
282
+ return set(env.get(d["n"], ()))
283
+ if k == "new":
284
+ return {d["class"]} if d.get("class") else set()
285
+ if k == "alt":
286
+ out = set()
287
+ for o in d["opts"]:
288
+ out |= self.type_of(o, fn, env, depth + 1)
289
+ return out
290
+ if k == "prop":
291
+ out = set()
292
+ for t in self.type_of(d.get("of"), fn, env, depth + 1):
293
+ for rule in self.prop_rules:
294
+ r = rule(self, t, d.get("n"))
295
+ if r:
296
+ out |= r
297
+ if self.cls(t):
298
+ p = self.find_prop(t, d.get("n"))
299
+ if p:
300
+ out |= self.norm(p.get("types"), t)
301
+ return out
302
+ if k == "sprop":
303
+ c = d.get("class")
304
+ p = self.find_prop(c, d.get("n")) if c and self.cls(c) else None
305
+ return self.norm(p.get("types"), c) if p else set()
306
+ if k == "mcall":
307
+ out = set()
308
+ for t in self.type_of(d.get("of"), fn, env, depth + 1):
309
+ for rule in self.method_call_rules:
310
+ r = rule(self, t, d.get("m"), d.get("args", []))
311
+ if r:
312
+ out |= r
313
+ if self.cls(t):
314
+ m = self.find_method(t, d.get("m"))
315
+ if m:
316
+ out |= self.returns_of(m, t)
317
+ return out
318
+ if k == "scall":
319
+ c, out = d.get("class"), set()
320
+ for rule in self.static_call_rules:
321
+ r = rule(self, c, d.get("m"), d.get("args", []))
322
+ if r:
323
+ out |= r
324
+ if c and self.cls(c):
325
+ m = self.find_method(c, d.get("m"))
326
+ if m:
327
+ out |= self.returns_of(m, c)
328
+ return out
329
+ if k == "func":
330
+ out = set()
331
+ for rule in self.func_rules:
332
+ r = rule(self, d.get("n"), d.get("args", []))
333
+ if r:
334
+ out |= r
335
+ f = self.functions.get((d.get("n") or "").lower())
336
+ if f:
337
+ out |= self.returns_of(f, None)
338
+ return out
339
+ if k == "elem":
340
+ out = set()
341
+ for t in self.type_of(d.get("of"), fn, env, depth + 1):
342
+ if t.startswith("collection:"):
343
+ out.add(t.split(":", 1)[1])
344
+ return out
345
+ return set()
346
+
347
+ def infer_env(self, fn: PhpFunc) -> dict:
348
+ env = defaultdict(set)
349
+ for p in fn.params:
350
+ env[p["name"]] |= self.norm(p.get("types"), fn.cls)
351
+ assigns = [f for f in fn.facts if f["t"] in ("assign", "vartype")]
352
+ for _ in range(3):
353
+ changed = False
354
+ for f in assigns:
355
+ v = f["var"]
356
+ new = self.norm(f["types"], fn.cls) if f["t"] == "vartype" else self.type_of(f["expr"], fn, env)
357
+ if new - env[v]:
358
+ env[v] |= new
359
+ changed = True
360
+ if not changed:
361
+ break
362
+ return env
363
+
364
+ def run_inference(self, iterations=3):
365
+ self._infer(self.all_funcs, iterations)
366
+ self._infer(self.test_funcs, iterations) # after the application, so tests see its inferred return types
367
+
368
+ def _infer(self, funcs, iterations):
369
+ for _ in range(iterations):
370
+ changed = 0
371
+ for fn in funcs:
372
+ fn.env = self.infer_env(fn)
373
+ inf = set()
374
+ for f in fn.facts:
375
+ if f["t"] == "return" and not f.get("ctx"): # ignore returns inside closures
376
+ inf |= self.type_of(f["expr"], fn, fn.env)
377
+ if inf - fn.inferred:
378
+ fn.inferred |= inf
379
+ changed += 1
380
+ if not changed:
381
+ break
382
+
383
+ # ---------------- graph emission ----------------
384
+ def emit_declarations(self):
385
+ b = self.b
386
+ for c in self.classes.values():
387
+ ta = {"test": True} if c.fqcn in self.test_classes else {}
388
+ cid = b.add_node(c.kind, c.fqcn, name=c.fqcn.split("\\")[-1], fqn=c.fqcn, file=c.file, line=c.line,
389
+ end_line=c.end_line, module=module_of(c.file), doc=c.doc, lang="php",
390
+ attrs={**({"abstract": c.abstract} if c.abstract else {}), **ta})
391
+ for rel, kind in ((c.extends, "EXTENDS"), (c.implements, "IMPLEMENTS"), (c.traits, "USES_TRAIT")):
392
+ for p in rel:
393
+ pc = self.cls(p)
394
+ tid = f"{pc.kind}:{pc.fqcn}" if pc else b.add_node("external_class", p, name=p.split("\\")[-1], fqn=p, lang="php")
395
+ b.add_edge(cid, tid, kind, c.file, c.line, EXACT)
396
+ for p in c.props.values():
397
+ pid = b.add_node("property", f"{c.fqcn}::${p['name']}", name=f"${p['name']}", fqn=f"{c.fqcn}::${p['name']}",
398
+ file=c.file, line=p.get("line"), module=module_of(c.file), doc=p.get("doc"), lang="php",
399
+ attrs={**({"types": p.get("types"), "default": p.get("default")} if (p.get("types") or p.get("default") is not None) else {}),
400
+ **self._stored_attrs(p), **ta})
401
+ b.add_edge(cid, pid, "CONTAINS", c.file, p.get("line"), EXACT)
402
+ for kind, vals in (("enum_case", c.cases), ("constant", c.consts)): # #84
403
+ for v in vals:
404
+ vid = b.add_node(kind, f"{c.fqcn}::{v['name']}", name=v["name"], fqn=f"{c.fqcn}::{v['name']}",
405
+ file=c.file, line=v.get("line"), module=module_of(c.file), lang="php", attrs={**ta})
406
+ b.add_edge(cid, vid, "CONTAINS", c.file, v.get("line"), EXACT)
407
+ for m in c.methods.values():
408
+ b.add_node("method", f"{c.fqcn}::{m.name}", name=m.name, fqn=f"{c.fqcn}::{m.name}", file=c.file, line=m.line,
409
+ end_line=m.end_line, module=module_of(c.file), doc=m.doc, lang="php",
410
+ attrs={**{k: v for k, v in (("visibility", m.visibility), ("static", m.static), ("abstract", m.abstract)) if v and v != "public"}, **ta})
411
+ b.add_edge(cid, m.id, "CONTAINS", c.file, m.line, EXACT)
412
+ if m.name.lower() == "__construct":
413
+ for p in m.params:
414
+ for t in self.norm(p.get("types"), c.fqcn):
415
+ tc = self.cls(t)
416
+ if tc:
417
+ b.add_edge(cid, f"{tc.kind}:{tc.fqcn}", "INJECTS", c.file, p.get("line"), EXACT, param=p["name"])
418
+ # dispatch edges to overriding / implementing methods
419
+ for a in self.ancestors(c.fqcn, include_self=False):
420
+ ac = self.cls(a)
421
+ if not ac:
422
+ continue
423
+ for ln, m in c.methods.items():
424
+ if ln in ac.methods and ln != "__construct":
425
+ kind = "IMPLEMENTED_BY" if (ac.kind == "interface" or ac.methods[ln].abstract) else "OVERRIDDEN_BY"
426
+ b.add_edge(ac.methods[ln].id, m.id, kind, c.file, m.line, RESOLVED)
427
+ for f in self.functions.values():
428
+ b.add_node("function", f.name, name=f.name, fqn=f.name, file=f.file, line=f.line, end_line=f.end_line,
429
+ module=module_of(f.file), doc=f.doc, lang="php", attrs={"test": True} if is_test_path(f.file) else {})
430
+ for s in self.scripts.values():
431
+ b.add_node("script", s.file, name=s.file, file=s.file, line=1, module=module_of(s.file), lang="php",
432
+ attrs={"test": True} if is_test_path(s.file) else {})
433
+
434
+ @staticmethod
435
+ def _is_stored(p: dict | None) -> bool:
436
+ """A declared (or constructor-promoted) instance property; not a docblock `@property` (Laravel's magic
437
+ attributes, which are columns) and not a static one."""
438
+ return bool(p) and p.get("src") != "doc" and not p.get("static")
439
+
440
+ def _stored_attrs(self, p: dict) -> dict:
441
+ if not self._is_stored(p):
442
+ return {}
443
+ return {"property": "stored", **({"promoted": True} if p.get("promoted") else {})}
444
+
445
+ def _prop_owner(self, fqcn: str, name: str) -> str | None:
446
+ """The class (`fqcn` or an ancestor / used trait) that declares instance property `name`."""
447
+ for a in self.ancestors(fqcn):
448
+ c = self.cls(a)
449
+ if c and name in c.props:
450
+ return c.fqcn if self._is_stored(c.props[name]) else None
451
+ return None
452
+
453
+ @classmethod
454
+ def _recv_text(cls, r: dict | None, depth: int = 0) -> str:
455
+ """`$cart`, `$this->pricer`, `$a->b->c`; `(call)` / `(expr)` for anything else."""
456
+ k = (r or {}).get("k")
457
+ if k == "this":
458
+ return "$this"
459
+ if k == "var" and r.get("n"):
460
+ return f"${r['n']}"
461
+ if k == "prop" and r.get("n") and depth < 3:
462
+ return f"{cls._recv_text(r.get('of'), depth + 1)}->{r['n']}"[:40]
463
+ return "(call)" if k in ("call", "mcall", "scall") else "(expr)"
464
+
465
+ def _emit_prop(self, fn: "PhpFunc", f: dict, ctx: "ResolveCtx"):
466
+ """`$this->x` / `$obj->x` reads and writes of a declared property (#88): READS_PROP / WRITES_PROP onto the
467
+ `property:` node. A plain `= v` is a write; `+=`, `++`, `[]=` / `[k] =` (`via: item`) and `unset()` are
468
+ writes with `via` (the walk also records them as reads). `$this` binds exactly; a typed receiver (a
469
+ parameter, a typed property, a local built by `new`) binds resolved; an unknown one binds nothing."""
470
+ name, recv = f.get("prop"), f.get("recv") or {}
471
+ if not name:
472
+ return
473
+ this = recv.get("k") == "this"
474
+ write = f["t"] == "pwrite" or bool(f.get("write"))
475
+ owners = []
476
+ for t in (ctx.type_of(recv) if recv else ()):
477
+ o = self.cls(t) and self._prop_owner(t, name)
478
+ if o and o not in owners:
479
+ owners.append(o)
480
+ if not owners:
481
+ self.stats["property_refs_unresolved"] += 1
482
+ return
483
+ for o in owners:
484
+ self.stats["property_writes" if write else "property_reads"] += 1
485
+ self.b.add_edge(fn.id, f"property:{o}::${name}", "WRITES_PROP" if write else "READS_PROP", fn.file,
486
+ f.get("line"), EXACT if this else RESOLVED,
487
+ receiver="this" if this else self._recv_text(recv),
488
+ **({"via": f["via"]} if f.get("via") else {}))
489
+
490
+ def value_of(self, fqcn: str, name: str | None) -> str | None:
491
+ """The enum case or class constant `Class::NAME` names: the class's own, else an ancestor's or an
492
+ implemented interface's (constants are inherited; enum cases are not)."""
493
+ if not name or name == "class":
494
+ return None
495
+ for i, a in enumerate([fqcn] + [x for x in self.ancestors(fqcn, include_self=False) if x != fqcn]):
496
+ ac = self.cls(a)
497
+ if ac is None:
498
+ continue
499
+ if i == 0 and any(v["name"] == name for v in ac.cases):
500
+ return f"enum_case:{ac.fqcn}::{name}"
501
+ if any(v["name"] == name for v in ac.consts):
502
+ return f"constant:{ac.fqcn}::{name}"
503
+ return None
504
+
505
+ def fn_node_exists(self, fn: PhpFunc) -> bool:
506
+ return self.b.has(fn.id)
507
+
508
+ def emit_references(self):
509
+ self._emit_refs(self.all_funcs, self.fact_handlers)
510
+ # test code: calls / instantiations / class refs plus the test-only handlers; framework handlers (columns,
511
+ # bindings, schedules, listeners...) never run on tests, so a test cannot add facts to the application graph
512
+ self._emit_refs(self.test_funcs, self.test_fact_handlers)
513
+
514
+ def _emit_refs(self, funcs, handlers):
515
+ b = self.b
516
+ for fn in funcs:
517
+ ctx = ResolveCtx(self, fn)
518
+ for i, f in enumerate(fn.facts):
519
+ b.current_gate = fn.dead.get(i)
520
+ t = f["t"]
521
+ line = f.get("line")
522
+ if t == "call":
523
+ self._emit_call(fn, f, ctx)
524
+ elif t == "new" and f.get("class"):
525
+ c = self.cls(f["class"])
526
+ if c:
527
+ b.add_edge(fn.id, f"{c.kind}:{c.fqcn}", "INSTANTIATES", fn.file, line, EXACT)
528
+ ctor = self.find_method(c.fqcn, "__construct")
529
+ if ctor:
530
+ b.add_edge(fn.id, ctor.id, "CALLS", fn.file, line, EXACT)
531
+ elif t in ("fetch", "pwrite"):
532
+ self._emit_prop(fn, f, ctx)
533
+ elif t == "classref" and f.get("class"):
534
+ c = self.cls(f["class"])
535
+ if c:
536
+ b.add_edge(fn.id, f"{c.kind}:{c.fqcn}", "REFERENCES", fn.file, line, EXACT)
537
+ vid = self.value_of(c.fqcn, f.get("const"))
538
+ if vid:
539
+ b.add_edge(fn.id, vid, "USES_VALUE", fn.file, line, EXACT)
540
+ for h in handlers:
541
+ h(self, fn, f, ctx)
542
+ b.current_gate = None
543
+
544
+ def call_targets(self, fn: PhpFunc, f: dict, ctx: ResolveCtx) -> tuple[list, str]:
545
+ """Resolve a call fact to (targets [(PhpFunc, confidence)], status)."""
546
+ kind = f["kind"]
547
+ if kind == "static":
548
+ c = f.get("class")
549
+ if c and self.cls(c):
550
+ m = self.find_method(c, f.get("m"))
551
+ if m:
552
+ return [(m, EXACT)], "resolved"
553
+ return [], "unresolved_static"
554
+ if kind == "func":
555
+ pf = self.functions.get((f.get("n") or "").lower())
556
+ return ([(pf, EXACT)], "resolved") if pf else ([], "func")
557
+ recv = f.get("recv") or {}
558
+ types = ctx.type_of(recv)
559
+ out = []
560
+ self.last_recv = []
561
+ for t in types:
562
+ c = self.cls(t)
563
+ if c:
564
+ m = self.find_method(t, f.get("m"))
565
+ if m and c.methods.get((f.get("m") or "").lower()) is not m:
566
+ self.last_recv.append(f"{c.kind}:{c.fqcn}") # found on an ancestor: the receiver (attrs.recv, #62)
567
+ if m and all(m is not o for o, _ in out):
568
+ out.append((m, EXACT if recv.get("k") == "this" else RESOLVED))
569
+ if out:
570
+ return out, "resolved"
571
+ if types:
572
+ return [], "typed_external"
573
+ name = (f.get("m") or "").lower()
574
+ cands = self.by_name.get(name, [])
575
+ if len(cands) == 1 and name not in HEURISTIC_STOP and len(name) >= 5 and not name.startswith("__"):
576
+ return [(cands[0], HEURISTIC)], "heuristic"
577
+ return [], "unresolved_method"
578
+
579
+ def _emit_call(self, fn: PhpFunc, f: dict, ctx: ResolveCtx):
580
+ b, line, kind = self.b, f.get("line"), f["kind"]
581
+ self.stats[f"calls_{kind}"] += 1
582
+ self.last_recv = []
583
+ targets, status = self.call_targets(fn, f, ctx)
584
+ for m, conf in targets:
585
+ if conf == HEURISTIC:
586
+ b.add_edge(fn.id, m.id, "CALLS", fn.file, line, HEURISTIC, via="unique-method-name")
587
+ else:
588
+ b.add_edge(fn.id, m.id, "CALLS", fn.file, line, conf,
589
+ **({"recv": sorted(set(self.last_recv))} if self.last_recv else {}))
590
+ if status == "resolved":
591
+ self.stats["calls_resolved"] += 1
592
+ elif status == "heuristic":
593
+ self.stats["calls_heuristic"] += 1
594
+ elif status in ("typed_external", "unresolved_static", "unresolved_method"):
595
+ self.stats[f"calls_{status}"] += 1
596
+
597
+
598
+ class PhpPlugin(LanguagePlugin):
599
+ name = "php"
600
+
601
+ def detect(self, project: Project) -> bool:
602
+ return project.exists("composer.json") or any(project.root.glob("*.php"))
603
+
604
+ def prerequisite_problem(self, project: Project) -> str | None:
605
+ if not shutil.which("php"):
606
+ return "php not installed (PHP 8.2+ is needed for the PHP extractor)"
607
+ if not extractors.status("php")["installed"] and not shutil.which("composer"):
608
+ return "PHP extractor dependencies missing and Composer not installed: install Composer, then run `cg setup php`"
609
+ return None
610
+
611
+ def list_files(self, project: Project) -> list[str]:
612
+ # tests/ is indexed as test code (codegraph/tests_index.py keeps it out of the application graph)
613
+ # Composer vendor/, Laravel storage/ and bootstrap/cache/ at the root (codegraph/presets/php.yaml) + .cg.yaml
614
+ rules = path_rules(project, "php")
615
+ out = []
616
+ for dp, dns, fns in os.walk(project.root):
617
+ rd = rel_dir(project.root, dp)
618
+ dns[:] = rules.prune(rd, dns)
619
+ pre = rd + "/" if rd else ""
620
+ for fn in fns:
621
+ if fn.endswith(".php") and not fn.startswith("._") and keep_file(os.path.join(dp, fn)) \
622
+ and not rules.excluded(pre + fn):
623
+ out.append(pre + fn)
624
+ return sorted(out)
625
+
626
+ def extract(self, project: Project) -> list[dict]:
627
+ files = self.list_files(project)
628
+ with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as fl:
629
+ fl.write("\n".join(files))
630
+ exdir = extractors.ensure("php")
631
+ res = subprocess.run(["php", str(exdir / EXTRACTOR.name), str(project.root), fl.name], capture_output=True, text=True, check=True)
632
+ os.unlink(fl.name)
633
+ return [json.loads(line) for line in res.stdout.splitlines() if line.strip()]
634
+
635
+ def index(self, project: Project, builder: GraphBuilder, frameworks: list[FrameworkPlugin]) -> dict:
636
+ t0 = time.time()
637
+ records = self.extract(project)
638
+ t_extract = time.time() - t0
639
+ self.file_report = {"seen": [r["file"] for r in records if r.get("file")],
640
+ "parse_failed": [r["file"] for r in records if r.get("error") and r.get("file")],
641
+ # `Syntax error, unexpected '}' on line 12`: the line for coverage (#73)
642
+ "syntax_errors": {r["file"]: [[int(m.group(1))] * 2] if (m := re.search(r"on line (\d+)", r["error"])) else []
643
+ for r in records if r.get("error") and r.get("file")}}
644
+ prog = PhpProgram(project, records, builder)
645
+ self.program = prog
646
+ for fw in frameworks:
647
+ fw.register_hooks(prog)
648
+ t1 = time.time()
649
+ prog.run_inference()
650
+ gate_stats = {}
651
+ self.gate_predicates = []
652
+ for sc in (project.options.get("gates") or [])[:1]: # beta: one scenario per index
653
+ from .gating import GateEvaluator
654
+ tg = time.time()
655
+ ev = GateEvaluator(prog, sc)
656
+ gate_stats[sc["name"]] = ev.run()
657
+ gate_stats[sc["name"]]["seconds"] = round(time.time() - tg, 2)
658
+ self.gate_predicates += [(sc["name"], mid, json.dumps(v), how) for mid, (v, how) in ev.predicates.items()]
659
+ prog.emit_declarations()
660
+ prog.emit_references()
661
+ stats = {"files": len(records), "parse_errors": sum(1 for r in records if r.get("error")),
662
+ "classes": len(prog.classes) - len(prog.test_classes), "functions": len(prog.all_funcs),
663
+ "test_files": sum(1 for r in records if is_test_path(r["file"])), "test_functions": len(prog.test_funcs),
664
+ "extract_seconds": round(t_extract, 2), "resolve_seconds": round(time.time() - t1, 2)}
665
+ stats.update(dict(prog.stats))
666
+ if gate_stats:
667
+ stats["gates"] = gate_stats
668
+ return stats