@lamentis/naome 1.3.11 → 1.3.13
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 +244 -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/graph_builder.rs +130 -30
- package/crates/naome-core/src/architecture/scan/imports/extractors/swift.rs +48 -0
- package/crates/naome-core/src/architecture/scan/imports/extractors.rs +7 -7
- package/crates/naome-core/src/architecture/scan/imports/resolver.rs +44 -22
- package/crates/naome-core/src/architecture/scan/imports.rs +17 -0
- package/crates/naome-core/src/architecture/scan/manifest/common.rs +102 -0
- package/crates/naome-core/src/architecture/scan/manifest/parsers/json.rs +46 -0
- package/crates/naome-core/src/architecture/scan/manifest/parsers/other.rs +280 -0
- package/crates/naome-core/src/architecture/scan/manifest/parsers/toml.rs +184 -0
- package/crates/naome-core/src/architecture/scan/manifest/parsers.rs +3 -0
- package/crates/naome-core/src/architecture/scan/manifest.rs +33 -0
- package/crates/naome-core/src/architecture/scan.rs +27 -1
- 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_manifests.rs +289 -0
- package/crates/naome-core/tests/architecture_rules.rs +498 -0
- package/crates/naome-core/tests/architecture_support/mod.rs +80 -0
- package/crates/naome-core/tests/architecture_swift.rs +111 -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 +2 -2
- package/templates/naome-root/docs/naome/architecture-fitness.md +61 -8
package/Cargo.lock
CHANGED
|
@@ -76,7 +76,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
|
|
76
76
|
|
|
77
77
|
[[package]]
|
|
78
78
|
name = "naome-cli"
|
|
79
|
-
version = "1.3.
|
|
79
|
+
version = "1.3.13"
|
|
80
80
|
dependencies = [
|
|
81
81
|
"naome-core",
|
|
82
82
|
"serde_json",
|
|
@@ -84,7 +84,7 @@ dependencies = [
|
|
|
84
84
|
|
|
85
85
|
[[package]]
|
|
86
86
|
name = "naome-core"
|
|
87
|
-
version = "1.3.
|
|
87
|
+
version = "1.3.13"
|
|
88
88
|
dependencies = [
|
|
89
89
|
"serde",
|
|
90
90
|
"serde_json",
|
|
@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
|
|
4
4
|
use naome_core::{
|
|
5
5
|
default_architecture_config_text, format_architecture_explain, format_architecture_scan,
|
|
6
6
|
format_architecture_validation, scan_architecture, validate_architecture,
|
|
7
|
-
ArchitectureScanOptions,
|
|
7
|
+
ArchitectureScanOptions, ARCHITECTURE_RULE_IDS,
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
use crate::cli_args::{has_flag, option_value};
|
|
@@ -52,11 +52,7 @@ fn run_arch_explain(root: &Path, args: &[String]) -> Result<(), Box<dyn std::err
|
|
|
52
52
|
"schema": "naome.arch.explain.v1",
|
|
53
53
|
"layers": scan.config.layers.keys().collect::<Vec<_>>(),
|
|
54
54
|
"contexts": scan.config.contexts.keys().collect::<Vec<_>>(),
|
|
55
|
-
"rules":
|
|
56
|
-
"arch.max_file_lines",
|
|
57
|
-
"arch.generated_manual_boundary",
|
|
58
|
-
"arch.no_forbidden_layer_dependencies"
|
|
59
|
-
],
|
|
55
|
+
"rules": ARCHITECTURE_RULE_IDS,
|
|
60
56
|
"extractors": ["path", "typescript", "javascript", "rust", "python", "go"]
|
|
61
57
|
}))?
|
|
62
58
|
);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
use std::fs;
|
|
2
|
+
use std::process::Command;
|
|
3
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
4
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
5
|
+
|
|
6
|
+
use serde_json::Value;
|
|
7
|
+
|
|
8
|
+
static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
9
|
+
|
|
10
|
+
#[test]
|
|
11
|
+
fn architecture_explain_json_lists_all_validation_rules() {
|
|
12
|
+
let root = fixture_root();
|
|
13
|
+
let output = Command::new(env!("CARGO_BIN_EXE_naome"))
|
|
14
|
+
.args(["arch", "explain", "--json"])
|
|
15
|
+
.current_dir(&root)
|
|
16
|
+
.output()
|
|
17
|
+
.unwrap();
|
|
18
|
+
|
|
19
|
+
assert!(
|
|
20
|
+
output.status.success(),
|
|
21
|
+
"{}{}",
|
|
22
|
+
String::from_utf8_lossy(&output.stdout),
|
|
23
|
+
String::from_utf8_lossy(&output.stderr)
|
|
24
|
+
);
|
|
25
|
+
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
26
|
+
let rules = payload["rules"].as_array().unwrap();
|
|
27
|
+
|
|
28
|
+
for rule in [
|
|
29
|
+
"arch.no_cross_context_internal_imports",
|
|
30
|
+
"arch.public_api_boundary",
|
|
31
|
+
"arch.no_cycles",
|
|
32
|
+
"arch.no_transitive_forbidden_layer_dependencies",
|
|
33
|
+
"arch.max_imports_per_file",
|
|
34
|
+
"arch.max_fan_out",
|
|
35
|
+
"arch.external_dependency_policy",
|
|
36
|
+
] {
|
|
37
|
+
assert!(rules.iter().any(|value| value == rule), "{rule}");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fn fixture_root() -> std::path::PathBuf {
|
|
42
|
+
let nonce = SystemTime::now()
|
|
43
|
+
.duration_since(UNIX_EPOCH)
|
|
44
|
+
.unwrap()
|
|
45
|
+
.as_nanos();
|
|
46
|
+
let counter = FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
47
|
+
let root = std::env::temp_dir().join(format!(
|
|
48
|
+
"naome-arch-cli-fixture-{}-{nonce}-{counter}",
|
|
49
|
+
std::process::id()
|
|
50
|
+
));
|
|
51
|
+
fs::create_dir_all(&root).unwrap();
|
|
52
|
+
fs::create_dir_all(root.join(".naome")).unwrap();
|
|
53
|
+
fs::write(
|
|
54
|
+
root.join(".naomeignore"),
|
|
55
|
+
".naome/archive/\n.naome/tasks/\n",
|
|
56
|
+
)
|
|
57
|
+
.unwrap();
|
|
58
|
+
fs::write(root.join(".naome/task-state.json"), "{}\n").unwrap();
|
|
59
|
+
root
|
|
60
|
+
}
|
|
@@ -2,7 +2,9 @@ use crate::models::NaomeError;
|
|
|
2
2
|
|
|
3
3
|
use super::scalar::{clean, indent, parse_bool, section_name};
|
|
4
4
|
use super::ConfigParser;
|
|
5
|
-
use crate::architecture::config::{
|
|
5
|
+
use crate::architecture::config::{
|
|
6
|
+
ContextConfig, ExternalDependencyPolicy, IgnoreRule, LayerConfig, RuleConfig,
|
|
7
|
+
};
|
|
6
8
|
use crate::architecture::output::Severity;
|
|
7
9
|
|
|
8
10
|
pub(super) fn parse_layers(parser: &mut ConfigParser<'_>) -> Result<(), NaomeError> {
|
|
@@ -53,6 +55,47 @@ pub(super) fn parse_allowed_dependencies(parser: &mut ConfigParser<'_>) -> Resul
|
|
|
53
55
|
Ok(())
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
pub(super) fn parse_external_dependencies(parser: &mut ConfigParser<'_>) -> Result<(), NaomeError> {
|
|
59
|
+
while let Some((_, line)) = parser.peek_line() {
|
|
60
|
+
if indent(line) == 0 {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
let (line_number, line) = parser.next_line().unwrap();
|
|
64
|
+
let name = section_name(line, 2).ok_or_else(|| {
|
|
65
|
+
parser.error(line_number, "expected dependency policy owner".to_string())
|
|
66
|
+
})?;
|
|
67
|
+
let policy = parse_external_dependency_policy(parser)?;
|
|
68
|
+
parser
|
|
69
|
+
.config
|
|
70
|
+
.external_dependencies
|
|
71
|
+
.insert(name.to_string(), policy);
|
|
72
|
+
}
|
|
73
|
+
Ok(())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fn parse_external_dependency_policy(
|
|
77
|
+
parser: &mut ConfigParser<'_>,
|
|
78
|
+
) -> Result<ExternalDependencyPolicy, NaomeError> {
|
|
79
|
+
let mut policy = ExternalDependencyPolicy::default();
|
|
80
|
+
while let Some((_, child)) = parser.peek_line() {
|
|
81
|
+
if indent(child) <= 2 {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
let (child_line, child) = parser.next_line().unwrap();
|
|
85
|
+
match child.trim() {
|
|
86
|
+
"allow:" => policy.allow = parser.parse_list(6)?,
|
|
87
|
+
"allow: []" => policy.allow = Vec::new(),
|
|
88
|
+
other => {
|
|
89
|
+
return Err(parser.error(
|
|
90
|
+
child_line,
|
|
91
|
+
format!("unsupported external dependency policy key: {other}"),
|
|
92
|
+
))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
Ok(policy)
|
|
97
|
+
}
|
|
98
|
+
|
|
56
99
|
fn parse_context(parser: &mut ConfigParser<'_>) -> Result<ContextConfig, NaomeError> {
|
|
57
100
|
let mut context = ContextConfig::default();
|
|
58
101
|
while let Some((_, child)) = parser.peek_line() {
|
|
@@ -41,6 +41,7 @@ impl<'a> ConfigParser<'a> {
|
|
|
41
41
|
"layers:" => sections::parse_layers(self)?,
|
|
42
42
|
"contexts:" => sections::parse_contexts(self)?,
|
|
43
43
|
"allowed_dependencies:" => sections::parse_allowed_dependencies(self)?,
|
|
44
|
+
"external_dependencies:" => sections::parse_external_dependencies(self)?,
|
|
44
45
|
"rules:" => sections::parse_rules(self)?,
|
|
45
46
|
"ignore:" => sections::parse_ignore(self)?,
|
|
46
47
|
other => {
|
|
@@ -13,6 +13,7 @@ pub struct ArchitectureConfig {
|
|
|
13
13
|
pub layers: BTreeMap<String, LayerConfig>,
|
|
14
14
|
pub contexts: BTreeMap<String, ContextConfig>,
|
|
15
15
|
pub allowed_dependencies: BTreeMap<String, Vec<String>>,
|
|
16
|
+
pub external_dependencies: BTreeMap<String, ExternalDependencyPolicy>,
|
|
16
17
|
pub rules: BTreeMap<String, RuleConfig>,
|
|
17
18
|
pub ignore: Vec<IgnoreRule>,
|
|
18
19
|
}
|
|
@@ -28,6 +29,11 @@ pub struct ContextConfig {
|
|
|
28
29
|
pub public_api: Vec<String>,
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
33
|
+
pub struct ExternalDependencyPolicy {
|
|
34
|
+
pub allow: Vec<String>,
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
32
38
|
pub struct RuleConfig {
|
|
33
39
|
pub enabled: bool,
|
|
@@ -104,6 +110,35 @@ rules:
|
|
|
104
110
|
no_forbidden_layer_dependencies:
|
|
105
111
|
enabled: true
|
|
106
112
|
severity: error
|
|
113
|
+
no_cross_context_internal_imports:
|
|
114
|
+
enabled: true
|
|
115
|
+
severity: error
|
|
116
|
+
public_api_boundary:
|
|
117
|
+
enabled: true
|
|
118
|
+
severity: error
|
|
119
|
+
no_cycles:
|
|
120
|
+
enabled: true
|
|
121
|
+
severity: error
|
|
122
|
+
no_transitive_forbidden_layer_dependencies:
|
|
123
|
+
enabled: true
|
|
124
|
+
severity: error
|
|
125
|
+
max_imports_per_file:
|
|
126
|
+
enabled: true
|
|
127
|
+
value: 20
|
|
128
|
+
severity: warning
|
|
129
|
+
max_fan_out:
|
|
130
|
+
enabled: true
|
|
131
|
+
value: 20
|
|
132
|
+
severity: warning
|
|
133
|
+
external_dependency_policy:
|
|
134
|
+
enabled: true
|
|
135
|
+
severity: error
|
|
136
|
+
|
|
137
|
+
external_dependencies:
|
|
138
|
+
domain:
|
|
139
|
+
allow: []
|
|
140
|
+
infrastructure:
|
|
141
|
+
allow: []
|
|
107
142
|
|
|
108
143
|
ignore:
|
|
109
144
|
- path: "generated/**"
|
|
@@ -59,6 +59,19 @@ pub struct ArchitectureValidation {
|
|
|
59
59
|
pub agent_feedback: Vec<ArchitectureAgentFeedback>,
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
pub const ARCHITECTURE_RULE_IDS: &[&str] = &[
|
|
63
|
+
"arch.max_file_lines",
|
|
64
|
+
"arch.generated_manual_boundary",
|
|
65
|
+
"arch.no_forbidden_layer_dependencies",
|
|
66
|
+
"arch.no_cross_context_internal_imports",
|
|
67
|
+
"arch.public_api_boundary",
|
|
68
|
+
"arch.no_cycles",
|
|
69
|
+
"arch.no_transitive_forbidden_layer_dependencies",
|
|
70
|
+
"arch.max_imports_per_file",
|
|
71
|
+
"arch.max_fan_out",
|
|
72
|
+
"arch.external_dependency_policy",
|
|
73
|
+
];
|
|
74
|
+
|
|
62
75
|
impl Severity {
|
|
63
76
|
pub fn parse(value: &str) -> Option<Self> {
|
|
64
77
|
match value {
|
|
@@ -163,7 +176,8 @@ pub fn format_architecture_explain(scan: &ArchitectureScanReport) -> String {
|
|
|
163
176
|
.collect::<Vec<_>>()
|
|
164
177
|
.join(", ");
|
|
165
178
|
format!(
|
|
166
|
-
"NAOME Architecture Fitness\nrules:
|
|
179
|
+
"NAOME Architecture Fitness\nrules: {}\nlayers: {}\ncontexts: {}\npath extractor: enabled for every repository\nimport extractors: typescript, javascript, rust, python, go\n",
|
|
180
|
+
ARCHITECTURE_RULE_IDS.join(", "),
|
|
167
181
|
empty_label(&layers),
|
|
168
182
|
empty_label(&contexts)
|
|
169
183
|
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
use crate::architecture::output::{ArchitectureViolation, Severity};
|
|
2
|
+
use crate::architecture::scan::{ArchitectureScanReport, FileFact, ImportTarget};
|
|
3
|
+
|
|
4
|
+
pub(super) fn validate_file_size_budget(
|
|
5
|
+
scan: &ArchitectureScanReport,
|
|
6
|
+
violations: &mut Vec<ArchitectureViolation>,
|
|
7
|
+
rules_executed: &mut Vec<String>,
|
|
8
|
+
) {
|
|
9
|
+
let Some((severity, limit)) = configured_budget(
|
|
10
|
+
scan,
|
|
11
|
+
"max_file_lines",
|
|
12
|
+
"arch.max_file_lines",
|
|
13
|
+
rules_executed,
|
|
14
|
+
) else {
|
|
15
|
+
return;
|
|
16
|
+
};
|
|
17
|
+
for fact in scan.file_facts.values() {
|
|
18
|
+
push_budget_violation(
|
|
19
|
+
violations,
|
|
20
|
+
BudgetFinding {
|
|
21
|
+
id: "arch.max_file_lines",
|
|
22
|
+
violation_type: "file_size_budget",
|
|
23
|
+
severity,
|
|
24
|
+
path: &fact.path,
|
|
25
|
+
actual: fact.line_count,
|
|
26
|
+
limit,
|
|
27
|
+
unit: "lines",
|
|
28
|
+
suggestion: "Split the file into cohesive modules or move generated content behind an explicit ignore rule.",
|
|
29
|
+
instruction: format!(
|
|
30
|
+
"Reduce {} below {} lines or add a justified generated-code ignore rule if it is not manually owned.",
|
|
31
|
+
fact.path, limit
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub(super) fn validate_dependency_budgets(
|
|
39
|
+
scan: &ArchitectureScanReport,
|
|
40
|
+
violations: &mut Vec<ArchitectureViolation>,
|
|
41
|
+
rules_executed: &mut Vec<String>,
|
|
42
|
+
) {
|
|
43
|
+
for budget in [
|
|
44
|
+
BudgetRule {
|
|
45
|
+
config_key: "max_imports_per_file",
|
|
46
|
+
id: "arch.max_imports_per_file",
|
|
47
|
+
violation_type: "import_count_budget",
|
|
48
|
+
metric: BudgetMetric::ImportCount,
|
|
49
|
+
unit: "imports",
|
|
50
|
+
suggestion: "Split responsibilities or introduce a smaller facade to reduce imports.",
|
|
51
|
+
instruction:
|
|
52
|
+
"Reduce imports in this file before continuing architecture-sensitive work.",
|
|
53
|
+
},
|
|
54
|
+
BudgetRule {
|
|
55
|
+
config_key: "max_fan_out",
|
|
56
|
+
id: "arch.max_fan_out",
|
|
57
|
+
violation_type: "fan_out_budget",
|
|
58
|
+
metric: BudgetMetric::FanOut,
|
|
59
|
+
unit: "unique targets",
|
|
60
|
+
suggestion: "Reduce direct dependencies or move orchestration into a narrower module.",
|
|
61
|
+
instruction: "Lower this file's fan-out before adding more direct dependencies.",
|
|
62
|
+
},
|
|
63
|
+
] {
|
|
64
|
+
validate_measured_budget(scan, violations, rules_executed, budget);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[derive(Clone, Copy)]
|
|
69
|
+
struct BudgetRule {
|
|
70
|
+
config_key: &'static str,
|
|
71
|
+
id: &'static str,
|
|
72
|
+
violation_type: &'static str,
|
|
73
|
+
metric: BudgetMetric,
|
|
74
|
+
unit: &'static str,
|
|
75
|
+
suggestion: &'static str,
|
|
76
|
+
instruction: &'static str,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#[derive(Clone, Copy)]
|
|
80
|
+
enum BudgetMetric {
|
|
81
|
+
FanOut,
|
|
82
|
+
ImportCount,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
struct BudgetFinding<'a> {
|
|
86
|
+
id: &'static str,
|
|
87
|
+
violation_type: &'static str,
|
|
88
|
+
severity: Severity,
|
|
89
|
+
path: &'a str,
|
|
90
|
+
actual: usize,
|
|
91
|
+
limit: usize,
|
|
92
|
+
unit: &'static str,
|
|
93
|
+
suggestion: &'static str,
|
|
94
|
+
instruction: String,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fn validate_measured_budget(
|
|
98
|
+
scan: &ArchitectureScanReport,
|
|
99
|
+
violations: &mut Vec<ArchitectureViolation>,
|
|
100
|
+
rules_executed: &mut Vec<String>,
|
|
101
|
+
budget: BudgetRule,
|
|
102
|
+
) {
|
|
103
|
+
let Some((severity, limit)) =
|
|
104
|
+
configured_budget(scan, budget.config_key, budget.id, rules_executed)
|
|
105
|
+
else {
|
|
106
|
+
return;
|
|
107
|
+
};
|
|
108
|
+
for fact in scan.file_facts.values() {
|
|
109
|
+
push_budget_violation(
|
|
110
|
+
violations,
|
|
111
|
+
BudgetFinding {
|
|
112
|
+
id: budget.id,
|
|
113
|
+
violation_type: budget.violation_type,
|
|
114
|
+
severity,
|
|
115
|
+
path: &fact.path,
|
|
116
|
+
actual: measure_budget(fact, budget.metric),
|
|
117
|
+
limit,
|
|
118
|
+
unit: budget.unit,
|
|
119
|
+
suggestion: budget.suggestion,
|
|
120
|
+
instruction: budget.instruction.to_string(),
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
fn measure_budget(fact: &FileFact, metric: BudgetMetric) -> usize {
|
|
127
|
+
match metric {
|
|
128
|
+
BudgetMetric::FanOut => fact
|
|
129
|
+
.imports
|
|
130
|
+
.iter()
|
|
131
|
+
.map(|import| stable_target_id(&import.target))
|
|
132
|
+
.collect::<std::collections::BTreeSet<_>>()
|
|
133
|
+
.len(),
|
|
134
|
+
BudgetMetric::ImportCount => fact.imports.len(),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fn configured_budget(
|
|
139
|
+
scan: &ArchitectureScanReport,
|
|
140
|
+
config_key: &str,
|
|
141
|
+
rule_id: &str,
|
|
142
|
+
rules_executed: &mut Vec<String>,
|
|
143
|
+
) -> Option<(Severity, usize)> {
|
|
144
|
+
let rule = scan.config.rule(config_key);
|
|
145
|
+
if !rule.enabled {
|
|
146
|
+
return None;
|
|
147
|
+
}
|
|
148
|
+
rules_executed.push(rule_id.to_string());
|
|
149
|
+
rule.value.map(|limit| (rule.severity, limit))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fn push_budget_violation(violations: &mut Vec<ArchitectureViolation>, finding: BudgetFinding<'_>) {
|
|
153
|
+
if finding.actual <= finding.limit {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
violations.push(ArchitectureViolation {
|
|
157
|
+
id: finding.id.to_string(),
|
|
158
|
+
severity: finding.severity,
|
|
159
|
+
violation_type: finding.violation_type.to_string(),
|
|
160
|
+
message: format!(
|
|
161
|
+
"{} has {} {}, exceeding the configured budget of {}.",
|
|
162
|
+
finding.path, finding.actual, finding.unit, finding.limit
|
|
163
|
+
),
|
|
164
|
+
from: Some(format!("file:{}", finding.path)),
|
|
165
|
+
to: None,
|
|
166
|
+
path: Some(finding.path.to_string()),
|
|
167
|
+
source_range: None,
|
|
168
|
+
suggestion: finding.suggestion.to_string(),
|
|
169
|
+
agent_instruction: finding.instruction,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fn stable_target_id(target: &ImportTarget) -> String {
|
|
174
|
+
match target {
|
|
175
|
+
ImportTarget::File(path) => format!("file:{path}"),
|
|
176
|
+
ImportTarget::ExternalDependency(package) => format!("external:{package}"),
|
|
177
|
+
ImportTarget::Unknown(specifier) => format!("unknown:{specifier}"),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
use crate::architecture::output::ArchitectureViolation;
|
|
2
|
+
use crate::architecture::scan::{ArchitectureScanReport, FileFact};
|
|
3
|
+
use crate::paths;
|
|
4
|
+
|
|
5
|
+
use super::graph;
|
|
6
|
+
|
|
7
|
+
pub(super) fn validate_context_rules(
|
|
8
|
+
scan: &ArchitectureScanReport,
|
|
9
|
+
violations: &mut Vec<ArchitectureViolation>,
|
|
10
|
+
rules_executed: &mut Vec<String>,
|
|
11
|
+
) {
|
|
12
|
+
for boundary in [
|
|
13
|
+
BoundaryRule {
|
|
14
|
+
config_key: "no_cross_context_internal_imports",
|
|
15
|
+
rule_id: "arch.no_cross_context_internal_imports",
|
|
16
|
+
violation_type: "forbidden_context_dependency",
|
|
17
|
+
message: |from, to| format!("{from} imports internal context file {to}."),
|
|
18
|
+
suggestion: "Import a declared public API entrypoint for the target context instead.",
|
|
19
|
+
instruction:
|
|
20
|
+
"Do not import another bounded context's internal files; route through its public API.",
|
|
21
|
+
internal_targets_only: true,
|
|
22
|
+
},
|
|
23
|
+
BoundaryRule {
|
|
24
|
+
config_key: "public_api_boundary",
|
|
25
|
+
rule_id: "arch.public_api_boundary",
|
|
26
|
+
violation_type: "public_api_boundary",
|
|
27
|
+
message: |from, to| format!("{from} crosses into {to} without using its public API."),
|
|
28
|
+
suggestion: "Change the import to a path listed in the target context public_api.",
|
|
29
|
+
instruction:
|
|
30
|
+
"Use the target bounded context public API; do not import its internal files.",
|
|
31
|
+
internal_targets_only: false,
|
|
32
|
+
},
|
|
33
|
+
] {
|
|
34
|
+
validate_boundary(scan, violations, rules_executed, boundary);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
struct BoundaryRule {
|
|
39
|
+
config_key: &'static str,
|
|
40
|
+
rule_id: &'static str,
|
|
41
|
+
violation_type: &'static str,
|
|
42
|
+
message: fn(&str, &str) -> String,
|
|
43
|
+
suggestion: &'static str,
|
|
44
|
+
instruction: &'static str,
|
|
45
|
+
internal_targets_only: bool,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fn validate_boundary(
|
|
49
|
+
scan: &ArchitectureScanReport,
|
|
50
|
+
violations: &mut Vec<ArchitectureViolation>,
|
|
51
|
+
rules_executed: &mut Vec<String>,
|
|
52
|
+
boundary: BoundaryRule,
|
|
53
|
+
) {
|
|
54
|
+
let rule = scan.config.rule(boundary.config_key);
|
|
55
|
+
if !rule.enabled {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
rules_executed.push(boundary.rule_id.to_string());
|
|
59
|
+
for import in graph::file_import_edges(scan) {
|
|
60
|
+
let Some(from_fact) = scan.file_facts.get(import.from_path) else {
|
|
61
|
+
continue;
|
|
62
|
+
};
|
|
63
|
+
let Some(to_fact) = scan.file_facts.get(import.to_path) else {
|
|
64
|
+
continue;
|
|
65
|
+
};
|
|
66
|
+
if compatible(scan, &from_fact.contexts, to_fact, import.to_path) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if boundary.internal_targets_only && !is_internal_path(import.to_path) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
violations.push(ArchitectureViolation {
|
|
73
|
+
id: boundary.rule_id.to_string(),
|
|
74
|
+
severity: rule.severity,
|
|
75
|
+
violation_type: boundary.violation_type.to_string(),
|
|
76
|
+
message: (boundary.message)(import.from_path, import.to_path),
|
|
77
|
+
from: Some(format!("file:{}", import.from_path)),
|
|
78
|
+
to: Some(format!("file:{}", import.to_path)),
|
|
79
|
+
path: Some(import.from_path.to_string()),
|
|
80
|
+
source_range: scan.graph.edges[import.edge_index]
|
|
81
|
+
.metadata
|
|
82
|
+
.source_range
|
|
83
|
+
.clone(),
|
|
84
|
+
suggestion: boundary.suggestion.to_string(),
|
|
85
|
+
agent_instruction: boundary.instruction.to_string(),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn compatible(
|
|
91
|
+
scan: &ArchitectureScanReport,
|
|
92
|
+
from_contexts: &[String],
|
|
93
|
+
to_fact: &FileFact,
|
|
94
|
+
to_path: &str,
|
|
95
|
+
) -> bool {
|
|
96
|
+
let from_effective = effective_contexts(scan, from_contexts);
|
|
97
|
+
let to_effective = effective_contexts(scan, &to_fact.contexts);
|
|
98
|
+
if to_effective.is_empty() {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if from_effective.is_empty() {
|
|
102
|
+
return is_public_api(scan, to_path, &to_effective);
|
|
103
|
+
}
|
|
104
|
+
from_effective
|
|
105
|
+
.iter()
|
|
106
|
+
.any(|context| to_effective.contains(context))
|
|
107
|
+
|| is_public_api(scan, to_path, &to_effective)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fn is_public_api(scan: &ArchitectureScanReport, path: &str, contexts: &[String]) -> bool {
|
|
111
|
+
contexts.iter().any(|context| {
|
|
112
|
+
scan.config
|
|
113
|
+
.contexts
|
|
114
|
+
.get(context)
|
|
115
|
+
.is_some_and(|config| paths::matches_any(path, &config.public_api))
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fn is_internal_path(path: &str) -> bool {
|
|
120
|
+
path.split('/').any(|segment| segment == "internal")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
fn effective_contexts(scan: &ArchitectureScanReport, contexts: &[String]) -> Vec<String> {
|
|
124
|
+
contexts
|
|
125
|
+
.iter()
|
|
126
|
+
.filter(|context| !is_catch_all_context(scan, context))
|
|
127
|
+
.cloned()
|
|
128
|
+
.collect()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
fn is_catch_all_context(scan: &ArchitectureScanReport, context: &str) -> bool {
|
|
132
|
+
scan.config.contexts.get(context).is_some_and(|config| {
|
|
133
|
+
config
|
|
134
|
+
.paths
|
|
135
|
+
.iter()
|
|
136
|
+
.any(|path| matches!(path.as_str(), "**" | "src/**"))
|
|
137
|
+
})
|
|
138
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
use crate::architecture::output::ArchitectureViolation;
|
|
2
|
+
use crate::architecture::scan::ArchitectureScanReport;
|
|
3
|
+
|
|
4
|
+
use super::graph;
|
|
5
|
+
|
|
6
|
+
pub(super) fn validate_cycles(
|
|
7
|
+
scan: &ArchitectureScanReport,
|
|
8
|
+
violations: &mut Vec<ArchitectureViolation>,
|
|
9
|
+
rules_executed: &mut Vec<String>,
|
|
10
|
+
) {
|
|
11
|
+
let rule = scan.config.rule("no_cycles");
|
|
12
|
+
if !rule.enabled {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
rules_executed.push("arch.no_cycles".to_string());
|
|
16
|
+
let adjacency = graph::file_cycle_adjacency(scan);
|
|
17
|
+
for component in graph::strongly_connected_components(&adjacency) {
|
|
18
|
+
let path = component.first().cloned();
|
|
19
|
+
violations.push(ArchitectureViolation {
|
|
20
|
+
id: "arch.no_cycles".to_string(),
|
|
21
|
+
severity: rule.severity,
|
|
22
|
+
violation_type: "cycle".to_string(),
|
|
23
|
+
message: format!(
|
|
24
|
+
"Architecture import cycle detected: {}.",
|
|
25
|
+
component.join(" -> ")
|
|
26
|
+
),
|
|
27
|
+
from: path.as_ref().map(|path| format!("file:{path}")),
|
|
28
|
+
to: None,
|
|
29
|
+
path,
|
|
30
|
+
source_range: None,
|
|
31
|
+
suggestion:
|
|
32
|
+
"Break the cycle by extracting a lower-level dependency or inverting one edge."
|
|
33
|
+
.to_string(),
|
|
34
|
+
agent_instruction:
|
|
35
|
+
"Break this import cycle before adding more behavior; do not suppress the rule."
|
|
36
|
+
.to_string(),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|