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
|
@@ -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"
|