@lamentis/naome 1.3.16 → 1.4.0

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 (40) 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 +16 -2
  4. package/crates/naome-cli/src/architecture_init/infer.rs +131 -0
  5. package/crates/naome-cli/src/architecture_init/render.rs +56 -0
  6. package/crates/naome-cli/src/architecture_init/repository.rs +59 -0
  7. package/crates/naome-cli/src/architecture_init.rs +17 -0
  8. package/crates/naome-cli/src/main.rs +2 -1
  9. package/crates/naome-cli/tests/architecture_cli.rs +75 -0
  10. package/crates/naome-core/Cargo.toml +1 -1
  11. package/crates/naome-core/src/architecture/config_findings/configuration/coverage.rs +81 -0
  12. package/crates/naome-core/src/architecture/config_findings/configuration/overlap.rs +117 -0
  13. package/crates/naome-core/src/architecture/config_findings/configuration.rs +12 -0
  14. package/crates/naome-core/src/architecture/config_findings/imports.rs +30 -0
  15. package/crates/naome-core/src/architecture/config_findings.rs +50 -0
  16. package/crates/naome-core/src/architecture/explain.rs +45 -0
  17. package/crates/naome-core/src/architecture/output.rs +211 -155
  18. package/crates/naome-core/src/architecture/rules.rs +4 -3
  19. package/crates/naome-core/src/architecture/scan/cache.rs +1 -1
  20. package/crates/naome-core/src/architecture/scan/imports/resolver/candidates.rs +71 -0
  21. package/crates/naome-core/src/architecture/scan/imports/resolver/js_ts_alias.rs +241 -0
  22. package/crates/naome-core/src/architecture/scan/imports/resolver.rs +162 -91
  23. package/crates/naome-core/src/architecture/scan.rs +20 -6
  24. package/crates/naome-core/src/architecture.rs +8 -3
  25. package/crates/naome-core/src/lib.rs +9 -7
  26. package/crates/naome-core/tests/architecture.rs +30 -0
  27. package/crates/naome-core/tests/architecture_acceptance.rs +304 -0
  28. package/crates/naome-core/tests/architecture_aliases.rs +101 -0
  29. package/crates/naome-core/tests/architecture_cache.rs +57 -0
  30. package/crates/naome-core/tests/architecture_config.rs +155 -1
  31. package/crates/naome-core/tests/architecture_rules.rs +32 -0
  32. package/crates/naome-core/tests/architecture_unresolved.rs +36 -0
  33. package/native/darwin-arm64/naome +0 -0
  34. package/native/linux-x64/naome +0 -0
  35. package/package.json +1 -1
  36. package/templates/naome-root/.naome/bin/check-harness-health.js +1 -0
  37. package/templates/naome-root/.naome/bin/check-task-state.js +1 -0
  38. package/templates/naome-root/.naome/manifest.json +2 -2
  39. package/templates/naome-root/.naome/verification.json +6 -1
  40. package/templates/naome-root/docs/naome/architecture-fitness.md +76 -59
package/Cargo.lock CHANGED
@@ -76,7 +76,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
76
76
 
77
77
  [[package]]
78
78
  name = "naome-cli"
79
- version = "1.3.16"
79
+ version = "1.4.0"
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.16"
87
+ version = "1.4.0"
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.16"
3
+ version = "1.4.0"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -2,11 +2,12 @@ use std::fs;
2
2
  use std::path::{Path, PathBuf};
3
3
 
4
4
  use naome_core::{
5
- config_findings_for, default_architecture_config_text, format_architecture_explain,
5
+ architecture_validation_sarif_with_root, config_findings_for, format_architecture_explain,
6
6
  format_architecture_scan, format_architecture_validation, scan_architecture,
7
7
  validate_architecture, ArchitectureScanOptions, ARCHITECTURE_RULE_IDS,
8
8
  };
9
9
 
10
+ use crate::architecture_init::architecture_init_config_text;
10
11
  use crate::cli_args::{has_flag, option_value};
11
12
 
12
13
  pub fn run_arch_command(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
@@ -28,7 +29,7 @@ fn run_arch_init(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error:
28
29
  if path.exists() {
29
30
  return Err(format!("{} already exists", display_path(root, &path)).into());
30
31
  }
31
- fs::write(&path, default_architecture_config_text())?;
32
+ fs::write(&path, architecture_init_config_text(root))?;
32
33
  if has_flag(args, "--json") {
33
34
  println!(
34
35
  "{}",
@@ -87,6 +88,19 @@ fn run_arch_validate(root: &Path, args: &[String]) -> Result<(), Box<dyn std::er
87
88
  root,
88
89
  scan_options(root, args, has_flag(args, "--changed-only")),
89
90
  )?;
91
+ if has_flag(args, "--sarif") {
92
+ let output =
93
+ serde_json::to_string_pretty(&architecture_validation_sarif_with_root(&report, root))?;
94
+ if let Some(path) = option_value(args, "--output") {
95
+ fs::write(root.join(path), output)?;
96
+ } else {
97
+ println!("{output}");
98
+ }
99
+ if report.status == "fail" {
100
+ std::process::exit(1);
101
+ }
102
+ return Ok(());
103
+ }
90
104
  let json = has_flag(args, "--json") || has_flag(args, "--agent-feedback");
91
105
  if json {
92
106
  if has_flag(args, "--agent-feedback") {
@@ -0,0 +1,131 @@
1
+ use std::collections::BTreeSet;
2
+
3
+ use super::render::SectionBlock;
4
+
5
+ pub fn layers(files: &BTreeSet<String>) -> Vec<SectionBlock> {
6
+ [
7
+ block_if_present(
8
+ "ui",
9
+ files,
10
+ &["src/components/**", "src/ui/**", "Sources/**/Views/**"],
11
+ ),
12
+ block_if_present(
13
+ "application",
14
+ files,
15
+ &["src/features/**", "src/usecases/**", "src/application/**"],
16
+ ),
17
+ block_if_present("domain", files, &["src/domain/**", "Sources/**/Domain/**"]),
18
+ block_if_present(
19
+ "infrastructure",
20
+ files,
21
+ &[
22
+ "src/infrastructure/**",
23
+ "src/adapters/**",
24
+ "Sources/**/Infrastructure/**",
25
+ ],
26
+ ),
27
+ ]
28
+ .into_iter()
29
+ .flatten()
30
+ .collect()
31
+ }
32
+
33
+ pub fn contexts(files: &BTreeSet<String>) -> Vec<SectionBlock> {
34
+ let mut contexts = src_contexts(files)
35
+ .into_iter()
36
+ .map(|name| SectionBlock {
37
+ name: name.clone(),
38
+ paths: vec![format!("src/{name}/**")],
39
+ public_api: vec![
40
+ format!("src/{name}/index.*"),
41
+ format!("src/{name}/public/**"),
42
+ ],
43
+ })
44
+ .collect::<Vec<_>>();
45
+ contexts.extend(swift_contexts(files));
46
+ contexts
47
+ }
48
+
49
+ fn block_if_present(
50
+ name: &str,
51
+ files: &BTreeSet<String>,
52
+ candidates: &[&str],
53
+ ) -> Option<SectionBlock> {
54
+ let paths = candidates
55
+ .iter()
56
+ .filter(|pattern| files.iter().any(|file| glob_like_matches(file, pattern)))
57
+ .map(|pattern| (*pattern).to_string())
58
+ .collect::<Vec<_>>();
59
+ (!paths.is_empty()).then(|| SectionBlock {
60
+ name: name.to_string(),
61
+ paths,
62
+ public_api: Vec::new(),
63
+ })
64
+ }
65
+
66
+ fn src_contexts(files: &BTreeSet<String>) -> Vec<String> {
67
+ let ignored = [
68
+ "adapters",
69
+ "application",
70
+ "components",
71
+ "domain",
72
+ "infrastructure",
73
+ "ui",
74
+ ];
75
+ let mut contexts = BTreeSet::new();
76
+ for file in files {
77
+ let Some(rest) = file.strip_prefix("src/") else {
78
+ continue;
79
+ };
80
+ let Some((name, _)) = rest.split_once('/') else {
81
+ continue;
82
+ };
83
+ if !ignored.contains(&name) {
84
+ contexts.insert(name.to_string());
85
+ }
86
+ }
87
+ contexts.into_iter().collect()
88
+ }
89
+
90
+ fn swift_contexts(files: &BTreeSet<String>) -> Vec<SectionBlock> {
91
+ let mut targets = BTreeSet::new();
92
+ for file in files {
93
+ let Some(rest) = file.strip_prefix("Sources/") else {
94
+ continue;
95
+ };
96
+ if let Some((target, _)) = rest.split_once('/') {
97
+ targets.insert(target.to_string());
98
+ }
99
+ }
100
+ targets
101
+ .into_iter()
102
+ .map(|target| SectionBlock {
103
+ name: target.to_lowercase(),
104
+ paths: vec![format!("Sources/{target}/**")],
105
+ public_api: vec![format!("Sources/{target}/Public/**")],
106
+ })
107
+ .collect()
108
+ }
109
+
110
+ fn glob_like_matches(path: &str, pattern: &str) -> bool {
111
+ if !pattern.contains("**") {
112
+ return path == pattern;
113
+ }
114
+ let mut remainder = path;
115
+ for (index, part) in pattern.split("**").enumerate() {
116
+ if part.is_empty() {
117
+ continue;
118
+ }
119
+ if index == 0 {
120
+ let prefix = part.trim_end_matches('/');
121
+ if !path.starts_with(prefix) {
122
+ return false;
123
+ }
124
+ }
125
+ let Some(found) = remainder.find(part.trim_matches('*')) else {
126
+ return false;
127
+ };
128
+ remainder = &remainder[found + part.len().min(remainder.len())..];
129
+ }
130
+ true
131
+ }
@@ -0,0 +1,56 @@
1
+ use naome_core::default_architecture_config_text;
2
+
3
+ pub struct SectionBlock {
4
+ pub name: String,
5
+ pub paths: Vec<String>,
6
+ pub public_api: Vec<String>,
7
+ }
8
+
9
+ pub fn config(layers: Vec<SectionBlock>, contexts: Vec<SectionBlock>) -> String {
10
+ let mut text = "# NAOME architecture fitness configuration.\n".to_string();
11
+ text.push_str("layers:\n");
12
+ for layer in layers {
13
+ text.push_str(&layer.render_paths());
14
+ }
15
+ text.push_str("\nallowed_dependencies:\n");
16
+ text.push_str(" ui:\n - application\n - domain\n");
17
+ text.push_str(" application:\n - domain\n");
18
+ text.push_str(" infrastructure:\n - domain\n");
19
+ text.push_str(" domain:\n\ncontexts:\n");
20
+ if contexts.is_empty() {
21
+ text.push_str(" default:\n paths:\n - \"src/**\"\n public_api:\n - \"src/index.*\"\n");
22
+ } else {
23
+ for context in contexts {
24
+ text.push_str(&context.render_context());
25
+ }
26
+ }
27
+ text.push_str("\nrules:\n");
28
+ text.push_str(default_rules_tail());
29
+ text
30
+ }
31
+
32
+ impl SectionBlock {
33
+ fn render_paths(&self) -> String {
34
+ let mut text = format!(" {}:\n paths:\n", self.name);
35
+ for path in &self.paths {
36
+ text.push_str(&format!(" - \"{path}\"\n"));
37
+ }
38
+ text
39
+ }
40
+
41
+ fn render_context(&self) -> String {
42
+ let mut text = self.render_paths();
43
+ text.push_str(" public_api:\n");
44
+ for path in &self.public_api {
45
+ text.push_str(&format!(" - \"{path}\"\n"));
46
+ }
47
+ text
48
+ }
49
+ }
50
+
51
+ fn default_rules_tail() -> &'static str {
52
+ default_architecture_config_text()
53
+ .split_once("\nrules:\n")
54
+ .map(|(_, tail)| tail)
55
+ .unwrap_or("")
56
+ }
@@ -0,0 +1,59 @@
1
+ use std::collections::BTreeSet;
2
+ use std::fs;
3
+ use std::path::{Path, PathBuf};
4
+
5
+ use naome_core::{naomeignore_patterns, path_matches_any};
6
+
7
+ pub fn files(root: &Path) -> BTreeSet<String> {
8
+ let mut found = BTreeSet::new();
9
+ let ignore_patterns = naomeignore_patterns(root);
10
+ let mut pending = vec![root.to_path_buf()];
11
+ while let Some(dir) = pending.pop() {
12
+ let Ok(entries) = fs::read_dir(&dir) else {
13
+ continue;
14
+ };
15
+ for entry in entries.flatten() {
16
+ let path = entry.path();
17
+ let relative = normalize(path.strip_prefix(root).unwrap_or(&path));
18
+ if ignored(&relative, &ignore_patterns) {
19
+ continue;
20
+ }
21
+ let Ok(metadata) = fs::symlink_metadata(&path) else {
22
+ continue;
23
+ };
24
+ if metadata.is_dir() {
25
+ pending.push(path);
26
+ } else if metadata.is_file() {
27
+ found.insert(relative);
28
+ }
29
+ }
30
+ }
31
+ found
32
+ }
33
+
34
+ fn ignored(path: &str, ignore_patterns: &[String]) -> bool {
35
+ path.is_empty()
36
+ || path == ".git"
37
+ || path.starts_with(".git/")
38
+ || path == ".naome"
39
+ || path.starts_with(".naome/")
40
+ || path == "node_modules"
41
+ || path.contains("/node_modules/")
42
+ || build_output(path)
43
+ || matches_any(path, ignore_patterns)
44
+ }
45
+
46
+ fn build_output(path: &str) -> bool {
47
+ matches!(path, "target" | "dist" | "build")
48
+ || path.contains("/target/")
49
+ || path.contains("/dist/")
50
+ || path.contains("/build/")
51
+ }
52
+
53
+ fn normalize(path: impl Into<PathBuf>) -> String {
54
+ path.into().to_string_lossy().replace('\\', "/")
55
+ }
56
+
57
+ fn matches_any(path: &str, patterns: &[String]) -> bool {
58
+ path_matches_any(path, patterns)
59
+ }
@@ -0,0 +1,17 @@
1
+ mod infer;
2
+ mod render;
3
+ mod repository;
4
+
5
+ use std::path::Path;
6
+
7
+ use naome_core::default_architecture_config_text;
8
+
9
+ pub fn architecture_init_config_text(root: &Path) -> String {
10
+ let files = repository::files(root);
11
+ let layers = infer::layers(&files);
12
+ let contexts = infer::contexts(&files);
13
+ if layers.is_empty() && contexts.is_empty() {
14
+ return default_architecture_config_text().to_string();
15
+ }
16
+ render::config(layers, contexts)
17
+ }
@@ -1,4 +1,5 @@
1
1
  mod architecture_commands;
2
+ mod architecture_init;
2
3
  mod check_commands;
3
4
  mod cli_args;
4
5
  mod context_commands;
@@ -61,7 +62,7 @@ const HELP: &str = r#"Usage:
61
62
  naome arch init [--config <path>] [--json]
62
63
  naome arch explain [--config <path>] [--json]
63
64
  naome arch scan [--config <path>] [--changed-only] [--write] [--output <path>] [--json]
64
- naome arch validate [--config <path>] [--changed-only] [--agent-feedback] [--json]
65
+ naome arch validate [--config <path>] [--changed-only] [--agent-feedback] [--json|--sarif] [--output <path>]
65
66
  naome workflow search-profile [--json]
66
67
  naome workflow agent-plan [--json]
67
68
  naome workflow context-delta [--read-path <path>...] [--json]
@@ -38,6 +38,81 @@ fn architecture_explain_json_lists_all_validation_rules() {
38
38
  }
39
39
  }
40
40
 
41
+ #[test]
42
+ fn architecture_init_generates_repo_aware_layers_and_contexts() {
43
+ let root = fixture_root();
44
+ write_fixture_file(&root, "src/billing/index.ts", "export const billing = 1;\n");
45
+ write_fixture_file(
46
+ &root,
47
+ "src/ticketing/index.ts",
48
+ "export const ticketing = 1;\n",
49
+ );
50
+ write_fixture_file(&root, "src/domain/event.ts", "export const event = 1;\n");
51
+ write_fixture_file(&root, "src/infrastructure/db.ts", "export const db = 1;\n");
52
+ write_fixture_file(
53
+ &root,
54
+ "Sources/App/Views/HomeView.swift",
55
+ "import SwiftUI\n",
56
+ );
57
+
58
+ run_arch_init(&root);
59
+ let config = fs::read_to_string(root.join("naome.arch.yaml")).unwrap();
60
+
61
+ assert!(config.contains("ui:"));
62
+ assert!(config.contains("\"Sources/**/Views/**\""));
63
+ assert!(config.contains("billing:"));
64
+ assert!(config.contains("\"src/billing/**\""));
65
+ assert!(config.contains("ticketing:"));
66
+ assert!(config.contains("\"src/ticketing/**\""));
67
+ assert!(config.contains("public_api:"));
68
+ assert!(config.contains("\"src/billing/index.*\""));
69
+ }
70
+
71
+ #[test]
72
+ fn architecture_init_respects_naomeignore_boundaries() {
73
+ let root = fixture_root();
74
+ fs::write(
75
+ root.join(".naomeignore"),
76
+ ".naome/archive/\n.naome/tasks/\nvendor/\ngenerated/**\n",
77
+ )
78
+ .unwrap();
79
+ write_fixture_file(&root, "vendor/billing/index.ts", "export const x = 1;\n");
80
+ write_fixture_file(
81
+ &root,
82
+ "generated/ticketing/index.ts",
83
+ "export const y = 1;\n",
84
+ );
85
+ write_fixture_file(&root, "src/domain/event.ts", "export const event = 1;\n");
86
+
87
+ run_arch_init(&root);
88
+ let config = fs::read_to_string(root.join("naome.arch.yaml")).unwrap();
89
+
90
+ assert!(config.contains("\"src/domain/**\""));
91
+ assert!(!config.contains("vendor"));
92
+ assert!(!config.contains("generated/ticketing"));
93
+ }
94
+
95
+ fn run_arch_init(root: &std::path::Path) {
96
+ let output = Command::new(env!("CARGO_BIN_EXE_naome"))
97
+ .args(["arch", "init"])
98
+ .current_dir(root)
99
+ .output()
100
+ .unwrap();
101
+
102
+ assert!(
103
+ output.status.success(),
104
+ "{}{}",
105
+ String::from_utf8_lossy(&output.stdout),
106
+ String::from_utf8_lossy(&output.stderr)
107
+ );
108
+ }
109
+
110
+ fn write_fixture_file(root: &std::path::Path, path: &str, content: &str) {
111
+ let target = root.join(path);
112
+ fs::create_dir_all(target.parent().unwrap()).unwrap();
113
+ fs::write(target, content).unwrap();
114
+ }
115
+
41
116
  fn fixture_root() -> std::path::PathBuf {
42
117
  let nonce = SystemTime::now()
43
118
  .duration_since(UNIX_EPOCH)
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-core"
3
- version = "1.3.16"
3
+ version = "1.4.0"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -0,0 +1,81 @@
1
+ use super::super::super::output::ArchitectureConfigFinding;
2
+ use super::super::super::scan::{ArchitectureScanReport, FileFact};
3
+
4
+ pub fn findings(scan: &ArchitectureScanReport) -> Vec<ArchitectureConfigFinding> {
5
+ let mut findings = Vec::new();
6
+ findings.extend(unmatched_layer_findings(scan));
7
+ findings.extend(unmatched_context_findings(scan));
8
+ findings
9
+ }
10
+
11
+ fn unmatched_layer_findings(scan: &ArchitectureScanReport) -> Vec<ArchitectureConfigFinding> {
12
+ unmatched_membership_findings(
13
+ scan,
14
+ scan.config.layers.keys(),
15
+ MembershipFinding {
16
+ id: "arch.config.layer_matches_no_files",
17
+ subject_prefix: "layer",
18
+ label: "Layer",
19
+ noun: "layer",
20
+ },
21
+ |fact| &fact.layers,
22
+ )
23
+ }
24
+
25
+ fn unmatched_context_findings(scan: &ArchitectureScanReport) -> Vec<ArchitectureConfigFinding> {
26
+ unmatched_membership_findings(
27
+ scan,
28
+ scan.config.contexts.keys(),
29
+ MembershipFinding {
30
+ id: "arch.config.context_matches_no_files",
31
+ subject_prefix: "context",
32
+ label: "Context",
33
+ noun: "context",
34
+ },
35
+ |fact| &fact.contexts,
36
+ )
37
+ }
38
+
39
+ struct MembershipFinding {
40
+ id: &'static str,
41
+ subject_prefix: &'static str,
42
+ label: &'static str,
43
+ noun: &'static str,
44
+ }
45
+
46
+ fn unmatched_membership_findings<'a, I, F>(
47
+ scan: &ArchitectureScanReport,
48
+ names: I,
49
+ finding: MembershipFinding,
50
+ memberships: F,
51
+ ) -> Vec<ArchitectureConfigFinding>
52
+ where
53
+ I: Iterator<Item = &'a String>,
54
+ F: Fn(&FileFact) -> &[String],
55
+ {
56
+ names
57
+ .filter(|name| {
58
+ !scan
59
+ .file_facts
60
+ .values()
61
+ .any(|fact| memberships(fact).iter().any(|member| member == *name))
62
+ })
63
+ .map(|name| ArchitectureConfigFinding {
64
+ id: finding.id.to_string(),
65
+ severity: "warning".to_string(),
66
+ subject: format!("{}:{name}", finding.subject_prefix),
67
+ message: format!(
68
+ "{} {name} does not match any scanned repository file.",
69
+ finding.label
70
+ ),
71
+ suggestion: format!(
72
+ "Update {name} path patterns in naome.arch.yaml or remove the {} if it is not part of this repository.",
73
+ finding.noun
74
+ ),
75
+ agent_instruction: format!(
76
+ "Do not rely on {} {name} for architecture enforcement until its paths match repository files.",
77
+ finding.noun
78
+ ),
79
+ })
80
+ .collect()
81
+ }
@@ -0,0 +1,117 @@
1
+ use super::super::super::output::ArchitectureConfigFinding;
2
+ use super::super::super::scan::ArchitectureScanReport;
3
+
4
+ pub fn findings(scan: &ArchitectureScanReport) -> Vec<ArchitectureConfigFinding> {
5
+ let mut findings = Vec::new();
6
+ findings.extend(broad_layer_findings(scan));
7
+ findings.extend(catch_all_context_findings(scan));
8
+ findings
9
+ }
10
+
11
+ fn broad_layer_findings(scan: &ArchitectureScanReport) -> Vec<ArchitectureConfigFinding> {
12
+ let mut findings = Vec::new();
13
+ for (layer_name, layer) in &scan.config.layers {
14
+ let Some(broad_pattern) = layer
15
+ .paths
16
+ .iter()
17
+ .find(|pattern| is_broad_source_pattern(pattern))
18
+ else {
19
+ continue;
20
+ };
21
+ let overlapping_layers = scan
22
+ .config
23
+ .layers
24
+ .iter()
25
+ .filter(|(other_name, other_layer)| {
26
+ other_name.as_str() != layer_name.as_str()
27
+ && other_layer
28
+ .paths
29
+ .iter()
30
+ .any(|pattern| pattern_is_narrower_than(pattern, broad_pattern))
31
+ })
32
+ .map(|(name, _)| name.clone())
33
+ .collect::<Vec<_>>();
34
+ if overlapping_layers.is_empty() {
35
+ continue;
36
+ }
37
+ findings.push(ArchitectureConfigFinding {
38
+ id: "arch.config.broad_layer_overlap".to_string(),
39
+ severity: "warning".to_string(),
40
+ subject: format!("layer:{layer_name}"),
41
+ message: format!(
42
+ "Layer {layer_name} uses broad path pattern {broad_pattern} while narrower layers also match inside it: {}.",
43
+ overlapping_layers.join(", ")
44
+ ),
45
+ suggestion: format!(
46
+ "Keep broad compatibility layers intentional and make allowed_dependencies explicit, or narrow {layer_name} so files do not inherit multiple architectural meanings by default."
47
+ ),
48
+ agent_instruction: format!(
49
+ "Do not rely on broad layer {layer_name} to hide architecture boundaries; prefer narrower layer paths or explicit allow rules."
50
+ ),
51
+ });
52
+ }
53
+ findings
54
+ }
55
+
56
+ fn catch_all_context_findings(scan: &ArchitectureScanReport) -> Vec<ArchitectureConfigFinding> {
57
+ if scan.config.contexts.len() <= 1 {
58
+ return Vec::new();
59
+ }
60
+ scan.config
61
+ .contexts
62
+ .iter()
63
+ .filter_map(|(context_name, context)| {
64
+ let broad_pattern = context
65
+ .paths
66
+ .iter()
67
+ .find(|pattern| is_broad_source_pattern(pattern))?;
68
+ let specific_contexts = scan
69
+ .config
70
+ .contexts
71
+ .iter()
72
+ .filter(|(other_name, other_context)| {
73
+ other_name.as_str() != context_name.as_str()
74
+ && other_context
75
+ .paths
76
+ .iter()
77
+ .any(|pattern| pattern_is_narrower_than(pattern, broad_pattern))
78
+ })
79
+ .map(|(name, _)| name.clone())
80
+ .collect::<Vec<_>>();
81
+ if specific_contexts.is_empty() {
82
+ return None;
83
+ }
84
+ Some(ArchitectureConfigFinding {
85
+ id: "arch.config.catch_all_context_with_specific_contexts".to_string(),
86
+ severity: "warning".to_string(),
87
+ subject: format!("context:{context_name}"),
88
+ message: format!(
89
+ "Context {context_name} uses catch-all path pattern {broad_pattern} alongside narrower contexts: {}.",
90
+ specific_contexts.join(", ")
91
+ ),
92
+ suggestion: format!(
93
+ "Treat {context_name} as a compatibility bucket only. Move shared code into explicit public APIs or replace the catch-all context with narrower bounded contexts."
94
+ ),
95
+ agent_instruction: format!(
96
+ "Do not classify cross-context imports as safe only because {context_name} also matches them; model the real bounded context or public API."
97
+ ),
98
+ })
99
+ })
100
+ .collect()
101
+ }
102
+
103
+ fn is_broad_source_pattern(pattern: &str) -> bool {
104
+ matches!(pattern, "**" | "**/*" | "src/**" | "packages/**/src/**")
105
+ }
106
+
107
+ fn pattern_is_narrower_than(pattern: &str, broad_pattern: &str) -> bool {
108
+ if pattern == broad_pattern {
109
+ return false;
110
+ }
111
+ match broad_pattern {
112
+ "**" | "**/*" => true,
113
+ "src/**" => pattern.starts_with("src/") && pattern != "src/**",
114
+ "packages/**/src/**" => pattern.starts_with("packages/") && pattern.contains("/src/"),
115
+ _ => false,
116
+ }
117
+ }
@@ -0,0 +1,12 @@
1
+ mod coverage;
2
+ mod overlap;
3
+
4
+ use super::super::output::ArchitectureConfigFinding;
5
+ use super::super::scan::ArchitectureScanReport;
6
+
7
+ pub fn findings(scan: &ArchitectureScanReport) -> Vec<ArchitectureConfigFinding> {
8
+ let mut findings = Vec::new();
9
+ findings.extend(overlap::findings(scan));
10
+ findings.extend(coverage::findings(scan));
11
+ findings
12
+ }
@@ -0,0 +1,30 @@
1
+ use super::super::output::ArchitectureConfigFinding;
2
+ use super::super::scan::{ArchitectureScanReport, ImportTarget};
3
+
4
+ pub fn findings(scan: &ArchitectureScanReport) -> Vec<ArchitectureConfigFinding> {
5
+ let mut findings = Vec::new();
6
+ for fact in scan.file_facts.values() {
7
+ for import in &fact.imports {
8
+ let ImportTarget::Unknown(specifier) = &import.target else {
9
+ continue;
10
+ };
11
+ findings.push(ArchitectureConfigFinding {
12
+ id: "arch.import.unresolved".to_string(),
13
+ severity: "warning".to_string(),
14
+ subject: format!("file:{}", fact.path),
15
+ message: format!(
16
+ "{} imports {}, but NAOME could not resolve it to a repository file or external dependency.",
17
+ fact.path, specifier
18
+ ),
19
+ suggestion: format!(
20
+ "Resolve or remove import {specifier}, or add the missing resolver alias/manifest context if the import is valid."
21
+ ),
22
+ agent_instruction: format!(
23
+ "Resolve or remove import {specifier} in {} before relying on architecture validation for this dependency edge.",
24
+ fact.path
25
+ ),
26
+ });
27
+ }
28
+ }
29
+ findings
30
+ }