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,164 @@
1
+ """The configuration pass graphify runs, and the status record that proves it ran.
2
+
3
+ A `LanguageResolver`, not a `CallTargetSource`. The distinction is not bookkeeping: a
4
+ `CallTargetSource` answers "what does this call site call" and is offered sites by the
5
+ member-call resolver, while this pass reads files no call site mentions and produces nodes that
6
+ do not exist yet. Forcing it into that port would mean inventing call sites to carry facts that
7
+ are not calls.
8
+
9
+ WHY THE SUFFIX GATE SAYS `.php` WHILE THIS READS `.yaml`
10
+
11
+ graphify activates a registered resolver only when the corpus it is building holds a file with
12
+ one of the resolver's suffixes (`resolver_registry.run_language_resolvers`). YAML is not in that
13
+ corpus: `detect.py:45` lists `.yaml` and `.yml` in `DOC_EXTENSIONS`, so a YAML file takes the
14
+ document path and never reaches the code extractor. Measured on a fixture of one PHP file and one
15
+ `config/packages/messenger.yaml`, built with two probe resolvers registered: the `.php`-gated
16
+ probe ran, the `.yaml`-gated probe did not, and the published graph held 3 nodes, none of them
17
+ from the YAML file.
18
+
19
+ So a `.yaml` gate would never fire and this pass would be dead code that looks registered. The
20
+ gate is `.php` — "this is a PHP repository, so it may be a Symfony application" — and the files
21
+ are found by walking `config/` under the repository root rather than by reading the corpus. The
22
+ two are deliberately about different files.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import time
28
+ from collections.abc import Callable, Sequence
29
+ from typing import TYPE_CHECKING
30
+
31
+ from . import status
32
+ from .resolver import PHP_SUFFIXES
33
+
34
+ if TYPE_CHECKING:
35
+ from .config import ConfigFileFinder, GraphWriter, SafeDocumentParser
36
+ from .config.ports import ConfigFactReader
37
+
38
+ RESOLVER_NAME = "php_symfony_config"
39
+
40
+ # Reasons this module owns. A reader's own reasons arrive prefixed with the reader's name, so
41
+ # the histogram stays a work list rather than a pile of colliding words.
42
+ REASON_NO_FACTS = "document_held_no_indexed_fact"
43
+ # `config.safe_yaml` imports pyyaml, which graphify does not depend on. The package is therefore
44
+ # imported inside `run`, so that a build without pyyaml records this skip instead of failing
45
+ # at import — before any record could be written.
46
+ REASON_PYYAML_MISSING = "pyyaml-not-installed"
47
+
48
+
49
+ def run(
50
+ per_file: Sequence[dict],
51
+ all_nodes: list[dict],
52
+ all_edges: list[dict],
53
+ *,
54
+ repo_root: str = ".",
55
+ readers: Sequence[ConfigFactReader] | None = None,
56
+ finder: ConfigFileFinder | None = None,
57
+ parser: SafeDocumentParser | None = None,
58
+ writer: GraphWriter | None = None,
59
+ ) -> status.ResolverStatus:
60
+ """Index the configuration facts under `repo_root`, and record what happened either way."""
61
+ record = status.ResolverStatus(name=RESOLVER_NAME)
62
+ try:
63
+ try:
64
+ from . import config as cfg
65
+ except ImportError as exc:
66
+ if getattr(exc, "name", None) != "yaml":
67
+ raise
68
+ record.state = "skipped"
69
+ record.reason = REASON_PYYAML_MISSING
70
+ return record
71
+
72
+ finder = finder or cfg.ConfigFileFinder(repo_root)
73
+ available, why_not = finder.available()
74
+ if not available:
75
+ # Most repositories graphify indexes are not Symfony applications. Recording this
76
+ # as a skip with its reason is what keeps "not applicable here" distinguishable
77
+ # from "registered but silently did nothing", which are the same artifact otherwise.
78
+ record.state = "skipped"
79
+ record.reason = why_not
80
+ return record
81
+
82
+ parser = parser or cfg.SafeDocumentParser()
83
+ readers = readers if readers is not None else cfg.default_readers()
84
+ writer = writer or cfg.GraphWriter(all_nodes, all_edges)
85
+ documents = cfg.DocumentNodeFactory()
86
+
87
+ files = finder.find()
88
+ record.files_seen = len(files)
89
+ record.eligible_sites = len(files)
90
+ for relative_path, absolute_path in files:
91
+ parsed = parser.parse(absolute_path)
92
+ if parsed.data is None:
93
+ # A malformed file costs that file. It is counted by name and the pass carries
94
+ # on, because one bad indent must not decide whether the rest is indexed.
95
+ record.unresolvable(f"{parser.__class__.__name__.lower()}:{parsed.reason}")
96
+ continue
97
+ document = cfg.ConfigDocument(path=relative_path, data=parsed.data)
98
+ collected = cfg.FactSet()
99
+ for reader in readers:
100
+ facts = reader.read(document)
101
+ for reason, count in facts.unresolved.items():
102
+ record.unresolvable(f"{reader.name}:{reason}", count)
103
+ facts.unresolved.clear()
104
+ collected.extend(facts)
105
+ if not collected.nodes and not collected.edges:
106
+ record.unresolvable(REASON_NO_FACTS)
107
+ continue
108
+ # The file node goes in ahead of the readers' own, because every edge they produce
109
+ # starts at it and the writer resolves an endpoint only to a node already written.
110
+ collected.nodes.insert(0, documents.node(relative_path))
111
+ writer.write(collected)
112
+ record.resolved_sites += 1
113
+
114
+ for reason, count in writer.unresolved.items():
115
+ record.unresolvable(reason, count)
116
+ record.edges_added = writer.edges_added
117
+ record.state = "completed"
118
+ return record
119
+ except Exception as exc:
120
+ record.state = "failed"
121
+ record.reason = f"{type(exc).__name__}: {exc}"
122
+ raise
123
+ finally:
124
+ record.finished_at = time.time()
125
+ status.write([record])
126
+
127
+
128
+ def make_resolver(
129
+ repo_root: str = ".", readers_factory: Callable[[], Sequence[ConfigFactReader]] | None = None
130
+ ):
131
+ """Wrap `run` in the `(per_file, all_nodes, all_edges) -> None` shape graphify calls.
132
+
133
+ The readers are assembled at resolution time, not at registration time: registration happens
134
+ at import, before the repository under build is settled. `None` means the shipped readers,
135
+ which `run` names itself so that nothing here imports the config package.
136
+ """
137
+
138
+ def resolve(per_file, all_nodes, all_edges) -> None:
139
+ readers = readers_factory() if readers_factory is not None else None
140
+ run(per_file, all_nodes, all_edges, repo_root=repo_root, readers=readers)
141
+
142
+ resolve.__name__ = RESOLVER_NAME
143
+ return resolve
144
+
145
+
146
+ def register(repo_root: str = ".") -> bool:
147
+ """Register this pass with graphify. Returns whether this call did the registering.
148
+
149
+ Idempotent by name: graphify runs its registry in order without deduplicating, so a double
150
+ registration would run the pass twice and the second run would find every node already
151
+ present and report a build that did nothing.
152
+ """
153
+ from graphify import resolver_registry
154
+
155
+ if any(r.name == RESOLVER_NAME for r in resolver_registry.registered_resolvers()):
156
+ return False
157
+ resolver_registry.register(
158
+ resolver_registry.LanguageResolver(
159
+ name=RESOLVER_NAME,
160
+ suffixes=PHP_SUFFIXES,
161
+ resolve=make_resolver(repo_root),
162
+ )
163
+ )
164
+ return True
@@ -0,0 +1,50 @@
1
+ """Symfony container overlay: which implementation actually arrives at an injection site.
2
+
3
+ Measured on the reference application: of 715 injected properties, 652 carry a concrete class
4
+ and 63 an interface. The 652 are already answered from source by the package's existing
5
+ sources. The remaining 63 cannot be — the type engine can only say `LoggerInterface`, and which
6
+ concrete service arrives is decided by the container, not by the code. This package reads that
7
+ decision out of the compiled container and writes it as an overlay, never as graph edges.
8
+
9
+ Four units, one concern each: `locator` finds the file, `reader` parses one format, `facts`
10
+ models what was found, `overlay` writes the artifact. `layer` composes them and records a
11
+ status. A second container format is a second `ContainerReader`, not a branch in the first.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .facts import (
17
+ Alias,
18
+ ArgumentKind,
19
+ ContainerFacts,
20
+ Decoration,
21
+ Injection,
22
+ Member,
23
+ Service,
24
+ TagMembership,
25
+ )
26
+ from .layer import LAYER_NAME, ContainerOverlayLayer
27
+ from .locator import SymfonyCacheLocator
28
+ from .overlay import ARTIFACT_KIND, RELATION, OverlayWriter
29
+ from .ports import ContainerLocator, ContainerReader
30
+ from .reader import SymfonyXmlContainerReader
31
+
32
+ __all__ = [
33
+ "ARTIFACT_KIND",
34
+ "Alias",
35
+ "ArgumentKind",
36
+ "ContainerFacts",
37
+ "ContainerLocator",
38
+ "ContainerOverlayLayer",
39
+ "ContainerReader",
40
+ "Decoration",
41
+ "Injection",
42
+ "LAYER_NAME",
43
+ "Member",
44
+ "OverlayWriter",
45
+ "RELATION",
46
+ "Service",
47
+ "SymfonyCacheLocator",
48
+ "SymfonyXmlContainerReader",
49
+ "TagMembership",
50
+ ]
@@ -0,0 +1,178 @@
1
+ """What a compiled DI container says, modelled without reference to any file format.
2
+
3
+ This is the middle of three units: `reader` turns one container format into these objects,
4
+ `overlay` turns these objects into an artifact. A second container format (a Laravel dump, a
5
+ PHP-DI compiled file) is a second reader against this model, never a branch inside the first.
6
+
7
+ Two rules are carried by the model itself rather than by discipline in the reader.
8
+
9
+ **No values, ever.** An argument keeps its KIND (`service`, `scalar`, `env`, ...) and, for an
10
+ env reference, only the variable NAME. There is no field to put a DSN, a password, a default
11
+ or an expanded `%env(...)%` in, so a reader cannot leak one by being careless. `eval/canaries.py`
12
+ plants fake secrets and asserts they reach no artifact; the absence of the field is what makes
13
+ that hold by construction.
14
+
15
+ **Injection-site identity.** Measured on the reference application: 715 injected properties,
16
+ 652 with a concrete class (resolved from source by the existing sources) and 63 with an
17
+ interface — 8%. Those 63 are why this exists, and a global interface-to-class map would answer
18
+ them wrongly: named aliases, per-argument bindings, decorators and contextual wiring all make
19
+ the same interface arrive as different classes at different parameters. So the unit of this
20
+ model is `Injection` — consumer, member, position — not "what implements X".
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+ from enum import Enum
27
+
28
+
29
+ class ArgumentKind(str, Enum):
30
+ """The kind of thing wired into one argument slot. Never the thing itself."""
31
+
32
+ SERVICE = "service" # a reference to another service id
33
+ SERVICE_CLOSURE = "service_closure" # a lazy reference; same target, deferred
34
+ TAGGED_ITERATOR = "tagged_iterator" # every service carrying a tag
35
+ ITERATOR = "iterator" # an explicit list of service references
36
+ COLLECTION = "collection" # a structured literal; its leaves are scalars
37
+ ENV = "env" # a `%env(...)%` reference, name kept, value not
38
+ SCALAR = "scalar" # a literal. Recorded as a kind and discarded.
39
+ ABSTRACT = "abstract" # an unfilled slot in an abstract definition
40
+ CONSTANT = "constant" # a PHP constant reference
41
+ UNKNOWN = "unknown"
42
+
43
+
44
+ class Member(str, Enum):
45
+ """Where in the consumer the argument lands."""
46
+
47
+ CONSTRUCTOR = "constructor"
48
+ CALL = "call" # a setter or any other post-construction method call
49
+ FACTORY = "factory" # an argument to the factory, not to the constructor
50
+ PROPERTY = "property"
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Service:
55
+ """One service definition. `class_fqn` is None for a synthetic or alias-only entry."""
56
+
57
+ service_id: str
58
+ class_fqn: str | None
59
+ public: bool = False
60
+ abstract: bool = False
61
+ lazy: bool = False
62
+ synthetic: bool = False
63
+ factory_class: str | None = None
64
+ factory_service: str | None = None
65
+ factory_method: str | None = None
66
+ # A named constructor (`constructor="fromCallable"`): the class is instantiated through a
67
+ # static method, so a consumer looking for `__construct` would find the wrong signature.
68
+ constructor: str | None = None
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Alias:
73
+ """`alias_id` resolves to `target_id`. The interface-to-implementation edge, when the
74
+ alias id happens to be an interface name — which the container never states, so the
75
+ reader does not claim it either."""
76
+
77
+ alias_id: str
78
+ target_id: str
79
+ public: bool = False
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Injection:
84
+ """One argument slot of one consumer, and what the container puts in it.
85
+
86
+ `position` is the 0-based index among the consumer's arguments. `parameter` is the
87
+ parameter NAME and is almost always None: the compiled XML keeps argument order, not
88
+ signatures. Recorded as a field anyway because a reader for a format that does keep names
89
+ can fill it, and a consumer joining on position must be able to tell "unnamed" from
90
+ "unknown".
91
+ """
92
+
93
+ consumer_id: str
94
+ consumer_class: str | None
95
+ member: Member
96
+ member_name: str | None # the method for Member.CALL, None for a constructor
97
+ position: int
98
+ parameter: str | None
99
+ kind: ArgumentKind
100
+ requested_id: str | None = None # the service id written in the definition
101
+ resolved_id: str | None = None # after following aliases
102
+ resolved_class: str | None = None # the class that actually arrives here
103
+ via_alias: bool = False
104
+ tag: str | None = None # for TAGGED_ITERATOR, the tag being collected
105
+ env_name: str | None = None # for ENV, the variable name. Never its value.
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class TagMembership:
110
+ tag: str
111
+ service_id: str
112
+ class_fqn: str | None
113
+ attributes: dict = field(default_factory=dict)
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class Decoration:
118
+ """`decorator_id` wraps `inner_id`, which holds the definition that was displaced.
119
+
120
+ The compiled container has no `decorates` attribute: Symfony applies decoration at compile
121
+ time, renaming the decorated definition to `<id>.inner` and giving its id to the decorator.
122
+ Measured on the reference application: 0 occurrences of `decorates=`, 28 services whose id
123
+ ends in `.inner`. So decoration is recovered from that renaming, and the field name says
124
+ what was observed rather than what was declared.
125
+ """
126
+
127
+ decorator_id: str
128
+ decorator_class: str | None
129
+ inner_id: str
130
+ inner_class: str | None
131
+
132
+
133
+ @dataclass
134
+ class ContainerFacts:
135
+ """Everything one container file states, plus what the reader refused and why."""
136
+
137
+ services: dict[str, Service] = field(default_factory=dict)
138
+ aliases: dict[str, Alias] = field(default_factory=dict)
139
+ injections: list[Injection] = field(default_factory=list)
140
+ tags: list[TagMembership] = field(default_factory=list)
141
+ decorations: list[Decoration] = field(default_factory=list)
142
+ environment: dict = field(default_factory=dict)
143
+ skipped: dict[str, int] = field(default_factory=dict)
144
+
145
+ def skip(self, reason: str, count: int = 1) -> None:
146
+ """Count a fact left out, under a fixed reason.
147
+
148
+ Free-text reasons produce a histogram of unique strings, which is not a work list.
149
+ """
150
+ self.skipped[reason] = self.skipped.get(reason, 0) + count
151
+
152
+ def resolve_id(self, service_id: str, _depth: int = 0) -> str:
153
+ """Follow aliases to the id that is actually instantiated.
154
+
155
+ Bounded depth because an alias cycle in a hand-written container would otherwise hang
156
+ the build, and a build that hangs is indistinguishable from one that is slow.
157
+ """
158
+ seen = service_id
159
+ for _ in range(16):
160
+ alias = self.aliases.get(seen)
161
+ if alias is None:
162
+ return seen
163
+ seen = alias.target_id
164
+ return seen
165
+
166
+ def class_of(self, service_id: str) -> str | None:
167
+ service = self.services.get(self.resolve_id(service_id))
168
+ return service.class_fqn if service else None
169
+
170
+ @property
171
+ def interface_bindings(self) -> list[Alias]:
172
+ """Aliases whose id looks like a PHP interface name.
173
+
174
+ `Interface` suffix only, which is a convention and not a guarantee — the container
175
+ states no such thing. Kept as a derived view so nothing downstream mistakes it for a
176
+ fact the container asserted.
177
+ """
178
+ return [a for a in self.aliases.values() if a.alias_id.endswith("Interface")]
@@ -0,0 +1,71 @@
1
+ """Runs locate -> read -> write, and records the outcome the way every other layer does.
2
+
3
+ The status record is not bookkeeping. graphify swallows a resolver exception and logs a
4
+ warning, so a layer that dies and a layer that was never registered look the same from outside
5
+ — and both look like a project that simply has little wiring. This layer therefore reports
6
+ `completed`, `skipped` with a reason, or `failed`, exactly as `status.py` intends, and no
7
+ absent container is ever a failure: the XML dump exists only when `kernel.debug` is on.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from ..status import ResolverStatus
16
+ from . import reasons
17
+ from .locator import SymfonyCacheLocator
18
+ from .overlay import DEFAULT_ARTIFACT_NAME, OverlayWriter
19
+ from .ports import ContainerLocator, ContainerReader
20
+ from .reader import SymfonyXmlContainerReader
21
+
22
+ LAYER_NAME = "php_container_overlay"
23
+
24
+
25
+ class ContainerOverlayLayer:
26
+ """Composition only. Every decision it could make belongs to one of its three parts."""
27
+
28
+ def __init__(
29
+ self,
30
+ locator: ContainerLocator | None = None,
31
+ reader: ContainerReader | None = None,
32
+ writer: OverlayWriter | None = None,
33
+ ) -> None:
34
+ self._locator = locator or SymfonyCacheLocator()
35
+ self._reader = reader or SymfonyXmlContainerReader()
36
+ self._writer = writer or OverlayWriter()
37
+
38
+ def run(
39
+ self, repo_root: Path, out_dir: Path, name: str = DEFAULT_ARTIFACT_NAME
40
+ ) -> ResolverStatus:
41
+ record = ResolverStatus(name=LAYER_NAME)
42
+ path, reason = self._locator.locate(Path(repo_root))
43
+ if path is None:
44
+ return self._skip(record, reason)
45
+
46
+ record.files_seen = 1
47
+ facts = self._reader.read(path)
48
+ if facts is None:
49
+ # Unreadable and malformed are one outcome here on purpose: both mean "there is a
50
+ # file and it did not parse", and the reader is the only thing that could tell
51
+ # them apart without a second stat call racing the cache being rewritten.
52
+ return self._skip(record, reasons.SKIP_XML_MALFORMED)
53
+
54
+ counts = self._writer.counts(facts)
55
+ artifact = self._writer.write(facts, Path(out_dir), name)
56
+ record.eligible_sites = counts["injection_sites"]
57
+ record.resolved_sites = counts["injection_sites_resolved_to_class"]
58
+ # Not graph edges. Counted under the same field because the build gate reads that
59
+ # field; `state`/`reason` and the artifact itself say what these actually are.
60
+ record.edges_added = counts["injection_sites_resolved_to_class"]
61
+ record.unresolved = dict(facts.skipped)
62
+ record.state = "completed"
63
+ record.reason = f"{artifact}"
64
+ record.finished_at = time.time()
65
+ return record
66
+
67
+ def _skip(self, record: ResolverStatus, reason: str) -> ResolverStatus:
68
+ record.state = "skipped"
69
+ record.reason = reason
70
+ record.finished_at = time.time()
71
+ return record
@@ -0,0 +1,74 @@
1
+ """Finds the compiled container, and distinguishes the several ways there can be none.
2
+
3
+ The distinction is the whole job. `var/cache/prod/` on a machine that never ran with
4
+ `kernel.debug` on has no XML dump at all — Symfony writes the human-readable XML only for the
5
+ debug container — so "no file" is the ordinary state of a production checkout and must not be
6
+ reported the same way as "this is not a Symfony project" or "the file is there and broken".
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from . import reasons
15
+
16
+ # Both halves of a Symfony install: the framework itself, and the bundle that compiles the
17
+ # container the dump comes from. Either one present is enough to call the project Symfony.
18
+ _SYMFONY_MARKERS = (
19
+ "symfony/framework-bundle",
20
+ "symfony/http-kernel",
21
+ "symfony/dependency-injection",
22
+ )
23
+
24
+
25
+ class SymfonyCacheLocator:
26
+ """Looks in `var/cache/*/` for `*Container.xml`."""
27
+
28
+ name = "symfony-var-cache"
29
+
30
+ def __init__(self, preferred_environments: tuple[str, ...] = ("dev", "test")) -> None:
31
+ # Preference, not a filter: a project with a custom environment name still gets found,
32
+ # it just gets picked after the conventional ones.
33
+ self._preferred = preferred_environments
34
+
35
+ def locate(self, repo_root: Path) -> tuple[Path | None, str]:
36
+ composer = repo_root / "composer.json"
37
+ if not composer.exists():
38
+ return None, reasons.SKIP_NOT_PHP_PROJECT
39
+ if not self._is_symfony(composer):
40
+ return None, reasons.SKIP_NOT_SYMFONY
41
+
42
+ cache_root = repo_root / "var" / "cache"
43
+ if not cache_root.is_dir():
44
+ return None, reasons.SKIP_NO_CACHE_DIR
45
+
46
+ candidates: list[tuple[int, Path]] = []
47
+ for environment_dir in sorted(p for p in cache_root.iterdir() if p.is_dir()):
48
+ rank = (
49
+ self._preferred.index(environment_dir.name)
50
+ if environment_dir.name in self._preferred
51
+ else len(self._preferred)
52
+ )
53
+ for candidate in sorted(environment_dir.glob("*Container.xml")):
54
+ # A link under `var/cache` can point anywhere on the machine; following it
55
+ # would read a file outside the repository under the repository's name.
56
+ if candidate.is_symlink():
57
+ continue
58
+ candidates.append((rank, candidate))
59
+ if not candidates:
60
+ return None, reasons.SKIP_XML_ABSENT
61
+ candidates.sort(key=lambda item: (item[0], str(item[1])))
62
+ return candidates[0][1], ""
63
+
64
+ def _is_symfony(self, composer: Path) -> bool:
65
+ try:
66
+ manifest = json.loads(composer.read_text(encoding="utf-8", errors="replace"))
67
+ except (ValueError, OSError):
68
+ return False
69
+ required = {}
70
+ for section in ("require", "require-dev"):
71
+ value = manifest.get(section)
72
+ if isinstance(value, dict):
73
+ required.update(value)
74
+ return any(marker in required for marker in _SYMFONY_MARKERS)