@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.
Files changed (41) hide show
  1. package/Cargo.lock +2 -2
  2. package/crates/naome-cli/Cargo.toml +1 -1
  3. package/crates/naome-cli/src/architecture_commands.rs +2 -6
  4. package/crates/naome-cli/tests/architecture_cli.rs +60 -0
  5. package/crates/naome-core/Cargo.toml +1 -1
  6. package/crates/naome-core/src/architecture/config/parser/sections.rs +44 -1
  7. package/crates/naome-core/src/architecture/config/parser.rs +1 -0
  8. package/crates/naome-core/src/architecture/config.rs +35 -0
  9. package/crates/naome-core/src/architecture/output.rs +15 -1
  10. package/crates/naome-core/src/architecture/rules/budgets.rs +179 -0
  11. package/crates/naome-core/src/architecture/rules/context.rs +138 -0
  12. package/crates/naome-core/src/architecture/rules/cycles.rs +39 -0
  13. package/crates/naome-core/src/architecture/rules/external.rs +244 -0
  14. package/crates/naome-core/src/architecture/rules/graph.rs +177 -0
  15. package/crates/naome-core/src/architecture/rules/transitive.rs +89 -0
  16. package/crates/naome-core/src/architecture/rules.rs +13 -39
  17. package/crates/naome-core/src/architecture/scan/graph_builder.rs +130 -30
  18. package/crates/naome-core/src/architecture/scan/imports/extractors/swift.rs +48 -0
  19. package/crates/naome-core/src/architecture/scan/imports/extractors.rs +7 -7
  20. package/crates/naome-core/src/architecture/scan/imports/resolver.rs +44 -22
  21. package/crates/naome-core/src/architecture/scan/imports.rs +17 -0
  22. package/crates/naome-core/src/architecture/scan/manifest/common.rs +102 -0
  23. package/crates/naome-core/src/architecture/scan/manifest/parsers/json.rs +46 -0
  24. package/crates/naome-core/src/architecture/scan/manifest/parsers/other.rs +280 -0
  25. package/crates/naome-core/src/architecture/scan/manifest/parsers/toml.rs +184 -0
  26. package/crates/naome-core/src/architecture/scan/manifest/parsers.rs +3 -0
  27. package/crates/naome-core/src/architecture/scan/manifest.rs +33 -0
  28. package/crates/naome-core/src/architecture/scan.rs +27 -1
  29. package/crates/naome-core/src/architecture.rs +1 -1
  30. package/crates/naome-core/src/lib.rs +1 -0
  31. package/crates/naome-core/tests/architecture.rs +53 -85
  32. package/crates/naome-core/tests/architecture_manifests.rs +289 -0
  33. package/crates/naome-core/tests/architecture_rules.rs +498 -0
  34. package/crates/naome-core/tests/architecture_support/mod.rs +80 -0
  35. package/crates/naome-core/tests/architecture_swift.rs +111 -0
  36. package/installer/harness-files.js +3 -3
  37. package/native/darwin-arm64/naome +0 -0
  38. package/native/linux-x64/naome +0 -0
  39. package/package.json +1 -1
  40. package/templates/naome-root/.naome/manifest.json +2 -2
  41. 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.11"
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.11"
87
+ version = "1.3.13"
88
88
  dependencies = [
89
89
  "serde",
90
90
  "serde_json",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-cli"
3
- version = "1.3.11"
3
+ version = "1.3.13"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-core"
3
- version = "1.3.11"
3
+ version = "1.3.13"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -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::{ContextConfig, IgnoreRule, LayerConfig, RuleConfig};
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: max_file_lines, generated_manual_boundary, no_forbidden_layer_dependencies\nlayers: {}\ncontexts: {}\npath extractor: enabled for every repository\nimport extractors: typescript, javascript, rust, python, go\n",
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
+ }