graphify-php 0.0.5__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 (50) hide show
  1. graphify_php/__init__.py +144 -0
  2. graphify_php/cli.py +355 -0
  3. graphify_php/config/__init__.py +47 -0
  4. graphify_php/config/class_lookup.py +85 -0
  5. graphify_php/config/discovery.py +55 -0
  6. graphify_php/config/document.py +29 -0
  7. graphify_php/config/emit.py +117 -0
  8. graphify_php/config/environment.py +108 -0
  9. graphify_php/config/facts.py +97 -0
  10. graphify_php/config/messaging.py +142 -0
  11. graphify_php/config/ports.py +23 -0
  12. graphify_php/config/safe_yaml.py +127 -0
  13. graphify_php/config_resolver.py +164 -0
  14. graphify_php/container/__init__.py +50 -0
  15. graphify_php/container/facts.py +178 -0
  16. graphify_php/container/layer.py +71 -0
  17. graphify_php/container/locator.py +74 -0
  18. graphify_php/container/overlay.py +149 -0
  19. graphify_php/container/ports.py +40 -0
  20. graphify_php/container/reader.py +330 -0
  21. graphify_php/container/reasons.py +31 -0
  22. graphify_php/graph_adapter.py +520 -0
  23. graphify_php/php/README.md +106 -0
  24. graphify_php/php/autoload.php +27 -0
  25. graphify_php/php/src/CallGraph/CallEdge.php +91 -0
  26. graphify_php/php/src/CallGraph/CallEdgeRule.php +61 -0
  27. graphify_php/php/src/CallGraph/CallEdgeWriter.php +19 -0
  28. graphify_php/php/src/CallGraph/JsonLinesWriter.php +64 -0
  29. graphify_php/php/src/CallGraph/MethodCallCollector.php +163 -0
  30. graphify_php/ports.py +174 -0
  31. graphify_php/resolver.py +519 -0
  32. graphify_php/shrink.py +413 -0
  33. graphify_php/shrink_cli.py +150 -0
  34. graphify_php/sources/phpstan_source.py +641 -0
  35. graphify_php/sources/route_source.py +105 -0
  36. graphify_php/sources/tree_sitter_source.py +428 -0
  37. graphify_php/status.py +136 -0
  38. graphify_php/syntax/call_sites.py +117 -0
  39. graphify_php/syntax/class_index.py +121 -0
  40. graphify_php/syntax/collectors.py +351 -0
  41. graphify_php/syntax/facts.py +171 -0
  42. graphify_php/syntax/file_scan.py +158 -0
  43. graphify_php/syntax/names.py +49 -0
  44. graphify_php/syntax/nodes.py +209 -0
  45. graphify_php/syntax/routes.py +288 -0
  46. graphify_php-0.0.5.dist-info/METADATA +218 -0
  47. graphify_php-0.0.5.dist-info/RECORD +50 -0
  48. graphify_php-0.0.5.dist-info/WHEEL +4 -0
  49. graphify_php-0.0.5.dist-info/entry_points.txt +3 -0
  50. graphify_php-0.0.5.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,288 @@
1
+ """Route declarations read from PHP attributes.
2
+
3
+ Why this is here at all. The call graph answers questions whose subject is a symbol. It cannot
4
+ answer one whose subject is a literal: "which repository owns `/api/v1/storage/upload/small`"
5
+ has no symbol in it, no node label contains a route path, and the ranked search therefore
6
+ returns a frontend caller and an unrelated class above the controller that actually serves it.
7
+ A route declaration puts the literal into the graph, attached to the method that serves it.
8
+
9
+ What a route declaration proves, and what it does not. It proves that this codebase declares
10
+ that path and names this controller method to serve it. It does NOT prove that a deployed
11
+ ingress, load balancer or reverse proxy routes that path here, nor that any environment loads
12
+ the route at all — Symfony filters by `env`, a firewall may block it, and another service may
13
+ answer the same path first. A reader will assume the stronger claim, so it is worth saying
14
+ plainly: this is a declaration in source, not an observation of production traffic.
15
+
16
+ The composition rules are read from Symfony's own loader rather than guessed, in the copy at
17
+ `vendor/symfony/routing/Loader/AttributeClassLoader.php`:
18
+
19
+ - path — `$paths[] = $prefix.$path`, plain string concatenation of the class-level path onto
20
+ the method-level one.
21
+ - name — `$name = $globals['name'].$name`, the class-level name used as a prefix.
22
+ - methods — `array_unique(array_merge($globals['methods'], $attr->methods))`, a UNION. A
23
+ method-level `methods:` does not replace the class-level one, which is the rule most likely
24
+ to be guessed wrong.
25
+ - an abstract class is skipped entirely (`load()` throws on it).
26
+ - a class with `__invoke` and no method-level route takes its route from the class attribute,
27
+ and `resetGlobals()` means no prefix is applied in that case — the class attribute IS the
28
+ route rather than a prefix for one.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass
34
+
35
+ from . import nodes
36
+
37
+ # Matched on the short name, because that is what the attribute is written as. A `use` that
38
+ # renames it is not followed: an alias for `Route` is vanishingly rare next to the cost of
39
+ # resolving every attribute name in the file, and a miss here loses a node rather than
40
+ # inventing one.
41
+ ROUTE_ATTRIBUTE = "Route"
42
+
43
+ STRING_KINDS = ("string", "encapsed_string")
44
+
45
+ # Why an effective route could not be composed. Fixed strings, same discipline as the call
46
+ # refusals: these are a work list, not a histogram of prose.
47
+ PATH_NOT_LITERAL = "path-not-literal"
48
+ PREFIX_NOT_LITERAL = "prefix-not-literal"
49
+ LOCALIZED_PATH = "localized-path"
50
+ NO_PATH = "no-path"
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class RouteDeclaration:
55
+ """One `#[Route]` as declared, with the controller method it sits on.
56
+
57
+ `path` is the template verbatim — `/api/v1/storage/upload/small/{id}` — because the
58
+ placeholder is what makes it searchable and matchable. It is None when composition needed
59
+ something this package cannot evaluate; `class_path` and `method_path` still carry
60
+ whichever halves were literal, so the declaration is a usable partial rather than a hole.
61
+ """
62
+
63
+ controller_class: str
64
+ controller_method: str
65
+ source_file: str
66
+ line: int
67
+ path: str | None = None
68
+ name: str | None = None
69
+ methods: tuple[str, ...] = ()
70
+ class_path: str | None = None
71
+ method_path: str | None = None
72
+ reason: str = ""
73
+
74
+ @property
75
+ def resolved(self) -> bool:
76
+ return self.path is not None
77
+
78
+ @property
79
+ def node_key(self) -> str:
80
+ """Identity of this declaration, not of the path.
81
+
82
+ The declaring method is in the key on purpose. Two repositories that both declare
83
+ `/health` declare two different things — each is served by its own controller — and
84
+ collapsing them onto one node would claim a relationship between two codebases that
85
+ share nothing but a string.
86
+ """
87
+ return (
88
+ f"{self.controller_class}::{self.controller_method}#"
89
+ f"{self.path or self.method_path or ''}"
90
+ )
91
+
92
+
93
+ def collect_routes(root, names, source_file: str) -> list[RouteDeclaration]:
94
+ """Every route declared in one file.
95
+
96
+ A separate pass over the tree rather than a hook in the walker: routes are not type facts,
97
+ and the walker's one reason to change is scope.
98
+ """
99
+ found: list[RouteDeclaration] = []
100
+ _walk(root, names, source_file, found)
101
+ return found
102
+
103
+
104
+ def _walk(node, names, source_file: str, found: list) -> None:
105
+ if node.type in nodes.CLASS_KINDS:
106
+ _read_class(node, names, source_file, found)
107
+ return
108
+ for child in node.children:
109
+ _walk(child, names, source_file, found)
110
+
111
+
112
+ def _read_class(node, names, source_file: str, found: list) -> None:
113
+ if nodes.child(node, "abstract_modifier") is not None:
114
+ # Symfony's loader throws rather than reading attributes from an abstract class, so a
115
+ # route on one is a prefix for subclasses, never a route in its own right.
116
+ return
117
+ class_fqn = names.qualify_declaration(nodes.name_of(node))
118
+ class_attr = _route_attribute(node)
119
+ prefix = _Prefix.read(class_attr)
120
+
121
+ body = nodes.child(node, "declaration_list", "enum_declaration_list")
122
+ methods = nodes.children(body, "method_declaration") if body is not None else []
123
+
124
+ before = len(found)
125
+ for method in methods:
126
+ attribute = _route_attribute(method)
127
+ if attribute is None:
128
+ continue
129
+ found.append(_compose(attribute, prefix, class_fqn, nodes.name_of(method), source_file))
130
+
131
+ if len(found) == before and class_attr is not None:
132
+ invoke = next((m for m in methods if nodes.name_of(m) == "__invoke"), None)
133
+ if invoke is not None:
134
+ # `resetGlobals()` in the loader: the class attribute is the route itself here, so
135
+ # it composes with nothing.
136
+ found.append(_compose(class_attr, _Prefix(), class_fqn, "__invoke", source_file))
137
+
138
+
139
+ @dataclass
140
+ class _Prefix:
141
+ """The class-level attribute, as the three things it contributes."""
142
+
143
+ path: str | None = None
144
+ name: str = ""
145
+ methods: tuple[str, ...] = ()
146
+ path_literal: bool = True
147
+
148
+ @classmethod
149
+ def read(cls, attribute) -> _Prefix:
150
+ if attribute is None:
151
+ return cls()
152
+ positional, named = _arguments(attribute)
153
+ path_node = named.get("path") or (positional[0] if positional else None)
154
+ path, literal = _path_of(path_node)
155
+ return cls(
156
+ path=path,
157
+ name=_string_value(named.get("name")) or "",
158
+ methods=_string_list(named.get("methods")),
159
+ path_literal=literal,
160
+ )
161
+
162
+
163
+ def _compose(
164
+ attribute, prefix: _Prefix, class_fqn: str, method_name: str, source_file: str
165
+ ) -> RouteDeclaration:
166
+ positional, named = _arguments(attribute)
167
+ path_node = named.get("path") or (positional[0] if positional else None)
168
+ method_path, literal = _path_of(path_node)
169
+
170
+ name = _string_value(named.get("name"))
171
+ # `array_unique(array_merge(...))`: a union, with the class-level entries first.
172
+ methods = tuple(dict.fromkeys(prefix.methods + _string_list(named.get("methods"))))
173
+
174
+ effective: str | None = None
175
+ reason = ""
176
+ if path_node is None:
177
+ reason = NO_PATH
178
+ elif not literal:
179
+ reason = (
180
+ LOCALIZED_PATH if method_path is None and _is_array(path_node) else PATH_NOT_LITERAL
181
+ )
182
+ elif not prefix.path_literal:
183
+ reason = PREFIX_NOT_LITERAL
184
+ else:
185
+ effective = (prefix.path or "") + method_path
186
+
187
+ return RouteDeclaration(
188
+ controller_class=class_fqn,
189
+ controller_method=method_name,
190
+ source_file=source_file,
191
+ line=nodes.line_span(attribute)[0],
192
+ path=effective,
193
+ name=(prefix.name + name) if name is not None else (prefix.name or None),
194
+ methods=methods,
195
+ class_path=prefix.path,
196
+ method_path=method_path,
197
+ reason=reason,
198
+ )
199
+
200
+
201
+ def _route_attribute(declaration):
202
+ """The `#[Route(...)]` on a class or method declaration, or None.
203
+
204
+ Only attribute groups directly on this declaration are read, so an attribute on a nested
205
+ declaration cannot be mistaken for one on its parent.
206
+ """
207
+ attributes = nodes.child(declaration, "attribute_list")
208
+ if attributes is None:
209
+ return None
210
+ for group in nodes.children(attributes, "attribute_group"):
211
+ for attribute in nodes.children(group, "attribute"):
212
+ written = nodes.node_text(nodes.child(attribute, "name", "qualified_name"))
213
+ if written.rsplit("\\", 1)[-1] == ROUTE_ATTRIBUTE:
214
+ return attribute
215
+ return None
216
+
217
+
218
+ def _arguments(attribute) -> tuple[list, dict]:
219
+ positional: list = []
220
+ named: dict = {}
221
+ argument_list = nodes.child(attribute, "arguments")
222
+ for argument in nodes.children(argument_list, "argument"):
223
+ label = nodes.child(argument, "name")
224
+ # Named nodes only: `name: "x"` puts a bare `:` token between the label and the value,
225
+ # and taking the first non-label child would take the colon.
226
+ value = next((c for c in argument.children if c.is_named and c is not label), None)
227
+ if value is None:
228
+ continue
229
+ if label is not None:
230
+ named[nodes.node_text(label)] = value
231
+ else:
232
+ positional.append(value)
233
+ return positional, named
234
+
235
+
236
+ def _path_of(node) -> tuple[str | None, bool]:
237
+ """(path, was-a-literal). A non-literal keeps `None` rather than a guessed value."""
238
+ if node is None:
239
+ return None, True
240
+ value = _string_value(node)
241
+ if value is None:
242
+ return None, False
243
+ return value, True
244
+
245
+
246
+ def _is_array(node) -> bool:
247
+ return node is not None and node.type == "array_creation_expression"
248
+
249
+
250
+ def _string_value(node) -> str | None:
251
+ """The text of a string literal, or None for anything that needs evaluating.
252
+
253
+ A concatenation, a constant or a variable all return None. Reading `self::PREFIX . '/x'`
254
+ as `/x` would put a path in the graph that the application never serves.
255
+ """
256
+ if node is None or node.type not in STRING_KINDS:
257
+ return None
258
+ parts = [c for c in node.children if c.is_named]
259
+ if len(parts) == 1 and parts[0].type == "string_content":
260
+ return nodes.node_text(parts[0])
261
+ if parts:
262
+ # An escape sequence or an interpolation: the value is not the source text, and the
263
+ # first `string_content` of `'/it\'s/{id}'` is `/it`, a path nothing serves.
264
+ return None
265
+ text = nodes.node_text(node)
266
+ return text[1:-1] if len(text) >= 2 and text[0] in "\"'" else text
267
+
268
+
269
+ def _string_list(node) -> tuple[str, ...]:
270
+ """`methods: 'GET'` and `methods: ['GET', 'POST']` are both written in this codebase."""
271
+ if node is None:
272
+ return ()
273
+ single = _string_value(node)
274
+ if single is not None:
275
+ return (single,)
276
+ if not _is_array(node):
277
+ return ()
278
+ out = []
279
+ for element in node.children:
280
+ value = _string_value(element)
281
+ if value is None:
282
+ for inner in element.children:
283
+ value = _string_value(inner)
284
+ if value is not None:
285
+ break
286
+ if value is not None:
287
+ out.append(value)
288
+ return tuple(out)
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.5
2
+ Name: graphify-php
3
+ Version: 0.0.5
4
+ Summary: PHP member-call resolution for graphify: binds $this->service->method() to the method node it calls.
5
+ Author: Andrii Afanasiev
6
+ License-Expression: Apache-2.0
7
+ License-File: LICENSE
8
+ Keywords: call-graph,code-graph,graphify,php,static-analysis
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Quality Assurance
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: pyyaml>=6.0
15
+ Provides-Extra: lint
16
+ Requires-Dist: ruff>=0.15; extra == 'lint'
17
+ Provides-Extra: test
18
+ Requires-Dist: graphifyy>=0.9.66; extra == 'test'
19
+ Requires-Dist: pytest>=7.0; extra == 'test'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # graphify-php
23
+
24
+ PHP member-call resolution for [graphify](https://github.com/OxCom/graphify). It binds
25
+ `$this->emails->reminderSubmitWeek($user)` to the method node it actually calls, so a PHP graph
26
+ has a call graph instead of a list of names.
27
+
28
+ ## The measurement
29
+
30
+ graphify ships cross-file member-call resolvers for Swift, Python, Ruby, TypeScript, C#, Java
31
+ and Rust, and none for PHP. Its PHP extractor marks `$obj->method()` as a member call and the
32
+ shared cross-file pass then drops it, because a bare `method` collides across the corpus and a
33
+ name-only match is not evidence.
34
+
35
+ On a reference Symfony application (584 PHP files, not shipped with this package), measured
36
+ from the published graph:
37
+
38
+ | | |
39
+ |---|---|
40
+ | PHP callable nodes | 3426 |
41
+ | `calls` edges in the whole graph | 2172 |
42
+ | PHP callables with an outgoing `calls` edge | 24.4 % |
43
+ | PHP callables with an incoming `calls` edge | 20.1 % |
44
+
45
+ Three quarters of the PHP callables in that graph are connected to nothing they call. Every
46
+ "what calls this", "what does this touch" and blast-radius question over PHP is answered from
47
+ that quarter.
48
+
49
+ ## What it does
50
+
51
+ The pass runs inside graphify's build, after extraction:
52
+
53
+ 1. collects every member call graphify left unresolved, from `per_file[*]["raw_calls"]`;
54
+ 2. hands the sources the PHP files graphify parsed as the `corpus` they may read declarations
55
+ from. A leaf service class has nothing unresolved in it, so it never appears among the sites,
56
+ and a source indexing only those files cannot tell whether it declares the method being
57
+ called on it. The set is bounded by the build, never by a filesystem walk;
58
+ 3. asks each configured **call target source** in precedence order for the receiver's class —
59
+ the precise source (a PHP type engine that already knows the project's types) first, the
60
+ tree-sitter parser second. A site the first source proves is never offered to the second, so
61
+ a weaker source can add reach but can never overwrite a stronger source's answer;
62
+ 4. maps each `(class FQN, method)` to a graphify node id;
63
+ 5. appends `calls` edges carrying the target's confidence (`EXTRACTED` when the type was read
64
+ from source, `INFERRED` when it was derived) and `_resolver: "graphify-php"`, which is how a
65
+ reader tells our edges from graphify's own and can drop them without a rebuild. `_via` names
66
+ the technique that proved each one (`tree_sitter:promoted-parameter`, `tree_sitter:self-scope`,
67
+ ...).
68
+
69
+ The marker is not `_origin`: graphify overwrites that on every edge with `"ast"` after the
70
+ resolvers run (`extract.py:8285-8289`), where it means the eviction tier rather than the author.
71
+
72
+ ### Scoped calls
73
+
74
+ `self::`, `parent::`, `static::` and `Class::method()` reach graphify with `is_member_call`
75
+ false and the **scope** as the callee — the method name is dropped
76
+ (`extractors/engine.py:6147-6152`). The resolver recovers it without parsing PHP: it asks the
77
+ graph which methods that class declares and offers each as a candidate, and the type source
78
+ refuses every name not actually written on that line. A class with ten methods costs ten
79
+ lookups and yields at most one edge.
80
+
81
+ `Class::method()` additionally already has a coarse `caller -> Class` edge, because graphify
82
+ resolves the scope name to the class node. Our method-level edge is a **second** edge between
83
+ different endpoints, so both are published. The narrow one carries `_refines` naming the coarse
84
+ one, so a consumer can collapse the pair instead of counting the call twice.
85
+
86
+ A source returns a target only when it can prove it. Chained calls, dynamic names, `static::`
87
+ and anything through `__call` are counted by reason, not guessed at: a wrong call edge is worse
88
+ than a missing one, and every counted reason is a line on the next version's work list.
89
+
90
+ ## Install and register
91
+
92
+ graphify-php is not on PyPI. Install a clone in editable mode into graphify's own environment;
93
+ the PHPStan source needs the `php/` directory of the checkout, which the wheel does not ship:
94
+
95
+ ```bash
96
+ git clone https://github.com/OxCom/graphify-php.git ~/src/graphify-php
97
+ pipx inject --editable --include-apps graphifyy ~/src/graphify-php
98
+ ```
99
+
100
+ The one runtime dependency is pyyaml. graphify and tree-sitter come from the environment that
101
+ runs the build; pinning them here would fight graphify's own pins. Virtual environments,
102
+ PHPStan, verification and troubleshooting: [docs/install.md](docs/install.md).
103
+
104
+ Registration is one call, made before graphify builds:
105
+
106
+ ```python
107
+ import graphify_php
108
+
109
+ graphify_php.register()
110
+ ```
111
+
112
+ It is idempotent and imports graphify lazily, so importing the package never requires graphify
113
+ to be installed.
114
+
115
+ ## Run a build
116
+
117
+ ```bash
118
+ graphify-php-build -- update .
119
+ ```
120
+
121
+ Everything after `--` is passed to graphify unchanged; options before it belong to the wrapper:
122
+
123
+ | Option | Meaning |
124
+ |---|---|
125
+ | `--graph PATH` | the graph artifact to protect (default `$GRAPHIFY_OUT/graph.json`) |
126
+ | `--expect RESOLVER` | a resolver that must report; repeatable, defaults to `php_member_calls` |
127
+
128
+ Exit codes:
129
+
130
+ | Code | Meaning |
131
+ |---|---|
132
+ | 0 | every expected resolver reported `completed` or `skipped` |
133
+ | 2 | an expected resolver reported `failed`, or never finished |
134
+ | 3 | an expected resolver never reported at all |
135
+ | other | graphify's own exit code, when graphify itself failed |
136
+
137
+ The wrapper sets `PYTHONHASHSEED=0` before calling graphify. graphify re-execs itself to pin
138
+ that seed for `update`, `extract`, `cluster-only` and `label` (`__main__.py:486-525`), and the
139
+ re-exec would replace the process — losing both the registration and the gate. Setting the seed
140
+ graphify would have set skips the re-exec and keeps its determinism guarantee.
141
+
142
+ ## Why the wrapper exists
143
+
144
+ graphify runs registered resolvers like this:
145
+
146
+ ```python
147
+ try:
148
+ resolver.resolve(per_file, all_nodes, all_edges)
149
+ except Exception as exc:
150
+ _LOG.warning("%s resolution failed, skipping: %s", resolver.name, exc)
151
+ ```
152
+
153
+ A resolver that raises is skipped and the build still publishes. A resolver that was never
154
+ registered — because the seam moved in a graphify upgrade, or because an install left this
155
+ package out — is not even logged. Both produce a valid graph with fewer `calls` edges, which is
156
+ exactly the shape of the defect this package was written to fix. The failure would be invisible
157
+ at the one moment it matters, and the warning goes to graphify's logger, which nothing running
158
+ the build reads.
159
+
160
+ So each resolver writes its own record — state, and counters that include the denominator,
161
+ because "resolved 400 calls" says nothing without "out of how many" — and `graphify-php-build`
162
+ reads that record and refuses to publish when an expected resolver did not complete. On a
163
+ refusal the previous graph is restored and the exit code is non-zero.
164
+
165
+ `skipped` with a reason is a success: a repository with no PHP type engine in `vendor/` is a
166
+ normal skip, and conflating it with a failure is how a missing layer becomes noise. `failed`,
167
+ and silence, are not.
168
+
169
+ The record path defaults to `graphify-out/.graphify-php-status.json` and is overridable with
170
+ `GRAPHIFY_PHP_STATUS`, so one machine can index many repositories without their builds sharing
171
+ a file.
172
+
173
+ ## Routes
174
+
175
+ A second registered pass publishes what `#[Route]` attributes declare. Each declaration becomes
176
+ one node — labelled with the path template, carrying the route name, the HTTP methods and
177
+ whether the path was composed whole — and one `handled_by` edge to the controller method that
178
+ serves it. On the reference Symfony application: **156 route nodes, 156 edges, no refusals**.
179
+
180
+ The node key is `Class::method#path`, so two controller methods declaring the same path stay two
181
+ nodes. That is not hypothetical: the reference application declares 156 routes over 135 distinct
182
+ path templates, the repeats being different HTTP verbs on one path.
183
+
184
+ The relation is `handled_by`, not `routed_to` — the latter already means messenger transport
185
+ routing, and merging the two would put HTTP paths and message transports in one query.
186
+
187
+ A declaration whose controller method is missing from the graph still gets its node; only the
188
+ edge is withheld, and the omission is counted under `controller_not_in_graph`. The path is a
189
+ fact about the codebase either way.
190
+
191
+ ## Verifying it works
192
+
193
+ ```bash
194
+ PYTHONPATH=src python -m pytest tests/test_cli_end_to_end.py -q
195
+ ```
196
+
197
+ That test generates a small PHP project in a temporary directory, runs graphify over it twice —
198
+ once plain, once with `register()` called first — and asserts the difference. On the generated
199
+ fixture the plain build produces **1** `calls` edge and the registered build **5**. It needs no
200
+ reference application and writes nothing outside `tmp_path`.
201
+
202
+ ## Extending
203
+
204
+ Adding a third source — a language server, an LSIF index — means implementing one protocol,
205
+ `CallTargetSource` in `ports.py`, and adding it to the factory in `__init__.py`. `resolver.py`
206
+ depends on the protocols and nothing else, and does not change.
207
+
208
+ ## CI
209
+
210
+ GitHub Actions runs three stages in order: **security** (gitleaks, trufflehog, pip-audit,
211
+ `php -l` over `php/`), **tests** (ruff lint and format check, pytest on Python 3.10–3.14) and
212
+ **build** (`python -m build`, then import and `graphify-php-build --help` from the installed
213
+ wheel). The same checks locally: `pip install -e ".[test,lint]"`, then `ruff check .`,
214
+ `ruff format --check .`, `python -m pytest`, and `for f in php/autoload.php php/src/CallGraph/*.php; do php -l "$f"; done`.
215
+
216
+ ## License
217
+
218
+ Apache-2.0.
@@ -0,0 +1,50 @@
1
+ graphify_php/__init__.py,sha256=4EDW1vFFduuzwPjNff8rcS8dMOG0pq0HUaApmQDRv2o,5451
2
+ graphify_php/cli.py,sha256=hPfvX46J3XK0YZbZv12sjNC3Ol0a96K_MEc7ilK9PvY,14459
3
+ graphify_php/config_resolver.py,sha256=d3azDRtheX9xAbz1viWyz_18DEjsqpAZJQGGQGWns68,7239
4
+ graphify_php/graph_adapter.py,sha256=jXge2Tz9vrNpUi5ptLmKDaXLhrzZO8S56iZJGKyj-pw,24022
5
+ graphify_php/ports.py,sha256=pC9yL3-ZtUzm3PXF7rU3Db_MElkA_Ea95_hvW7vTO2A,7390
6
+ graphify_php/resolver.py,sha256=NXDq_Qw4jN4YkYFcZPt6RyW06_vh4ycAHISuZuC_Un0,22249
7
+ graphify_php/shrink.py,sha256=QTT3ECHTFb4Arzsx5m0WdRrIiFfGzgTU0WDvGaA0n1Q,16114
8
+ graphify_php/shrink_cli.py,sha256=uDdkRikil8NHAiaqkVSigpg2bqpU75aYIJCmJykCrvk,5085
9
+ graphify_php/status.py,sha256=5-WMfwNzBtpdQe0olTKI006N-OOAvPfGr7KaujeVVLA,5804
10
+ graphify_php/config/__init__.py,sha256=H1qLCXTkLCkBmmDslviNgdDI-e1Iwa45eRmaKxtJ27E,1709
11
+ graphify_php/config/class_lookup.py,sha256=FJOIfXPQFfFmgyqRRojVug2-7sL5nKz8F-TYzhbNjnQ,3808
12
+ graphify_php/config/discovery.py,sha256=rnrQDWEK-d_gqfwOlg8eLI5ogaHfMWilDt988OPZxbo,2198
13
+ graphify_php/config/document.py,sha256=a2zFdO1FK0CDXQvzhNHXI1tOETC1azz0eArLc5ivVJc,1180
14
+ graphify_php/config/emit.py,sha256=BbMFHAhE7ZgD3gFJOQU-_fH40gv_xb9N9P8--qHJ0Y8,4975
15
+ graphify_php/config/environment.py,sha256=LA2VfQIKYxheYvdzwYU3Ejj2cnrnsaSGC9anEblrVWA,4737
16
+ graphify_php/config/facts.py,sha256=jGYWz5QlWJs5_WUIMlSw3FkD9Wa1ZLgZ_s-m9y_0iCY,3121
17
+ graphify_php/config/messaging.py,sha256=Mpv6Ip2GNRxGkx5Ey_c1HiXrR8fK34jj2u0Yv2HOA3E,5901
18
+ graphify_php/config/ports.py,sha256=T5HzCkpaS7mJ6Zq4YW6D6e9T4LLsNiHmPWgqqUaZYR0,915
19
+ graphify_php/config/safe_yaml.py,sha256=tIx1HtPrMkJ6s9m75giCZopRGVBFCqJLsFEng5fadak,5598
20
+ graphify_php/container/__init__.py,sha256=dv1EgzTd1vC7IotzA80rBesm-nu9NlG73p1nhnF04xc,1639
21
+ graphify_php/container/facts.py,sha256=GMEFdR91pycOccKdFgggj8ipyXO_3xSr2hsIWPIVI10,7441
22
+ graphify_php/container/layer.py,sha256=dZ8vCWbNdIrnKwsgRZfdq2OCfxwJiGNGZLHyfYKbX2I,3013
23
+ graphify_php/container/locator.py,sha256=whBDvP_oJ1deYTrxMjVw1W65ogdJjGQcT-kwahvtcjo,3075
24
+ graphify_php/container/overlay.py,sha256=nw8ezCW8eVmfYXGtOFM2hoM4_qj-sFCiPl5n3qpfs5I,6452
25
+ graphify_php/container/ports.py,sha256=fqzT-08PbsYJwfDN0jiUE3FWWvdAKSHXja8Mr94NPZ0,1447
26
+ graphify_php/container/reader.py,sha256=Y4C3VDFshmkqw2SksBG8WNxoaPU1bYF7Nm7B1Eo9JAI,14223
27
+ graphify_php/container/reasons.py,sha256=Hdz7PUJH3QIqUWJWIihHKWWqOZxefuy8OXWWEaYErF8,1516
28
+ graphify_php/sources/phpstan_source.py,sha256=peueo3NLJBxmLREcfmoFc2yk8PSw-ue5kxoMoxkEvBU,25686
29
+ graphify_php/sources/route_source.py,sha256=yRF7p1-9tuzaUjgO6fPuQUyhihFtXsFo6bci46oYbqQ,4402
30
+ graphify_php/sources/tree_sitter_source.py,sha256=mBCovpIB6kq8ED6qxggjtzd_CaBFm6CZET42uTwOWas,20154
31
+ graphify_php/syntax/call_sites.py,sha256=T4PAeDVDLad-oKb66IFNuSDRc4D6gQueCmy8v3nktAs,4863
32
+ graphify_php/syntax/class_index.py,sha256=JCISHN86mrYPnhHwcXc7zeMDSKWYzmhetsLW7xHzjSY,4991
33
+ graphify_php/syntax/collectors.py,sha256=E7BineMX1TD7caI02SVqY4PznOvQ1v-Clng5zrOt2R0,14668
34
+ graphify_php/syntax/facts.py,sha256=nvH3g9fwHx_I3DCBbcppL458V1gUuPYfjMvre-uPoLQ,7058
35
+ graphify_php/syntax/file_scan.py,sha256=KVgYGDOIkT1lg2exS7pRKLOY-jMxreniQT9250S6ofM,6807
36
+ graphify_php/syntax/names.py,sha256=eEhTP1mzIzlZZsLDUIGA0B1OnaZ-ykaTeuybZqw13NE,1932
37
+ graphify_php/syntax/nodes.py,sha256=N5FTloxczzuHi3Z7OyxlsCGA2hMS9JlhT7mBU93sF4o,7071
38
+ graphify_php/syntax/routes.py,sha256=oNCThLjRlmtD-dPLZfVyCTTIZzo23ZYCpWVqqEWmg_Q,11307
39
+ graphify_php/php/README.md,sha256=kYNfc5A5i2xT-zY97RTJiy9HOJOcTlFNWRtYxV4jdLo,5267
40
+ graphify_php/php/autoload.php,sha256=Boy0ncV9-YgSlqw1RFPKmgI43UfH0WNF-7SeJkz0hfU,673
41
+ graphify_php/php/src/CallGraph/CallEdge.php,sha256=CuX4y0xNbBuqpSZMEsrvCXosDKPF4RX314S1qrhDKJo,3130
42
+ graphify_php/php/src/CallGraph/CallEdgeRule.php,sha256=CrGqil6sQwYhdpIC5zEGALxZG63I7MLsLIisWgpBehM,1739
43
+ graphify_php/php/src/CallGraph/CallEdgeWriter.php,sha256=J0BqKmc8d9y1ffFSItSNw-KFrb46fspKBHBAOLneRfs,432
44
+ graphify_php/php/src/CallGraph/JsonLinesWriter.php,sha256=JgbVFse96vac__70ZSHKWZpv35gYIYyxVuSsgFB2A6U,1675
45
+ graphify_php/php/src/CallGraph/MethodCallCollector.php,sha256=cdlyz_49pD395lqPzAytKj2UQfz2gbURuewMA_Jw2IM,5593
46
+ graphify_php-0.0.5.dist-info/METADATA,sha256=fIpYmHSky1WqOotDhL20ogbORq_mBcDr03uNT6SJuPs,10241
47
+ graphify_php-0.0.5.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
48
+ graphify_php-0.0.5.dist-info/entry_points.txt,sha256=ex-KP3uMC0omVy6TCsQxScwhHGiawRtfAvucE3DaeN0,132
49
+ graphify_php-0.0.5.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
50
+ graphify_php-0.0.5.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ graphify-php-build = graphify_php.cli:_console_main
3
+ graphify-shrink-check = graphify_php.shrink_cli:_console_main