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