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 +15 -0
- modwire/_version.py +24 -0
- modwire/architecture/__init__.py +10 -0
- modwire/architecture/analyzers.py +140 -0
- modwire/architecture/matching.py +58 -0
- modwire/architecture/policy.py +63 -0
- modwire/architecture/render.py +98 -0
- modwire/architecture/violations.py +24 -0
- modwire/definitions.py +101 -0
- modwire/extraction.py +73 -0
- modwire/extractors/__init__.py +5 -0
- modwire/extractors/base.py +177 -0
- modwire/extractors/loader.py +31 -0
- modwire/extractors/php.py +170 -0
- modwire/extractors/python.py +113 -0
- modwire/extractors/scripts/php_extractor.php +816 -0
- modwire/extractors/scripts/python_extractor.py +398 -0
- modwire/extractors/scripts/typescript_extractor.js +1030 -0
- modwire/extractors/typescript.py +48 -0
- modwire/graph.py +56 -0
- modwire-1.0.0.dist-info/METADATA +111 -0
- modwire-1.0.0.dist-info/RECORD +25 -0
- modwire-1.0.0.dist-info/WHEEL +5 -0
- modwire-1.0.0.dist-info/licenses/LICENSE +21 -0
- modwire-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
]
|