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,149 @@
1
+ """Serialises container facts into an overlay artifact written beside the graph.
2
+
3
+ **Why this is not graph edges.** The mesh that consumes these graphs enforces one invariant: a
4
+ cross-project overlay is never merged into the structural graph, and a forbidden-edge validator
5
+ checks it. The reason is that the structural graph claims to describe the code, while every
6
+ fact in here is true only of one environment and one build profile — `kernel.environment: dev`,
7
+ with the debug bundle loaded, on the machine that compiled the cache. A `calls` edge asserted
8
+ from a dev container would be a claim about the code that the code does not make.
9
+
10
+ So: a separate file, a relation name of this layer's own (`binds_injection_to`), the
11
+ environment recorded inside the artifact, and `structural: false` stated rather than implied.
12
+ A consumer joins it against the graph at query time, and a consumer that does not want
13
+ environment-dependent facts simply does not read the file.
14
+
15
+ **Why there is no value anywhere in the output.** The artifact is assembled only from the
16
+ fields of `facts`, and that model has nowhere to put a value. An env reference contributes its
17
+ variable NAME; a scalar contributes the word `scalar`. `eval/canaries.py` plants fake secrets
18
+ and asserts none reaches an artifact — this writer passes by construction, not by filtering.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import time
25
+ from dataclasses import asdict
26
+ from pathlib import Path
27
+
28
+ from .facts import ArgumentKind, ContainerFacts, Injection
29
+
30
+ SCHEMA = 1
31
+ RELATION = "binds_injection_to"
32
+ ARTIFACT_KIND = "container-overlay"
33
+ DEFAULT_ARTIFACT_NAME = "container-overlay.json"
34
+
35
+ # Kinds that name another service, and so make an injection site joinable to a class node.
36
+ _BINDING_KINDS = (
37
+ ArgumentKind.SERVICE,
38
+ ArgumentKind.SERVICE_CLOSURE,
39
+ ArgumentKind.TAGGED_ITERATOR,
40
+ )
41
+
42
+
43
+ class OverlayWriter:
44
+ """Builds the artifact and, separately, writes it.
45
+
46
+ Split so a caller can assert on the document without a filesystem, which is what every
47
+ test here does.
48
+ """
49
+
50
+ def __init__(self, relation: str = RELATION) -> None:
51
+ self._relation = relation
52
+
53
+ def build(self, facts: ContainerFacts) -> dict:
54
+ bindings = [self._binding(i) for i in facts.injections]
55
+ return {
56
+ "schema": SCHEMA,
57
+ "kind": ARTIFACT_KIND,
58
+ "producer": "graphify-php.container",
59
+ "relation": self._relation,
60
+ # Stated, not implied. The validator on the consuming side rejects an overlay that
61
+ # claims to be structural, and a future writer that flips this is making a visible
62
+ # change rather than a quiet one.
63
+ "structural": False,
64
+ "generated_at": time.time(),
65
+ "environment": dict(facts.environment),
66
+ "counts": self.counts(facts),
67
+ "aliases": [
68
+ {
69
+ "alias": a.alias_id,
70
+ "target": a.target_id,
71
+ "target_class": facts.class_of(a.target_id),
72
+ }
73
+ for a in sorted(facts.aliases.values(), key=lambda a: a.alias_id)
74
+ ],
75
+ "bindings": bindings,
76
+ "tags": [
77
+ {
78
+ "tag": t.tag,
79
+ "service": t.service_id,
80
+ "class": t.class_fqn,
81
+ "attributes": t.attributes,
82
+ }
83
+ for t in facts.tags
84
+ ],
85
+ "decorations": [asdict(d) for d in facts.decorations],
86
+ "factories": [
87
+ {
88
+ "service": s.service_id,
89
+ "class": s.class_fqn,
90
+ "factory_class": s.factory_class,
91
+ "factory_service": s.factory_service,
92
+ "factory_method": s.factory_method,
93
+ "constructor": s.constructor,
94
+ }
95
+ for s in facts.services.values()
96
+ if s.factory_class or s.factory_service or s.constructor
97
+ ],
98
+ "dropped": dict(facts.skipped),
99
+ }
100
+
101
+ def counts(self, facts: ContainerFacts) -> dict:
102
+ interface_bindings = [
103
+ a for a in facts.interface_bindings if facts.class_of(a.target_id) is not None
104
+ ]
105
+ resolved = [i for i in facts.injections if i.resolved_class]
106
+ return {
107
+ "services": len(facts.services),
108
+ "aliases": len(facts.aliases),
109
+ "interface_bindings": len(interface_bindings),
110
+ "injection_sites": len(facts.injections),
111
+ # The denominator travels with the numerator: "resolved 2900" says nothing without
112
+ # "out of 8389", and a regression that halves coverage still looks large alone.
113
+ "injection_sites_resolved_to_class": len(resolved),
114
+ "tag_memberships": len(facts.tags),
115
+ "distinct_tags": len({t.tag for t in facts.tags}),
116
+ "decorations": len(facts.decorations),
117
+ "env_arguments": len([i for i in facts.injections if i.kind is ArgumentKind.ENV]),
118
+ }
119
+
120
+ def write(
121
+ self, facts: ContainerFacts, out_dir: Path, name: str = DEFAULT_ARTIFACT_NAME
122
+ ) -> Path:
123
+ out_dir.mkdir(parents=True, exist_ok=True)
124
+ path = out_dir / name
125
+ path.write_text(json.dumps(self.build(facts), indent=2, sort_keys=False))
126
+ return path
127
+
128
+ def _binding(self, injection: Injection) -> dict:
129
+ document = {
130
+ "relation": self._relation,
131
+ "consumer_service": injection.consumer_id,
132
+ "consumer_class": injection.consumer_class,
133
+ "member": injection.member.value,
134
+ "member_name": injection.member_name,
135
+ "position": injection.position,
136
+ "parameter": injection.parameter,
137
+ "kind": injection.kind.value,
138
+ }
139
+ if injection.kind in _BINDING_KINDS or injection.requested_id:
140
+ document["requested_service"] = injection.requested_id
141
+ document["resolved_service"] = injection.resolved_id
142
+ document["resolved_class"] = injection.resolved_class
143
+ document["via_alias"] = injection.via_alias
144
+ if injection.tag:
145
+ document["tag"] = injection.tag
146
+ if injection.kind is ArgumentKind.ENV:
147
+ # The name only. There is no branch anywhere in this file that reads a value.
148
+ document["env_name"] = injection.env_name
149
+ return document
@@ -0,0 +1,40 @@
1
+ """The seam between "read a container format" and "everything else".
2
+
3
+ Only two protocols, because only two things vary: where the compiled container is, and how to
4
+ parse it. The model (`facts`) and the artifact (`overlay`) do not vary with the format.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Protocol, runtime_checkable
11
+
12
+ from .facts import ContainerFacts
13
+
14
+
15
+ @runtime_checkable
16
+ class ContainerLocator(Protocol):
17
+ """Finds a compiled container in a repository, or says why there is none."""
18
+
19
+ name: str
20
+
21
+ def locate(self, repo_root: Path) -> tuple[Path | None, str]:
22
+ """Return (path, reason). A None path always carries a reason from the fixed
23
+ vocabulary in `reasons.py` — never free text, and never an exception: a repository
24
+ that is not Symfony, and a production cache built with `kernel.debug` off, both
25
+ legitimately have no file here."""
26
+
27
+
28
+ @runtime_checkable
29
+ class ContainerReader(Protocol):
30
+ """Turns one container file into facts."""
31
+
32
+ name: str
33
+ format: str # recorded in the artifact, so a consumer knows which reader spoke
34
+
35
+ def read(self, path: Path) -> ContainerFacts | None:
36
+ """Facts, or None when the file is unreadable or malformed.
37
+
38
+ None rather than an exception: a truncated cache file is a normal condition of a
39
+ cache directory being rewritten under us, and the layer counts it as a skip reason.
40
+ """
@@ -0,0 +1,330 @@
1
+ """Reads a Symfony compiled container dump (`*Container.xml`) into `facts`.
2
+
3
+ This is the only module that knows the Symfony XML schema. It runs no PHP, boots no kernel and
4
+ opens nothing but the one file it is given: the compiled dump already contains the result of
5
+ every compiler pass, so the answer to "which implementation is injected here" is a parse, not
6
+ an execution. `phpstan-symfony` reads the same file for the same reason.
7
+
8
+ What the compiled form does to the declarations, and what this reader therefore has to undo:
9
+
10
+ - Interface bindings survive as `<service id="…Interface" alias="…"/>`. 361 of them in the
11
+ reference container.
12
+ - Decoration does NOT survive as an attribute. Symfony applies it at compile time: the
13
+ decorated definition is renamed to `<id>.inner` and the decorator takes the original id.
14
+ 0 occurrences of `decorates=` in the reference container against 28 `.inner` ids.
15
+ - Parameter names do not survive at all. Arguments are positional. This is the single largest
16
+ gap in what a consumer can join on, and `Injection.parameter` stays None rather than being
17
+ filled with an index dressed up as a name.
18
+ - `%env(FOO)%` survives unexpanded, which is exactly why only its name is taken.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ import xml.etree.ElementTree as ET
25
+ from pathlib import Path
26
+
27
+ from . import reasons
28
+ from .facts import (
29
+ Alias,
30
+ ArgumentKind,
31
+ ContainerFacts,
32
+ Decoration,
33
+ Injection,
34
+ Member,
35
+ Service,
36
+ TagMembership,
37
+ )
38
+
39
+ NS = "{http://symfony.com/schema/dic/services}"
40
+
41
+ # `%env(bool:default:some_param:APP_DEBUG)%` — processors and defaults are colon-separated and
42
+ # the variable name is always last. Only that last segment is kept; every earlier segment may
43
+ # name a parameter whose value we are not entitled to touch. `[^()%]*` rather than `.*`: a
44
+ # greedy body makes `%env(A)%:%env(B)%` one match with the name `%env(B`, and a DSN that merely
45
+ # ends in `)%` would hand its tail over as a "name".
46
+ _ENV_EXPRESSION = re.compile(r"^%env\(([^()%]*)\)%$")
47
+
48
+ # What an environment variable can actually be called. Anything else in the last segment is
49
+ # a processor argument or a stray value, and neither belongs in the overlay.
50
+ _ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
51
+
52
+ _KIND_BY_TYPE = {
53
+ "service": ArgumentKind.SERVICE,
54
+ "service_closure": ArgumentKind.SERVICE_CLOSURE,
55
+ "tagged_iterator": ArgumentKind.TAGGED_ITERATOR,
56
+ "iterator": ArgumentKind.ITERATOR,
57
+ "collection": ArgumentKind.COLLECTION,
58
+ "abstract": ArgumentKind.ABSTRACT,
59
+ "constant": ArgumentKind.CONSTANT,
60
+ "string": ArgumentKind.SCALAR,
61
+ }
62
+
63
+ # The two container parameters recorded as provenance. An allowlist, not a filter: the
64
+ # parameters block holds project paths, mailer DSNs and API keys, so nothing is copied out of
65
+ # it except these two, which say which build profile the overlay describes.
66
+ _ENVIRONMENT_PARAMETERS = ("kernel.environment", "kernel.debug")
67
+
68
+
69
+ def env_name(text: str) -> str | None:
70
+ """The variable name inside a `%env(...)%` expression, or None.
71
+
72
+ Returns a name and never a value: the caller has no way to obtain the expansion from here,
73
+ which is the point.
74
+ """
75
+ match = _ENV_EXPRESSION.match(text.strip())
76
+ if not match:
77
+ return None
78
+ name = match.group(1).rsplit(":", 1)[-1].strip()
79
+ return name if _ENV_NAME.match(name) else None
80
+
81
+
82
+ class SymfonyXmlContainerReader:
83
+ """Parses one `App_*Container.xml`."""
84
+
85
+ name = "symfony-container-xml"
86
+ format = "symfony/compiled-container-xml"
87
+
88
+ def read(self, path: Path) -> ContainerFacts | None:
89
+ try:
90
+ # expat >= 2.7 rejects entity expansion and external entities; ParseError -> None.
91
+ root = ET.parse(path).getroot() # noqa: S314
92
+ except ET.ParseError:
93
+ return None
94
+ except OSError:
95
+ return None
96
+ if root.tag != NS + "container":
97
+ return None
98
+
99
+ facts = ContainerFacts()
100
+ self._read_environment(root, path, facts)
101
+ services_root = root.find(NS + "services")
102
+ if services_root is None:
103
+ return facts
104
+
105
+ # Direct children only. A nested `<service>` is an inline anonymous definition owned by
106
+ # the argument it sits in; treating it as a top-level service would invent an id.
107
+ for element in services_root.findall(NS + "service"):
108
+ self._read_service(element, facts)
109
+
110
+ self._resolve_injections(facts)
111
+ self._read_decorations(facts)
112
+ return facts
113
+
114
+ # --- provenance -------------------------------------------------------------------
115
+
116
+ def _read_environment(self, root: ET.Element, path: Path, facts: ContainerFacts) -> None:
117
+ parameters = root.find(NS + "parameters")
118
+ values: dict[str, str] = {}
119
+ if parameters is not None:
120
+ for parameter in parameters.findall(NS + "parameter"):
121
+ key = parameter.get("key")
122
+ if key in _ENVIRONMENT_PARAMETERS:
123
+ values[key] = (parameter.text or "").strip()
124
+ facts.environment = {
125
+ "container_file": str(path),
126
+ "container_class": path.stem,
127
+ "format": self.format,
128
+ "reader": self.name,
129
+ "kernel_environment": values.get("kernel.environment"),
130
+ "kernel_debug": values.get("kernel.debug"),
131
+ }
132
+
133
+ # --- services ---------------------------------------------------------------------
134
+
135
+ def _read_service(self, element: ET.Element, facts: ContainerFacts) -> None:
136
+ service_id = element.get("id")
137
+ if not service_id:
138
+ facts.skip(reasons.DROP_SERVICE_WITHOUT_ID)
139
+ return
140
+
141
+ alias_target = element.get("alias")
142
+ if alias_target:
143
+ facts.aliases[service_id] = Alias(
144
+ alias_id=service_id,
145
+ target_id=alias_target,
146
+ public=element.get("public") == "true",
147
+ )
148
+ return
149
+
150
+ factory = element.find(NS + "factory")
151
+ service = Service(
152
+ service_id=service_id,
153
+ class_fqn=element.get("class"),
154
+ public=element.get("public") == "true",
155
+ abstract=element.get("abstract") == "true",
156
+ lazy=element.get("lazy") == "true",
157
+ synthetic=element.get("synthetic") == "true",
158
+ factory_class=factory.get("class") if factory is not None else None,
159
+ factory_service=factory.get("service") if factory is not None else None,
160
+ factory_method=factory.get("method") if factory is not None else None,
161
+ constructor=element.get("constructor"),
162
+ )
163
+ facts.services[service_id] = service
164
+ if service.class_fqn is None and not service.synthetic:
165
+ facts.skip(reasons.DROP_SERVICE_WITHOUT_CLASS)
166
+
167
+ self._read_tags(element, service, facts)
168
+ # XmlDumper writes the arguments of a factory-built or named-constructor service at
169
+ # service level and emits `<factory/>` without children, so service-level arguments
170
+ # are the factory's whenever there is one. Recording them as constructor arguments
171
+ # would join them against a `__construct` signature they never reach.
172
+ if factory is not None:
173
+ member, member_name = Member.FACTORY, service.factory_method
174
+ elif service.constructor is not None:
175
+ member, member_name = Member.FACTORY, service.constructor
176
+ else:
177
+ member, member_name = Member.CONSTRUCTOR, None
178
+ self._read_arguments(element, service, member, member_name, facts)
179
+ for call in element.findall(NS + "call"):
180
+ self._read_arguments(call, service, Member.CALL, call.get("method"), facts)
181
+
182
+ def _read_tags(self, element: ET.Element, service: Service, facts: ContainerFacts) -> None:
183
+ for tag in element.findall(NS + "tag"):
184
+ tag_name = tag.get("name")
185
+ if not tag_name:
186
+ facts.skip(reasons.DROP_TAG_WITHOUT_NAME)
187
+ continue
188
+ # Tag attributes are wiring metadata — priority, event, command name, alias. They
189
+ # are identifiers and routing keys, so they are kept; nothing in a tag is a secret
190
+ # by Symfony's own design, since tags are read by compiler passes, not by code.
191
+ facts.tags.append(
192
+ TagMembership(
193
+ tag=tag_name,
194
+ service_id=service.service_id,
195
+ class_fqn=service.class_fqn,
196
+ attributes={k: v for k, v in tag.attrib.items() if k != "name"},
197
+ )
198
+ )
199
+
200
+ # --- arguments --------------------------------------------------------------------
201
+
202
+ def _read_arguments(
203
+ self,
204
+ parent: ET.Element,
205
+ service: Service,
206
+ member: Member,
207
+ member_name: str | None,
208
+ facts: ContainerFacts,
209
+ ) -> None:
210
+ for position, argument in enumerate(parent.findall(NS + "argument")):
211
+ facts.injections.append(
212
+ self._read_argument(argument, service, member, member_name, position, facts)
213
+ )
214
+
215
+ def _read_argument(
216
+ self,
217
+ argument: ET.Element,
218
+ service: Service,
219
+ member: Member,
220
+ member_name: str | None,
221
+ position: int,
222
+ facts: ContainerFacts,
223
+ ) -> Injection:
224
+ declared = argument.get("type")
225
+ kind = _KIND_BY_TYPE.get(declared) if declared else None
226
+ requested_id = argument.get("id")
227
+ tag = argument.get("tag")
228
+ name: str | None = None
229
+
230
+ if kind is None and declared is not None:
231
+ kind = ArgumentKind.UNKNOWN
232
+ facts.skip(reasons.DROP_ARGUMENT_UNKNOWN_KIND)
233
+ elif kind is None:
234
+ # No `type` attribute: a literal. The text is inspected only to tell an env
235
+ # reference from an ordinary scalar, and is then dropped.
236
+ text = argument.text or ""
237
+ name = env_name(text)
238
+ if name:
239
+ kind = ArgumentKind.ENV
240
+ elif text.strip().startswith("%env("):
241
+ kind = ArgumentKind.ENV
242
+ facts.skip(reasons.DROP_ENV_NAME_UNPARSEABLE)
243
+ else:
244
+ kind = ArgumentKind.SCALAR
245
+
246
+ if kind in (ArgumentKind.SERVICE, ArgumentKind.SERVICE_CLOSURE) and not requested_id:
247
+ # An inline anonymous definition: the target has no id to bind to.
248
+ facts.skip(reasons.DROP_INLINE_ANONYMOUS_SERVICE)
249
+ facts.skip(reasons.DROP_ARGUMENT_WITHOUT_TARGET)
250
+
251
+ return Injection(
252
+ consumer_id=service.service_id,
253
+ consumer_class=service.class_fqn,
254
+ member=member,
255
+ member_name=member_name,
256
+ position=position,
257
+ # Compiled Symfony XML carries no parameter names. Left None on purpose; see the
258
+ # module docstring.
259
+ parameter=None,
260
+ kind=kind,
261
+ requested_id=requested_id,
262
+ tag=tag,
263
+ env_name=name,
264
+ )
265
+
266
+ # --- second pass ------------------------------------------------------------------
267
+
268
+ def _resolve_injections(self, facts: ContainerFacts) -> None:
269
+ """Follow aliases once every service is known.
270
+
271
+ A second pass rather than resolution in place: the XML lists an alias after its target
272
+ as often as before it, and resolving eagerly would make the result depend on file order.
273
+ """
274
+ resolved: list[Injection] = []
275
+ for injection in facts.injections:
276
+ if injection.requested_id is None:
277
+ resolved.append(injection)
278
+ continue
279
+ target = facts.resolve_id(injection.requested_id)
280
+ service = facts.services.get(target)
281
+ if service is None and target not in facts.aliases:
282
+ facts.skip(reasons.DROP_ALIAS_TARGET_UNDEFINED)
283
+ resolved.append(
284
+ Injection(
285
+ consumer_id=injection.consumer_id,
286
+ consumer_class=injection.consumer_class,
287
+ member=injection.member,
288
+ member_name=injection.member_name,
289
+ position=injection.position,
290
+ parameter=injection.parameter,
291
+ kind=injection.kind,
292
+ requested_id=injection.requested_id,
293
+ resolved_id=target,
294
+ resolved_class=service.class_fqn if service else None,
295
+ via_alias=target != injection.requested_id,
296
+ tag=injection.tag,
297
+ env_name=injection.env_name,
298
+ )
299
+ )
300
+ facts.injections = resolved
301
+
302
+ def _read_decorations(self, facts: ContainerFacts) -> None:
303
+ """Recover decoration from the `.inner` renaming, by finding who holds the reference."""
304
+ consumers: dict[str, str] = {}
305
+ for injection in facts.injections:
306
+ if injection.requested_id and injection.requested_id.endswith(".inner"):
307
+ # The decorator is the service that took over the original id, so when that
308
+ # service references `<id>.inner` it wins over a collector that happens to be
309
+ # listed first and also holds the reference.
310
+ owner = injection.requested_id[: -len(".inner")]
311
+ if injection.consumer_id == owner:
312
+ consumers[injection.requested_id] = owner
313
+ else:
314
+ consumers.setdefault(injection.requested_id, injection.consumer_id)
315
+ for service_id, service in facts.services.items():
316
+ if not service_id.endswith(".inner"):
317
+ continue
318
+ decorator_id = consumers.get(service_id)
319
+ if decorator_id is None:
320
+ facts.skip(reasons.DROP_INNER_WITHOUT_DECORATOR)
321
+ continue
322
+ decorator = facts.services.get(decorator_id)
323
+ facts.decorations.append(
324
+ Decoration(
325
+ decorator_id=decorator_id,
326
+ decorator_class=decorator.class_fqn if decorator else None,
327
+ inner_id=service_id,
328
+ inner_class=service.class_fqn,
329
+ )
330
+ )
@@ -0,0 +1,31 @@
1
+ """The fixed vocabulary for everything this layer declines to do.
2
+
3
+ Two families, kept apart because they answer different questions. A SKIP_* reason explains why
4
+ the whole layer produced nothing and appears once, in the status record. A DROP_* reason
5
+ explains one fact left out of an artifact that was still produced, and appears as a counter.
6
+
7
+ Collapsing them would hide the difference between "no container here" and "a container with
8
+ 900 unreadable arguments", which look identical in a total of zero.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ # Layer-level: the overlay was not produced at all.
14
+ SKIP_NOT_PHP_PROJECT = "container-no-composer-json"
15
+ SKIP_NOT_SYMFONY = "container-not-a-symfony-project"
16
+ SKIP_NO_CACHE_DIR = "container-no-cache-directory"
17
+ SKIP_XML_ABSENT = "container-xml-absent"
18
+ SKIP_XML_UNREADABLE = "container-xml-unreadable"
19
+ SKIP_XML_MALFORMED = "container-xml-malformed"
20
+ SKIP_XML_NOT_A_CONTAINER = "container-xml-wrong-root-element"
21
+
22
+ # Fact-level: the overlay exists, this piece of it does not.
23
+ DROP_SERVICE_WITHOUT_ID = "service-without-id"
24
+ DROP_INLINE_ANONYMOUS_SERVICE = "inline-anonymous-service"
25
+ DROP_SERVICE_WITHOUT_CLASS = "service-without-class"
26
+ DROP_ALIAS_TARGET_UNDEFINED = "alias-target-not-defined"
27
+ DROP_ARGUMENT_WITHOUT_TARGET = "argument-service-without-id"
28
+ DROP_ARGUMENT_UNKNOWN_KIND = "argument-kind-unrecognised"
29
+ DROP_ENV_NAME_UNPARSEABLE = "env-expression-without-name"
30
+ DROP_INNER_WITHOUT_DECORATOR = "inner-service-without-decorator"
31
+ DROP_TAG_WITHOUT_NAME = "tag-without-name"