modwire 1.0.0__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.
modwire/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from .extraction import CodeMap, extract_code
2
+ from .extractors.loader import normalize_source_id, supported_languages
3
+ from .graph import DependencyGraph, Edge, Node, build_dependency_graph
4
+
5
+
6
+ __all__ = [
7
+ "CodeMap",
8
+ "DependencyGraph",
9
+ "Edge",
10
+ "Node",
11
+ "build_dependency_graph",
12
+ "extract_code",
13
+ "normalize_source_id",
14
+ "supported_languages",
15
+ ]
modwire/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.0.0'
22
+ __version_tuple__ = version_tuple = (1, 0, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,10 @@
1
+ from .analyzers import supported_analyzers
2
+ from .policy import ArchitecturePolicyEvaluator
3
+ from .violations import EdgeRuleViolation, FlowViolation
4
+
5
+ __all__ = [
6
+ "ArchitecturePolicyEvaluator",
7
+ "EdgeRuleViolation",
8
+ "FlowViolation",
9
+ "supported_analyzers",
10
+ ]
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Callable
5
+
6
+ from .violations import FlowViolation
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class FlowAnalyzer:
11
+ name: str
12
+ title: str
13
+ run: Callable
14
+
15
+
16
+ def supported_analyzers() -> tuple[str, ...]:
17
+ return tuple(_ANALYZERS)
18
+
19
+
20
+ def analyzer_title(name: str) -> str:
21
+ return _ANALYZERS[name].title
22
+
23
+
24
+ def run_analyzer(name: str, graph, tags, config):
25
+ analyzer = _ANALYZERS[name]
26
+ return analyzer.run(analyzer.name, graph, tags, config)
27
+
28
+
29
+ def _roots(graph):
30
+ incoming = {node_id: 0 for node_id in graph.node_ids()}
31
+ for edge in graph.edges:
32
+ if edge.to_id in incoming:
33
+ incoming[edge.to_id] += 1
34
+ roots = [node_id for node_id, count in incoming.items() if count == 0]
35
+ return tuple(sorted(roots or incoming))
36
+
37
+
38
+ def _dedupe(violations):
39
+ seen = set()
40
+ unique = []
41
+ for violation in violations:
42
+ key = (violation.violation_type, violation.path, violation.violation_index)
43
+ if key not in seen:
44
+ seen.add(key)
45
+ unique.append(violation)
46
+ return unique
47
+
48
+
49
+ def _flow(name, path, index, message):
50
+ return FlowViolation(name, tuple(path), index, f"analyzer:{name}", message)
51
+
52
+
53
+ def _backward_flow(name, graph, tags, config):
54
+ layers = config.rules.flow.layers
55
+
56
+ def layer(node):
57
+ return next((i for i, layer_name in enumerate(layers) if layer_name in tags.get(node, set())), None)
58
+
59
+ violations = []
60
+ seen = set()
61
+
62
+ def walk(node, current_layer, path):
63
+ if (node, current_layer) in seen:
64
+ return
65
+ seen.add((node, current_layer))
66
+ for edge in graph.outgoing(node):
67
+ next_layer = layer(edge.to_id)
68
+ if current_layer is not None and next_layer is not None and next_layer < current_layer:
69
+ violations.append(_flow(name, [*path, edge.to_id], len(path), "layer order violated"))
70
+ continue
71
+ walk(edge.to_id, current_layer if next_layer is None else next_layer, [*path, edge.to_id])
72
+
73
+ for root in _roots(graph):
74
+ walk(root, layer(root), [root])
75
+ return _dedupe(violations)
76
+
77
+
78
+ def _no_reentry(name, graph, tags, config):
79
+ module_tag = config.rules.flow.module_tag
80
+ violations = []
81
+ seen = set()
82
+
83
+ def walk(node, state, path):
84
+ if (node, state) in seen:
85
+ return
86
+ seen.add((node, state))
87
+ for edge in graph.outgoing(node):
88
+ inside = module_tag in tags.get(edge.to_id, set())
89
+ next_state = 1 if state == 0 and inside else 2 if state == 1 and not inside else state
90
+ if state == 2 and inside:
91
+ violations.append(_flow(name, [*path, edge.to_id], len(path), "module layer re-entered after exit"))
92
+ continue
93
+ walk(edge.to_id, next_state, [*path, edge.to_id])
94
+
95
+ for root in _roots(graph):
96
+ walk(root, 1 if module_tag in tags.get(root, set()) else 0, [root])
97
+ return _dedupe(violations)
98
+
99
+
100
+ def _no_cycles(name, graph, tags, config):
101
+ scoped = {node for node, node_tags in tags.items() if config.rules.flow.module_tag in node_tags}
102
+ seen, stack, index, emitted, violations = set(), [], {}, set(), []
103
+
104
+ def canonical(cycle):
105
+ ring = list(cycle[:-1])
106
+ first = min(tuple(ring[i:] + ring[:i]) for i in range(len(ring)))
107
+ return (*first, first[0])
108
+
109
+ def dfs(node):
110
+ seen.add(node)
111
+ index[node] = len(stack)
112
+ stack.append(node)
113
+ for edge in graph.outgoing(node):
114
+ if edge.to_id not in scoped:
115
+ continue
116
+ if edge.to_id in index:
117
+ cycle = tuple([*stack[index[edge.to_id] :], edge.to_id])
118
+ key = canonical(cycle)
119
+ if key not in emitted:
120
+ emitted.add(key)
121
+ violations.append(_flow(name, cycle, len(cycle) - 1, "module cycle detected"))
122
+ elif edge.to_id not in seen:
123
+ dfs(edge.to_id)
124
+ stack.pop()
125
+ index.pop(node, None)
126
+
127
+ for node in sorted(scoped):
128
+ if node not in seen:
129
+ dfs(node)
130
+ return violations
131
+
132
+
133
+ _ANALYZERS = {
134
+ analyzer.name: analyzer
135
+ for analyzer in (
136
+ FlowAnalyzer("backward-flow", "Backward Flow Violations", _backward_flow),
137
+ FlowAnalyzer("no-reentry", "No Re-Entry Violations", _no_reentry),
138
+ FlowAnalyzer("no-cycles", "Cycle Violations", _no_cycles),
139
+ )
140
+ }
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from functools import cache
5
+
6
+ from modwire.extractors.loader import normalize_source_id
7
+
8
+
9
+ def match_node(node_id, pattern, config, exclusions, *, scope=True, exclude=()):
10
+ language = config.language
11
+ path = normalize_source_id(language, node_id)
12
+ for ignored in (*exclusions.get(pattern, ()), *exclude):
13
+ for normalized_ignored in _normalized_patterns(language, ignored, config):
14
+ if _regex(normalized_ignored, True).match(path):
15
+ return None
16
+ for normalized in _normalized_patterns(language, pattern, config):
17
+ match = _regex(normalized, scope).match(path)
18
+ if match is not None:
19
+ return match.group(1), "*" in normalized or "?" in normalized
20
+ return None
21
+
22
+
23
+ def _normalized_patterns(language, pattern, config):
24
+ normalized = normalize_source_id(language, pattern).strip("/")
25
+ architecture_root = normalize_source_id(
26
+ language,
27
+ getattr(config, "architecture_root", None) or "",
28
+ ).strip("/")
29
+ if not architecture_root:
30
+ return (normalized,)
31
+
32
+ root_anchor = architecture_root.split("/", 1)[0]
33
+ is_full_path = normalized == architecture_root or normalized.startswith(
34
+ (f"{architecture_root}/", f"{root_anchor}/")
35
+ )
36
+ if is_full_path:
37
+ return (normalized,)
38
+ return (f"{architecture_root}/{normalized}",)
39
+
40
+
41
+ @cache
42
+ def _regex(pattern: str, scope: bool):
43
+ parts = ["^("]
44
+ i = 0
45
+ while i < len(pattern):
46
+ char = pattern[i]
47
+ if char == "*":
48
+ is_deep = i + 1 < len(pattern) and pattern[i + 1] == "*"
49
+ parts.append("(.*)" if is_deep else "([^/]*)")
50
+ i += 2 if is_deep else 1
51
+ elif char == "?":
52
+ parts.append("([^/])")
53
+ i += 1
54
+ else:
55
+ parts.append(re.escape(char))
56
+ i += 1
57
+ parts.append(")(?:/.*)?" if scope else ")")
58
+ return re.compile("".join(parts) + "$")
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from modwire.graph import DependencyGraph
4
+
5
+ from .analyzers import run_analyzer
6
+ from .matching import match_node
7
+ from .violations import EdgeRuleViolation, FlowViolation
8
+
9
+
10
+ class ArchitecturePolicyEvaluator:
11
+ def evaluate(self, graph: DependencyGraph, config):
12
+ tags = _tags(graph.node_ids(), config)
13
+ violations: list[EdgeRuleViolation | FlowViolation] = _edge_violations(graph, config)
14
+ for analyzer_name in config.rules.flow.analyzers:
15
+ violations.extend(run_analyzer(analyzer_name, graph, tags, config))
16
+ return violations
17
+
18
+
19
+ def _edge_violations(graph, config):
20
+ violations = []
21
+ exclusions = {rule.match: rule.excluded_patterns for rule in config.rules.tags}
22
+ for edge in graph.edges:
23
+ denied: tuple[str, str] | None = None
24
+ for rule in config.rules.boundaries:
25
+ source = match_node(edge.from_id, rule.source, config, exclusions)
26
+ if source is None:
27
+ continue
28
+ for target in rule.disallow:
29
+ target_match = match_node(edge.to_id, target, config, exclusions)
30
+ same_owner = (
31
+ rule.allow_same_match
32
+ and source[1]
33
+ and target_match is not None
34
+ and target_match[1]
35
+ and source[0] == target_match[0]
36
+ )
37
+ if target_match is not None and not same_owner:
38
+ denied = (rule.source, target)
39
+ for target in rule.allow:
40
+ if match_node(edge.to_id, target, config, exclusions, scope=target in exclusions):
41
+ denied = None
42
+ if denied:
43
+ violations.append(
44
+ EdgeRuleViolation(
45
+ edge.from_id,
46
+ edge.to_id,
47
+ denied[0],
48
+ denied[1],
49
+ f"boundary:{denied[0]}->{denied[1]}:deny",
50
+ )
51
+ )
52
+ return violations
53
+
54
+
55
+ def _tags(node_ids, config):
56
+ return {
57
+ node_id: {
58
+ rule.name
59
+ for rule in config.rules.tags
60
+ if match_node(node_id, rule.match, config, {}, exclude=rule.excluded_patterns)
61
+ }
62
+ for node_id in node_ids
63
+ }
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+
6
+ from .analyzers import analyzer_title
7
+ from .violations import EDGE_RULE_TYPE, EdgeRuleViolation, FlowViolation
8
+
9
+ GROUP_TITLES = {
10
+ EDGE_RULE_TYPE: "Edge Rule Violations",
11
+ }
12
+
13
+
14
+ def summary(report) -> str:
15
+ return "\n".join(
16
+ (
17
+ "Boundaries Summary:",
18
+ f"- Paths found in scope: {report.files_found}",
19
+ f"- Paths excluded by rules: {report.files_excluded}",
20
+ f"- Files checked: {report.files_checked}",
21
+ )
22
+ )
23
+
24
+
25
+ def violations(report) -> str:
26
+ groups: dict[str, list[str]] = {}
27
+ for violation in report.violations:
28
+ violation_type = _type(violation)
29
+ title = GROUP_TITLES.get(violation_type)
30
+ if title is None:
31
+ title = analyzer_title(violation_type)
32
+ groups.setdefault(title, []).append(compact(violation))
33
+ return "\n".join(
34
+ line
35
+ for title, entries in groups.items()
36
+ for line in (f"{title}:", *(f"- {entry}" for entry in entries))
37
+ )
38
+
39
+
40
+ def compact(violation: EdgeRuleViolation | FlowViolation) -> str:
41
+ if isinstance(violation, EdgeRuleViolation):
42
+ return (
43
+ f"{violation.source_id} -> [{violation.target_id}] "
44
+ f"blocked by {violation.source_pattern} -> {violation.target_pattern}"
45
+ )
46
+ path = list(violation.path)
47
+ path[violation.violation_index] = f"[{path[violation.violation_index]}]"
48
+ return f"{' -> '.join(path)} {violation.violation_type}"
49
+
50
+
51
+ def render_json(report) -> str:
52
+ return json.dumps(
53
+ {
54
+ "project_root": str(report.project_root),
55
+ "config_path": str(report.config_path),
56
+ "config_format": report.config_format,
57
+ "language": report.language,
58
+ "runtime_command": report.runtime_command,
59
+ "files_found": report.files_found,
60
+ "files_excluded": report.files_excluded,
61
+ "files_checked": report.files_checked,
62
+ "violations": [_json_violation(v) for v in report.violations],
63
+ },
64
+ indent=2,
65
+ sort_keys=True,
66
+ )
67
+
68
+
69
+ def render_dot(report) -> str:
70
+ edges = dict.fromkeys(edge for violation in report.violations for edge in _edges(violation))
71
+ return "\n".join(
72
+ ("digraph architecture_violations {", " rankdir=LR;")
73
+ + tuple(f' "{source}" -> "{target}";' for source, target in edges)
74
+ + ("}",)
75
+ )
76
+
77
+
78
+ def _json_violation(violation):
79
+ payload = asdict(violation)
80
+ payload["type"] = _type(violation)
81
+ payload["path"] = (
82
+ [violation.source_id, violation.target_id]
83
+ if isinstance(violation, EdgeRuleViolation)
84
+ else list(violation.path)
85
+ )
86
+ if isinstance(violation, EdgeRuleViolation):
87
+ payload["violation_index"] = 1
88
+ return payload
89
+
90
+
91
+ def _edges(violation):
92
+ if isinstance(violation, EdgeRuleViolation):
93
+ return ((violation.source_id, violation.target_id),)
94
+ return tuple(zip(violation.path, violation.path[1:], strict=False))
95
+
96
+
97
+ def _type(violation):
98
+ return EDGE_RULE_TYPE if isinstance(violation, EdgeRuleViolation) else violation.violation_type
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ EDGE_RULE_TYPE = "edge-rule"
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class EdgeRuleViolation:
11
+ source_id: str
12
+ target_id: str
13
+ source_pattern: str
14
+ target_pattern: str
15
+ rule_name: str
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class FlowViolation:
20
+ violation_type: str
21
+ path: tuple[str, ...]
22
+ violation_index: int
23
+ rule_name: str
24
+ message: str
modwire/definitions.py ADDED
@@ -0,0 +1,101 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ ImportCrossingType = Literal["module", "symbol"]
7
+ SourceVisibility = Literal["public", "protected", "private"]
8
+ SourceSignatureKind = Literal["call", "construct", "index"]
9
+
10
+
11
+ class SourceImport(BaseModel):
12
+ path: str
13
+ is_relative: bool
14
+ normalized_path: str
15
+ imported_name: str
16
+ is_aliased: bool
17
+ crossing_type: ImportCrossingType
18
+ file_barrier_crossed: bool
19
+ statement_id: int
20
+ join_key: str
21
+ uses_joined_import: bool
22
+
23
+
24
+ class SourceFunction(BaseModel):
25
+ name: str
26
+ visibility: SourceVisibility
27
+ visibility_intent: SourceVisibility
28
+ line_count: int
29
+ declared_args: int
30
+ optional_args: int
31
+
32
+
33
+ class SourceClassMethod(BaseModel):
34
+ name: str
35
+ visibility: SourceVisibility
36
+ visibility_intent: SourceVisibility
37
+ line_count: int
38
+ declared_args: int
39
+ optional_args: int
40
+
41
+
42
+ class SourceClassProperty(BaseModel):
43
+ name: str
44
+ is_optional: bool
45
+
46
+
47
+ class SourceSignature(BaseModel):
48
+ kind: SourceSignatureKind
49
+ line_count: int
50
+ declared_args: int
51
+ optional_args: int
52
+
53
+
54
+ class SourceClass(BaseModel):
55
+ name: str
56
+ visibility: SourceVisibility
57
+ visibility_intent: SourceVisibility
58
+ methods: list[SourceClassMethod]
59
+ properties: list[SourceClassProperty]
60
+ line_count: int
61
+
62
+
63
+ class SourceInterface(BaseModel):
64
+ name: str
65
+ visibility: SourceVisibility
66
+ visibility_intent: SourceVisibility
67
+ methods: list[SourceClassMethod]
68
+ properties: list[SourceClassProperty]
69
+ signatures: list[SourceSignature]
70
+ line_count: int
71
+
72
+
73
+ class SourceType(BaseModel):
74
+ name: str
75
+ visibility: SourceVisibility
76
+ visibility_intent: SourceVisibility
77
+ properties: list[SourceClassProperty]
78
+ signatures: list[SourceSignature]
79
+ line_count: int
80
+
81
+
82
+ class SourceAbstractClass(BaseModel):
83
+ name: str
84
+ visibility: SourceVisibility
85
+ visibility_intent: SourceVisibility
86
+ abstract_methods: list[SourceClassMethod]
87
+ concrete_methods: list[SourceClassMethod]
88
+ properties: list[SourceClassProperty]
89
+ line_count: int
90
+
91
+
92
+ class SourceFile(BaseModel):
93
+ imports: list[SourceImport]
94
+ classes: list[SourceClass]
95
+ interfaces: list[SourceInterface] = Field(default_factory=list)
96
+ types: list[SourceType] = Field(default_factory=list)
97
+ abstract_classes: list[SourceAbstractClass] = Field(default_factory=list)
98
+ functions: list[SourceFunction]
99
+ line_count: int
100
+ code_line_count: int
101
+ public_symbol_count: int
modwire/extraction.py ADDED
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from .definitions import SourceFile
7
+ from .extractors import load_extractor
8
+ from .graph import DependencyGraph, build_dependency_graph
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ExtractionSummary:
13
+ files_found: int
14
+ files_checked: int
15
+ files_excluded: int
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ExtractionResult:
20
+ files: dict[str, SourceFile]
21
+ summary: ExtractionSummary
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CodeMap:
26
+ graph: DependencyGraph
27
+ extraction_result: ExtractionResult
28
+ runtime_command: str
29
+
30
+
31
+ def extract_code(
32
+ language: str,
33
+ sources_root: Path,
34
+ exclusions: tuple[str, ...],
35
+ ) -> CodeMap:
36
+ """ Extracts the code from the given source root and builds a dependency graph.
37
+
38
+ Args:
39
+ language (str): programming language name, supprted are: "python", "javascript", "typescript"
40
+ sources_root (Path): where to look for source files, usually src or lib folder in your project
41
+ exclusions (tuple[str, ...]): glob patterns to exclude files from extraction, e.g. ("**/node_modules/**", "**/dist/**"), relative to sources_root (just like gitignore patterns)
42
+ Returns:
43
+ CodeMap: graph of the codebase and extraction result, including summary and runtime command used for extraction
44
+ """
45
+ assert sources_root.is_dir(), f"Project root {sources_root} is not a directory"
46
+
47
+ extractor = load_extractor(language)
48
+ extraction = extractor.extract_files(sources_root, exclusions)
49
+ extracted_files = extraction.files
50
+ dependency_graph = build_dependency_graph(extracted_files)
51
+
52
+ extraction_result = ExtractionResult(
53
+ files=extracted_files,
54
+ summary=ExtractionSummary(
55
+ files_found=extraction.files_found,
56
+ files_checked=len(extracted_files),
57
+ files_excluded=extraction.files_excluded,
58
+ ),
59
+ )
60
+
61
+ return CodeMap(
62
+ graph=dependency_graph,
63
+ extraction_result=extraction_result,
64
+ runtime_command=extractor.command,
65
+ )
66
+
67
+
68
+ __all__ = [
69
+ "CodeMap",
70
+ "ExtractionResult",
71
+ "ExtractionSummary",
72
+ "extract_code",
73
+ ]
@@ -0,0 +1,5 @@
1
+ from .base import SourceExtractor
2
+ from .loader import load_extractor
3
+
4
+
5
+ __all__ = ["SourceExtractor", "load_extractor"]