@lamentis/naome 1.3.11 → 1.3.12
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.
- package/Cargo.lock +2 -2
- package/crates/naome-cli/Cargo.toml +1 -1
- package/crates/naome-cli/src/architecture_commands.rs +2 -6
- package/crates/naome-cli/tests/architecture_cli.rs +60 -0
- package/crates/naome-core/Cargo.toml +1 -1
- package/crates/naome-core/src/architecture/config/parser/sections.rs +44 -1
- package/crates/naome-core/src/architecture/config/parser.rs +1 -0
- package/crates/naome-core/src/architecture/config.rs +35 -0
- package/crates/naome-core/src/architecture/output.rs +15 -1
- package/crates/naome-core/src/architecture/rules/budgets.rs +179 -0
- package/crates/naome-core/src/architecture/rules/context.rs +138 -0
- package/crates/naome-core/src/architecture/rules/cycles.rs +39 -0
- package/crates/naome-core/src/architecture/rules/external.rs +185 -0
- package/crates/naome-core/src/architecture/rules/graph.rs +177 -0
- package/crates/naome-core/src/architecture/rules/transitive.rs +89 -0
- package/crates/naome-core/src/architecture/rules.rs +13 -39
- package/crates/naome-core/src/architecture/scan/imports/extractors.rs +4 -7
- package/crates/naome-core/src/architecture/scan/imports/resolver.rs +3 -21
- package/crates/naome-core/src/architecture/scan/imports.rs +16 -0
- package/crates/naome-core/src/architecture.rs +1 -1
- package/crates/naome-core/src/lib.rs +1 -0
- package/crates/naome-core/tests/architecture.rs +53 -85
- package/crates/naome-core/tests/architecture_rules.rs +498 -0
- package/crates/naome-core/tests/architecture_support/mod.rs +78 -0
- package/installer/harness-files.js +3 -3
- package/native/darwin-arm64/naome +0 -0
- package/native/linux-x64/naome +0 -0
- package/package.json +1 -1
- package/templates/naome-root/.naome/manifest.json +1 -1
- package/templates/naome-root/docs/naome/architecture-fitness.md +49 -6
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
use crate::architecture::output::ArchitectureViolation;
|
|
2
|
+
use crate::architecture::scan::{ArchitectureScanReport, ImportTarget};
|
|
3
|
+
use crate::paths;
|
|
4
|
+
|
|
5
|
+
pub(super) fn validate_external_dependency_policy(
|
|
6
|
+
scan: &ArchitectureScanReport,
|
|
7
|
+
violations: &mut Vec<ArchitectureViolation>,
|
|
8
|
+
rules_executed: &mut Vec<String>,
|
|
9
|
+
) {
|
|
10
|
+
let rule = scan.config.rule("external_dependency_policy");
|
|
11
|
+
if !rule.enabled {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
rules_executed.push("arch.external_dependency_policy".to_string());
|
|
15
|
+
for fact in scan.file_facts.values() {
|
|
16
|
+
let owners = fact
|
|
17
|
+
.layers
|
|
18
|
+
.iter()
|
|
19
|
+
.chain(fact.contexts.iter())
|
|
20
|
+
.collect::<Vec<_>>();
|
|
21
|
+
for import in &fact.imports {
|
|
22
|
+
let ImportTarget::ExternalDependency(package) = &import.target else {
|
|
23
|
+
continue;
|
|
24
|
+
};
|
|
25
|
+
if is_standard_library_dependency(fact.language.as_deref(), package) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
for owner in &owners {
|
|
29
|
+
let Some(policy) = scan.config.external_dependencies.get(*owner) else {
|
|
30
|
+
continue;
|
|
31
|
+
};
|
|
32
|
+
if paths::matches_any(package, &policy.allow) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
violations.push(ArchitectureViolation {
|
|
36
|
+
id: "arch.external_dependency_policy".to_string(),
|
|
37
|
+
severity: rule.severity,
|
|
38
|
+
violation_type: "external_dependency_policy".to_string(),
|
|
39
|
+
message: format!(
|
|
40
|
+
"{} in {} imports external dependency {} without policy allowance.",
|
|
41
|
+
fact.path, owner, package
|
|
42
|
+
),
|
|
43
|
+
from: Some(format!("file:{}", fact.path)),
|
|
44
|
+
to: Some(format!("external:{package}")),
|
|
45
|
+
path: Some(fact.path.clone()),
|
|
46
|
+
source_range: import.source_range.clone(),
|
|
47
|
+
suggestion: format!(
|
|
48
|
+
"Move {} behind an allowed adapter or add it to external_dependencies.{}.allow with intent.",
|
|
49
|
+
package, owner
|
|
50
|
+
),
|
|
51
|
+
agent_instruction: format!(
|
|
52
|
+
"Do not import external dependency {} from {}; use an allowed boundary.",
|
|
53
|
+
package, owner
|
|
54
|
+
),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fn is_standard_library_dependency(language: Option<&str>, package: &str) -> bool {
|
|
62
|
+
let root = package
|
|
63
|
+
.strip_prefix("node:")
|
|
64
|
+
.unwrap_or(package)
|
|
65
|
+
.split(['/', '.', ':'])
|
|
66
|
+
.next()
|
|
67
|
+
.unwrap_or(package);
|
|
68
|
+
match language {
|
|
69
|
+
Some("go") => !package
|
|
70
|
+
.split('/')
|
|
71
|
+
.next()
|
|
72
|
+
.is_some_and(|prefix| prefix.contains('.')),
|
|
73
|
+
Some("javascript") | Some("typescript") => NODE_BUILTINS.contains(&root),
|
|
74
|
+
Some("python") => PYTHON_STDLIB.contains(&root),
|
|
75
|
+
Some("rust") => matches!(root, "std" | "core" | "alloc" | "proc_macro" | "test"),
|
|
76
|
+
_ => false,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const NODE_BUILTINS: &[&str] = &[
|
|
81
|
+
"assert",
|
|
82
|
+
"async_hooks",
|
|
83
|
+
"buffer",
|
|
84
|
+
"child_process",
|
|
85
|
+
"cluster",
|
|
86
|
+
"console",
|
|
87
|
+
"constants",
|
|
88
|
+
"crypto",
|
|
89
|
+
"diagnostics_channel",
|
|
90
|
+
"dns",
|
|
91
|
+
"domain",
|
|
92
|
+
"events",
|
|
93
|
+
"fs",
|
|
94
|
+
"http",
|
|
95
|
+
"http2",
|
|
96
|
+
"https",
|
|
97
|
+
"inspector",
|
|
98
|
+
"module",
|
|
99
|
+
"net",
|
|
100
|
+
"os",
|
|
101
|
+
"path",
|
|
102
|
+
"perf_hooks",
|
|
103
|
+
"process",
|
|
104
|
+
"punycode",
|
|
105
|
+
"querystring",
|
|
106
|
+
"readline",
|
|
107
|
+
"repl",
|
|
108
|
+
"stream",
|
|
109
|
+
"string_decoder",
|
|
110
|
+
"timers",
|
|
111
|
+
"tls",
|
|
112
|
+
"trace_events",
|
|
113
|
+
"tty",
|
|
114
|
+
"url",
|
|
115
|
+
"util",
|
|
116
|
+
"v8",
|
|
117
|
+
"vm",
|
|
118
|
+
"wasi",
|
|
119
|
+
"worker_threads",
|
|
120
|
+
"zlib",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
const PYTHON_STDLIB: &[&str] = &[
|
|
124
|
+
"abc",
|
|
125
|
+
"argparse",
|
|
126
|
+
"array",
|
|
127
|
+
"ast",
|
|
128
|
+
"asyncio",
|
|
129
|
+
"base64",
|
|
130
|
+
"bisect",
|
|
131
|
+
"calendar",
|
|
132
|
+
"collections",
|
|
133
|
+
"concurrent",
|
|
134
|
+
"contextlib",
|
|
135
|
+
"copy",
|
|
136
|
+
"csv",
|
|
137
|
+
"dataclasses",
|
|
138
|
+
"datetime",
|
|
139
|
+
"decimal",
|
|
140
|
+
"email",
|
|
141
|
+
"enum",
|
|
142
|
+
"functools",
|
|
143
|
+
"glob",
|
|
144
|
+
"gzip",
|
|
145
|
+
"hashlib",
|
|
146
|
+
"heapq",
|
|
147
|
+
"hmac",
|
|
148
|
+
"html",
|
|
149
|
+
"http",
|
|
150
|
+
"importlib",
|
|
151
|
+
"inspect",
|
|
152
|
+
"io",
|
|
153
|
+
"itertools",
|
|
154
|
+
"json",
|
|
155
|
+
"logging",
|
|
156
|
+
"math",
|
|
157
|
+
"multiprocessing",
|
|
158
|
+
"operator",
|
|
159
|
+
"os",
|
|
160
|
+
"pathlib",
|
|
161
|
+
"pickle",
|
|
162
|
+
"platform",
|
|
163
|
+
"queue",
|
|
164
|
+
"random",
|
|
165
|
+
"re",
|
|
166
|
+
"shlex",
|
|
167
|
+
"shutil",
|
|
168
|
+
"signal",
|
|
169
|
+
"socket",
|
|
170
|
+
"sqlite3",
|
|
171
|
+
"statistics",
|
|
172
|
+
"string",
|
|
173
|
+
"subprocess",
|
|
174
|
+
"sys",
|
|
175
|
+
"tempfile",
|
|
176
|
+
"threading",
|
|
177
|
+
"time",
|
|
178
|
+
"traceback",
|
|
179
|
+
"typing",
|
|
180
|
+
"unittest",
|
|
181
|
+
"urllib",
|
|
182
|
+
"uuid",
|
|
183
|
+
"xml",
|
|
184
|
+
"zipfile",
|
|
185
|
+
];
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
2
|
+
|
|
3
|
+
use crate::architecture::model::ArchitectureEdgeKind;
|
|
4
|
+
use crate::architecture::scan::ArchitectureScanReport;
|
|
5
|
+
|
|
6
|
+
#[derive(Debug, Clone)]
|
|
7
|
+
pub(super) struct ImportEdge<'a> {
|
|
8
|
+
pub(super) from_path: &'a str,
|
|
9
|
+
pub(super) to_path: &'a str,
|
|
10
|
+
pub(super) edge_index: usize,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
pub(super) fn file_import_edges(scan: &ArchitectureScanReport) -> Vec<ImportEdge<'_>> {
|
|
14
|
+
scan.graph
|
|
15
|
+
.edges
|
|
16
|
+
.iter()
|
|
17
|
+
.enumerate()
|
|
18
|
+
.filter_map(|(edge_index, edge)| {
|
|
19
|
+
(edge.kind == ArchitectureEdgeKind::Imports)
|
|
20
|
+
.then(|| {
|
|
21
|
+
Some(ImportEdge {
|
|
22
|
+
from_path: edge.from.strip_prefix("file:")?,
|
|
23
|
+
to_path: edge.to.strip_prefix("file:")?,
|
|
24
|
+
edge_index,
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
.flatten()
|
|
28
|
+
})
|
|
29
|
+
.collect()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub(super) fn file_import_adjacency(
|
|
33
|
+
scan: &ArchitectureScanReport,
|
|
34
|
+
) -> BTreeMap<String, Vec<String>> {
|
|
35
|
+
file_adjacency(file_import_edges(scan).into_iter())
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub(super) fn file_cycle_adjacency(scan: &ArchitectureScanReport) -> BTreeMap<String, Vec<String>> {
|
|
39
|
+
file_adjacency(
|
|
40
|
+
file_import_edges(scan)
|
|
41
|
+
.into_iter()
|
|
42
|
+
.filter(|edge| is_cycle_dependency(scan, edge)),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn file_adjacency<'a>(
|
|
47
|
+
edges: impl Iterator<Item = ImportEdge<'a>>,
|
|
48
|
+
) -> BTreeMap<String, Vec<String>> {
|
|
49
|
+
let mut adjacency = BTreeMap::<String, BTreeSet<String>>::new();
|
|
50
|
+
for edge in edges {
|
|
51
|
+
adjacency
|
|
52
|
+
.entry(edge.from_path.to_string())
|
|
53
|
+
.or_default()
|
|
54
|
+
.insert(edge.to_path.to_string());
|
|
55
|
+
}
|
|
56
|
+
adjacency
|
|
57
|
+
.into_iter()
|
|
58
|
+
.map(|(from, targets)| (from, targets.into_iter().collect()))
|
|
59
|
+
.collect()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fn is_cycle_dependency(scan: &ArchitectureScanReport, edge: &ImportEdge<'_>) -> bool {
|
|
63
|
+
let graph_edge = &scan.graph.edges[edge.edge_index];
|
|
64
|
+
if graph_edge.metadata.language.as_deref() != Some("rust") {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
let specifier = graph_edge
|
|
68
|
+
.metadata
|
|
69
|
+
.raw_origin
|
|
70
|
+
.get("specifier")
|
|
71
|
+
.and_then(serde_json::Value::as_str)
|
|
72
|
+
.unwrap_or_default();
|
|
73
|
+
!is_rust_module_cycle_exempt(edge.from_path, edge.to_path, specifier)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fn is_rust_module_cycle_exempt(from_path: &str, to_path: &str, specifier: &str) -> bool {
|
|
77
|
+
if !is_rust_module_family_reference(specifier) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
is_rust_parent_child_module_family(from_path, to_path)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fn is_rust_module_family_reference(specifier: &str) -> bool {
|
|
84
|
+
specifier.starts_with("self::")
|
|
85
|
+
|| specifier.starts_with("super::")
|
|
86
|
+
|| specifier.starts_with("crate::")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fn is_rust_parent_child_module_family(from_path: &str, to_path: &str) -> bool {
|
|
90
|
+
let Some(from_module) = rust_module_id(from_path) else {
|
|
91
|
+
return false;
|
|
92
|
+
};
|
|
93
|
+
let Some(to_module) = rust_module_id(to_path) else {
|
|
94
|
+
return false;
|
|
95
|
+
};
|
|
96
|
+
if from_module == to_module {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
from_module
|
|
100
|
+
.strip_prefix(&to_module)
|
|
101
|
+
.is_some_and(|suffix| suffix.starts_with('/'))
|
|
102
|
+
|| to_module
|
|
103
|
+
.strip_prefix(&from_module)
|
|
104
|
+
.is_some_and(|suffix| suffix.starts_with('/'))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fn rust_module_id(path: &str) -> Option<String> {
|
|
108
|
+
path.strip_suffix("/mod.rs")
|
|
109
|
+
.map(ToString::to_string)
|
|
110
|
+
.or_else(|| path.strip_suffix(".rs").map(ToString::to_string))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
pub(super) fn strongly_connected_components(
|
|
114
|
+
adjacency: &BTreeMap<String, Vec<String>>,
|
|
115
|
+
) -> Vec<Vec<String>> {
|
|
116
|
+
let mut state = TarjanState::default();
|
|
117
|
+
for node in adjacency.keys() {
|
|
118
|
+
if !state.indices.contains_key(node) {
|
|
119
|
+
strong_connect(node, adjacency, &mut state);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
state
|
|
123
|
+
.components
|
|
124
|
+
.into_iter()
|
|
125
|
+
.filter(|component| {
|
|
126
|
+
component.len() > 1
|
|
127
|
+
|| component.first().is_some_and(|node| {
|
|
128
|
+
adjacency
|
|
129
|
+
.get(node)
|
|
130
|
+
.is_some_and(|targets| targets.contains(node))
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
.collect()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[derive(Default)]
|
|
137
|
+
struct TarjanState {
|
|
138
|
+
next_index: usize,
|
|
139
|
+
stack: Vec<String>,
|
|
140
|
+
on_stack: BTreeSet<String>,
|
|
141
|
+
indices: BTreeMap<String, usize>,
|
|
142
|
+
lowlinks: BTreeMap<String, usize>,
|
|
143
|
+
components: Vec<Vec<String>>,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
fn strong_connect(node: &str, adjacency: &BTreeMap<String, Vec<String>>, state: &mut TarjanState) {
|
|
147
|
+
let index = state.next_index;
|
|
148
|
+
state.next_index += 1;
|
|
149
|
+
state.indices.insert(node.to_string(), index);
|
|
150
|
+
state.lowlinks.insert(node.to_string(), index);
|
|
151
|
+
state.stack.push(node.to_string());
|
|
152
|
+
state.on_stack.insert(node.to_string());
|
|
153
|
+
|
|
154
|
+
for target in adjacency.get(node).into_iter().flatten() {
|
|
155
|
+
if !state.indices.contains_key(target) {
|
|
156
|
+
strong_connect(target, adjacency, state);
|
|
157
|
+
let lowlink = state.lowlinks[node].min(state.lowlinks[target]);
|
|
158
|
+
state.lowlinks.insert(node.to_string(), lowlink);
|
|
159
|
+
} else if state.on_stack.contains(target) {
|
|
160
|
+
let lowlink = state.lowlinks[node].min(state.indices[target]);
|
|
161
|
+
state.lowlinks.insert(node.to_string(), lowlink);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if state.lowlinks[node] == state.indices[node] {
|
|
166
|
+
let mut component = Vec::new();
|
|
167
|
+
while let Some(member) = state.stack.pop() {
|
|
168
|
+
state.on_stack.remove(&member);
|
|
169
|
+
component.push(member.clone());
|
|
170
|
+
if member == node {
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
component.sort();
|
|
175
|
+
state.components.push(component);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
|
2
|
+
|
|
3
|
+
use crate::architecture::output::ArchitectureViolation;
|
|
4
|
+
use crate::architecture::scan::ArchitectureScanReport;
|
|
5
|
+
|
|
6
|
+
use super::graph;
|
|
7
|
+
|
|
8
|
+
pub(super) fn validate_transitive_layer_dependencies(
|
|
9
|
+
scan: &ArchitectureScanReport,
|
|
10
|
+
violations: &mut Vec<ArchitectureViolation>,
|
|
11
|
+
rules_executed: &mut Vec<String>,
|
|
12
|
+
) {
|
|
13
|
+
let rule = scan
|
|
14
|
+
.config
|
|
15
|
+
.rule("no_transitive_forbidden_layer_dependencies");
|
|
16
|
+
if !rule.enabled {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
rules_executed.push("arch.no_transitive_forbidden_layer_dependencies".to_string());
|
|
20
|
+
let adjacency = graph::file_import_adjacency(scan);
|
|
21
|
+
for (from_path, from_fact) in &scan.file_facts {
|
|
22
|
+
for from_layer in &from_fact.layers {
|
|
23
|
+
let Some((target_path, target_layers)) =
|
|
24
|
+
first_forbidden_target(scan, &adjacency, from_path, from_layer)
|
|
25
|
+
else {
|
|
26
|
+
continue;
|
|
27
|
+
};
|
|
28
|
+
violations.push(ArchitectureViolation {
|
|
29
|
+
id: "arch.no_transitive_forbidden_layer_dependencies".to_string(),
|
|
30
|
+
severity: rule.severity,
|
|
31
|
+
violation_type: "transitive_forbidden_dependency".to_string(),
|
|
32
|
+
message: format!(
|
|
33
|
+
"{from_path} in layer {from_layer} transitively reaches {target_path} in forbidden layer {target_layers}."
|
|
34
|
+
),
|
|
35
|
+
from: Some(format!("file:{from_path}")),
|
|
36
|
+
to: Some(format!("file:{target_path}")),
|
|
37
|
+
path: Some(from_path.clone()),
|
|
38
|
+
source_range: None,
|
|
39
|
+
suggestion: "Remove the indirect dependency chain or introduce an allowed boundary."
|
|
40
|
+
.to_string(),
|
|
41
|
+
agent_instruction:
|
|
42
|
+
"Do not reach forbidden layers indirectly; move the dependency behind an allowed interface."
|
|
43
|
+
.to_string(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fn first_forbidden_target(
|
|
50
|
+
scan: &ArchitectureScanReport,
|
|
51
|
+
adjacency: &BTreeMap<String, Vec<String>>,
|
|
52
|
+
from_path: &str,
|
|
53
|
+
from_layer: &str,
|
|
54
|
+
) -> Option<(String, String)> {
|
|
55
|
+
let mut queue = VecDeque::new();
|
|
56
|
+
let mut visited = BTreeSet::new();
|
|
57
|
+
for target in adjacency.get(from_path).into_iter().flatten() {
|
|
58
|
+
queue.push_back((target.clone(), 1usize));
|
|
59
|
+
}
|
|
60
|
+
while let Some((path, depth)) = queue.pop_front() {
|
|
61
|
+
if !visited.insert(path.clone()) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if depth > 1 {
|
|
65
|
+
let Some(fact) = scan.file_facts.get(&path) else {
|
|
66
|
+
continue;
|
|
67
|
+
};
|
|
68
|
+
if !fact.layers.is_empty() && !layer_allowed(scan, from_layer, &fact.layers) {
|
|
69
|
+
return Some((path, fact.layers.join(", ")));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for target in adjacency.get(&path).into_iter().flatten() {
|
|
73
|
+
queue.push_back((target.clone(), depth + 1));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
None
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fn layer_allowed(scan: &ArchitectureScanReport, from_layer: &str, targets: &[String]) -> bool {
|
|
80
|
+
let allowed = scan
|
|
81
|
+
.config
|
|
82
|
+
.allowed_dependencies
|
|
83
|
+
.get(from_layer)
|
|
84
|
+
.map(Vec::as_slice)
|
|
85
|
+
.unwrap_or(&[]);
|
|
86
|
+
targets
|
|
87
|
+
.iter()
|
|
88
|
+
.any(|to_layer| from_layer == to_layer || allowed.contains(to_layer))
|
|
89
|
+
}
|
|
@@ -7,13 +7,25 @@ use super::output::{
|
|
|
7
7
|
use super::scan::ArchitectureScanReport;
|
|
8
8
|
use crate::architecture::model::ArchitectureEdgeKind;
|
|
9
9
|
|
|
10
|
+
mod budgets;
|
|
11
|
+
mod context;
|
|
12
|
+
mod cycles;
|
|
13
|
+
mod external;
|
|
14
|
+
mod graph;
|
|
15
|
+
mod transitive;
|
|
16
|
+
|
|
10
17
|
pub fn validate_scan(scan: ArchitectureScanReport) -> ArchitectureValidation {
|
|
11
18
|
let mut violations = Vec::new();
|
|
12
19
|
let mut rules_executed = Vec::new();
|
|
13
20
|
|
|
14
|
-
|
|
21
|
+
budgets::validate_file_size_budget(&scan, &mut violations, &mut rules_executed);
|
|
15
22
|
validate_generated_manual_boundary(&scan, &mut violations, &mut rules_executed);
|
|
16
23
|
validate_forbidden_layer_dependencies(&scan, &mut violations, &mut rules_executed);
|
|
24
|
+
context::validate_context_rules(&scan, &mut violations, &mut rules_executed);
|
|
25
|
+
cycles::validate_cycles(&scan, &mut violations, &mut rules_executed);
|
|
26
|
+
transitive::validate_transitive_layer_dependencies(&scan, &mut violations, &mut rules_executed);
|
|
27
|
+
budgets::validate_dependency_budgets(&scan, &mut violations, &mut rules_executed);
|
|
28
|
+
external::validate_external_dependency_policy(&scan, &mut violations, &mut rules_executed);
|
|
17
29
|
|
|
18
30
|
violations.sort_by(|left, right| {
|
|
19
31
|
(
|
|
@@ -118,44 +130,6 @@ fn validate_forbidden_layer_dependencies(
|
|
|
118
130
|
}
|
|
119
131
|
}
|
|
120
132
|
|
|
121
|
-
fn validate_max_file_lines(
|
|
122
|
-
scan: &ArchitectureScanReport,
|
|
123
|
-
violations: &mut Vec<ArchitectureViolation>,
|
|
124
|
-
rules_executed: &mut Vec<String>,
|
|
125
|
-
) {
|
|
126
|
-
let rule = scan.config.rule("max_file_lines");
|
|
127
|
-
if !rule.enabled {
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
rules_executed.push("arch.max_file_lines".to_string());
|
|
131
|
-
let Some(limit) = rule.value else {
|
|
132
|
-
return;
|
|
133
|
-
};
|
|
134
|
-
for fact in scan.file_facts.values() {
|
|
135
|
-
if fact.line_count <= limit {
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
violations.push(ArchitectureViolation {
|
|
139
|
-
id: "arch.max_file_lines".to_string(),
|
|
140
|
-
severity: rule.severity,
|
|
141
|
-
violation_type: "file_size_budget".to_string(),
|
|
142
|
-
message: format!(
|
|
143
|
-
"{} has {} lines, exceeding the configured budget of {}.",
|
|
144
|
-
fact.path, fact.line_count, limit
|
|
145
|
-
),
|
|
146
|
-
from: Some(format!("file:{}", fact.path)),
|
|
147
|
-
to: None,
|
|
148
|
-
path: Some(fact.path.clone()),
|
|
149
|
-
source_range: None,
|
|
150
|
-
suggestion: "Split the file into cohesive modules or move generated content behind an explicit ignore rule.".to_string(),
|
|
151
|
-
agent_instruction: format!(
|
|
152
|
-
"Reduce {} below {} lines or add a justified generated-code ignore rule if it is not manually owned.",
|
|
153
|
-
fact.path, limit
|
|
154
|
-
),
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
133
|
fn validate_generated_manual_boundary(
|
|
160
134
|
scan: &ArchitectureScanReport,
|
|
161
135
|
violations: &mut Vec<ArchitectureViolation>,
|
|
@@ -69,12 +69,6 @@ fn extract_rust(content: &str) -> Vec<RawImport> {
|
|
|
69
69
|
.strip_prefix("use ")
|
|
70
70
|
.or_else(|| rust_visible_use_value(trimmed))
|
|
71
71
|
.map(rust_use_specifiers)
|
|
72
|
-
.or_else(|| {
|
|
73
|
-
trimmed
|
|
74
|
-
.strip_prefix("mod ")
|
|
75
|
-
.or_else(|| trimmed.strip_prefix("pub mod "))
|
|
76
|
-
.map(|value| vec![format!("rustmod:{}", value.trim_end_matches(';').trim())])
|
|
77
|
-
})
|
|
78
72
|
.or_else(|| {
|
|
79
73
|
trimmed
|
|
80
74
|
.strip_prefix("extern crate ")
|
|
@@ -198,7 +192,10 @@ fn starts_javascript_pending_import(line: &str) -> bool {
|
|
|
198
192
|
}
|
|
199
193
|
|
|
200
194
|
fn is_comment_only(line: &str) -> bool {
|
|
201
|
-
line.starts_with("//")
|
|
195
|
+
line.starts_with("//")
|
|
196
|
+
|| line.starts_with("/*")
|
|
197
|
+
|| line.starts_with('*')
|
|
198
|
+
|| line.starts_with('#')
|
|
202
199
|
}
|
|
203
200
|
|
|
204
201
|
fn rust_visible_use_value(line: &str) -> Option<&str> {
|
|
@@ -12,13 +12,6 @@ pub(super) fn resolve_import(
|
|
|
12
12
|
repository_files: &BTreeSet<String>,
|
|
13
13
|
) -> ImportTarget {
|
|
14
14
|
let language = language_for_path(from_path);
|
|
15
|
-
if from_path.ends_with(".rs") {
|
|
16
|
-
if let Some(module) = specifier.strip_prefix("rustmod:") {
|
|
17
|
-
return resolve_rust_module_declaration(from_path, module, repository_files)
|
|
18
|
-
.map(ImportTarget::File)
|
|
19
|
-
.unwrap_or_else(|| ImportTarget::Unknown(specifier.to_string()));
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
15
|
if specifier.starts_with('.') {
|
|
23
16
|
if from_path.ends_with(".py") {
|
|
24
17
|
return resolve_python_relative(from_path, specifier, repository_files)
|
|
@@ -155,19 +148,6 @@ fn resolve_rust_local(
|
|
|
155
148
|
}
|
|
156
149
|
}
|
|
157
150
|
|
|
158
|
-
fn resolve_rust_module_declaration(
|
|
159
|
-
from_path: &str,
|
|
160
|
-
module: &str,
|
|
161
|
-
repository_files: &BTreeSet<String>,
|
|
162
|
-
) -> Option<String> {
|
|
163
|
-
let module = module.trim();
|
|
164
|
-
if module.is_empty() || module.contains("::") || module.contains('/') {
|
|
165
|
-
return None;
|
|
166
|
-
}
|
|
167
|
-
let module_base = normalize(Path::new(&rust_module_directory(from_path)).join(module));
|
|
168
|
-
resolve_candidates(&module_base, repository_files, Some("rust"))
|
|
169
|
-
}
|
|
170
|
-
|
|
171
151
|
fn rust_module_directory(from_path: &str) -> String {
|
|
172
152
|
let source = Path::new(from_path);
|
|
173
153
|
let parent = source.parent().unwrap_or_else(|| Path::new(""));
|
|
@@ -217,7 +197,9 @@ fn go_module_for_file(
|
|
|
217
197
|
from_path: &str,
|
|
218
198
|
repository_files: &BTreeSet<String>,
|
|
219
199
|
) -> Option<String> {
|
|
220
|
-
let mut current = Path::new(from_path)
|
|
200
|
+
let mut current = Path::new(from_path)
|
|
201
|
+
.parent()
|
|
202
|
+
.unwrap_or_else(|| Path::new(""));
|
|
221
203
|
loop {
|
|
222
204
|
let go_mod_path = normalize(current.join("go.mod"));
|
|
223
205
|
if repository_files.contains(&go_mod_path) {
|
|
@@ -26,12 +26,25 @@ pub(super) fn extract_imports(
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
pub(super) fn package_name(specifier: &str) -> String {
|
|
29
|
+
let specifier = specifier
|
|
30
|
+
.split_once(" as ")
|
|
31
|
+
.map(|(package, _alias)| package)
|
|
32
|
+
.unwrap_or(specifier)
|
|
33
|
+
.trim();
|
|
29
34
|
if let Some(rest) = specifier.strip_prefix('@') {
|
|
30
35
|
let mut parts = rest.split('/');
|
|
31
36
|
let scope = parts.next().unwrap_or_default();
|
|
32
37
|
let package = parts.next().unwrap_or_default();
|
|
33
38
|
return format!("@{scope}/{package}");
|
|
34
39
|
}
|
|
40
|
+
if specifier
|
|
41
|
+
.split('/')
|
|
42
|
+
.next()
|
|
43
|
+
.is_some_and(|prefix| prefix.contains('.'))
|
|
44
|
+
&& specifier.contains('/')
|
|
45
|
+
{
|
|
46
|
+
return specifier.to_string();
|
|
47
|
+
}
|
|
35
48
|
specifier
|
|
36
49
|
.split("::")
|
|
37
50
|
.next()
|
|
@@ -39,6 +52,9 @@ pub(super) fn package_name(specifier: &str) -> String {
|
|
|
39
52
|
.split('/')
|
|
40
53
|
.next()
|
|
41
54
|
.unwrap_or(specifier)
|
|
55
|
+
.split('.')
|
|
56
|
+
.next()
|
|
57
|
+
.unwrap_or(specifier)
|
|
42
58
|
.to_string()
|
|
43
59
|
}
|
|
44
60
|
|
|
@@ -18,7 +18,7 @@ pub use model::{
|
|
|
18
18
|
pub use output::{
|
|
19
19
|
format_architecture_explain, format_architecture_scan, format_architecture_validation,
|
|
20
20
|
ArchitectureAgentFeedback, ArchitectureValidation, ArchitectureViolation, Severity,
|
|
21
|
-
ViolationSummary,
|
|
21
|
+
ViolationSummary, ARCHITECTURE_RULE_IDS,
|
|
22
22
|
};
|
|
23
23
|
pub use scan::{scan_architecture, ArchitectureScanOptions, ArchitectureScanReport};
|
|
24
24
|
|
|
@@ -27,6 +27,7 @@ pub use architecture::{
|
|
|
27
27
|
ArchitectureGraph, ArchitectureMetadata, ArchitectureNode, ArchitectureNodeKind,
|
|
28
28
|
ArchitectureScanOptions, ArchitectureScanReport, ArchitectureValidation, ArchitectureViolation,
|
|
29
29
|
ContextConfig, LayerConfig, RuleConfig, Severity, SourceRange, ViolationSummary,
|
|
30
|
+
ARCHITECTURE_RULE_IDS,
|
|
30
31
|
};
|
|
31
32
|
pub use context::{
|
|
32
33
|
select_context_for_changed_paths, select_context_for_prompt, ContextBudgetLedger,
|