modwire 2.1.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 (94) hide show
  1. modwire-2.3.0/.github/workflows/large-repo-fixtures.yml +88 -0
  2. {modwire-2.1.0 → modwire-2.3.0}/PKG-INFO +37 -1
  3. {modwire-2.1.0 → modwire-2.3.0}/README.md +36 -0
  4. {modwire-2.1.0 → modwire-2.3.0}/pyproject.toml +3 -2
  5. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/_version.py +3 -3
  6. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/architecture/__init__.py +2 -0
  7. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/architecture/analyzers.py +20 -13
  8. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/architecture/config.py +44 -0
  9. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/architecture/matching.py +44 -8
  10. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/architecture/policy.py +8 -2
  11. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extraction/manifest.py +5 -4
  12. modwire-2.3.0/src/modwire/extractors/base.py +781 -0
  13. modwire-2.3.0/src/modwire/extractors/php.py +348 -0
  14. modwire-2.3.0/src/modwire/extractors/python.py +472 -0
  15. modwire-2.3.0/src/modwire/extractors/resources.py +29 -0
  16. modwire-2.3.0/src/modwire/extractors/scripts/__init__.py +2 -0
  17. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extractors/scripts/php_extractor.php +25 -1
  18. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extractors/scripts/typescript_extractor.js +29 -3
  19. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extractors/typescript.py +27 -6
  20. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/metadata.py +16 -20
  21. {modwire-2.1.0 → modwire-2.3.0}/src/modwire.egg-info/PKG-INFO +37 -1
  22. {modwire-2.1.0 → modwire-2.3.0}/src/modwire.egg-info/SOURCES.txt +9 -1
  23. modwire-2.3.0/src/modwire.egg-info/scm_file_list.json +85 -0
  24. modwire-2.3.0/src/modwire.egg-info/scm_version.json +8 -0
  25. modwire-2.3.0/tests/large_projects/README.md +47 -0
  26. modwire-2.3.0/tests/large_projects/fixtures.json +242 -0
  27. modwire-2.3.0/tests/large_projects/run_large_project_fixtures.py +534 -0
  28. {modwire-2.1.0 → modwire-2.3.0}/tests/test_api.py +204 -34
  29. {modwire-2.1.0 → modwire-2.3.0}/tests/test_architecture_api.py +95 -0
  30. modwire-2.1.0/src/modwire/extractors/base.py +0 -356
  31. modwire-2.1.0/src/modwire/extractors/php.py +0 -199
  32. modwire-2.1.0/src/modwire/extractors/python.py +0 -150
  33. {modwire-2.1.0 → modwire-2.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  34. {modwire-2.1.0 → modwire-2.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  35. {modwire-2.1.0 → modwire-2.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  36. {modwire-2.1.0 → modwire-2.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  37. {modwire-2.1.0 → modwire-2.3.0}/.github/workflows/ci.yml +0 -0
  38. {modwire-2.1.0 → modwire-2.3.0}/.github/workflows/release.yml +0 -0
  39. {modwire-2.1.0 → modwire-2.3.0}/.gitignore +0 -0
  40. {modwire-2.1.0 → modwire-2.3.0}/CONTRIBUTING.md +0 -0
  41. {modwire-2.1.0 → modwire-2.3.0}/LICENSE +0 -0
  42. {modwire-2.1.0 → modwire-2.3.0}/docs/wiki/Development-checks.md +0 -0
  43. {modwire-2.1.0 → modwire-2.3.0}/docs/wiki/Home.md +0 -0
  44. {modwire-2.1.0 → modwire-2.3.0}/docs/wiki/Reporting-bugs.md +0 -0
  45. {modwire-2.1.0 → modwire-2.3.0}/docs/wiki/Requesting-features.md +0 -0
  46. {modwire-2.1.0 → modwire-2.3.0}/setup.cfg +0 -0
  47. {modwire-2.1.0 → modwire-2.3.0}/show_test_source_files.py +0 -0
  48. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/__init__.py +0 -0
  49. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/architecture/insights.py +0 -0
  50. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/architecture/render.py +0 -0
  51. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/architecture/violations.py +0 -0
  52. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/callables.py +0 -0
  53. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/definitions.py +0 -0
  54. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/exports.py +0 -0
  55. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extraction/__init__.py +0 -0
  56. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extraction/cache.py +0 -0
  57. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extraction/models.py +0 -0
  58. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extraction/roots.py +0 -0
  59. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extraction/serialization.py +0 -0
  60. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extraction/service.py +0 -0
  61. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extractors/__init__.py +0 -0
  62. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extractors/loader.py +0 -0
  63. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/extractors/scripts/python_extractor.py +0 -0
  64. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/graph.py +0 -0
  65. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/shape/__init__.py +0 -0
  66. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/shape/config.py +0 -0
  67. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/shape/evaluator.py +0 -0
  68. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/shape/rules.py +0 -0
  69. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/shape/violations.py +0 -0
  70. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/testing/__init__.py +0 -0
  71. {modwire-2.1.0 → modwire-2.3.0}/src/modwire/testing/factories.py +0 -0
  72. {modwire-2.1.0 → modwire-2.3.0}/src/modwire.egg-info/dependency_links.txt +0 -0
  73. {modwire-2.1.0 → modwire-2.3.0}/src/modwire.egg-info/requires.txt +0 -0
  74. {modwire-2.1.0 → modwire-2.3.0}/src/modwire.egg-info/top_level.txt +0 -0
  75. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/php/ignored/generated.php +0 -0
  76. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/php/src/application/use_cases/activate.php +0 -0
  77. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/php/src/domain/model/user.php +0 -0
  78. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/php/src/domain/services/policy.php +0 -0
  79. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/php/src/interfaces/http/controller.php +0 -0
  80. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/python/ignored/generated.py +0 -0
  81. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/python/src/application/use_cases/activate.py +0 -0
  82. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/python/src/domain/model/user.py +0 -0
  83. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/python/src/domain/services/policy.py +0 -0
  84. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/python/src/interfaces/http/controller.py +0 -0
  85. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/typescript/ignored/generated.ts +0 -0
  86. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/typescript/src/application/use_cases/activate.ts +0 -0
  87. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/typescript/src/domain/model/profile.tsx +0 -0
  88. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/typescript/src/domain/model/user.ts +0 -0
  89. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/typescript/src/domain/services/audit.js +0 -0
  90. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/typescript/src/domain/services/policy.ts +0 -0
  91. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/typescript/src/interfaces/http/controller.ts +0 -0
  92. {modwire-2.1.0 → modwire-2.3.0}/tests/apps/typescript/src/interfaces/http/view.jsx +0 -0
  93. {modwire-2.1.0 → modwire-2.3.0}/tests/test_standalone.py +0 -0
  94. {modwire-2.1.0 → modwire-2.3.0}/uv.lock +0 -0
@@ -0,0 +1,88 @@
1
+ name: Large Repository Fixtures
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ fixture:
7
+ description: "Fixture id to run, or all."
8
+ required: true
9
+ default: "all"
10
+ type: choice
11
+ options:
12
+ - all
13
+ - django
14
+ - ansible
15
+ - salt
16
+ - vscode
17
+ - angular
18
+ - typescript
19
+ - wordpress
20
+ - drupal
21
+ - moodle
22
+ mode:
23
+ description: "Fixture execution mode."
24
+ required: true
25
+ default: "enclosure"
26
+ type: choice
27
+ options:
28
+ - enclosure
29
+ - modwire
30
+ - both
31
+ command-timeout:
32
+ description: "Seconds allowed for each enclosure command."
33
+ required: true
34
+ default: "60"
35
+ schedule:
36
+ - cron: "17 3 * * 0"
37
+
38
+ jobs:
39
+ large-fixture:
40
+ name: ${{ matrix.fixture }}
41
+ runs-on: ubuntu-latest
42
+ timeout-minutes: 60
43
+ if: github.event_name != 'workflow_dispatch' || inputs.fixture == 'all' || inputs.fixture == matrix.fixture
44
+ strategy:
45
+ fail-fast: false
46
+ matrix:
47
+ fixture:
48
+ - django
49
+ - ansible
50
+ - salt
51
+ - vscode
52
+ - angular
53
+ - typescript
54
+ - wordpress
55
+ - drupal
56
+ - moodle
57
+
58
+ steps:
59
+ - name: Check out repository
60
+ uses: actions/checkout@v4
61
+
62
+ - name: Set up Python
63
+ uses: actions/setup-python@v5
64
+ with:
65
+ python-version: "3.13"
66
+
67
+ - name: Set up Node.js
68
+ uses: actions/setup-node@v4
69
+ with:
70
+ node-version: "20"
71
+
72
+ - name: Set up PHP
73
+ uses: shivammathur/setup-php@v2
74
+ with:
75
+ php-version: "8.3"
76
+
77
+ - name: Install enclosure and local modwire
78
+ run: |
79
+ python -m pip install --upgrade pip
80
+ python -m pip install "enclosure @ git+https://github.com/9orky/enclosure.git"
81
+ python -m pip install -e .
82
+
83
+ - name: Run large fixture
84
+ run: >
85
+ python tests/large_projects/run_large_project_fixtures.py
86
+ --fixture "${{ matrix.fixture }}"
87
+ --mode "${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'enclosure' }}"
88
+ --command-timeout "${{ github.event_name == 'workflow_dispatch' && inputs['command-timeout'] || '60' }}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modwire
3
- Version: 2.1.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
@@ -72,8 +72,9 @@ include-package-data = true
72
72
  where = ["src"]
73
73
 
74
74
  [tool.setuptools.package-data]
75
- modwire = [
76
- "extractors/scripts/*",
75
+ "modwire.extractors.scripts" = [
76
+ "*.js",
77
+ "*.php",
77
78
  ]
78
79
 
79
80
  [tool.setuptools_scm]
@@ -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.1.0'
22
- __version_tuple__ = version_tuple = (2, 1, 0)
21
+ __version__ = version = '2.3.0'
22
+ __version_tuple__ = version_tuple = (2, 3, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g54f33f5a3'
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
 
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from .._version import __version__
9
9
  from ..extractors import load_extractor
10
10
  from ..extractors.base import _collect_extraction_targets
11
+ from ..extractors.resources import extractor_script_path
11
12
  from .models import SourceManifest, SourceManifestEntry
12
13
  from .roots import (
13
14
  DEFAULT_SOURCE_ROOTS,
@@ -57,14 +58,14 @@ def discover_sources(
57
58
  mtime_ns=stat.st_mtime_ns,
58
59
  )
59
60
  )
60
- extractor_path = (
61
- Path(__file__).parents[1] / "extractors" / "scripts" / extractor.extractor_file
62
- ).resolve()
63
61
  runtime_path = shutil.which(extractor.command) or ""
64
62
  runtime_mtime_ns = 0
65
63
  if runtime_path != "":
66
64
  runtime_mtime_ns = Path(runtime_path).stat().st_mtime_ns
67
65
 
66
+ with extractor_script_path(extractor.extractor_file) as extractor_path:
67
+ extractor_mtime_ns = extractor_path.stat().st_mtime_ns
68
+
68
69
  return SourceManifest(
69
70
  language=language,
70
71
  workspace_root=(
@@ -83,7 +84,7 @@ def discover_sources(
83
84
  runtime_mtime_ns=runtime_mtime_ns,
84
85
  extractor_file=extractor.extractor_file,
85
86
  extractor_path=extractor_path,
86
- extractor_mtime_ns=extractor_path.stat().st_mtime_ns,
87
+ extractor_mtime_ns=extractor_mtime_ns,
87
88
  modwire_version=__version__,
88
89
  files_found=files_found,
89
90
  files_checked=len(entries),