@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.
- package/Cargo.lock +2 -2
- package/crates/naome-cli/Cargo.toml +1 -1
- package/crates/naome-cli/src/architecture_commands.rs +16 -2
- package/crates/naome-cli/src/architecture_init/infer.rs +131 -0
- package/crates/naome-cli/src/architecture_init/render.rs +56 -0
- package/crates/naome-cli/src/architecture_init/repository.rs +59 -0
- package/crates/naome-cli/src/architecture_init.rs +17 -0
- package/crates/naome-cli/src/main.rs +2 -1
- package/crates/naome-cli/tests/architecture_cli.rs +75 -0
- package/crates/naome-core/Cargo.toml +1 -1
- package/crates/naome-core/src/architecture/config_findings/configuration/coverage.rs +81 -0
- package/crates/naome-core/src/architecture/config_findings/configuration/overlap.rs +117 -0
- package/crates/naome-core/src/architecture/config_findings/configuration.rs +12 -0
- package/crates/naome-core/src/architecture/config_findings/imports.rs +30 -0
- package/crates/naome-core/src/architecture/config_findings.rs +50 -0
- package/crates/naome-core/src/architecture/explain.rs +45 -0
- package/crates/naome-core/src/architecture/output.rs +211 -155
- package/crates/naome-core/src/architecture/rules.rs +4 -3
- package/crates/naome-core/src/architecture/scan/cache.rs +1 -1
- package/crates/naome-core/src/architecture/scan/imports/resolver/candidates.rs +71 -0
- package/crates/naome-core/src/architecture/scan/imports/resolver/js_ts_alias.rs +241 -0
- package/crates/naome-core/src/architecture/scan/imports/resolver.rs +162 -91
- package/crates/naome-core/src/architecture/scan.rs +20 -6
- package/crates/naome-core/src/architecture.rs +8 -3
- package/crates/naome-core/src/lib.rs +9 -7
- package/crates/naome-core/tests/architecture.rs +30 -0
- package/crates/naome-core/tests/architecture_acceptance.rs +304 -0
- package/crates/naome-core/tests/architecture_aliases.rs +101 -0
- package/crates/naome-core/tests/architecture_cache.rs +57 -0
- package/crates/naome-core/tests/architecture_config.rs +155 -1
- package/crates/naome-core/tests/architecture_rules.rs +32 -0
- package/crates/naome-core/tests/architecture_unresolved.rs +36 -0
- package/native/darwin-arm64/naome +0 -0
- package/native/linux-x64/naome +0 -0
- package/package.json +1 -1
- package/templates/naome-root/.naome/bin/check-harness-health.js +1 -0
- package/templates/naome-root/.naome/bin/check-task-state.js +1 -0
- package/templates/naome-root/.naome/manifest.json +2 -2
- package/templates/naome-root/.naome/verification.json +6 -1
- 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.
|
|
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.
|
|
87
|
+
version = "1.4.0"
|
|
88
88
|
dependencies = [
|
|
89
89
|
"serde",
|
|
90
90
|
"serde_json",
|
|
@@ -2,11 +2,12 @@ use std::fs;
|
|
|
2
2
|
use std::path::{Path, PathBuf};
|
|
3
3
|
|
|
4
4
|
use naome_core::{
|
|
5
|
-
|
|
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,
|
|
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)
|
|
@@ -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
|
+
}
|