modwire 2.2.0__tar.gz → 2.3.0__tar.gz
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.
- {modwire-2.2.0 → modwire-2.3.0}/PKG-INFO +37 -1
- {modwire-2.2.0 → modwire-2.3.0}/README.md +36 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/_version.py +3 -3
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/__init__.py +2 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/analyzers.py +20 -13
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/config.py +44 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/matching.py +44 -8
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/policy.py +8 -2
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire.egg-info/PKG-INFO +37 -1
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire.egg-info/SOURCES.txt +2 -0
- modwire-2.3.0/src/modwire.egg-info/scm_file_list.json +85 -0
- modwire-2.3.0/src/modwire.egg-info/scm_version.json +8 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/test_architecture_api.py +95 -0
- {modwire-2.2.0 → modwire-2.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/.github/workflows/ci.yml +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/.github/workflows/large-repo-fixtures.yml +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/.github/workflows/release.yml +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/.gitignore +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/CONTRIBUTING.md +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/LICENSE +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/docs/wiki/Development-checks.md +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/docs/wiki/Home.md +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/docs/wiki/Reporting-bugs.md +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/docs/wiki/Requesting-features.md +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/pyproject.toml +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/setup.cfg +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/show_test_source_files.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/__init__.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/insights.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/render.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/violations.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/callables.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/definitions.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/exports.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/__init__.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/cache.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/manifest.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/models.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/roots.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/serialization.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/service.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/__init__.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/base.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/loader.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/php.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/python.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/resources.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/scripts/__init__.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/scripts/php_extractor.php +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/scripts/python_extractor.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/scripts/typescript_extractor.js +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/typescript.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/graph.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/metadata.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/shape/__init__.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/shape/config.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/shape/evaluator.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/shape/rules.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/shape/violations.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/testing/__init__.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire/testing/factories.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire.egg-info/dependency_links.txt +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire.egg-info/requires.txt +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/src/modwire.egg-info/top_level.txt +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/php/ignored/generated.php +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/php/src/application/use_cases/activate.php +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/php/src/domain/model/user.php +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/php/src/domain/services/policy.php +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/php/src/interfaces/http/controller.php +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/python/ignored/generated.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/python/src/application/use_cases/activate.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/python/src/domain/model/user.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/python/src/domain/services/policy.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/python/src/interfaces/http/controller.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/ignored/generated.ts +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/application/use_cases/activate.ts +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/domain/model/profile.tsx +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/domain/model/user.ts +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/domain/services/audit.js +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/domain/services/policy.ts +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/interfaces/http/controller.ts +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/interfaces/http/view.jsx +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/large_projects/README.md +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/large_projects/fixtures.json +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/large_projects/run_large_project_fixtures.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/test_api.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/tests/test_standalone.py +0 -0
- {modwire-2.2.0 → modwire-2.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: modwire
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Extract source-code dependencies and build dependency graphs.
|
|
5
5
|
Author: Tomasz Szpak
|
|
6
6
|
License-Expression: MIT
|
|
@@ -136,6 +136,7 @@ from modwire import extract_code
|
|
|
136
136
|
from modwire.architecture import (
|
|
137
137
|
ArchitectureBoundaryRule,
|
|
138
138
|
ArchitectureConfig,
|
|
139
|
+
ArchitectureFlowRealm,
|
|
139
140
|
ArchitectureFlowRules,
|
|
140
141
|
ArchitecturePolicyEvaluator,
|
|
141
142
|
ArchitectureRules,
|
|
@@ -176,6 +177,41 @@ violations = ArchitecturePolicyEvaluator().evaluate(code_map.graph, config)
|
|
|
176
177
|
print(render_violations(tuple(violations)))
|
|
177
178
|
```
|
|
178
179
|
|
|
180
|
+
For repositories with more than one module ladder, configure flow realms and run
|
|
181
|
+
the same analyzers across each module tag:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
config = ArchitectureConfig(
|
|
185
|
+
language="python",
|
|
186
|
+
architecture_root="src",
|
|
187
|
+
rules=ArchitectureRules(
|
|
188
|
+
tags=(
|
|
189
|
+
ArchitectureTagRule(name="backend_module", match="features/*"),
|
|
190
|
+
ArchitectureTagRule(name="gui_page", match="app/pages/*"),
|
|
191
|
+
ArchitectureTagRule(name="domain", match="features/*/domain"),
|
|
192
|
+
ArchitectureTagRule(name="application", match="features/*/application"),
|
|
193
|
+
),
|
|
194
|
+
flow=ArchitectureFlowRules(
|
|
195
|
+
realms=(
|
|
196
|
+
ArchitectureFlowRealm(
|
|
197
|
+
name="backend",
|
|
198
|
+
module_tag="backend_module",
|
|
199
|
+
layers=("domain", "application"),
|
|
200
|
+
),
|
|
201
|
+
ArchitectureFlowRealm(
|
|
202
|
+
name="gui",
|
|
203
|
+
module_tag="gui_page",
|
|
204
|
+
),
|
|
205
|
+
),
|
|
206
|
+
analyzers=("backward-flow", "no-cycles"),
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`backward-flow` evaluates realms with layers and skips layerless realms; scoped
|
|
213
|
+
analyzers such as `no-cycles` evaluate every configured realm.
|
|
214
|
+
|
|
179
215
|
Architecture insight helpers summarize ownership and graph pressure:
|
|
180
216
|
|
|
181
217
|
```python
|
|
@@ -103,6 +103,7 @@ from modwire import extract_code
|
|
|
103
103
|
from modwire.architecture import (
|
|
104
104
|
ArchitectureBoundaryRule,
|
|
105
105
|
ArchitectureConfig,
|
|
106
|
+
ArchitectureFlowRealm,
|
|
106
107
|
ArchitectureFlowRules,
|
|
107
108
|
ArchitecturePolicyEvaluator,
|
|
108
109
|
ArchitectureRules,
|
|
@@ -143,6 +144,41 @@ violations = ArchitecturePolicyEvaluator().evaluate(code_map.graph, config)
|
|
|
143
144
|
print(render_violations(tuple(violations)))
|
|
144
145
|
```
|
|
145
146
|
|
|
147
|
+
For repositories with more than one module ladder, configure flow realms and run
|
|
148
|
+
the same analyzers across each module tag:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
config = ArchitectureConfig(
|
|
152
|
+
language="python",
|
|
153
|
+
architecture_root="src",
|
|
154
|
+
rules=ArchitectureRules(
|
|
155
|
+
tags=(
|
|
156
|
+
ArchitectureTagRule(name="backend_module", match="features/*"),
|
|
157
|
+
ArchitectureTagRule(name="gui_page", match="app/pages/*"),
|
|
158
|
+
ArchitectureTagRule(name="domain", match="features/*/domain"),
|
|
159
|
+
ArchitectureTagRule(name="application", match="features/*/application"),
|
|
160
|
+
),
|
|
161
|
+
flow=ArchitectureFlowRules(
|
|
162
|
+
realms=(
|
|
163
|
+
ArchitectureFlowRealm(
|
|
164
|
+
name="backend",
|
|
165
|
+
module_tag="backend_module",
|
|
166
|
+
layers=("domain", "application"),
|
|
167
|
+
),
|
|
168
|
+
ArchitectureFlowRealm(
|
|
169
|
+
name="gui",
|
|
170
|
+
module_tag="gui_page",
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
analyzers=("backward-flow", "no-cycles"),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`backward-flow` evaluates realms with layers and skips layerless realms; scoped
|
|
180
|
+
analyzers such as `no-cycles` evaluate every configured realm.
|
|
181
|
+
|
|
146
182
|
Architecture insight helpers summarize ownership and graph pressure:
|
|
147
183
|
|
|
148
184
|
```python
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 3, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gdb0b34253'
|
|
@@ -4,6 +4,7 @@ from .config import (
|
|
|
4
4
|
ArchitectureConfig,
|
|
5
5
|
ArchitectureConfigError,
|
|
6
6
|
ArchitectureConfigIssue,
|
|
7
|
+
ArchitectureFlowRealm,
|
|
7
8
|
ArchitectureFlowRules,
|
|
8
9
|
ArchitectureRules,
|
|
9
10
|
ArchitectureTagRule,
|
|
@@ -32,6 +33,7 @@ __all__ = [
|
|
|
32
33
|
"ArchitectureConfig",
|
|
33
34
|
"ArchitectureConfigError",
|
|
34
35
|
"ArchitectureConfigIssue",
|
|
36
|
+
"ArchitectureFlowRealm",
|
|
35
37
|
"ArchitectureFlowRules",
|
|
36
38
|
"ArchitectureMap",
|
|
37
39
|
"ArchitecturePolicyEvaluator",
|
|
@@ -40,9 +40,9 @@ def analyzer_title(name: str) -> str:
|
|
|
40
40
|
return _ANALYZERS[name].title
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
def run_analyzer(name: str, graph, tags, config):
|
|
43
|
+
def run_analyzer(name: str, graph, tags, config, realm=None):
|
|
44
44
|
analyzer = _ANALYZERS[name]
|
|
45
|
-
return analyzer.run(analyzer.name, graph, tags, config)
|
|
45
|
+
return analyzer.run(analyzer.name, graph, tags, config, realm)
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def _roots(graph):
|
|
@@ -65,12 +65,18 @@ def _dedupe(violations):
|
|
|
65
65
|
return unique
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
def _flow(name, path, index, message):
|
|
69
|
-
|
|
68
|
+
def _flow(name, path, index, message, realm=None):
|
|
69
|
+
realm_name = "" if realm is None else getattr(realm, "name", "") or getattr(
|
|
70
|
+
realm,
|
|
71
|
+
"module_tag",
|
|
72
|
+
"",
|
|
73
|
+
)
|
|
74
|
+
rule_name = f"analyzer:{realm_name}:{name}" if realm_name else f"analyzer:{name}"
|
|
75
|
+
return FlowViolation(name, tuple(path), index, rule_name, message)
|
|
70
76
|
|
|
71
77
|
|
|
72
|
-
def _backward_flow(name, graph, tags, config):
|
|
73
|
-
layers = config.rules.flow.layers
|
|
78
|
+
def _backward_flow(name, graph, tags, config, realm):
|
|
79
|
+
layers = realm.layers if realm is not None else config.rules.flow.layers
|
|
74
80
|
|
|
75
81
|
def layer(node):
|
|
76
82
|
return next((i for i, layer_name in enumerate(layers) if layer_name in tags.get(node, set())), None)
|
|
@@ -85,7 +91,7 @@ def _backward_flow(name, graph, tags, config):
|
|
|
85
91
|
for edge in graph.outgoing(node):
|
|
86
92
|
next_layer = layer(edge.to_id)
|
|
87
93
|
if current_layer is not None and next_layer is not None and next_layer < current_layer:
|
|
88
|
-
violations.append(_flow(name, [*path, edge.to_id], len(path), "layer order violated"))
|
|
94
|
+
violations.append(_flow(name, [*path, edge.to_id], len(path), "layer order violated", realm))
|
|
89
95
|
continue
|
|
90
96
|
walk(edge.to_id, current_layer if next_layer is None else next_layer, [*path, edge.to_id])
|
|
91
97
|
|
|
@@ -94,8 +100,8 @@ def _backward_flow(name, graph, tags, config):
|
|
|
94
100
|
return _dedupe(violations)
|
|
95
101
|
|
|
96
102
|
|
|
97
|
-
def _no_reentry(name, graph, tags, config):
|
|
98
|
-
module_tag = config.rules.flow.module_tag
|
|
103
|
+
def _no_reentry(name, graph, tags, config, realm):
|
|
104
|
+
module_tag = realm.module_tag if realm is not None else config.rules.flow.module_tag
|
|
99
105
|
violations = []
|
|
100
106
|
seen = set()
|
|
101
107
|
|
|
@@ -107,7 +113,7 @@ def _no_reentry(name, graph, tags, config):
|
|
|
107
113
|
inside = module_tag in tags.get(edge.to_id, set())
|
|
108
114
|
next_state = 1 if state == 0 and inside else 2 if state == 1 and not inside else state
|
|
109
115
|
if state == 2 and inside:
|
|
110
|
-
violations.append(_flow(name, [*path, edge.to_id], len(path), "module layer re-entered after exit"))
|
|
116
|
+
violations.append(_flow(name, [*path, edge.to_id], len(path), "module layer re-entered after exit", realm))
|
|
111
117
|
continue
|
|
112
118
|
walk(edge.to_id, next_state, [*path, edge.to_id])
|
|
113
119
|
|
|
@@ -116,8 +122,9 @@ def _no_reentry(name, graph, tags, config):
|
|
|
116
122
|
return _dedupe(violations)
|
|
117
123
|
|
|
118
124
|
|
|
119
|
-
def _no_cycles(name, graph, tags, config):
|
|
120
|
-
|
|
125
|
+
def _no_cycles(name, graph, tags, config, realm):
|
|
126
|
+
module_tag = realm.module_tag if realm is not None else config.rules.flow.module_tag
|
|
127
|
+
scoped = {node for node, node_tags in tags.items() if module_tag in node_tags}
|
|
121
128
|
seen, stack, index, emitted, violations = set(), [], {}, set(), []
|
|
122
129
|
|
|
123
130
|
def canonical(cycle):
|
|
@@ -137,7 +144,7 @@ def _no_cycles(name, graph, tags, config):
|
|
|
137
144
|
key = canonical(cycle)
|
|
138
145
|
if key not in emitted:
|
|
139
146
|
emitted.add(key)
|
|
140
|
-
violations.append(_flow(name, cycle, len(cycle) - 1, "module cycle detected"))
|
|
147
|
+
violations.append(_flow(name, cycle, len(cycle) - 1, "module cycle detected", realm))
|
|
141
148
|
elif edge.to_id not in seen:
|
|
142
149
|
dfs(edge.to_id)
|
|
143
150
|
stack.pop()
|
|
@@ -31,11 +31,20 @@ class ArchitectureBoundaryRule(BaseModel):
|
|
|
31
31
|
allow_same_match: bool = False
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
class ArchitectureFlowRealm(BaseModel):
|
|
35
|
+
model_config = ConfigDict(frozen=True, from_attributes=True)
|
|
36
|
+
|
|
37
|
+
name: str = ""
|
|
38
|
+
module_tag: str = ""
|
|
39
|
+
layers: tuple[str, ...] = ()
|
|
40
|
+
|
|
41
|
+
|
|
34
42
|
class ArchitectureFlowRules(BaseModel):
|
|
35
43
|
model_config = ConfigDict(frozen=True, from_attributes=True)
|
|
36
44
|
|
|
37
45
|
layers: tuple[str, ...] = ()
|
|
38
46
|
module_tag: str = ""
|
|
47
|
+
realms: tuple[ArchitectureFlowRealm, ...] = ()
|
|
39
48
|
analyzers: tuple[str, ...] = ()
|
|
40
49
|
|
|
41
50
|
@field_validator("analyzers")
|
|
@@ -50,6 +59,28 @@ class ArchitectureFlowRules(BaseModel):
|
|
|
50
59
|
|
|
51
60
|
@model_validator(mode="after")
|
|
52
61
|
def analyzers_have_required_config(self) -> ArchitectureFlowRules:
|
|
62
|
+
if self.realms:
|
|
63
|
+
if "backward-flow" in self.analyzers and not any(
|
|
64
|
+
realm.layers for realm in self.realms
|
|
65
|
+
):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"backward-flow requires at least one rules.flow.realms entry "
|
|
68
|
+
"with layers"
|
|
69
|
+
)
|
|
70
|
+
scoped_analyzers = {"no-reentry", "no-cycles"}.intersection(
|
|
71
|
+
self.analyzers
|
|
72
|
+
)
|
|
73
|
+
missing_module_tag = tuple(
|
|
74
|
+
index for index, realm in enumerate(self.realms) if not realm.module_tag
|
|
75
|
+
)
|
|
76
|
+
if scoped_analyzers and missing_module_tag:
|
|
77
|
+
names = ", ".join(sorted(scoped_analyzers))
|
|
78
|
+
indexes = ", ".join(str(index) for index in missing_module_tag)
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"{names} require rules.flow.realms module_tag values "
|
|
81
|
+
f"(missing at indexes: {indexes})"
|
|
82
|
+
)
|
|
83
|
+
return self
|
|
53
84
|
if "backward-flow" in self.analyzers and not self.layers:
|
|
54
85
|
raise ValueError("backward-flow requires rules.flow.layers")
|
|
55
86
|
scoped_analyzers = {"no-reentry", "no-cycles"}.intersection(self.analyzers)
|
|
@@ -114,13 +145,26 @@ def validate_policy_config(config) -> ArchitectureConfig:
|
|
|
114
145
|
) from error
|
|
115
146
|
|
|
116
147
|
|
|
148
|
+
def flow_realms(flow: ArchitectureFlowRules) -> tuple[ArchitectureFlowRealm, ...]:
|
|
149
|
+
if flow.realms:
|
|
150
|
+
return flow.realms
|
|
151
|
+
return (
|
|
152
|
+
ArchitectureFlowRealm(
|
|
153
|
+
module_tag=flow.module_tag,
|
|
154
|
+
layers=flow.layers,
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
117
159
|
__all__ = [
|
|
118
160
|
"ArchitectureBoundaryRule",
|
|
119
161
|
"ArchitectureConfig",
|
|
120
162
|
"ArchitectureConfigError",
|
|
121
163
|
"ArchitectureConfigIssue",
|
|
164
|
+
"ArchitectureFlowRealm",
|
|
122
165
|
"ArchitectureFlowRules",
|
|
123
166
|
"ArchitectureRules",
|
|
124
167
|
"ArchitectureTagRule",
|
|
168
|
+
"flow_realms",
|
|
125
169
|
"validate_policy_config",
|
|
126
170
|
]
|
|
@@ -43,14 +43,31 @@ class TagMatcher:
|
|
|
43
43
|
self.tags = tuple(getattr(config.rules, "tags", ()))
|
|
44
44
|
|
|
45
45
|
def match(self, node_id: str, name: str, *, scope: bool = True) -> TagMatch | None:
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
matches = self.matches(node_id, name, scope=scope)
|
|
47
|
+
if matches:
|
|
48
|
+
return matches[0]
|
|
49
|
+
if self._has_tag(name):
|
|
50
|
+
return None
|
|
51
|
+
pattern = name
|
|
48
52
|
return self.match_pattern(
|
|
49
53
|
node_id,
|
|
50
54
|
pattern,
|
|
51
55
|
name=name,
|
|
52
56
|
scope=scope,
|
|
53
|
-
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def matches(
|
|
60
|
+
self,
|
|
61
|
+
node_id: str,
|
|
62
|
+
name: str,
|
|
63
|
+
*,
|
|
64
|
+
scope: bool = True,
|
|
65
|
+
) -> tuple[TagMatch, ...]:
|
|
66
|
+
return tuple(
|
|
67
|
+
match
|
|
68
|
+
for tag in self.tags
|
|
69
|
+
if tag.name == name
|
|
70
|
+
if (match := self._match_tag(node_id, tag, scope=scope)) is not None
|
|
54
71
|
)
|
|
55
72
|
|
|
56
73
|
def match_pattern(
|
|
@@ -80,9 +97,19 @@ class TagMatcher:
|
|
|
80
97
|
)
|
|
81
98
|
return None
|
|
82
99
|
|
|
83
|
-
def first_match(
|
|
100
|
+
def first_match(
|
|
101
|
+
self,
|
|
102
|
+
node_id: str,
|
|
103
|
+
names: tuple[str, ...],
|
|
104
|
+
*,
|
|
105
|
+
scope: bool = True,
|
|
106
|
+
) -> TagMatch | None:
|
|
84
107
|
return next(
|
|
85
|
-
(
|
|
108
|
+
(
|
|
109
|
+
tag_match
|
|
110
|
+
for name in names
|
|
111
|
+
if (tag_match := self.match(node_id, name, scope=scope)) is not None
|
|
112
|
+
),
|
|
86
113
|
None,
|
|
87
114
|
)
|
|
88
115
|
|
|
@@ -90,7 +117,7 @@ class TagMatcher:
|
|
|
90
117
|
return tuple(
|
|
91
118
|
match
|
|
92
119
|
for tag in self.tags
|
|
93
|
-
if (match := self.
|
|
120
|
+
if (match := self._match_tag(node_id, tag)) is not None
|
|
94
121
|
)
|
|
95
122
|
|
|
96
123
|
def map_code_map(self, code_map) -> TagMap:
|
|
@@ -111,8 +138,17 @@ class TagMatcher:
|
|
|
111
138
|
return path[len(architecture_root) + 1 :]
|
|
112
139
|
return path
|
|
113
140
|
|
|
114
|
-
def
|
|
115
|
-
return
|
|
141
|
+
def _match_tag(self, node_id: str, tag, *, scope: bool = True) -> TagMatch | None:
|
|
142
|
+
return self.match_pattern(
|
|
143
|
+
node_id,
|
|
144
|
+
tag.match,
|
|
145
|
+
name=tag.name,
|
|
146
|
+
scope=scope,
|
|
147
|
+
exclude=tag.excluded_patterns,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _has_tag(self, name: str) -> bool:
|
|
151
|
+
return any(tag.name == name for tag in self.tags)
|
|
116
152
|
|
|
117
153
|
|
|
118
154
|
def _normalized_patterns(language, pattern, config):
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from modwire.graph import DependencyGraph
|
|
4
4
|
|
|
5
5
|
from .analyzers import run_analyzer
|
|
6
|
-
from .config import validate_policy_config
|
|
6
|
+
from .config import flow_realms, validate_policy_config
|
|
7
7
|
from .matching import TagMatcher
|
|
8
8
|
from .violations import EdgeRuleViolation, FlowViolation
|
|
9
9
|
|
|
@@ -14,7 +14,13 @@ class ArchitecturePolicyEvaluator:
|
|
|
14
14
|
tags = _tags(graph.node_ids(), config)
|
|
15
15
|
violations: list[EdgeRuleViolation | FlowViolation] = _edge_violations(graph, config)
|
|
16
16
|
for analyzer_name in config.rules.flow.analyzers:
|
|
17
|
-
|
|
17
|
+
for realm in flow_realms(config.rules.flow):
|
|
18
|
+
if analyzer_name == "backward-flow" and not realm.layers:
|
|
19
|
+
continue
|
|
20
|
+
analyzer_realm = realm if config.rules.flow.realms else None
|
|
21
|
+
violations.extend(
|
|
22
|
+
run_analyzer(analyzer_name, graph, tags, config, analyzer_realm)
|
|
23
|
+
)
|
|
18
24
|
return violations
|
|
19
25
|
|
|
20
26
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: modwire
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Extract source-code dependencies and build dependency graphs.
|
|
5
5
|
Author: Tomasz Szpak
|
|
6
6
|
License-Expression: MIT
|
|
@@ -136,6 +136,7 @@ from modwire import extract_code
|
|
|
136
136
|
from modwire.architecture import (
|
|
137
137
|
ArchitectureBoundaryRule,
|
|
138
138
|
ArchitectureConfig,
|
|
139
|
+
ArchitectureFlowRealm,
|
|
139
140
|
ArchitectureFlowRules,
|
|
140
141
|
ArchitecturePolicyEvaluator,
|
|
141
142
|
ArchitectureRules,
|
|
@@ -176,6 +177,41 @@ violations = ArchitecturePolicyEvaluator().evaluate(code_map.graph, config)
|
|
|
176
177
|
print(render_violations(tuple(violations)))
|
|
177
178
|
```
|
|
178
179
|
|
|
180
|
+
For repositories with more than one module ladder, configure flow realms and run
|
|
181
|
+
the same analyzers across each module tag:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
config = ArchitectureConfig(
|
|
185
|
+
language="python",
|
|
186
|
+
architecture_root="src",
|
|
187
|
+
rules=ArchitectureRules(
|
|
188
|
+
tags=(
|
|
189
|
+
ArchitectureTagRule(name="backend_module", match="features/*"),
|
|
190
|
+
ArchitectureTagRule(name="gui_page", match="app/pages/*"),
|
|
191
|
+
ArchitectureTagRule(name="domain", match="features/*/domain"),
|
|
192
|
+
ArchitectureTagRule(name="application", match="features/*/application"),
|
|
193
|
+
),
|
|
194
|
+
flow=ArchitectureFlowRules(
|
|
195
|
+
realms=(
|
|
196
|
+
ArchitectureFlowRealm(
|
|
197
|
+
name="backend",
|
|
198
|
+
module_tag="backend_module",
|
|
199
|
+
layers=("domain", "application"),
|
|
200
|
+
),
|
|
201
|
+
ArchitectureFlowRealm(
|
|
202
|
+
name="gui",
|
|
203
|
+
module_tag="gui_page",
|
|
204
|
+
),
|
|
205
|
+
),
|
|
206
|
+
analyzers=("backward-flow", "no-cycles"),
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`backward-flow` evaluates realms with layers and skips layerless realms; scoped
|
|
213
|
+
analyzers such as `no-cycles` evaluate every configured realm.
|
|
214
|
+
|
|
179
215
|
Architecture insight helpers summarize ownership and graph pressure:
|
|
180
216
|
|
|
181
217
|
```python
|
|
@@ -27,6 +27,8 @@ src/modwire.egg-info/PKG-INFO
|
|
|
27
27
|
src/modwire.egg-info/SOURCES.txt
|
|
28
28
|
src/modwire.egg-info/dependency_links.txt
|
|
29
29
|
src/modwire.egg-info/requires.txt
|
|
30
|
+
src/modwire.egg-info/scm_file_list.json
|
|
31
|
+
src/modwire.egg-info/scm_version.json
|
|
30
32
|
src/modwire.egg-info/top_level.txt
|
|
31
33
|
src/modwire/architecture/__init__.py
|
|
32
34
|
src/modwire/architecture/analyzers.py
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [
|
|
3
|
+
"README.md",
|
|
4
|
+
"uv.lock",
|
|
5
|
+
"LICENSE",
|
|
6
|
+
"pyproject.toml",
|
|
7
|
+
"show_test_source_files.py",
|
|
8
|
+
"CONTRIBUTING.md",
|
|
9
|
+
".gitignore",
|
|
10
|
+
"docs/wiki/Reporting-bugs.md",
|
|
11
|
+
"docs/wiki/Development-checks.md",
|
|
12
|
+
"docs/wiki/Home.md",
|
|
13
|
+
"docs/wiki/Requesting-features.md",
|
|
14
|
+
"src/modwire/__init__.py",
|
|
15
|
+
"src/modwire/definitions.py",
|
|
16
|
+
"src/modwire/graph.py",
|
|
17
|
+
"src/modwire/metadata.py",
|
|
18
|
+
"src/modwire/exports.py",
|
|
19
|
+
"src/modwire/callables.py",
|
|
20
|
+
"src/modwire/extractors/__init__.py",
|
|
21
|
+
"src/modwire/extractors/typescript.py",
|
|
22
|
+
"src/modwire/extractors/resources.py",
|
|
23
|
+
"src/modwire/extractors/loader.py",
|
|
24
|
+
"src/modwire/extractors/php.py",
|
|
25
|
+
"src/modwire/extractors/python.py",
|
|
26
|
+
"src/modwire/extractors/base.py",
|
|
27
|
+
"src/modwire/extractors/scripts/python_extractor.py",
|
|
28
|
+
"src/modwire/extractors/scripts/__init__.py",
|
|
29
|
+
"src/modwire/extractors/scripts/php_extractor.php",
|
|
30
|
+
"src/modwire/extractors/scripts/typescript_extractor.js",
|
|
31
|
+
"src/modwire/architecture/__init__.py",
|
|
32
|
+
"src/modwire/architecture/violations.py",
|
|
33
|
+
"src/modwire/architecture/config.py",
|
|
34
|
+
"src/modwire/architecture/analyzers.py",
|
|
35
|
+
"src/modwire/architecture/render.py",
|
|
36
|
+
"src/modwire/architecture/insights.py",
|
|
37
|
+
"src/modwire/architecture/matching.py",
|
|
38
|
+
"src/modwire/architecture/policy.py",
|
|
39
|
+
"src/modwire/extraction/roots.py",
|
|
40
|
+
"src/modwire/extraction/__init__.py",
|
|
41
|
+
"src/modwire/extraction/models.py",
|
|
42
|
+
"src/modwire/extraction/serialization.py",
|
|
43
|
+
"src/modwire/extraction/manifest.py",
|
|
44
|
+
"src/modwire/extraction/cache.py",
|
|
45
|
+
"src/modwire/extraction/service.py",
|
|
46
|
+
"src/modwire/shape/rules.py",
|
|
47
|
+
"src/modwire/shape/__init__.py",
|
|
48
|
+
"src/modwire/shape/violations.py",
|
|
49
|
+
"src/modwire/shape/config.py",
|
|
50
|
+
"src/modwire/shape/evaluator.py",
|
|
51
|
+
"src/modwire/testing/__init__.py",
|
|
52
|
+
"src/modwire/testing/factories.py",
|
|
53
|
+
"tests/test_api.py",
|
|
54
|
+
"tests/test_standalone.py",
|
|
55
|
+
"tests/test_architecture_api.py",
|
|
56
|
+
"tests/large_projects/README.md",
|
|
57
|
+
"tests/large_projects/run_large_project_fixtures.py",
|
|
58
|
+
"tests/large_projects/fixtures.json",
|
|
59
|
+
"tests/apps/python/src/domain/model/user.py",
|
|
60
|
+
"tests/apps/python/src/domain/services/policy.py",
|
|
61
|
+
"tests/apps/python/src/interfaces/http/controller.py",
|
|
62
|
+
"tests/apps/python/src/application/use_cases/activate.py",
|
|
63
|
+
"tests/apps/python/ignored/generated.py",
|
|
64
|
+
"tests/apps/php/src/domain/model/user.php",
|
|
65
|
+
"tests/apps/php/src/domain/services/policy.php",
|
|
66
|
+
"tests/apps/php/src/interfaces/http/controller.php",
|
|
67
|
+
"tests/apps/php/src/application/use_cases/activate.php",
|
|
68
|
+
"tests/apps/php/ignored/generated.php",
|
|
69
|
+
"tests/apps/typescript/src/domain/model/user.ts",
|
|
70
|
+
"tests/apps/typescript/src/domain/model/profile.tsx",
|
|
71
|
+
"tests/apps/typescript/src/domain/services/policy.ts",
|
|
72
|
+
"tests/apps/typescript/src/domain/services/audit.js",
|
|
73
|
+
"tests/apps/typescript/src/interfaces/http/controller.ts",
|
|
74
|
+
"tests/apps/typescript/src/interfaces/http/view.jsx",
|
|
75
|
+
"tests/apps/typescript/src/application/use_cases/activate.ts",
|
|
76
|
+
"tests/apps/typescript/ignored/generated.ts",
|
|
77
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
78
|
+
".github/ISSUE_TEMPLATE/bug_report.yml",
|
|
79
|
+
".github/ISSUE_TEMPLATE/config.yml",
|
|
80
|
+
".github/ISSUE_TEMPLATE/feature_request.yml",
|
|
81
|
+
".github/workflows/release.yml",
|
|
82
|
+
".github/workflows/large-repo-fixtures.yml",
|
|
83
|
+
".github/workflows/ci.yml"
|
|
84
|
+
]
|
|
85
|
+
}
|
|
@@ -11,6 +11,7 @@ from modwire.architecture import (
|
|
|
11
11
|
ArchitectureConfig,
|
|
12
12
|
ArchitectureConfigError,
|
|
13
13
|
ArchitectureFlowRules,
|
|
14
|
+
ArchitectureFlowRealm,
|
|
14
15
|
ArchitectureMap,
|
|
15
16
|
ArchitecturePolicyEvaluator,
|
|
16
17
|
ArchitectureRules,
|
|
@@ -120,6 +121,36 @@ class ArchitectureApiTest(unittest.TestCase):
|
|
|
120
121
|
"src/features/billing",
|
|
121
122
|
)
|
|
122
123
|
|
|
124
|
+
def test_tag_matcher_exposes_duplicate_name_matches_explicitly(self) -> None:
|
|
125
|
+
matcher = TagMatcher(
|
|
126
|
+
ArchitectureConfig(
|
|
127
|
+
language="python",
|
|
128
|
+
architecture_root="src",
|
|
129
|
+
rules=ArchitectureRules(
|
|
130
|
+
tags=(
|
|
131
|
+
ArchitectureTagRule(name="module", match="features/*"),
|
|
132
|
+
ArchitectureTagRule(name="module", match="features/*/domain"),
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
matches = matcher.matches("src/features/billing/domain", "module")
|
|
139
|
+
tag_matches = matcher.tags_for("src/features/billing/domain")
|
|
140
|
+
|
|
141
|
+
self.assertEqual(
|
|
142
|
+
[match.pattern for match in matches],
|
|
143
|
+
["features/*", "features/*/domain"],
|
|
144
|
+
)
|
|
145
|
+
self.assertEqual(
|
|
146
|
+
[match.pattern for match in tag_matches],
|
|
147
|
+
["features/*", "features/*/domain"],
|
|
148
|
+
)
|
|
149
|
+
self.assertEqual(
|
|
150
|
+
matcher.match("src/features/billing/domain", "module").pattern,
|
|
151
|
+
"features/*",
|
|
152
|
+
)
|
|
153
|
+
|
|
123
154
|
def test_architecture_config_models_validate_and_evaluate_policy(self) -> None:
|
|
124
155
|
graph = DependencyGraph()
|
|
125
156
|
graph.add_edge("src/features/billing/ui", "src/features/billing/domain")
|
|
@@ -217,6 +248,70 @@ class ArchitectureApiTest(unittest.TestCase):
|
|
|
217
248
|
|
|
218
249
|
self.assertIn(expected_message, context.exception.to_dict()["issues"][0]["message"])
|
|
219
250
|
|
|
251
|
+
def test_legacy_flow_module_tag_still_evaluates_without_realm_rule_names(self) -> None:
|
|
252
|
+
graph = DependencyGraph()
|
|
253
|
+
graph.add_edge("src/features/billing", "src/features/orders")
|
|
254
|
+
graph.add_edge("src/features/orders", "src/features/billing")
|
|
255
|
+
config = ArchitectureConfig(
|
|
256
|
+
language="python",
|
|
257
|
+
architecture_root="src",
|
|
258
|
+
rules=ArchitectureRules(
|
|
259
|
+
tags=(ArchitectureTagRule(name="module", match="features/*"),),
|
|
260
|
+
flow=ArchitectureFlowRules(
|
|
261
|
+
module_tag="module",
|
|
262
|
+
analyzers=("no-cycles",),
|
|
263
|
+
),
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
violations = ArchitecturePolicyEvaluator().evaluate(graph, config)
|
|
268
|
+
|
|
269
|
+
self.assertEqual(len(violations), 1)
|
|
270
|
+
self.assertEqual(violations[0].violation_type, "no-cycles")
|
|
271
|
+
self.assertEqual(violations[0].rule_name, "analyzer:no-cycles")
|
|
272
|
+
|
|
273
|
+
def test_flow_realms_evaluate_each_module_tag_independently(self) -> None:
|
|
274
|
+
graph = DependencyGraph()
|
|
275
|
+
graph.add_edge("src/features/billing", "src/features/orders")
|
|
276
|
+
graph.add_edge("src/features/orders", "src/features/billing")
|
|
277
|
+
graph.add_edge("src/app/pages/home", "src/app/pages/settings")
|
|
278
|
+
graph.add_edge("src/app/pages/settings", "src/app/pages/home")
|
|
279
|
+
config = ArchitectureConfig(
|
|
280
|
+
language="python",
|
|
281
|
+
architecture_root="src",
|
|
282
|
+
rules=ArchitectureRules(
|
|
283
|
+
tags=(
|
|
284
|
+
ArchitectureTagRule(name="backend_module", match="features/*"),
|
|
285
|
+
ArchitectureTagRule(name="gui_page", match="app/pages/*"),
|
|
286
|
+
),
|
|
287
|
+
flow=ArchitectureFlowRules(
|
|
288
|
+
realms=(
|
|
289
|
+
ArchitectureFlowRealm(
|
|
290
|
+
name="backend",
|
|
291
|
+
module_tag="backend_module",
|
|
292
|
+
layers=("domain", "application"),
|
|
293
|
+
),
|
|
294
|
+
ArchitectureFlowRealm(
|
|
295
|
+
name="gui",
|
|
296
|
+
module_tag="gui_page",
|
|
297
|
+
layers=(),
|
|
298
|
+
),
|
|
299
|
+
),
|
|
300
|
+
analyzers=("backward-flow", "no-cycles"),
|
|
301
|
+
),
|
|
302
|
+
),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
violations = ArchitecturePolicyEvaluator().evaluate(graph, config)
|
|
306
|
+
|
|
307
|
+
self.assertEqual(
|
|
308
|
+
[(violation.violation_type, violation.rule_name) for violation in violations],
|
|
309
|
+
[
|
|
310
|
+
("no-cycles", "analyzer:backend:no-cycles"),
|
|
311
|
+
("no-cycles", "analyzer:gui:no-cycles"),
|
|
312
|
+
],
|
|
313
|
+
)
|
|
314
|
+
|
|
220
315
|
def test_flow_violations_have_stable_dict_shape(self) -> None:
|
|
221
316
|
violation = FlowViolation(
|
|
222
317
|
violation_type="backward-flow",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|