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,29 @@
1
+ """The node standing for a configuration file, and the key every reader addresses it by.
2
+
3
+ The file node belongs to no single reader: routing edges and environment edges both start at it,
4
+ and whichever reader emitted it would silently own a node the other depends on. It is emitted by
5
+ the pass instead, and only for a document that produced at least one fact — a Symfony `config/`
6
+ holds dozens of files about caching, routing and security that this package indexes nothing
7
+ from, and a node per file would bury the two facts it does index.
8
+
9
+ The key is the path relative to the repository root. Build paths are absolute while a build runs
10
+ and relativized only at publish time, so keying on the absolute path would make the same file
11
+ produce a different node on another machine.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .facts import ConfigNode
17
+
18
+
19
+ def file_key(path: str) -> str:
20
+ return f"cfg::file::{path}"
21
+
22
+
23
+ class DocumentNodeFactory:
24
+ """Builds the node for one configuration file."""
25
+
26
+ def node(self, path: str) -> ConfigNode:
27
+ return ConfigNode(
28
+ key=file_key(path), label=path.rsplit("/", 1)[-1], kind="config_file", source_file=path
29
+ )
@@ -0,0 +1,117 @@
1
+ """Turning facts into graphify nodes and edges, the only module that knows graphify's schema.
2
+
3
+ Two jobs, both of which every reader would otherwise have to repeat: give a `NodeRef` an id, and
4
+ keep the graph free of duplicates. Readers emit the same transport node once per routing entry
5
+ on purpose — it is simpler to state the fact than to remember whether it was already stated —
6
+ and deduplication here is what makes that safe.
7
+
8
+ The schema written against, read from a real build: a node carries `id`, `label`, `file_type`,
9
+ `source_file`, `source_location` and `_origin`; an edge carries `source`, `target`, `relation`,
10
+ `confidence`, `weight` and `_origin`.
11
+
12
+ `_origin` is not the place for authorship. graphify overwrites it with `"ast"` on every edge
13
+ after the resolvers run (extract.py:8285-8289); it means the eviction tier, not the author. The
14
+ marker lives in a key of this package's own, which survives.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .class_lookup import GraphClassIndex
20
+ from .facts import REF_CONFIG, REF_PHP_CLASS, ConfigEdge, ConfigNode, FactSet, NodeRef
21
+
22
+ EDGE_MARKER = "_resolver"
23
+ EDGE_ORIGIN = "graphify-php"
24
+
25
+ REASON_CLASS_NOT_IN_GRAPH = "message_class_not_in_graph"
26
+
27
+ # A configuration node describes the running application, not prose about it, so it takes the
28
+ # same `file_type` as the code nodes it sits beside. A consumer filtering the graph down to the
29
+ # code view would otherwise drop exactly the facts this package exists to add.
30
+ FILE_TYPE = "code"
31
+
32
+ # Configuration facts are read straight out of a declaration, not derived from one. That is
33
+ # graphify's definition of EXTRACTED, and using its vocabulary keeps these edges rankable
34
+ # alongside its own.
35
+ CONFIDENCE = "EXTRACTED"
36
+
37
+
38
+ class GraphWriter:
39
+ """Appends configuration nodes and edges to graphify's lists, once each."""
40
+
41
+ def __init__(
42
+ self, all_nodes: list[dict], all_edges: list[dict], classes: GraphClassIndex | None = None
43
+ ) -> None:
44
+ self._nodes = all_nodes
45
+ self._edges = all_edges
46
+ self._classes = classes if classes is not None else GraphClassIndex(all_nodes, all_edges)
47
+ self._node_ids = {n.get("id") for n in all_nodes}
48
+ self._pairs = {(e.get("source"), e.get("target"), e.get("relation")) for e in all_edges}
49
+ self.nodes_added = 0
50
+ self.edges_added = 0
51
+ self.unresolved: dict[str, int] = {}
52
+
53
+ def write(self, facts: FactSet) -> None:
54
+ for node in facts.nodes:
55
+ self._add_node(node)
56
+ for edge in facts.edges:
57
+ self._add_edge(edge)
58
+
59
+ def _add_node(self, node: ConfigNode) -> None:
60
+ if node.key in self._node_ids:
61
+ return
62
+ self._node_ids.add(node.key)
63
+ self._nodes.append(
64
+ {
65
+ "id": node.key,
66
+ "label": node.label,
67
+ "file_type": FILE_TYPE,
68
+ "source_file": node.source_file,
69
+ "source_location": node.source_location or "L1",
70
+ "_origin": "ast",
71
+ EDGE_MARKER: EDGE_ORIGIN,
72
+ "_config_kind": node.kind,
73
+ }
74
+ )
75
+ self.nodes_added += 1
76
+
77
+ def _add_edge(self, edge: ConfigEdge) -> None:
78
+ source = self._resolve(edge.source)
79
+ target = self._resolve(edge.target)
80
+ if source is None or target is None or source == target:
81
+ return
82
+ key = (source, target, edge.relation)
83
+ if key in self._pairs:
84
+ return
85
+ self._pairs.add(key)
86
+ self._edges.append(
87
+ {
88
+ "source": source,
89
+ "target": target,
90
+ "relation": edge.relation,
91
+ "confidence": CONFIDENCE,
92
+ "source_file": edge.source_file,
93
+ "source_location": edge.source_location or "L1",
94
+ "weight": 1.0,
95
+ EDGE_MARKER: EDGE_ORIGIN,
96
+ }
97
+ )
98
+ self.edges_added += 1
99
+
100
+ def _resolve(self, ref: NodeRef) -> str | None:
101
+ if ref.kind == REF_CONFIG:
102
+ # A config endpoint is only usable once its node exists. Facts list their nodes
103
+ # before their edges, so an unknown key here means a reader referenced a node it
104
+ # never emitted, which is a bug in that reader rather than a missing class.
105
+ return ref.value if ref.value in self._node_ids else None
106
+ if ref.kind == REF_PHP_CLASS:
107
+ found = self._classes.node_id(ref.value)
108
+ if found is None:
109
+ # The transport node is already written by the time this runs, so the
110
+ # application's transport list stays complete. What is lost is the one edge,
111
+ # and the count is what says how much of the routing table points outside the
112
+ # indexed tree.
113
+ self.unresolved[REASON_CLASS_NOT_IN_GRAPH] = (
114
+ self.unresolved.get(REASON_CLASS_NOT_IN_GRAPH, 0) + 1
115
+ )
116
+ return found
117
+ return None
@@ -0,0 +1,108 @@
1
+ """Environment references: which variable NAME a configuration file reads.
2
+
3
+ `client_secret: '%env(MICROSOFT_CLIENT_SECRET)%'` is the only place that binds the variable to
4
+ the service that consumes it. No PHP names it — the container resolves the placeholder before
5
+ any code runs — so "what breaks if this variable is unset" has no answer in a graph built from
6
+ PHP alone.
7
+
8
+ The name is indexed. The value is never touched, in any form: not the default inside
9
+ `%env(default:app.fallback:NAME)%`, not the DSN the placeholder is embedded in, not the result
10
+ of resolving it. A configuration file is where credentials live, and a graph is published,
11
+ diffed and served to other tools. `eval/canaries.py` plants fake secrets in fixtures and asserts
12
+ they reach no artifact; this reader is the one that would break that if it kept a value.
13
+
14
+ Processor forms are Symfony's: `%env(bool:DEBUG)%`, `%env(default:fallback_param:DSN)%`,
15
+ `%env(key:secret:JSON_FILE)%`. Every one of them puts the variable name last, so the innermost
16
+ name is the final colon-separated segment.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from collections.abc import Iterator
23
+
24
+ from .document import file_key
25
+ from .facts import ConfigDocument, ConfigEdge, ConfigNode, FactSet, NodeRef
26
+
27
+ RELATION_READS_ENV = "reads_env"
28
+
29
+ REASON_UNRECOGNISED_NAME = "env_name_not_an_identifier"
30
+ REASON_DEPTH_LIMIT = "env_depth_limit"
31
+
32
+ # `[^()%]*` on purpose: a placeholder cannot contain a nested `%env(` and stopping at the first
33
+ # `%` keeps a run-on string like `'%env(A)%:%env(B)%'` as two matches instead of one wrong one.
34
+ _PLACEHOLDER = re.compile(r"%env\(([^()%]*)\)%")
35
+
36
+ # The name as an environment variable can actually be spelled. A segment that fails this is a
37
+ # processor argument this reader does not model, and emitting it would put a made-up variable
38
+ # in the graph.
39
+ _NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
40
+
41
+ # Configuration nests a few levels. The bound stops a generated or vendored dump from turning
42
+ # one file into an unbounded walk.
43
+ _MAX_DEPTH = 12
44
+
45
+
46
+ class EnvReferenceReader:
47
+ """Reads `%env(...)%` placeholders out of one document, names only."""
48
+
49
+ name = "env_references"
50
+
51
+ def read(self, document: ConfigDocument) -> FactSet:
52
+ facts = FactSet()
53
+ file_ref = NodeRef.config(file_key(document.path))
54
+ seen: set[str] = set()
55
+ for placeholder in self._placeholders(document.data, _MAX_DEPTH, facts):
56
+ name = placeholder.rsplit(":", 1)[-1].strip()
57
+ if not _NAME.match(name):
58
+ facts.unresolvable(REASON_UNRECOGNISED_NAME)
59
+ continue
60
+ if name in seen:
61
+ continue
62
+ seen.add(name)
63
+ node_key = env_key(name)
64
+ facts.nodes.append(
65
+ ConfigNode(key=node_key, label=name, kind="env_var", source_file=document.path)
66
+ )
67
+ facts.edges.append(
68
+ ConfigEdge(
69
+ source=file_ref,
70
+ target=NodeRef.config(node_key),
71
+ relation=RELATION_READS_ENV,
72
+ source_file=document.path,
73
+ )
74
+ )
75
+ return facts
76
+
77
+ def _placeholders(self, node, depth: int, facts: FactSet) -> Iterator[str]:
78
+ """Every placeholder body in the document, keys included.
79
+
80
+ Only the text inside `env(...)` ever leaves this method. The scalar that carried it —
81
+ which may be a DSN with an inline password — is not yielded, not stored and not logged.
82
+ """
83
+ if depth < 0:
84
+ # Past the bound the subtree is not walked, so a placeholder in it would vanish
85
+ # without trace. One count per cut-off subtree says that something was left out;
86
+ # the subtree is not read, so the count is not a number of placeholders.
87
+ facts.unresolvable(REASON_DEPTH_LIMIT)
88
+ return
89
+ if isinstance(node, str):
90
+ yield from _PLACEHOLDER.findall(node)
91
+ elif isinstance(node, dict):
92
+ for key, value in node.items():
93
+ if isinstance(key, str):
94
+ yield from _PLACEHOLDER.findall(key)
95
+ yield from self._placeholders(value, depth - 1, facts)
96
+ elif isinstance(node, (list, tuple)):
97
+ for item in node:
98
+ yield from self._placeholders(item, depth - 1, facts)
99
+
100
+
101
+ def env_key(name: str) -> str:
102
+ """Node key for a variable, shared across every file that reads it.
103
+
104
+ One variable is one node however many files reference it: the question this answers is
105
+ "everything that breaks when `MICROSOFT_CLIENT_SECRET` is unset", which a per-file node
106
+ would split into as many answers as there are files.
107
+ """
108
+ return f"cfg::env::{name}"
@@ -0,0 +1,97 @@
1
+ """What a reader returns, and what the writer turns into graph nodes and edges.
2
+
3
+ The whole package is shaped around this one dataclass set so that a third configuration fact
4
+ — a scheduler definition, a workflow transition — is a new reader class and nothing else. A
5
+ reader never touches graphify's node schema and the writer never learns what a transport is,
6
+ so neither of them grows a branch per fact kind.
7
+
8
+ An endpoint is a `NodeRef` rather than a node id because a fact knows what it means, not what
9
+ the graph calls it. `config` means "a node this package emits, keyed by `value`"; `php_class`
10
+ means "the class graphify already indexed under this FQN, if it did". Resolution is the
11
+ writer's job.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ # A node this package emits, addressed by the key the reader chose.
19
+ REF_CONFIG = "config"
20
+ # A class node graphify already made. May not exist: a message class outside the indexed tree
21
+ # is the normal case, not an error.
22
+ REF_PHP_CLASS = "php_class"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class NodeRef:
27
+ kind: str
28
+ value: str
29
+
30
+ @staticmethod
31
+ def config(key: str) -> NodeRef:
32
+ return NodeRef(REF_CONFIG, key)
33
+
34
+ @staticmethod
35
+ def php_class(fqn: str) -> NodeRef:
36
+ return NodeRef(REF_PHP_CLASS, fqn)
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ConfigNode:
41
+ """A node to add to the graph if it is not already there.
42
+
43
+ `key` is stable across builds and across files: two `messenger.yaml` files routing to
44
+ `cron` describe one transport, and emitting it twice would split a consumer's view of who
45
+ sends to it.
46
+ """
47
+
48
+ key: str
49
+ label: str
50
+ kind: str # config_file | transport | env_var
51
+ source_file: str = ""
52
+ source_location: str = ""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ConfigEdge:
57
+ source: NodeRef
58
+ target: NodeRef
59
+ relation: str
60
+ source_file: str = ""
61
+ source_location: str = ""
62
+
63
+
64
+ @dataclass
65
+ class FactSet:
66
+ """One reader's answer for one document.
67
+
68
+ `unresolved` carries the reader's own reason vocabulary. The resolver prefixes it with the
69
+ reader name, so two readers may use the same word without merging into one meaningless
70
+ count.
71
+ """
72
+
73
+ nodes: list[ConfigNode] = field(default_factory=list)
74
+ edges: list[ConfigEdge] = field(default_factory=list)
75
+ unresolved: dict[str, int] = field(default_factory=dict)
76
+
77
+ def unresolvable(self, reason: str, count: int = 1) -> None:
78
+ self.unresolved[reason] = self.unresolved.get(reason, 0) + count
79
+
80
+ def extend(self, other: FactSet) -> None:
81
+ self.nodes.extend(other.nodes)
82
+ self.edges.extend(other.edges)
83
+ for reason, count in other.unresolved.items():
84
+ self.unresolvable(reason, count)
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class ConfigDocument:
89
+ """One parsed YAML document, with the path it came from.
90
+
91
+ `path` is relative to the repository root, because it becomes a node label and an absolute
92
+ build path would make the same file produce a different node on another machine — the
93
+ graph is published and compared across machines.
94
+ """
95
+
96
+ path: str
97
+ data: dict
@@ -0,0 +1,142 @@
1
+ """Message routing: which transport carries which message class.
2
+
3
+ Nothing in PHP states this. The sender calls `$bus->dispatch($message)` and the handler declares
4
+ `__invoke(TimezoneInitializedMessage $m)`; the transport between them exists only as a key in
5
+ `messenger.yaml`. A graph built from PHP alone has the sender and the handler as unconnected
6
+ islands, and `rg 'TimezoneInitializedMessage'` returns all three files without saying which is
7
+ which.
8
+
9
+ The reader emits the transport as a node in its own right and the message class as the edge
10
+ source, so "what does `cron` carry" and "where does this message go" are the same lookup from
11
+ two ends.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from collections.abc import Iterator
18
+
19
+ from .document import file_key
20
+ from .facts import ConfigDocument, ConfigEdge, ConfigNode, FactSet, NodeRef
21
+
22
+ RELATION_ROUTED_TO = "routed_to"
23
+ RELATION_DECLARES = "declares_transport"
24
+
25
+ REASON_NOT_A_CLASS = "routing_key_is_not_a_class"
26
+ REASON_NO_TRANSPORT = "routing_entry_names_no_transport"
27
+
28
+ # Symfony accepts `'App\Foo': async`, `'App\Foo': [async, audit]` and
29
+ # `'App\Foo': {senders: [async]}`. The third form is the documented long form of the first two.
30
+ _SENDERS_KEY = "senders"
31
+
32
+ # A routing key is a class or interface FQN. Anything else in that position — a `*` wildcard
33
+ # included — is a Symfony feature this reader does not model, and guessing would put a
34
+ # fabricated class on the edge.
35
+ _CLASS_KEY = re.compile(
36
+ r"^\\?[A-Za-z_\x80-\xff][\w\x80-\xff]*(\\[A-Za-z_\x80-\xff][\w\x80-\xff]*)*$"
37
+ )
38
+
39
+ # How deep to look for a `messenger` block. Symfony nests it at `framework.messenger` and under
40
+ # an environment guard at `when@test.framework.messenger`; three is one more than the deepest
41
+ # real form, and the bound is what keeps a large document from being walked in full.
42
+ _MAX_DEPTH = 3
43
+
44
+
45
+ class MessageRoutingReader:
46
+ """Reads `messenger` routing tables out of one document."""
47
+
48
+ name = "message_routing"
49
+
50
+ def read(self, document: ConfigDocument) -> FactSet:
51
+ facts = FactSet()
52
+ tables = list(self._routing_tables(document.data))
53
+ if not tables:
54
+ return facts
55
+
56
+ file_ref = NodeRef.config(file_key(document.path))
57
+ for table in tables:
58
+ for key, value in table.items():
59
+ transports = list(self._transports(value))
60
+ if not transports:
61
+ facts.unresolvable(REASON_NO_TRANSPORT)
62
+ continue
63
+ for transport in transports:
64
+ node_key = transport_key(transport)
65
+ facts.nodes.append(
66
+ ConfigNode(
67
+ key=node_key,
68
+ label=transport,
69
+ kind="transport",
70
+ source_file=document.path,
71
+ )
72
+ )
73
+ # The file-to-transport edge is written whether or not the message class is
74
+ # in the graph. A transport whose message lives in a package this build did
75
+ # not index is still a transport this application declares, and dropping the
76
+ # node would report the application as having fewer than it has.
77
+ facts.edges.append(
78
+ ConfigEdge(
79
+ source=file_ref,
80
+ target=NodeRef.config(node_key),
81
+ relation=RELATION_DECLARES,
82
+ source_file=document.path,
83
+ )
84
+ )
85
+ class_fqn = str(key).lstrip("\\")
86
+ if not _CLASS_KEY.match(class_fqn):
87
+ facts.unresolvable(REASON_NOT_A_CLASS)
88
+ continue
89
+ facts.edges.append(
90
+ ConfigEdge(
91
+ source=NodeRef.php_class(class_fqn),
92
+ target=NodeRef.config(node_key),
93
+ relation=RELATION_ROUTED_TO,
94
+ source_file=document.path,
95
+ )
96
+ )
97
+ return facts
98
+
99
+ def _routing_tables(self, data: dict) -> Iterator[dict]:
100
+ """Every `routing` mapping this document declares.
101
+
102
+ Both shapes are accepted because both occur: the full `framework.messenger.routing`
103
+ path an application file uses, and a bare top-level `routing` in a file that is already
104
+ scoped to messenger by an import.
105
+ """
106
+ top = data.get("routing")
107
+ if isinstance(top, dict):
108
+ yield top
109
+ for block in self._messenger_blocks(data, _MAX_DEPTH):
110
+ table = block.get("routing")
111
+ if isinstance(table, dict):
112
+ yield table
113
+
114
+ def _messenger_blocks(self, node, depth: int) -> Iterator[dict]:
115
+ if depth < 0 or not isinstance(node, dict):
116
+ return
117
+ for key, value in node.items():
118
+ if not isinstance(value, dict):
119
+ continue
120
+ if key == "messenger":
121
+ yield value
122
+ else:
123
+ yield from self._messenger_blocks(value, depth - 1)
124
+
125
+ def _transports(self, value) -> Iterator[str]:
126
+ if isinstance(value, str):
127
+ yield value
128
+ elif isinstance(value, list):
129
+ for item in value:
130
+ if isinstance(item, str):
131
+ yield item
132
+ elif isinstance(value, dict):
133
+ yield from self._transports(value.get(_SENDERS_KEY))
134
+
135
+
136
+ def transport_key(name: str) -> str:
137
+ """Node key for a transport, shared by every file that names it.
138
+
139
+ Two files routing to `cron` describe one transport. Keying by name is what keeps them one
140
+ node, so "everything on cron" is a single neighbourhood rather than one per file.
141
+ """
142
+ return f"cfg::transport::{name}"
@@ -0,0 +1,23 @@
1
+ """The one interface a configuration fact has to implement.
2
+
3
+ A reader is handed a parsed document and returns facts. It does not know where the file came
4
+ from, how it was parsed, what a graph node is, or which other readers exist. That is what makes
5
+ a third fact — scheduler definitions, workflow transitions — a new class in this package rather
6
+ than a branch in an existing one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from .facts import ConfigDocument, FactSet
14
+
15
+
16
+ @runtime_checkable
17
+ class ConfigFactReader(Protocol):
18
+ name: str
19
+
20
+ def read(self, document: ConfigDocument) -> FactSet:
21
+ """Facts this reader finds in one document. A document with none of its fact yields an
22
+ empty `FactSet`, never an exception: most configuration files hold neither routing nor
23
+ an environment reference, and that is not a condition worth reporting."""
@@ -0,0 +1,127 @@
1
+ """Parsing one YAML document, under the assumptions a configuration directory earns.
2
+
3
+ Symfony configuration is not plain YAML. It carries tags the framework resolves at container
4
+ build time — `!php/const App\\Kernel::VERSION`, `!php/enum App\\Status::Draft`, `!tagged_iterator`
5
+ — and a parser that resolves tags would need PHP to mean anything by them. They are kept as
6
+ opaque strings: the identifier inside is still text this package may index, and nothing here
7
+ ever has to decide what it evaluates to.
8
+
9
+ Three refusals, each because a configuration directory is read from a repository this package
10
+ does not control:
11
+
12
+ - the loader is `SafeLoader`, never `Loader` or `UnsafeLoader`, so no tag can construct a
13
+ Python object;
14
+ - alias resolution is capped. PyYAML composes an alias to the node it already built rather
15
+ than to a copy, so the classic billion-laughs file does not multiply memory here — measured,
16
+ a four-level 9-way alias bomb parses fine. The cap bounds compose time against a file with
17
+ far more references than a hand-written configuration has, and it is a stated limit rather
18
+ than a guess about what PyYAML will keep doing;
19
+ - a file above the size cap is refused unread rather than parsed, so one pathological file
20
+ cannot decide how long a build takes.
21
+
22
+ A refusal is counted and named. It never raises out of this module: graphify swallows a
23
+ resolver exception into a log line nobody reads, so a malformed file must not be able to take
24
+ the whole pass down with it.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ import yaml
33
+
34
+ # A Symfony configuration file is a few kilobytes. The cap is generous enough that no real one
35
+ # meets it and low enough that a checked-in fixture or generated dump cannot dominate a build.
36
+ MAX_BYTES = 2 * 1024 * 1024
37
+
38
+ # Aliases in real Symfony configuration number in the tens — merge keys in `services.yaml` and
39
+ # little else. Anything past this is generated or hostile, and either way is not something this
40
+ # package needs to finish parsing.
41
+ MAX_ALIASES = 500
42
+
43
+ REASON_TOO_LARGE = "file_too_large"
44
+ REASON_UNREADABLE = "file_unreadable"
45
+ REASON_MALFORMED = "yaml_malformed"
46
+ REASON_TOO_DEEP = "yaml_nested_too_deep"
47
+ REASON_ALIAS_BUDGET = "yaml_alias_budget_exceeded"
48
+ REASON_NOT_A_MAPPING = "document_not_a_mapping"
49
+
50
+
51
+ class _AliasBudgetExceeded(Exception):
52
+ pass
53
+
54
+
55
+ class ConfigSafeLoader(yaml.SafeLoader):
56
+ """`SafeLoader` that tolerates Symfony's tags and refuses to expand aliases without bound."""
57
+
58
+ def __init__(self, stream) -> None:
59
+ super().__init__(stream)
60
+ self._aliases_seen = 0
61
+
62
+ def compose_node(self, parent, index):
63
+ # Every `*ref` passes through here. Counting events rather than measuring the result
64
+ # keeps the bound ahead of the expansion instead of behind it.
65
+ if self.check_event(yaml.events.AliasEvent):
66
+ self._aliases_seen += 1
67
+ if self._aliases_seen > MAX_ALIASES:
68
+ raise _AliasBudgetExceeded(f"more than {MAX_ALIASES} alias expansions")
69
+ return super().compose_node(parent, index)
70
+
71
+
72
+ def _opaque(loader: ConfigSafeLoader, tag_suffix: str, node) -> str:
73
+ """Any tag the framework owns, kept as the text it was written as.
74
+
75
+ `!php/const App\\Kernel::VERSION` becomes `!php/const App\\Kernel::VERSION`. A reader that
76
+ wants the class name can read it; nothing here claims to know the value, which is the only
77
+ honest answer without running PHP.
78
+ """
79
+ if isinstance(node, yaml.ScalarNode):
80
+ return f"{node.tag} {loader.construct_scalar(node)}".strip()
81
+ # A sequence or mapping under a custom tag carries no scalar to keep. The tag alone records
82
+ # that something framework-specific was here without inventing a structure for it.
83
+ return str(node.tag)
84
+
85
+
86
+ ConfigSafeLoader.add_multi_constructor("", _opaque)
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class ParseResult:
91
+ """Either a mapping, or the named reason there is none. Never both, never an exception."""
92
+
93
+ data: dict | None
94
+ reason: str = ""
95
+
96
+
97
+ class SafeDocumentParser:
98
+ """Reads one file and returns its top-level mapping, or why it did not."""
99
+
100
+ def parse(self, path: Path) -> ParseResult:
101
+ try:
102
+ if path.stat().st_size > MAX_BYTES:
103
+ return ParseResult(None, REASON_TOO_LARGE)
104
+ text = path.read_text(encoding="utf-8", errors="replace")
105
+ except OSError:
106
+ return ParseResult(None, REASON_UNREADABLE)
107
+
108
+ try:
109
+ data = yaml.load(text, Loader=ConfigSafeLoader) # noqa: S506 - restricted loader
110
+ except _AliasBudgetExceeded:
111
+ return ParseResult(None, REASON_ALIAS_BUDGET)
112
+ except yaml.YAMLError:
113
+ # A hand-edited configuration file with a bad indent is ordinary. It costs this one
114
+ # file, and the count is what makes it visible instead of silent.
115
+ return ParseResult(None, REASON_MALFORMED)
116
+ except RecursionError:
117
+ # PyYAML composes one stack frame per nesting level, so ten kilobytes of `[[[[` is
118
+ # enough to overflow it. That is not a YAMLError, and uncaught it would take the
119
+ # whole pass down for one file.
120
+ return ParseResult(None, REASON_TOO_DEEP)
121
+
122
+ if not isinstance(data, dict):
123
+ # An empty file parses to `None` and a list-rooted file to a list. Neither is a
124
+ # Symfony configuration document, and both are normal enough to count rather than
125
+ # complain about.
126
+ return ParseResult(None, REASON_NOT_A_MAPPING)
127
+ return ParseResult(data)