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,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
|