archunitpython 1.1.2__py3-none-any.whl → 1.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- archunitpython/__init__.py +9 -1
- archunitpython/common/extraction/extract_graph.py +122 -29
- archunitpython/common/pattern_matching.py +1 -3
- archunitpython/common/projection/cycles/johnsons_apsp.py +4 -9
- archunitpython/common/projection/cycles/tarjan_scc.py +2 -6
- archunitpython/common/projection/project_cycles.py +1 -2
- archunitpython/common/util/logger.py +1 -3
- archunitpython/files/assertion/custom_file_logic.py +3 -9
- archunitpython/files/assertion/depend_on_external_modules.py +3 -8
- archunitpython/files/assertion/depend_on_files.py +4 -12
- archunitpython/files/fluentapi/files.py +9 -27
- archunitpython/graph/__init__.py +35 -0
- archunitpython/graph/graph_reporter.py +795 -0
- archunitpython/layers/__init__.py +8 -0
- archunitpython/layers/assertion/__init__.py +9 -0
- archunitpython/layers/assertion/layer_dependencies.py +83 -0
- archunitpython/layers/fluentapi/__init__.py +6 -0
- archunitpython/layers/fluentapi/layers.py +116 -0
- archunitpython/metrics/assertion/metric_thresholds.py +1 -3
- archunitpython/metrics/calculation/distance.py +1 -3
- archunitpython/metrics/extraction/extract_class_info.py +3 -9
- archunitpython/metrics/fluentapi/export_utils.py +2 -6
- archunitpython/metrics/fluentapi/metrics.py +12 -38
- archunitpython/slices/fluentapi/slices.py +4 -12
- archunitpython/slices/uml/generate_rules.py +2 -6
- archunitpython/testing/common/violation_factory.py +11 -3
- {archunitpython-1.1.2.dist-info → archunitpython-1.2.1.dist-info}/METADATA +161 -6
- {archunitpython-1.1.2.dist-info → archunitpython-1.2.1.dist-info}/RECORD +30 -23
- {archunitpython-1.1.2.dist-info → archunitpython-1.2.1.dist-info}/WHEEL +0 -0
- {archunitpython-1.1.2.dist-info → archunitpython-1.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Violation gathering for layer dependency rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from archunitpython.common.assertion.violation import Violation
|
|
8
|
+
from archunitpython.common.pattern_matching import matches_pattern
|
|
9
|
+
from archunitpython.common.projection.types import ProjectedEdge
|
|
10
|
+
from archunitpython.common.types import Filter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class LayerDependencyViolation(Violation):
|
|
15
|
+
"""A dependency from one named layer to a disallowed named layer."""
|
|
16
|
+
|
|
17
|
+
dependency: ProjectedEdge
|
|
18
|
+
source_layer: str
|
|
19
|
+
target_layer: str
|
|
20
|
+
rule: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class LayerDefinition:
|
|
25
|
+
"""A named architectural layer with one or more file filters."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
filters: tuple[Filter, ...]
|
|
29
|
+
|
|
30
|
+
def matches(self, file_path: str) -> bool:
|
|
31
|
+
return any(matches_pattern(file_path, filter_) for filter_ in self.filters)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def gather_layer_dependency_violations(
|
|
35
|
+
edges: list[ProjectedEdge],
|
|
36
|
+
layers: list[LayerDefinition],
|
|
37
|
+
allowed_dependencies: dict[str, set[str]],
|
|
38
|
+
forbidden_dependencies: dict[str, set[str]],
|
|
39
|
+
) -> list[Violation]:
|
|
40
|
+
"""Check cross-layer dependencies against allowlist/blocklist rules."""
|
|
41
|
+
violations: list[Violation] = []
|
|
42
|
+
|
|
43
|
+
for edge in edges:
|
|
44
|
+
source_layer = _find_layer(edge.source_label, layers)
|
|
45
|
+
target_layer = _find_layer(edge.target_label, layers)
|
|
46
|
+
|
|
47
|
+
if source_layer is None or target_layer is None:
|
|
48
|
+
continue
|
|
49
|
+
if source_layer == target_layer:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
forbidden_targets = forbidden_dependencies.get(source_layer, set())
|
|
53
|
+
if target_layer in forbidden_targets:
|
|
54
|
+
violations.append(
|
|
55
|
+
LayerDependencyViolation(
|
|
56
|
+
dependency=edge,
|
|
57
|
+
source_layer=source_layer,
|
|
58
|
+
target_layer=target_layer,
|
|
59
|
+
rule="may_not_depend_on_layers",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
if source_layer in allowed_dependencies:
|
|
65
|
+
allowed_targets = allowed_dependencies[source_layer]
|
|
66
|
+
if target_layer not in allowed_targets:
|
|
67
|
+
violations.append(
|
|
68
|
+
LayerDependencyViolation(
|
|
69
|
+
dependency=edge,
|
|
70
|
+
source_layer=source_layer,
|
|
71
|
+
target_layer=target_layer,
|
|
72
|
+
rule="may_only_depend_on_layers",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return violations
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _find_layer(file_path: str, layers: list[LayerDefinition]) -> str | None:
|
|
80
|
+
for layer in layers:
|
|
81
|
+
if layer.matches(file_path):
|
|
82
|
+
return layer.name
|
|
83
|
+
return None
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Fluent API builder chain for layer-level architecture rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from archunitpython.common.assertion.violation import Violation
|
|
6
|
+
from archunitpython.common.extraction.extract_graph import extract_graph
|
|
7
|
+
from archunitpython.common.fluentapi.checkable import CheckOptions
|
|
8
|
+
from archunitpython.common.projection.edge_projections import per_internal_edge
|
|
9
|
+
from archunitpython.common.projection.project_edges import project_edges
|
|
10
|
+
from archunitpython.common.regex_factory import RegexFactory
|
|
11
|
+
from archunitpython.common.types import Filter, Pattern
|
|
12
|
+
from archunitpython.layers.assertion.layer_dependencies import (
|
|
13
|
+
LayerDefinition,
|
|
14
|
+
gather_layer_dependency_violations,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def project_layers(project_path: str | None = None) -> "LayeredArchitecture":
|
|
19
|
+
"""Entry point for layer-level architecture rules."""
|
|
20
|
+
return LayeredArchitecture(project_path)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
layers = project_layers
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LayeredArchitecture:
|
|
27
|
+
"""Builder and checkable for named layer dependency policies."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, project_path: str | None = None) -> None:
|
|
30
|
+
self._project_path = project_path
|
|
31
|
+
self._layers: dict[str, list[Filter]] = {}
|
|
32
|
+
self._allowed_dependencies: dict[str, set[str]] = {}
|
|
33
|
+
self._forbidden_dependencies: dict[str, set[str]] = {}
|
|
34
|
+
|
|
35
|
+
def layer(self, name: str) -> "LayerDefinitionBuilder":
|
|
36
|
+
"""Start defining a named layer."""
|
|
37
|
+
self._layers.setdefault(name, [])
|
|
38
|
+
return LayerDefinitionBuilder(self, name)
|
|
39
|
+
|
|
40
|
+
def where_layer(self, name: str) -> "LayerDependencyRuleBuilder":
|
|
41
|
+
"""Start defining dependency rules for a named layer."""
|
|
42
|
+
self._layers.setdefault(name, [])
|
|
43
|
+
return LayerDependencyRuleBuilder(self, name)
|
|
44
|
+
|
|
45
|
+
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
46
|
+
graph = extract_graph(self._project_path, options=options)
|
|
47
|
+
edges = project_edges(graph, per_internal_edge())
|
|
48
|
+
layer_definitions = [
|
|
49
|
+
LayerDefinition(name=name, filters=tuple(filters))
|
|
50
|
+
for name, filters in self._layers.items()
|
|
51
|
+
if filters
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
return gather_layer_dependency_violations(
|
|
55
|
+
edges,
|
|
56
|
+
layer_definitions,
|
|
57
|
+
self._allowed_dependencies,
|
|
58
|
+
self._forbidden_dependencies,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _add_layer_filter(self, name: str, filter_: Filter) -> None:
|
|
62
|
+
self._layers.setdefault(name, []).append(filter_)
|
|
63
|
+
|
|
64
|
+
def _allow_only(self, source_layer: str, target_layers: tuple[str, ...]) -> None:
|
|
65
|
+
self._allowed_dependencies[source_layer] = set(target_layers)
|
|
66
|
+
|
|
67
|
+
def _forbid(self, source_layer: str, target_layers: tuple[str, ...]) -> None:
|
|
68
|
+
self._forbidden_dependencies.setdefault(source_layer, set()).update(target_layers)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class LayerDefinitionBuilder:
|
|
72
|
+
"""Configure the file patterns that make up one layer."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, architecture: LayeredArchitecture, layer_name: str) -> None:
|
|
75
|
+
self._architecture = architecture
|
|
76
|
+
self._layer_name = layer_name
|
|
77
|
+
|
|
78
|
+
def defined_by(self, path: Pattern) -> LayeredArchitecture:
|
|
79
|
+
"""Define this layer by a full path pattern."""
|
|
80
|
+
self._architecture._add_layer_filter(
|
|
81
|
+
self._layer_name,
|
|
82
|
+
RegexFactory.path_matcher(path),
|
|
83
|
+
)
|
|
84
|
+
return self._architecture
|
|
85
|
+
|
|
86
|
+
def defined_by_folder(self, folder: Pattern) -> LayeredArchitecture:
|
|
87
|
+
"""Define this layer by a folder pattern."""
|
|
88
|
+
self._architecture._add_layer_filter(
|
|
89
|
+
self._layer_name,
|
|
90
|
+
RegexFactory.folder_matcher(folder),
|
|
91
|
+
)
|
|
92
|
+
return self._architecture
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class LayerDependencyRuleBuilder:
|
|
96
|
+
"""Configure allowed or forbidden dependencies for one layer."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, architecture: LayeredArchitecture, layer_name: str) -> None:
|
|
99
|
+
self._architecture = architecture
|
|
100
|
+
self._layer_name = layer_name
|
|
101
|
+
|
|
102
|
+
def may_only_depend_on_layers(
|
|
103
|
+
self,
|
|
104
|
+
*layer_names: str,
|
|
105
|
+
) -> LayeredArchitecture:
|
|
106
|
+
"""Allow this layer to depend only on the listed other layers."""
|
|
107
|
+
self._architecture._allow_only(self._layer_name, layer_names)
|
|
108
|
+
return self._architecture
|
|
109
|
+
|
|
110
|
+
def may_not_depend_on_layers(
|
|
111
|
+
self,
|
|
112
|
+
*layer_names: str,
|
|
113
|
+
) -> LayeredArchitecture:
|
|
114
|
+
"""Forbid this layer from depending on the listed other layers."""
|
|
115
|
+
self._architecture._forbid(self._layer_name, layer_names)
|
|
116
|
+
return self._architecture
|
|
@@ -31,9 +31,7 @@ class FileCountViolation(Violation):
|
|
|
31
31
|
comparison: MetricComparison
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def check_threshold(
|
|
35
|
-
value: float, threshold: float, comparison: MetricComparison
|
|
36
|
-
) -> bool:
|
|
34
|
+
def check_threshold(value: float, threshold: float, comparison: MetricComparison) -> bool:
|
|
37
35
|
"""Check if a value violates a threshold.
|
|
38
36
|
|
|
39
37
|
Returns True if the value is a VIOLATION.
|
|
@@ -103,8 +103,6 @@ def calculate_distance_metrics_for_project(
|
|
|
103
103
|
average_instability=sum(m.instability for m in metrics) / len(metrics),
|
|
104
104
|
average_distance=sum(m.distance for m in metrics) / len(metrics),
|
|
105
105
|
files_in_zone_of_pain=sum(1 for m in metrics if m.in_zone_of_pain),
|
|
106
|
-
files_in_zone_of_uselessness=sum(
|
|
107
|
-
1 for m in metrics if m.in_zone_of_uselessness
|
|
108
|
-
),
|
|
106
|
+
files_in_zone_of_uselessness=sum(1 for m in metrics if m.in_zone_of_uselessness),
|
|
109
107
|
total_files=len(files),
|
|
110
108
|
)
|
|
@@ -129,9 +129,7 @@ def _extract_class(node: ast.ClassDef, file_path: str) -> ClassInfo:
|
|
|
129
129
|
for item in ast.walk(node):
|
|
130
130
|
if isinstance(item, ast.Assign):
|
|
131
131
|
for target in item.targets:
|
|
132
|
-
if isinstance(target, ast.Attribute) and isinstance(
|
|
133
|
-
target.value, ast.Name
|
|
134
|
-
):
|
|
132
|
+
if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name):
|
|
135
133
|
if target.value.id == "self":
|
|
136
134
|
field_name = target.attr
|
|
137
135
|
if field_name not in fields:
|
|
@@ -142,9 +140,7 @@ def _extract_class(node: ast.ClassDef, file_path: str) -> ClassInfo:
|
|
|
142
140
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
143
141
|
method_name = item.name
|
|
144
142
|
accessed = _find_field_accesses(item, set(fields.keys()))
|
|
145
|
-
methods.append(
|
|
146
|
-
MethodInfo(name=method_name, accessed_fields=accessed)
|
|
147
|
-
)
|
|
143
|
+
methods.append(MethodInfo(name=method_name, accessed_fields=accessed))
|
|
148
144
|
# Update field access tracking
|
|
149
145
|
for field_name in accessed:
|
|
150
146
|
if field_name in fields:
|
|
@@ -159,9 +155,7 @@ def _extract_class(node: ast.ClassDef, file_path: str) -> ClassInfo:
|
|
|
159
155
|
)
|
|
160
156
|
|
|
161
157
|
|
|
162
|
-
def _extract_enhanced_class(
|
|
163
|
-
node: ast.ClassDef, file_path: str
|
|
164
|
-
) -> EnhancedClassInfo:
|
|
158
|
+
def _extract_enhanced_class(node: ast.ClassDef, file_path: str) -> EnhancedClassInfo:
|
|
165
159
|
"""Extract EnhancedClassInfo from a ClassDef AST node."""
|
|
166
160
|
base = _extract_class(node, file_path)
|
|
167
161
|
|
|
@@ -35,11 +35,7 @@ class MetricsExporter:
|
|
|
35
35
|
HTML content as a string. Also writes to file if output_path specified.
|
|
36
36
|
"""
|
|
37
37
|
opts = options or ExportOptions()
|
|
38
|
-
timestamp = (
|
|
39
|
-
datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
40
|
-
if opts.include_timestamp
|
|
41
|
-
else ""
|
|
42
|
-
)
|
|
38
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if opts.include_timestamp else ""
|
|
43
39
|
|
|
44
40
|
css = opts.custom_css or _DEFAULT_CSS
|
|
45
41
|
|
|
@@ -55,7 +51,7 @@ class MetricsExporter:
|
|
|
55
51
|
</head>
|
|
56
52
|
<body>
|
|
57
53
|
<h1>{opts.title}</h1>
|
|
58
|
-
{f'<p class="timestamp">Generated: {timestamp}</p>' if timestamp else
|
|
54
|
+
{f'<p class="timestamp">Generated: {timestamp}</p>' if timestamp else ""}
|
|
59
55
|
<table>
|
|
60
56
|
<thead>
|
|
61
57
|
<tr><th>Metric</th><th>Value</th></tr>
|
|
@@ -97,9 +97,7 @@ class MetricsBuilder:
|
|
|
97
97
|
)
|
|
98
98
|
|
|
99
99
|
|
|
100
|
-
def _get_filtered_classes(
|
|
101
|
-
project_path: str | None, filters: list[Filter]
|
|
102
|
-
) -> list[ClassInfo]:
|
|
100
|
+
def _get_filtered_classes(project_path: str | None, filters: list[Filter]) -> list[ClassInfo]:
|
|
103
101
|
classes = extract_class_info(project_path)
|
|
104
102
|
if not filters:
|
|
105
103
|
return classes
|
|
@@ -119,39 +117,25 @@ class CountMetricsBuilder:
|
|
|
119
117
|
self._filters = filters
|
|
120
118
|
|
|
121
119
|
def method_count(self) -> "ClassMetricThresholdBuilder":
|
|
122
|
-
return ClassMetricThresholdBuilder(
|
|
123
|
-
self._project_path, self._filters, MethodCountMetric()
|
|
124
|
-
)
|
|
120
|
+
return ClassMetricThresholdBuilder(self._project_path, self._filters, MethodCountMetric())
|
|
125
121
|
|
|
126
122
|
def field_count(self) -> "ClassMetricThresholdBuilder":
|
|
127
|
-
return ClassMetricThresholdBuilder(
|
|
128
|
-
self._project_path, self._filters, FieldCountMetric()
|
|
129
|
-
)
|
|
123
|
+
return ClassMetricThresholdBuilder(self._project_path, self._filters, FieldCountMetric())
|
|
130
124
|
|
|
131
125
|
def lines_of_code(self) -> "FileMetricThresholdBuilder":
|
|
132
|
-
return FileMetricThresholdBuilder(
|
|
133
|
-
self._project_path, self._filters, LinesOfCodeMetric()
|
|
134
|
-
)
|
|
126
|
+
return FileMetricThresholdBuilder(self._project_path, self._filters, LinesOfCodeMetric())
|
|
135
127
|
|
|
136
128
|
def statements(self) -> "FileMetricThresholdBuilder":
|
|
137
|
-
return FileMetricThresholdBuilder(
|
|
138
|
-
self._project_path, self._filters, StatementCountMetric()
|
|
139
|
-
)
|
|
129
|
+
return FileMetricThresholdBuilder(self._project_path, self._filters, StatementCountMetric())
|
|
140
130
|
|
|
141
131
|
def imports(self) -> "FileMetricThresholdBuilder":
|
|
142
|
-
return FileMetricThresholdBuilder(
|
|
143
|
-
self._project_path, self._filters, ImportCountMetric()
|
|
144
|
-
)
|
|
132
|
+
return FileMetricThresholdBuilder(self._project_path, self._filters, ImportCountMetric())
|
|
145
133
|
|
|
146
134
|
def classes(self) -> "FileMetricThresholdBuilder":
|
|
147
|
-
return FileMetricThresholdBuilder(
|
|
148
|
-
self._project_path, self._filters, ClassCountMetric()
|
|
149
|
-
)
|
|
135
|
+
return FileMetricThresholdBuilder(self._project_path, self._filters, ClassCountMetric())
|
|
150
136
|
|
|
151
137
|
def functions(self) -> "FileMetricThresholdBuilder":
|
|
152
|
-
return FileMetricThresholdBuilder(
|
|
153
|
-
self._project_path, self._filters, FunctionCountMetric()
|
|
154
|
-
)
|
|
138
|
+
return FileMetricThresholdBuilder(self._project_path, self._filters, FunctionCountMetric())
|
|
155
139
|
|
|
156
140
|
|
|
157
141
|
class ClassMetricThresholdBuilder:
|
|
@@ -340,19 +324,13 @@ class DistanceMetricsBuilder:
|
|
|
340
324
|
self._filters = filters
|
|
341
325
|
|
|
342
326
|
def abstractness(self) -> "DistanceThresholdBuilder":
|
|
343
|
-
return DistanceThresholdBuilder(
|
|
344
|
-
self._project_path, self._filters, "abstractness"
|
|
345
|
-
)
|
|
327
|
+
return DistanceThresholdBuilder(self._project_path, self._filters, "abstractness")
|
|
346
328
|
|
|
347
329
|
def instability(self) -> "DistanceThresholdBuilder":
|
|
348
|
-
return DistanceThresholdBuilder(
|
|
349
|
-
self._project_path, self._filters, "instability"
|
|
350
|
-
)
|
|
330
|
+
return DistanceThresholdBuilder(self._project_path, self._filters, "instability")
|
|
351
331
|
|
|
352
332
|
def distance_from_main_sequence(self) -> "DistanceThresholdBuilder":
|
|
353
|
-
return DistanceThresholdBuilder(
|
|
354
|
-
self._project_path, self._filters, "distance"
|
|
355
|
-
)
|
|
333
|
+
return DistanceThresholdBuilder(self._project_path, self._filters, "distance")
|
|
356
334
|
|
|
357
335
|
def not_in_zone_of_pain(self) -> "ZoneCondition":
|
|
358
336
|
return ZoneCondition(self._project_path, self._filters, "pain")
|
|
@@ -440,11 +418,7 @@ class ZoneCondition:
|
|
|
440
418
|
|
|
441
419
|
for file_result in files:
|
|
442
420
|
dm = calculate_file_distance_metrics(file_result, files)
|
|
443
|
-
in_zone =
|
|
444
|
-
dm.in_zone_of_pain
|
|
445
|
-
if self._zone_type == "pain"
|
|
446
|
-
else dm.in_zone_of_uselessness
|
|
447
|
-
)
|
|
421
|
+
in_zone = dm.in_zone_of_pain if self._zone_type == "pain" else dm.in_zone_of_uselessness
|
|
448
422
|
|
|
449
423
|
if in_zone:
|
|
450
424
|
violations.append(
|
|
@@ -61,15 +61,11 @@ class SliceConditionBuilder:
|
|
|
61
61
|
|
|
62
62
|
def should(self) -> "PositiveConditionBuilder":
|
|
63
63
|
"""Begin positive assertion (slices SHOULD ...)."""
|
|
64
|
-
return PositiveConditionBuilder(
|
|
65
|
-
self._project_path, self._pattern, self._regex
|
|
66
|
-
)
|
|
64
|
+
return PositiveConditionBuilder(self._project_path, self._pattern, self._regex)
|
|
67
65
|
|
|
68
66
|
def should_not(self) -> "NegativeConditionBuilder":
|
|
69
67
|
"""Begin negative assertion (slices SHOULD NOT ...)."""
|
|
70
|
-
return NegativeConditionBuilder(
|
|
71
|
-
self._project_path, self._pattern, self._regex
|
|
72
|
-
)
|
|
68
|
+
return NegativeConditionBuilder(self._project_path, self._pattern, self._regex)
|
|
73
69
|
|
|
74
70
|
|
|
75
71
|
class PositiveConditionBuilder:
|
|
@@ -133,9 +129,7 @@ class NegativeConditionBuilder:
|
|
|
133
129
|
self._regex = regex
|
|
134
130
|
self._forbidden_deps: list[tuple[str, str]] = []
|
|
135
131
|
|
|
136
|
-
def contain_dependency(
|
|
137
|
-
self, source: str, target: str
|
|
138
|
-
) -> "NegativeSliceCondition":
|
|
132
|
+
def contain_dependency(self, source: str, target: str) -> "NegativeSliceCondition":
|
|
139
133
|
"""Assert that a specific dependency should NOT exist."""
|
|
140
134
|
return NegativeSliceCondition(
|
|
141
135
|
self._project_path,
|
|
@@ -170,9 +164,7 @@ class PositiveSliceCondition:
|
|
|
170
164
|
mapper = self._get_mapper()
|
|
171
165
|
edges = project_edges(graph, mapper)
|
|
172
166
|
|
|
173
|
-
return gather_positive_violations(
|
|
174
|
-
edges, rules, contained_nodes, self._coherence_options
|
|
175
|
-
)
|
|
167
|
+
return gather_positive_violations(edges, rules, contained_nodes, self._coherence_options)
|
|
176
168
|
|
|
177
169
|
def _get_mapper(self) -> MapFunction:
|
|
178
170
|
if self._pattern:
|
|
@@ -43,9 +43,7 @@ def generate_rule(puml_content: str) -> tuple[list[Rule], list[str]]:
|
|
|
43
43
|
continue
|
|
44
44
|
|
|
45
45
|
# Match component declarations: component [Name] or component [Name] #Color
|
|
46
|
-
comp_match = re.match(
|
|
47
|
-
r"component\s+\[([^\]]+)\]", stripped
|
|
48
|
-
)
|
|
46
|
+
comp_match = re.match(r"component\s+\[([^\]]+)\]", stripped)
|
|
49
47
|
if comp_match:
|
|
50
48
|
name = comp_match.group(1).strip()
|
|
51
49
|
if name not in contained_nodes:
|
|
@@ -53,9 +51,7 @@ def generate_rule(puml_content: str) -> tuple[list[Rule], list[str]]:
|
|
|
53
51
|
continue
|
|
54
52
|
|
|
55
53
|
# Match relationships: [Source] --> [Target] or [Source] -> [Target]
|
|
56
|
-
rel_match = re.match(
|
|
57
|
-
r"\[([^\]]+)\]\s*-+>\s*\[([^\]]+)\]", stripped
|
|
58
|
-
)
|
|
54
|
+
rel_match = re.match(r"\[([^\]]+)\]\s*-+>\s*\[([^\]]+)\]", stripped)
|
|
59
55
|
if rel_match:
|
|
60
56
|
source = rel_match.group(1).strip()
|
|
61
57
|
target = rel_match.group(2).strip()
|
|
@@ -12,6 +12,7 @@ from archunitpython.files.assertion.depend_on_external_modules import (
|
|
|
12
12
|
)
|
|
13
13
|
from archunitpython.files.assertion.depend_on_files import ViolatingFileDependency
|
|
14
14
|
from archunitpython.files.assertion.matching_files import ViolatingNode
|
|
15
|
+
from archunitpython.layers.assertion.layer_dependencies import LayerDependencyViolation
|
|
15
16
|
from archunitpython.metrics.assertion.metric_thresholds import (
|
|
16
17
|
FileCountViolation,
|
|
17
18
|
MetricViolation,
|
|
@@ -65,9 +66,7 @@ class ViolationFactory:
|
|
|
65
66
|
)
|
|
66
67
|
|
|
67
68
|
if isinstance(violation, ViolatingCycle):
|
|
68
|
-
cycle_str = " -> ".join(
|
|
69
|
-
e.source_label for e in violation.cycle
|
|
70
|
-
)
|
|
69
|
+
cycle_str = " -> ".join(e.source_label for e in violation.cycle)
|
|
71
70
|
return TestViolation(
|
|
72
71
|
message="Circular dependency detected",
|
|
73
72
|
details=f"Cycle: {cycle_str}",
|
|
@@ -79,6 +78,15 @@ class ViolationFactory:
|
|
|
79
78
|
details=f"File: {violation.file_info.path}",
|
|
80
79
|
)
|
|
81
80
|
|
|
81
|
+
if isinstance(violation, LayerDependencyViolation):
|
|
82
|
+
edge = violation.dependency
|
|
83
|
+
return TestViolation(
|
|
84
|
+
message="Layer dependency violation",
|
|
85
|
+
details=f"Layer '{violation.source_layer}' depends on "
|
|
86
|
+
f"disallowed layer '{violation.target_layer}' via "
|
|
87
|
+
f"'{edge.source_label}' -> '{edge.target_label}'",
|
|
88
|
+
)
|
|
89
|
+
|
|
82
90
|
if isinstance(violation, MetricViolation):
|
|
83
91
|
return TestViolation(
|
|
84
92
|
message=f"Metric '{violation.metric_name}' violation",
|