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.
Files changed (91) hide show
  1. {modwire-2.2.0 → modwire-2.3.0}/PKG-INFO +37 -1
  2. {modwire-2.2.0 → modwire-2.3.0}/README.md +36 -0
  3. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/_version.py +3 -3
  4. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/__init__.py +2 -0
  5. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/analyzers.py +20 -13
  6. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/config.py +44 -0
  7. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/matching.py +44 -8
  8. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/policy.py +8 -2
  9. {modwire-2.2.0 → modwire-2.3.0}/src/modwire.egg-info/PKG-INFO +37 -1
  10. {modwire-2.2.0 → modwire-2.3.0}/src/modwire.egg-info/SOURCES.txt +2 -0
  11. modwire-2.3.0/src/modwire.egg-info/scm_file_list.json +85 -0
  12. modwire-2.3.0/src/modwire.egg-info/scm_version.json +8 -0
  13. {modwire-2.2.0 → modwire-2.3.0}/tests/test_architecture_api.py +95 -0
  14. {modwire-2.2.0 → modwire-2.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  15. {modwire-2.2.0 → modwire-2.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  16. {modwire-2.2.0 → modwire-2.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  17. {modwire-2.2.0 → modwire-2.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {modwire-2.2.0 → modwire-2.3.0}/.github/workflows/ci.yml +0 -0
  19. {modwire-2.2.0 → modwire-2.3.0}/.github/workflows/large-repo-fixtures.yml +0 -0
  20. {modwire-2.2.0 → modwire-2.3.0}/.github/workflows/release.yml +0 -0
  21. {modwire-2.2.0 → modwire-2.3.0}/.gitignore +0 -0
  22. {modwire-2.2.0 → modwire-2.3.0}/CONTRIBUTING.md +0 -0
  23. {modwire-2.2.0 → modwire-2.3.0}/LICENSE +0 -0
  24. {modwire-2.2.0 → modwire-2.3.0}/docs/wiki/Development-checks.md +0 -0
  25. {modwire-2.2.0 → modwire-2.3.0}/docs/wiki/Home.md +0 -0
  26. {modwire-2.2.0 → modwire-2.3.0}/docs/wiki/Reporting-bugs.md +0 -0
  27. {modwire-2.2.0 → modwire-2.3.0}/docs/wiki/Requesting-features.md +0 -0
  28. {modwire-2.2.0 → modwire-2.3.0}/pyproject.toml +0 -0
  29. {modwire-2.2.0 → modwire-2.3.0}/setup.cfg +0 -0
  30. {modwire-2.2.0 → modwire-2.3.0}/show_test_source_files.py +0 -0
  31. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/__init__.py +0 -0
  32. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/insights.py +0 -0
  33. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/render.py +0 -0
  34. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/architecture/violations.py +0 -0
  35. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/callables.py +0 -0
  36. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/definitions.py +0 -0
  37. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/exports.py +0 -0
  38. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/__init__.py +0 -0
  39. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/cache.py +0 -0
  40. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/manifest.py +0 -0
  41. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/models.py +0 -0
  42. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/roots.py +0 -0
  43. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/serialization.py +0 -0
  44. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extraction/service.py +0 -0
  45. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/__init__.py +0 -0
  46. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/base.py +0 -0
  47. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/loader.py +0 -0
  48. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/php.py +0 -0
  49. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/python.py +0 -0
  50. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/resources.py +0 -0
  51. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/scripts/__init__.py +0 -0
  52. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/scripts/php_extractor.php +0 -0
  53. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/scripts/python_extractor.py +0 -0
  54. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/scripts/typescript_extractor.js +0 -0
  55. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/extractors/typescript.py +0 -0
  56. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/graph.py +0 -0
  57. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/metadata.py +0 -0
  58. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/shape/__init__.py +0 -0
  59. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/shape/config.py +0 -0
  60. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/shape/evaluator.py +0 -0
  61. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/shape/rules.py +0 -0
  62. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/shape/violations.py +0 -0
  63. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/testing/__init__.py +0 -0
  64. {modwire-2.2.0 → modwire-2.3.0}/src/modwire/testing/factories.py +0 -0
  65. {modwire-2.2.0 → modwire-2.3.0}/src/modwire.egg-info/dependency_links.txt +0 -0
  66. {modwire-2.2.0 → modwire-2.3.0}/src/modwire.egg-info/requires.txt +0 -0
  67. {modwire-2.2.0 → modwire-2.3.0}/src/modwire.egg-info/top_level.txt +0 -0
  68. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/php/ignored/generated.php +0 -0
  69. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/php/src/application/use_cases/activate.php +0 -0
  70. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/php/src/domain/model/user.php +0 -0
  71. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/php/src/domain/services/policy.php +0 -0
  72. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/php/src/interfaces/http/controller.php +0 -0
  73. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/python/ignored/generated.py +0 -0
  74. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/python/src/application/use_cases/activate.py +0 -0
  75. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/python/src/domain/model/user.py +0 -0
  76. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/python/src/domain/services/policy.py +0 -0
  77. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/python/src/interfaces/http/controller.py +0 -0
  78. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/ignored/generated.ts +0 -0
  79. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/application/use_cases/activate.ts +0 -0
  80. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/domain/model/profile.tsx +0 -0
  81. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/domain/model/user.ts +0 -0
  82. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/domain/services/audit.js +0 -0
  83. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/domain/services/policy.ts +0 -0
  84. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/interfaces/http/controller.ts +0 -0
  85. {modwire-2.2.0 → modwire-2.3.0}/tests/apps/typescript/src/interfaces/http/view.jsx +0 -0
  86. {modwire-2.2.0 → modwire-2.3.0}/tests/large_projects/README.md +0 -0
  87. {modwire-2.2.0 → modwire-2.3.0}/tests/large_projects/fixtures.json +0 -0
  88. {modwire-2.2.0 → modwire-2.3.0}/tests/large_projects/run_large_project_fixtures.py +0 -0
  89. {modwire-2.2.0 → modwire-2.3.0}/tests/test_api.py +0 -0
  90. {modwire-2.2.0 → modwire-2.3.0}/tests/test_standalone.py +0 -0
  91. {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.2.0
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.2.0'
22
- __version_tuple__ = version_tuple = (2, 2, 0)
21
+ __version__ = version = '2.3.0'
22
+ __version_tuple__ = version_tuple = (2, 3, 0)
23
23
 
24
- __commit_id__ = commit_id = 'gc116aabfa'
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
- return FlowViolation(name, tuple(path), index, f"analyzer:{name}", message)
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
- scoped = {node for node, node_tags in tags.items() if config.rules.flow.module_tag in node_tags}
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
- tag = self._tag(name)
47
- pattern = tag.match if tag is not None else name
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
- exclude=() if tag is None else tag.excluded_patterns,
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(self, node_id: str, names: tuple[str, ...]) -> TagMatch | None:
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
- (match for name in names if (match := self.match(node_id, name)) is not None),
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.match(node_id, tag.name)) is not None
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 _tag(self, name: str):
115
- return next((tag for tag in self.tags if tag.name == name), None)
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
- violations.extend(run_analyzer(analyzer_name, graph, tags, config))
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.2.0
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
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.3.0",
3
+ "distance": 0,
4
+ "node": "gdb0b34253d73c2613cd3738350c5ec308b8e4f13",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-06-22"
8
+ }
@@ -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