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