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.
- graphify_php/__init__.py +144 -0
- graphify_php/cli.py +355 -0
- graphify_php/config/__init__.py +47 -0
- graphify_php/config/class_lookup.py +85 -0
- graphify_php/config/discovery.py +55 -0
- graphify_php/config/document.py +29 -0
- graphify_php/config/emit.py +117 -0
- graphify_php/config/environment.py +108 -0
- graphify_php/config/facts.py +97 -0
- graphify_php/config/messaging.py +142 -0
- graphify_php/config/ports.py +23 -0
- graphify_php/config/safe_yaml.py +127 -0
- graphify_php/config_resolver.py +164 -0
- graphify_php/container/__init__.py +50 -0
- graphify_php/container/facts.py +178 -0
- graphify_php/container/layer.py +71 -0
- graphify_php/container/locator.py +74 -0
- graphify_php/container/overlay.py +149 -0
- graphify_php/container/ports.py +40 -0
- graphify_php/container/reader.py +330 -0
- graphify_php/container/reasons.py +31 -0
- graphify_php/graph_adapter.py +520 -0
- graphify_php/php/README.md +106 -0
- graphify_php/php/autoload.php +27 -0
- graphify_php/php/src/CallGraph/CallEdge.php +91 -0
- graphify_php/php/src/CallGraph/CallEdgeRule.php +61 -0
- graphify_php/php/src/CallGraph/CallEdgeWriter.php +19 -0
- graphify_php/php/src/CallGraph/JsonLinesWriter.php +64 -0
- graphify_php/php/src/CallGraph/MethodCallCollector.php +163 -0
- graphify_php/ports.py +174 -0
- graphify_php/resolver.py +519 -0
- graphify_php/shrink.py +413 -0
- graphify_php/shrink_cli.py +150 -0
- graphify_php/sources/phpstan_source.py +641 -0
- graphify_php/sources/route_source.py +105 -0
- graphify_php/sources/tree_sitter_source.py +428 -0
- graphify_php/status.py +136 -0
- graphify_php/syntax/call_sites.py +117 -0
- graphify_php/syntax/class_index.py +121 -0
- graphify_php/syntax/collectors.py +351 -0
- graphify_php/syntax/facts.py +171 -0
- graphify_php/syntax/file_scan.py +158 -0
- graphify_php/syntax/names.py +49 -0
- graphify_php/syntax/nodes.py +209 -0
- graphify_php/syntax/routes.py +288 -0
- graphify_php-0.0.5.dist-info/METADATA +218 -0
- graphify_php-0.0.5.dist-info/RECORD +50 -0
- graphify_php-0.0.5.dist-info/WHEEL +4 -0
- graphify_php-0.0.5.dist-info/entry_points.txt +3 -0
- graphify_php-0.0.5.dist-info/licenses/LICENSE +201 -0
graphify_php/__init__.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""PHP member-call resolution for graphify.
|
|
2
|
+
|
|
3
|
+
graphify ships cross-file member-call resolvers for Swift, Python, Ruby, TypeScript, C#, Java
|
|
4
|
+
and Rust, and none for PHP: its PHP extractor marks `$this->emails->send()` as a member call and
|
|
5
|
+
then the shared pass drops it, because a bare `send` collides across the corpus. The measured
|
|
6
|
+
result on one Symfony application is 2172 `calls` edges against 3426 callable nodes — 24 % of
|
|
7
|
+
callables with an outgoing call edge, 20 % with an incoming one.
|
|
8
|
+
|
|
9
|
+
This package fills that gap through graphify's own extension seam, without forking it:
|
|
10
|
+
|
|
11
|
+
import graphify_php
|
|
12
|
+
graphify_php.register()
|
|
13
|
+
|
|
14
|
+
`register()` is idempotent, and it imports graphify lazily so that importing this package — in a
|
|
15
|
+
test, in a linter, in a tool that only wants the ports — never requires graphify to be installed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from importlib import import_module
|
|
22
|
+
|
|
23
|
+
from .ports import CallTargetSource
|
|
24
|
+
from .resolver import (
|
|
25
|
+
PHP_SUFFIXES,
|
|
26
|
+
RESOLVER_NAME,
|
|
27
|
+
RESOLVER_NAME_ROUTES,
|
|
28
|
+
make_resolver,
|
|
29
|
+
make_route_resolver,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"RESOLVER_NAME",
|
|
34
|
+
"RESOLVER_NAME_ROUTES",
|
|
35
|
+
"default_route_sources",
|
|
36
|
+
"default_sources",
|
|
37
|
+
"register",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_registered = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def default_sources(repo_root: str = ".") -> Sequence[CallTargetSource]:
|
|
44
|
+
"""Assemble the shipped `CallTargetSource` list, in precedence order.
|
|
45
|
+
|
|
46
|
+
The one place the concrete sources are named. A third source — a language server, an LSIF
|
|
47
|
+
index — becomes a line in `graphify_php.sources`, not an edit to `resolver`, which is why
|
|
48
|
+
`resolver` is handed this list instead of building it.
|
|
49
|
+
|
|
50
|
+
Sources are optional at import: this package's only hard requirement is `ports` and
|
|
51
|
+
`status`, and a build with no sources installed records a skip with a reason rather than
|
|
52
|
+
raising inside graphify's exception-swallowing driver.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
from . import sources as _sources
|
|
56
|
+
except ImportError:
|
|
57
|
+
return ()
|
|
58
|
+
|
|
59
|
+
# A factory inside `sources` wins when there is one: it owns its own assembly, including
|
|
60
|
+
# any constructor argument a source grows later.
|
|
61
|
+
for attribute in ("default_sources", "build_sources"):
|
|
62
|
+
factory = getattr(_sources, attribute, None)
|
|
63
|
+
if callable(factory):
|
|
64
|
+
return factory(repo_root)
|
|
65
|
+
listed = getattr(_sources, "SOURCES", None)
|
|
66
|
+
if listed:
|
|
67
|
+
return tuple(listed)
|
|
68
|
+
|
|
69
|
+
# Precedence, and the only place it is written down: the type engine knows untyped
|
|
70
|
+
# properties, chains and docblocks that the parser must refuse, so it answers first.
|
|
71
|
+
assembled: list[CallTargetSource] = []
|
|
72
|
+
for module_name, class_name in (
|
|
73
|
+
("phpstan_source", "PhpStanSource"),
|
|
74
|
+
("tree_sitter_source", "TreeSitterSource"),
|
|
75
|
+
):
|
|
76
|
+
try:
|
|
77
|
+
module = import_module(f"{__name__}.sources.{module_name}")
|
|
78
|
+
except ImportError:
|
|
79
|
+
continue
|
|
80
|
+
source = getattr(module, class_name, None)
|
|
81
|
+
if source is not None:
|
|
82
|
+
assembled.append(source())
|
|
83
|
+
return tuple(assembled)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def default_route_sources(repo_root: str = "."):
|
|
87
|
+
"""The shipped `RouteDeclarationSource` list.
|
|
88
|
+
|
|
89
|
+
Separate from `default_sources` because the two ports answer different questions and a
|
|
90
|
+
source implements one or the other. Same tolerance: a missing `sources` package means an
|
|
91
|
+
empty list and a recorded skip, not an import error inside graphify's swallowing driver.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
module = import_module(f"{__name__}.sources.route_source")
|
|
95
|
+
except ImportError:
|
|
96
|
+
return ()
|
|
97
|
+
source = getattr(module, "TreeSitterRouteSource", None)
|
|
98
|
+
return (source(),) if source is not None else ()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def register(
|
|
102
|
+
sources_factory=default_sources,
|
|
103
|
+
repo_root: str = ".",
|
|
104
|
+
route_sources_factory=default_route_sources,
|
|
105
|
+
) -> bool:
|
|
106
|
+
"""Register the PHP resolver with graphify. Returns whether this call did the registering.
|
|
107
|
+
|
|
108
|
+
Idempotent by name rather than by a flag alone: a process that imported this package twice
|
|
109
|
+
under two module paths would otherwise register twice, and graphify runs the registry in
|
|
110
|
+
order without deduplicating, so the pass would run twice and the second run would find
|
|
111
|
+
every edge already present and report zero work.
|
|
112
|
+
"""
|
|
113
|
+
global _registered
|
|
114
|
+
from graphify import resolver_registry
|
|
115
|
+
|
|
116
|
+
if _registered or any(
|
|
117
|
+
r.name == RESOLVER_NAME for r in resolver_registry.registered_resolvers()
|
|
118
|
+
):
|
|
119
|
+
_registered = True
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
resolver_registry.register(
|
|
123
|
+
resolver_registry.LanguageResolver(
|
|
124
|
+
name=RESOLVER_NAME,
|
|
125
|
+
suffixes=PHP_SUFFIXES,
|
|
126
|
+
resolve=make_resolver(sources_factory, repo_root),
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
# A second pass, not a stage of the first: routes add nodes rather than connect existing
|
|
130
|
+
# ones, and a separate registration gives them a status record the build gate can require.
|
|
131
|
+
resolver_registry.register(
|
|
132
|
+
resolver_registry.LanguageResolver(
|
|
133
|
+
name=RESOLVER_NAME_ROUTES,
|
|
134
|
+
suffixes=PHP_SUFFIXES,
|
|
135
|
+
resolve=make_route_resolver(route_sources_factory, repo_root),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
# The Symfony configuration pass. Registered here because this is the one registration a
|
|
139
|
+
# build makes; a pass left out of it never runs, however complete its own `register` is.
|
|
140
|
+
from . import config_resolver
|
|
141
|
+
|
|
142
|
+
config_resolver.register(repo_root)
|
|
143
|
+
_registered = True
|
|
144
|
+
return True
|
graphify_php/cli.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""`graphify-php-build` — run graphify with this package registered, and gate the publish.
|
|
2
|
+
|
|
3
|
+
The wrapper exists because graphify's resolver driver is deliberately forgiving:
|
|
4
|
+
|
|
5
|
+
except Exception as exc:
|
|
6
|
+
_LOG.warning("%s resolution failed, skipping: %s", resolver.name, exc)
|
|
7
|
+
|
|
8
|
+
A resolver that raises is skipped and the build still publishes. A resolver that was never
|
|
9
|
+
registered — because the seam moved in an upgrade, or because an install left this package out —
|
|
10
|
+
is not even logged. Both produce a valid graph with fewer `calls` edges, which is exactly the
|
|
11
|
+
shape of the defect this package was written to fix, so the failure is invisible at the one
|
|
12
|
+
moment it matters.
|
|
13
|
+
|
|
14
|
+
So the gate is here rather than in the resolver: run the build, then read the record the resolver
|
|
15
|
+
wrote, and refuse to publish unless every expected resolver reported a terminal state. `skipped`
|
|
16
|
+
with a reason is a success — a repository without PHPStan in `vendor/` is a normal skip. `failed`
|
|
17
|
+
is not, and neither is silence.
|
|
18
|
+
|
|
19
|
+
Exit codes:
|
|
20
|
+
|
|
21
|
+
0 every expected resolver reported `completed` or `skipped`
|
|
22
|
+
2 an expected resolver reported `failed` (or never finished)
|
|
23
|
+
3 an expected resolver never reported at all, or no record was written
|
|
24
|
+
4 the shrink guard refused the new graph (see shrink.py); graph and manifest restored
|
|
25
|
+
* graphify's own exit code, when graphify itself failed
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import os
|
|
32
|
+
import shutil
|
|
33
|
+
import sys
|
|
34
|
+
from collections.abc import Callable, Sequence
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from . import shrink, shrink_cli, status
|
|
38
|
+
from .config_resolver import RESOLVER_NAME as RESOLVER_NAME_CONFIG
|
|
39
|
+
from .resolver import RESOLVER_NAME, RESOLVER_NAME_ROUTES
|
|
40
|
+
|
|
41
|
+
EXIT_OK = 0
|
|
42
|
+
EXIT_RESOLVER_FAILED = 2
|
|
43
|
+
EXIT_RESOLVER_MISSING = 3
|
|
44
|
+
EXIT_SHRINK_REFUSED = 4
|
|
45
|
+
|
|
46
|
+
MESH_MANAGED_ENV = "GRAPHIFY_MESH_MANAGED"
|
|
47
|
+
|
|
48
|
+
_TERMINAL_OK = ("completed", "skipped")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def default_graph_path() -> Path:
|
|
52
|
+
"""Where graphify publishes, honouring the same `GRAPHIFY_OUT` override graphify reads."""
|
|
53
|
+
out = os.environ.get("GRAPHIFY_PHP_GRAPH")
|
|
54
|
+
if out:
|
|
55
|
+
return Path(out)
|
|
56
|
+
return Path(os.environ.get("GRAPHIFY_OUT", "graphify-out")) / "graph.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check(record: dict, expected: Sequence[str]) -> tuple[int, str]:
|
|
60
|
+
"""Decide whether this build may publish. Pure, so the gate is testable without a build."""
|
|
61
|
+
resolvers = {r.get("name"): r for r in (record.get("resolvers") or [])}
|
|
62
|
+
if not resolvers:
|
|
63
|
+
return EXIT_RESOLVER_MISSING, (
|
|
64
|
+
f"no resolver status record at {status.status_path()}: "
|
|
65
|
+
f"{', '.join(expected)} never reported"
|
|
66
|
+
)
|
|
67
|
+
for name in expected:
|
|
68
|
+
entry = resolvers.get(name)
|
|
69
|
+
if entry is None:
|
|
70
|
+
return EXIT_RESOLVER_MISSING, (
|
|
71
|
+
f"resolver {name!r} never reported; graphify ran without it "
|
|
72
|
+
f"(registration seam moved, or the package is not installed)"
|
|
73
|
+
)
|
|
74
|
+
state = entry.get("state", "")
|
|
75
|
+
if state not in _TERMINAL_OK:
|
|
76
|
+
reason = entry.get("reason") or "no reason recorded"
|
|
77
|
+
return EXIT_RESOLVER_FAILED, f"resolver {name!r} state={state}: {reason}"
|
|
78
|
+
return EXIT_OK, ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _run_graphify(args: Sequence[str]) -> int:
|
|
82
|
+
from graphify.__main__ import main as graphify_main
|
|
83
|
+
|
|
84
|
+
# graphify re-execs itself with PYTHONHASHSEED=0 for `update`, `extract`, `cluster-only` and
|
|
85
|
+
# `label` (__main__.py:486-525), which would replace this process — losing the registration
|
|
86
|
+
# made a moment ago and the gate that runs after. It skips the re-exec when the caller has
|
|
87
|
+
# already chosen a seed, so choosing the same one it would have keeps us in-process and
|
|
88
|
+
# keeps graphify's determinism guarantee intact.
|
|
89
|
+
os.environ.setdefault("PYTHONHASHSEED", "0")
|
|
90
|
+
|
|
91
|
+
argv = sys.argv
|
|
92
|
+
sys.argv = ["graphify", *args]
|
|
93
|
+
try:
|
|
94
|
+
graphify_main()
|
|
95
|
+
return 0
|
|
96
|
+
except SystemExit as exc:
|
|
97
|
+
# graphify exits with a message string in places (`sys.exit("ERROR: ...")`). That is a
|
|
98
|
+
# failure, and the message is all the caller gets, so it goes to stderr here.
|
|
99
|
+
code = exc.code
|
|
100
|
+
if isinstance(code, int):
|
|
101
|
+
return code
|
|
102
|
+
if code is None:
|
|
103
|
+
return 0
|
|
104
|
+
print(code, file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
finally:
|
|
107
|
+
sys.argv = argv
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _split_argv(argv: Sequence[str]) -> tuple[list[str], list[str]]:
|
|
111
|
+
"""Our options before `--`, graphify's after it.
|
|
112
|
+
|
|
113
|
+
A shared namespace would break the moment graphify adds a flag we already use, and the
|
|
114
|
+
wrapper must stay transparent to every graphify option, present and future.
|
|
115
|
+
"""
|
|
116
|
+
argv = list(argv)
|
|
117
|
+
if "--" in argv:
|
|
118
|
+
cut = argv.index("--")
|
|
119
|
+
return argv[:cut], argv[cut + 1 :]
|
|
120
|
+
return [], argv
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parser() -> argparse.ArgumentParser:
|
|
124
|
+
parser = argparse.ArgumentParser(
|
|
125
|
+
prog="graphify-php-build",
|
|
126
|
+
description="Run graphify with graphify_php registered, refusing to publish a graph "
|
|
127
|
+
"whose expected resolvers did not complete.",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--graph",
|
|
131
|
+
type=Path,
|
|
132
|
+
default=None,
|
|
133
|
+
help="graph artifact to protect (default: $GRAPHIFY_OUT/graph.json)",
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--expect",
|
|
137
|
+
action="append",
|
|
138
|
+
dest="expect",
|
|
139
|
+
default=None,
|
|
140
|
+
metavar="RESOLVER",
|
|
141
|
+
help="resolver that must report (repeatable, default: every resolver "
|
|
142
|
+
"this package registers)",
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"--version",
|
|
146
|
+
action="store_true",
|
|
147
|
+
help="print this package's and graphify's versions and exit, touching no file",
|
|
148
|
+
)
|
|
149
|
+
return parser
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _wants_version(ours: Sequence[str], passthrough: Sequence[str]) -> bool:
|
|
153
|
+
"""`--version` before `--`, or alone: a bare flag lands in graphify's half by the split rule."""
|
|
154
|
+
return "--version" in ours or (not ours and list(passthrough) == ["--version"])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def version_line() -> str:
|
|
158
|
+
"""One line a caller can parse: `graphify-php <ver> (graphify <ver>)`."""
|
|
159
|
+
ours = status.installed_version(status.PRODUCER_NAME)
|
|
160
|
+
graphify = status.installed_version(status.GRAPHIFY_DISTRIBUTION)
|
|
161
|
+
return f"{status.PRODUCER_NAME} {ours} (graphify {graphify})"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main(
|
|
165
|
+
argv: Sequence[str] | None = None, run_graphify: Callable[[Sequence[str]], int] | None = None
|
|
166
|
+
) -> int:
|
|
167
|
+
ours, passthrough = _split_argv(sys.argv[1:] if argv is None else argv)
|
|
168
|
+
# Before any path is computed or unlinked: graphify-mesh probes the build interpreter with
|
|
169
|
+
# this, and it used to fall through to graphify and delete the status record in the cwd.
|
|
170
|
+
if _wants_version(ours, passthrough):
|
|
171
|
+
print(version_line())
|
|
172
|
+
return EXIT_OK
|
|
173
|
+
opts = _parser().parse_args(ours)
|
|
174
|
+
expected = opts.expect or [RESOLVER_NAME, RESOLVER_NAME_ROUTES, RESOLVER_NAME_CONFIG]
|
|
175
|
+
graph = opts.graph or default_graph_path()
|
|
176
|
+
|
|
177
|
+
# A record from an earlier build would satisfy this build's gate, which is the one failure
|
|
178
|
+
# the gate exists to catch. Start from nothing so silence stays visible as silence.
|
|
179
|
+
record_path = status.status_path()
|
|
180
|
+
record_path.unlink(missing_ok=True)
|
|
181
|
+
|
|
182
|
+
backup = graph.with_suffix(graph.suffix + ".graphify-php-prev")
|
|
183
|
+
had_previous = graph.exists()
|
|
184
|
+
if had_previous:
|
|
185
|
+
shutil.copy2(graph, backup)
|
|
186
|
+
# graphify 0.9.89/0.9.92 `update` re-extracts every code file and ignores the manifest. The
|
|
187
|
+
# manifest is still snapshotted and restored with the graph, so the pair stays consistent
|
|
188
|
+
# and a graphify version with a truly incremental `update` does not skip files a rolled-back
|
|
189
|
+
# graph lacks.
|
|
190
|
+
manifest = graph.parent / "manifest.json"
|
|
191
|
+
manifest_backup = manifest.with_suffix(manifest.suffix + ".graphify-php-prev")
|
|
192
|
+
had_manifest = manifest.exists()
|
|
193
|
+
if had_manifest:
|
|
194
|
+
shutil.copy2(manifest, manifest_backup)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
runner = run_graphify or _run_graphify
|
|
198
|
+
code = runner(passthrough)
|
|
199
|
+
if code != EXIT_OK:
|
|
200
|
+
print(f"graphify-php-build: graphify exited {code}", file=sys.stderr)
|
|
201
|
+
return code
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
code, message = check(status.read(), expected)
|
|
205
|
+
except Exception as exc: # a record the gate cannot read is a record that proves nothing
|
|
206
|
+
code, message = EXIT_RESOLVER_MISSING, f"status record unreadable: {exc}"
|
|
207
|
+
if code == EXIT_OK:
|
|
208
|
+
return _shrink_gate(
|
|
209
|
+
graph,
|
|
210
|
+
backup if had_previous else None,
|
|
211
|
+
manifest,
|
|
212
|
+
manifest_backup if had_manifest else None,
|
|
213
|
+
passthrough,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Refusing to publish means the graph a reader would pick up must be the one that was
|
|
217
|
+
# there before, not the short one this build just wrote.
|
|
218
|
+
if had_previous:
|
|
219
|
+
os.replace(backup, graph)
|
|
220
|
+
restored = f"previous graph at {graph} and its manifest left untouched"
|
|
221
|
+
else:
|
|
222
|
+
graph.unlink(missing_ok=True)
|
|
223
|
+
restored = f"no graph published at {graph}"
|
|
224
|
+
_restore_manifest(manifest, manifest_backup if had_manifest else None)
|
|
225
|
+
print(f"graphify-php-build: {message}", file=sys.stderr)
|
|
226
|
+
print(f"graphify-php-build: refusing to publish; {restored}", file=sys.stderr)
|
|
227
|
+
return code
|
|
228
|
+
finally:
|
|
229
|
+
for left in (backup, manifest_backup):
|
|
230
|
+
if left.exists():
|
|
231
|
+
left.unlink(missing_ok=True)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _restore_manifest(manifest: Path, manifest_backup: Path | None) -> None:
|
|
235
|
+
"""Put back the manifest that matches the graph being kept.
|
|
236
|
+
|
|
237
|
+
The failed build's manifest lists files as indexed that the kept graph does not hold, and the
|
|
238
|
+
next incremental `update` would skip them.
|
|
239
|
+
"""
|
|
240
|
+
if manifest_backup is not None:
|
|
241
|
+
os.replace(manifest_backup, manifest)
|
|
242
|
+
else:
|
|
243
|
+
# No manifest before this build: dropping the new one makes the next build a full one.
|
|
244
|
+
manifest.unlink(missing_ok=True)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _shrink_gate(
|
|
248
|
+
graph: Path,
|
|
249
|
+
graph_backup: Path | None,
|
|
250
|
+
manifest: Path,
|
|
251
|
+
manifest_backup: Path | None,
|
|
252
|
+
passthrough: Sequence[str],
|
|
253
|
+
) -> int:
|
|
254
|
+
"""Refuse a graph that lost nodes or edges nothing on disk explains.
|
|
255
|
+
|
|
256
|
+
The resolver gate only proves the resolvers ran. A build can pass it and still publish a
|
|
257
|
+
graph missing half its call edges, which is the case this guard exists for.
|
|
258
|
+
"""
|
|
259
|
+
if os.environ.get(MESH_MANAGED_ENV) == "1":
|
|
260
|
+
print(
|
|
261
|
+
f"graphify-php-build: {MESH_MANAGED_ENV}=1, shrink guard left to graphify-mesh",
|
|
262
|
+
file=sys.stderr,
|
|
263
|
+
)
|
|
264
|
+
return EXIT_OK
|
|
265
|
+
policy = shrink_cli.env_policy()
|
|
266
|
+
if policy == shrink.POLICY_OFF or graph_backup is None:
|
|
267
|
+
return EXIT_OK
|
|
268
|
+
if manifest_backup is None and policy == shrink.POLICY_ATTRIBUTED:
|
|
269
|
+
# With no old manifest every file on disk reads as changed, which explains every loss.
|
|
270
|
+
# Attribution cannot judge this build, so the legacy count floor does.
|
|
271
|
+
print(
|
|
272
|
+
f"graphify-php-build: no previous manifest at {manifest}; shrink guard cannot "
|
|
273
|
+
f"attribute loss, using the {shrink.POLICY_COUNT} policy",
|
|
274
|
+
file=sys.stderr,
|
|
275
|
+
)
|
|
276
|
+
policy = shrink.POLICY_COUNT
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
report = shrink.classify_loss(
|
|
280
|
+
shrink_cli.load_json(graph_backup, missing_ok=False),
|
|
281
|
+
shrink_cli.load_json(graph, missing_ok=False),
|
|
282
|
+
shrink_cli.load_json(manifest_backup, missing_ok=False) if manifest_backup else {},
|
|
283
|
+
shrink_cli.load_json(manifest, missing_ok=True),
|
|
284
|
+
Path(repo_root_from(passthrough)),
|
|
285
|
+
policy=policy,
|
|
286
|
+
tolerance=shrink_cli.env_tolerance(),
|
|
287
|
+
)
|
|
288
|
+
verdict = report.verdict
|
|
289
|
+
summary = (
|
|
290
|
+
f"shrink guard ({policy}) refused: nodes {report.nodes['old']} -> "
|
|
291
|
+
f"{report.nodes['new']}, unexplained {report.nodes['unexplained']} "
|
|
292
|
+
f"(floor {report.nodes['floor']} of {report.nodes['base']}); edges "
|
|
293
|
+
f"{report.edges['old']} -> {report.edges['new']}, unexplained "
|
|
294
|
+
f"{report.edges['unexplained']}, resolver lost {report.edges['resolver_lost']} "
|
|
295
|
+
f"of {report.edges['resolver_old']}"
|
|
296
|
+
)
|
|
297
|
+
reasons = report.reasons
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
# A guard that cannot judge the build fails closed, like the resolver gate does. Any
|
|
300
|
+
# exception, not only bad input: one that escaped would leave the short graph published.
|
|
301
|
+
verdict, reasons = "refuse", []
|
|
302
|
+
summary = f"shrink guard could not run: {type(exc).__name__}: {exc}"
|
|
303
|
+
if verdict == "accept":
|
|
304
|
+
return EXIT_OK
|
|
305
|
+
|
|
306
|
+
os.replace(graph_backup, graph)
|
|
307
|
+
_restore_manifest(manifest, manifest_backup)
|
|
308
|
+
print(f"graphify-php-build: {summary}", file=sys.stderr)
|
|
309
|
+
for reason in reasons:
|
|
310
|
+
print(f"graphify-php-build: - {reason}", file=sys.stderr)
|
|
311
|
+
print(
|
|
312
|
+
f"graphify-php-build: refusing to publish; previous graph at {graph} and its "
|
|
313
|
+
"manifest restored",
|
|
314
|
+
file=sys.stderr,
|
|
315
|
+
)
|
|
316
|
+
return EXIT_SHRINK_REFUSED
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def repo_root_from(args: Sequence[str]) -> str:
|
|
320
|
+
"""The repository graphify was pointed at, which is what the type sources resolve against.
|
|
321
|
+
|
|
322
|
+
graphify takes it as a positional path (`graphify update <path>`), so the cwd is not it: a
|
|
323
|
+
build launched from anywhere else would hand the sources a root with no PHP under it.
|
|
324
|
+
Resolved, so the shrink guard matches graphify's absolute paths through a symlinked root.
|
|
325
|
+
"""
|
|
326
|
+
for arg in args[1:]:
|
|
327
|
+
if not arg.startswith("-") and Path(arg).is_dir():
|
|
328
|
+
return str(Path(arg).resolve())
|
|
329
|
+
return str(Path.cwd().resolve())
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _console_main() -> None:
|
|
333
|
+
ours, passthrough = _split_argv(sys.argv[1:])
|
|
334
|
+
# Help needs no graphify, and `register` imports it; an environment without graphify
|
|
335
|
+
# would otherwise lose `--help` to a ModuleNotFoundError. A bare `--help` with no `--`
|
|
336
|
+
# lands in graphify's half by the split rule, but nothing else is there to pass on, so it
|
|
337
|
+
# is ours.
|
|
338
|
+
help_flags = {"-h", "--help"}
|
|
339
|
+
if help_flags & set(ours) or (
|
|
340
|
+
not ours and len(passthrough) == 1 and passthrough[0] in help_flags
|
|
341
|
+
):
|
|
342
|
+
_parser().print_help()
|
|
343
|
+
sys.exit(0)
|
|
344
|
+
# `register` imports graphify and binds the resolvers; a version probe needs neither.
|
|
345
|
+
if _wants_version(ours, passthrough):
|
|
346
|
+
sys.exit(main())
|
|
347
|
+
|
|
348
|
+
from . import register
|
|
349
|
+
|
|
350
|
+
register(repo_root=repo_root_from(passthrough))
|
|
351
|
+
sys.exit(main())
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if __name__ == "__main__":
|
|
355
|
+
_console_main()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Configuration facts Symfony declares in YAML and nowhere in PHP.
|
|
2
|
+
|
|
3
|
+
Two of them today: which transport carries which message, and which environment variable a
|
|
4
|
+
configuration file reads. Both are invisible to a graph built from PHP source — the first exists
|
|
5
|
+
only as a routing key, the second only as a container placeholder — and both are the kind of
|
|
6
|
+
question a text search answers with every file and no direction.
|
|
7
|
+
|
|
8
|
+
The pieces are separate because a third fact is coming: `ConfigFileFinder` decides what to read,
|
|
9
|
+
`SafeDocumentParser` turns one file into a mapping or a named refusal, a reader turns one mapping
|
|
10
|
+
into facts, and `GraphWriter` turns facts into graph nodes and edges. A new fact is a new reader
|
|
11
|
+
registered in `default_readers`; nothing else changes.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
|
|
18
|
+
from .discovery import ConfigFileFinder
|
|
19
|
+
from .document import DocumentNodeFactory, file_key
|
|
20
|
+
from .emit import GraphWriter
|
|
21
|
+
from .environment import EnvReferenceReader
|
|
22
|
+
from .facts import ConfigDocument, ConfigEdge, ConfigNode, FactSet, NodeRef
|
|
23
|
+
from .messaging import MessageRoutingReader
|
|
24
|
+
from .ports import ConfigFactReader
|
|
25
|
+
from .safe_yaml import SafeDocumentParser
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ConfigDocument",
|
|
29
|
+
"ConfigEdge",
|
|
30
|
+
"ConfigFactReader",
|
|
31
|
+
"ConfigFileFinder",
|
|
32
|
+
"ConfigNode",
|
|
33
|
+
"DocumentNodeFactory",
|
|
34
|
+
"file_key",
|
|
35
|
+
"EnvReferenceReader",
|
|
36
|
+
"FactSet",
|
|
37
|
+
"GraphWriter",
|
|
38
|
+
"MessageRoutingReader",
|
|
39
|
+
"NodeRef",
|
|
40
|
+
"SafeDocumentParser",
|
|
41
|
+
"default_readers",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_readers() -> Sequence[ConfigFactReader]:
|
|
46
|
+
"""The readers a build runs, and the one place they are named."""
|
|
47
|
+
return (MessageRoutingReader(), EnvReferenceReader())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Finding the graph node for a class FQN written in YAML.
|
|
2
|
+
|
|
3
|
+
graphify labels a class node with its SHORT name — `AbstractCronMessage`, never
|
|
4
|
+
`App\\Cron\\Message\\AbstractCronMessage` — so a routing key cannot be looked up directly. The
|
|
5
|
+
short name is matched and then re-checked against the file path, because two classes sharing a
|
|
6
|
+
short name is ordinary in a Symfony application and binding to the wrong one puts a false edge
|
|
7
|
+
in the graph.
|
|
8
|
+
|
|
9
|
+
A miss is the expected outcome for a message class that lives in a package outside the indexed
|
|
10
|
+
tree. It is counted, never guessed at: an edge from the wrong class is worse than no edge, which
|
|
11
|
+
is graphify's own bar for its member-call resolvers.
|
|
12
|
+
|
|
13
|
+
Kept separate from `graph_adapter` deliberately. That module answers "which node is method M of
|
|
14
|
+
class C" for call resolution and is another author's file; this one answers a different question
|
|
15
|
+
for a different pass and has no reason to make them move together.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _key(label: str) -> str:
|
|
24
|
+
"""graphify's own label normalisation, so keys made here agree with labels made there."""
|
|
25
|
+
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fqn_parts(fqn: str) -> list[str]:
|
|
29
|
+
return [p for p in str(fqn).replace("/", "\\").split("\\") if p]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _path_parts(source_file: str) -> list[str]:
|
|
33
|
+
stripped = re.sub(r"\.[^./\\]+$", "", str(source_file or ""))
|
|
34
|
+
return [p for p in stripped.replace("\\", "/").split("/") if p]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _tail_overlap(a: list[str], b: list[str]) -> int:
|
|
38
|
+
count = 0
|
|
39
|
+
# Paths differ in length on purpose; the overlap stops at the shorter one.
|
|
40
|
+
for left, right in zip(reversed(a), reversed(b), strict=False):
|
|
41
|
+
if _key(left) != _key(right):
|
|
42
|
+
break
|
|
43
|
+
count += 1
|
|
44
|
+
return count
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GraphClassIndex:
|
|
48
|
+
"""Class FQN -> graphify node id, or nothing."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, all_nodes: list[dict], all_edges: list[dict]) -> None:
|
|
51
|
+
# Only a class some file contains is a candidate. graphify also carries synthesised and
|
|
52
|
+
# external class nodes; an edge into one of those points at nothing a reader can open.
|
|
53
|
+
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
|
|
54
|
+
self._by_short: dict[str, list[dict]] = {}
|
|
55
|
+
for node in all_nodes:
|
|
56
|
+
if node.get("_callable_class") and node.get("id") in contained:
|
|
57
|
+
self._by_short.setdefault(_key(node.get("label", "")), []).append(node)
|
|
58
|
+
|
|
59
|
+
def node_id(self, class_fqn: str) -> str | None:
|
|
60
|
+
parts = _fqn_parts(class_fqn)
|
|
61
|
+
if not parts:
|
|
62
|
+
return None
|
|
63
|
+
candidates = self._by_short.get(_key(parts[-1]), [])
|
|
64
|
+
if not candidates:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
# Directories only. The file stem repeats the class name, so including it would score
|
|
68
|
+
# every candidate identically and decide nothing.
|
|
69
|
+
namespace = parts[:-1]
|
|
70
|
+
scored = [
|
|
71
|
+
(_tail_overlap(namespace, _path_parts(c.get("source_file", ""))[:-1]), c)
|
|
72
|
+
for c in candidates
|
|
73
|
+
]
|
|
74
|
+
best = max(score for score, _ in scored)
|
|
75
|
+
winners = [c for score, c in scored if score == best]
|
|
76
|
+
if best == 0 or len(winners) != 1:
|
|
77
|
+
return None
|
|
78
|
+
# A lone candidate is not evidence. `Vendor\Billing\Event\SyncUsers` in messenger.yaml
|
|
79
|
+
# and the project's own `src/Cron/Message/SyncUsers.php` share a short name and nothing
|
|
80
|
+
# else, and binding them would be exactly the false edge this module exists to refuse.
|
|
81
|
+
# The namespace minus its root (`App`, `Vendor`) has to match the path tail; the same
|
|
82
|
+
# rule `graph_adapter._class_node` applies for call resolution.
|
|
83
|
+
if len(namespace) >= 2 and best < len(namespace) - 1:
|
|
84
|
+
return None
|
|
85
|
+
return winners[0].get("id")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Finding the files to read, and nothing else.
|
|
2
|
+
|
|
3
|
+
Scope is `config/**/*.yaml` under the repository root. Not a tree walk: the repositories this
|
|
4
|
+
runs against carry `vendor/` and `node_modules/`, where a walk costs seconds and finds YAML that
|
|
5
|
+
belongs to somebody else's package. `config/` is where Symfony puts the declarations this
|
|
6
|
+
package indexes, and a file outside it is out of scope by construction rather than by a filter.
|
|
7
|
+
|
|
8
|
+
A repository with no `config/` directory is a plain skip. Most repositories graphify indexes are
|
|
9
|
+
not Symfony applications, so this is the common case and must not read as a fault.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
CONFIG_DIRNAME = "config"
|
|
17
|
+
SUFFIX = ".yaml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigFileFinder:
|
|
21
|
+
"""Lists the configuration files in scope, relative paths and absolute paths together."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, repo_root: str | Path, dirname: str = CONFIG_DIRNAME) -> None:
|
|
24
|
+
self._root = Path(repo_root)
|
|
25
|
+
self._dirname = dirname
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def config_dir(self) -> Path:
|
|
29
|
+
return self._root / self._dirname
|
|
30
|
+
|
|
31
|
+
def available(self) -> tuple[bool, str]:
|
|
32
|
+
directory = self.config_dir
|
|
33
|
+
if not directory.is_dir():
|
|
34
|
+
return False, f"no {self._dirname}/ directory under {self._root}"
|
|
35
|
+
return True, ""
|
|
36
|
+
|
|
37
|
+
def find(self) -> list[tuple[str, Path]]:
|
|
38
|
+
"""`(path relative to the repository root, absolute path)`, sorted.
|
|
39
|
+
|
|
40
|
+
Sorted because the node and edge lists this feeds are published and diffed between
|
|
41
|
+
builds; directory order is not stable across machines and would make every build look
|
|
42
|
+
changed.
|
|
43
|
+
|
|
44
|
+
Symlinks are skipped: a link inside `config/` can point anywhere on the machine, and
|
|
45
|
+
following one would read a file outside the repository under the repository's name.
|
|
46
|
+
"""
|
|
47
|
+
directory = self.config_dir
|
|
48
|
+
if not directory.is_dir():
|
|
49
|
+
return []
|
|
50
|
+
found: list[tuple[str, Path]] = []
|
|
51
|
+
for path in sorted(directory.rglob(f"*{SUFFIX}")):
|
|
52
|
+
if path.is_symlink() or not path.is_file():
|
|
53
|
+
continue
|
|
54
|
+
found.append((path.relative_to(self._root).as_posix(), path))
|
|
55
|
+
return found
|