@lamentis/naome 1.3.9 → 1.3.11
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/README.md +10 -0
- package/bin/naome.js +1 -1
- package/crates/naome-cli/Cargo.toml +1 -1
- package/crates/naome-cli/src/architecture_commands.rs +127 -0
- package/crates/naome-cli/src/cli_args.rs +4 -0
- package/crates/naome-cli/src/dispatcher.rs +2 -0
- package/crates/naome-cli/src/main.rs +6 -0
- package/crates/naome-core/Cargo.toml +1 -1
- package/crates/naome-core/src/architecture/config/parser/scalar.rs +26 -0
- package/crates/naome-core/src/architecture/config/parser/sections.rs +154 -0
- package/crates/naome-core/src/architecture/config/parser.rs +97 -0
- package/crates/naome-core/src/architecture/config.rs +126 -0
- package/crates/naome-core/src/architecture/model.rs +80 -0
- package/crates/naome-core/src/architecture/output.rs +178 -0
- package/crates/naome-core/src/architecture/rules.rs +212 -0
- package/crates/naome-core/src/architecture/scan/graph_builder/emit.rs +118 -0
- package/crates/naome-core/src/architecture/scan/graph_builder/facts.rs +87 -0
- package/crates/naome-core/src/architecture/scan/graph_builder.rs +211 -0
- package/crates/naome-core/src/architecture/scan/imports/extractors.rs +407 -0
- package/crates/naome-core/src/architecture/scan/imports/resolver.rs +334 -0
- package/crates/naome-core/src/architecture/scan/imports.rs +59 -0
- package/crates/naome-core/src/architecture/scan/path_scan.rs +92 -0
- package/crates/naome-core/src/architecture/scan.rs +95 -0
- package/crates/naome-core/src/architecture.rs +31 -0
- package/crates/naome-core/src/install_plan.rs +2 -0
- package/crates/naome-core/src/lib.rs +16 -8
- package/crates/naome-core/tests/architecture.rs +548 -0
- package/crates/naome-core/tests/harness_health.rs +1 -0
- package/installer/harness-files.js +3 -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 +7 -7
- package/templates/naome-root/.naome/bin/check-task-state.js +7 -7
- package/templates/naome-root/.naome/bin/naome.js +2 -2
- package/templates/naome-root/.naome/manifest.json +10 -8
- package/templates/naome-root/.naome/verification.json +15 -1
- package/templates/naome-root/docs/naome/architecture-fitness.md +109 -0
- package/templates/naome-root/docs/naome/index.md +4 -3
- package/templates/naome-root/docs/naome/testing.md +6 -3
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
use serde_json::Value;
|
|
3
|
+
|
|
4
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
5
|
+
#[serde(rename_all = "snake_case")]
|
|
6
|
+
pub enum ArchitectureNodeKind {
|
|
7
|
+
Repository,
|
|
8
|
+
Package,
|
|
9
|
+
Directory,
|
|
10
|
+
File,
|
|
11
|
+
Module,
|
|
12
|
+
Symbol,
|
|
13
|
+
Layer,
|
|
14
|
+
BoundedContext,
|
|
15
|
+
ExternalDependency,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
19
|
+
#[serde(rename_all = "snake_case")]
|
|
20
|
+
pub enum ArchitectureEdgeKind {
|
|
21
|
+
Contains,
|
|
22
|
+
Imports,
|
|
23
|
+
DependsOn,
|
|
24
|
+
Calls,
|
|
25
|
+
Exports,
|
|
26
|
+
Implements,
|
|
27
|
+
Extends,
|
|
28
|
+
ReadsFrom,
|
|
29
|
+
WritesTo,
|
|
30
|
+
Unknown,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
34
|
+
pub struct SourceRange {
|
|
35
|
+
pub start_line: usize,
|
|
36
|
+
pub start_column: usize,
|
|
37
|
+
pub end_line: usize,
|
|
38
|
+
pub end_column: usize,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
42
|
+
pub struct ArchitectureMetadata {
|
|
43
|
+
pub path: Option<String>,
|
|
44
|
+
pub language: Option<String>,
|
|
45
|
+
pub source_range: Option<SourceRange>,
|
|
46
|
+
pub confidence: f32,
|
|
47
|
+
pub extractor: String,
|
|
48
|
+
pub raw_origin: Value,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
52
|
+
pub struct ArchitectureNode {
|
|
53
|
+
pub id: String,
|
|
54
|
+
pub kind: ArchitectureNodeKind,
|
|
55
|
+
pub label: String,
|
|
56
|
+
pub metadata: ArchitectureMetadata,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
60
|
+
pub struct ArchitectureEdge {
|
|
61
|
+
pub id: String,
|
|
62
|
+
pub from: String,
|
|
63
|
+
pub to: String,
|
|
64
|
+
pub kind: ArchitectureEdgeKind,
|
|
65
|
+
pub label: String,
|
|
66
|
+
pub metadata: ArchitectureMetadata,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
70
|
+
pub struct ArchitectureGraph {
|
|
71
|
+
pub nodes: Vec<ArchitectureNode>,
|
|
72
|
+
pub edges: Vec<ArchitectureEdge>,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
impl ArchitectureGraph {
|
|
76
|
+
pub fn sort_stable(&mut self) {
|
|
77
|
+
self.nodes.sort_by(|left, right| left.id.cmp(&right.id));
|
|
78
|
+
self.edges.sort_by(|left, right| left.id.cmp(&right.id));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
|
|
3
|
+
use super::model::SourceRange;
|
|
4
|
+
use super::scan::ArchitectureScanReport;
|
|
5
|
+
|
|
6
|
+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
7
|
+
#[serde(rename_all = "snake_case")]
|
|
8
|
+
pub enum Severity {
|
|
9
|
+
Error,
|
|
10
|
+
Warning,
|
|
11
|
+
Info,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
15
|
+
#[serde(rename_all = "camelCase")]
|
|
16
|
+
pub struct ViolationSummary {
|
|
17
|
+
pub errors: usize,
|
|
18
|
+
pub warnings: usize,
|
|
19
|
+
pub info: usize,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
23
|
+
pub struct ArchitectureViolation {
|
|
24
|
+
pub id: String,
|
|
25
|
+
pub severity: Severity,
|
|
26
|
+
#[serde(rename = "type")]
|
|
27
|
+
pub violation_type: String,
|
|
28
|
+
pub message: String,
|
|
29
|
+
pub from: Option<String>,
|
|
30
|
+
pub to: Option<String>,
|
|
31
|
+
pub path: Option<String>,
|
|
32
|
+
pub source_range: Option<SourceRange>,
|
|
33
|
+
pub suggestion: String,
|
|
34
|
+
pub agent_instruction: String,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
38
|
+
pub struct ArchitectureAgentFeedback {
|
|
39
|
+
pub problem: String,
|
|
40
|
+
pub repair: String,
|
|
41
|
+
pub files: Vec<String>,
|
|
42
|
+
pub must_not_do: Vec<String>,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
46
|
+
#[serde(rename_all = "camelCase")]
|
|
47
|
+
pub struct ArchitectureValidation {
|
|
48
|
+
pub schema: String,
|
|
49
|
+
pub status: String,
|
|
50
|
+
pub summary: ViolationSummary,
|
|
51
|
+
pub files_scanned: usize,
|
|
52
|
+
pub graph_nodes: usize,
|
|
53
|
+
pub graph_edges: usize,
|
|
54
|
+
pub rules_executed: Vec<String>,
|
|
55
|
+
pub changed_only_requested: bool,
|
|
56
|
+
pub changed_only_degraded_to_full_scan: bool,
|
|
57
|
+
pub violations: Vec<ArchitectureViolation>,
|
|
58
|
+
#[serde(rename = "agent_feedback")]
|
|
59
|
+
pub agent_feedback: Vec<ArchitectureAgentFeedback>,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
impl Severity {
|
|
63
|
+
pub fn parse(value: &str) -> Option<Self> {
|
|
64
|
+
match value {
|
|
65
|
+
"error" => Some(Self::Error),
|
|
66
|
+
"warning" | "warn" => Some(Self::Warning),
|
|
67
|
+
"info" => Some(Self::Info),
|
|
68
|
+
_ => None,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pub fn architecture_agent_feedback(
|
|
74
|
+
violations: &[ArchitectureViolation],
|
|
75
|
+
) -> Vec<ArchitectureAgentFeedback> {
|
|
76
|
+
violations
|
|
77
|
+
.iter()
|
|
78
|
+
.map(|violation| ArchitectureAgentFeedback {
|
|
79
|
+
problem: violation.message.clone(),
|
|
80
|
+
repair: violation.suggestion.clone(),
|
|
81
|
+
files: violation.path.iter().cloned().collect(),
|
|
82
|
+
must_not_do: vec![
|
|
83
|
+
"Do not suppress architecture rules without an explicit ignore reason.".to_string(),
|
|
84
|
+
"Do not edit generated files unless the architecture config allows it.".to_string(),
|
|
85
|
+
],
|
|
86
|
+
})
|
|
87
|
+
.collect()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pub fn summary_for(violations: &[ArchitectureViolation]) -> ViolationSummary {
|
|
91
|
+
ViolationSummary {
|
|
92
|
+
errors: violations
|
|
93
|
+
.iter()
|
|
94
|
+
.filter(|violation| violation.severity == Severity::Error)
|
|
95
|
+
.count(),
|
|
96
|
+
warnings: violations
|
|
97
|
+
.iter()
|
|
98
|
+
.filter(|violation| violation.severity == Severity::Warning)
|
|
99
|
+
.count(),
|
|
100
|
+
info: violations
|
|
101
|
+
.iter()
|
|
102
|
+
.filter(|violation| violation.severity == Severity::Info)
|
|
103
|
+
.count(),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
pub fn format_architecture_validation(report: &ArchitectureValidation) -> String {
|
|
108
|
+
let mut lines = vec![
|
|
109
|
+
format!(
|
|
110
|
+
"NAOME architecture fitness {}",
|
|
111
|
+
report.status.to_uppercase()
|
|
112
|
+
),
|
|
113
|
+
format!("files scanned: {}", report.files_scanned),
|
|
114
|
+
format!("graph nodes: {}", report.graph_nodes),
|
|
115
|
+
format!("graph edges: {}", report.graph_edges),
|
|
116
|
+
format!("rules executed: {}", report.rules_executed.join(", ")),
|
|
117
|
+
format!(
|
|
118
|
+
"violations: {} errors, {} warnings, {} info",
|
|
119
|
+
report.summary.errors, report.summary.warnings, report.summary.info
|
|
120
|
+
),
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
if report.changed_only_degraded_to_full_scan {
|
|
124
|
+
lines.push("changed-only requested: degraded to full scan for soundness".to_string());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for violation in report.violations.iter().take(10) {
|
|
128
|
+
lines.push(format!(
|
|
129
|
+
"- {:?} {} {}",
|
|
130
|
+
violation.severity,
|
|
131
|
+
violation.path.as_deref().unwrap_or("<repository>"),
|
|
132
|
+
violation.message
|
|
133
|
+
));
|
|
134
|
+
lines.push(format!(" fix: {}", violation.suggestion));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
lines.push(String::new());
|
|
138
|
+
lines.join("\n")
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
pub fn format_architecture_scan(report: &ArchitectureScanReport) -> String {
|
|
142
|
+
format!(
|
|
143
|
+
"NAOME architecture scan\nfiles scanned: {}\ngraph nodes: {}\ngraph edges: {}\n",
|
|
144
|
+
report.files_scanned,
|
|
145
|
+
report.graph.nodes.len(),
|
|
146
|
+
report.graph.edges.len()
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pub fn format_architecture_explain(scan: &ArchitectureScanReport) -> String {
|
|
151
|
+
let layers = scan
|
|
152
|
+
.config
|
|
153
|
+
.layers
|
|
154
|
+
.keys()
|
|
155
|
+
.cloned()
|
|
156
|
+
.collect::<Vec<_>>()
|
|
157
|
+
.join(", ");
|
|
158
|
+
let contexts = scan
|
|
159
|
+
.config
|
|
160
|
+
.contexts
|
|
161
|
+
.keys()
|
|
162
|
+
.cloned()
|
|
163
|
+
.collect::<Vec<_>>()
|
|
164
|
+
.join(", ");
|
|
165
|
+
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",
|
|
167
|
+
empty_label(&layers),
|
|
168
|
+
empty_label(&contexts)
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
fn empty_label(value: &str) -> &str {
|
|
173
|
+
if value.is_empty() {
|
|
174
|
+
"<none>"
|
|
175
|
+
} else {
|
|
176
|
+
value
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
use crate::paths;
|
|
2
|
+
|
|
3
|
+
use super::output::{
|
|
4
|
+
architecture_agent_feedback, summary_for, ArchitectureValidation, ArchitectureViolation,
|
|
5
|
+
Severity,
|
|
6
|
+
};
|
|
7
|
+
use super::scan::ArchitectureScanReport;
|
|
8
|
+
use crate::architecture::model::ArchitectureEdgeKind;
|
|
9
|
+
|
|
10
|
+
pub fn validate_scan(scan: ArchitectureScanReport) -> ArchitectureValidation {
|
|
11
|
+
let mut violations = Vec::new();
|
|
12
|
+
let mut rules_executed = Vec::new();
|
|
13
|
+
|
|
14
|
+
validate_max_file_lines(&scan, &mut violations, &mut rules_executed);
|
|
15
|
+
validate_generated_manual_boundary(&scan, &mut violations, &mut rules_executed);
|
|
16
|
+
validate_forbidden_layer_dependencies(&scan, &mut violations, &mut rules_executed);
|
|
17
|
+
|
|
18
|
+
violations.sort_by(|left, right| {
|
|
19
|
+
(
|
|
20
|
+
severity_rank(left.severity),
|
|
21
|
+
left.id.as_str(),
|
|
22
|
+
left.path.as_deref().unwrap_or(""),
|
|
23
|
+
left.message.as_str(),
|
|
24
|
+
)
|
|
25
|
+
.cmp(&(
|
|
26
|
+
severity_rank(right.severity),
|
|
27
|
+
right.id.as_str(),
|
|
28
|
+
right.path.as_deref().unwrap_or(""),
|
|
29
|
+
right.message.as_str(),
|
|
30
|
+
))
|
|
31
|
+
});
|
|
32
|
+
let summary = summary_for(&violations);
|
|
33
|
+
let status = if summary.errors > 0 { "fail" } else { "pass" }.to_string();
|
|
34
|
+
let agent_feedback = architecture_agent_feedback(&violations);
|
|
35
|
+
|
|
36
|
+
ArchitectureValidation {
|
|
37
|
+
schema: "naome.arch.validation.v1".to_string(),
|
|
38
|
+
status,
|
|
39
|
+
summary,
|
|
40
|
+
files_scanned: scan.files_scanned,
|
|
41
|
+
graph_nodes: scan.graph.nodes.len(),
|
|
42
|
+
graph_edges: scan.graph.edges.len(),
|
|
43
|
+
rules_executed,
|
|
44
|
+
changed_only_requested: scan.changed_only_requested,
|
|
45
|
+
changed_only_degraded_to_full_scan: scan.changed_only_degraded_to_full_scan,
|
|
46
|
+
violations,
|
|
47
|
+
agent_feedback,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fn validate_forbidden_layer_dependencies(
|
|
52
|
+
scan: &ArchitectureScanReport,
|
|
53
|
+
violations: &mut Vec<ArchitectureViolation>,
|
|
54
|
+
rules_executed: &mut Vec<String>,
|
|
55
|
+
) {
|
|
56
|
+
let rule = scan.config.rule("no_forbidden_layer_dependencies");
|
|
57
|
+
if !rule.enabled {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
rules_executed.push("arch.no_forbidden_layer_dependencies".to_string());
|
|
61
|
+
for edge in &scan.graph.edges {
|
|
62
|
+
if edge.kind != ArchitectureEdgeKind::Imports {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
let Some(from_path) = edge.from.strip_prefix("file:") else {
|
|
66
|
+
continue;
|
|
67
|
+
};
|
|
68
|
+
let Some(to_path) = edge.to.strip_prefix("file:") else {
|
|
69
|
+
continue;
|
|
70
|
+
};
|
|
71
|
+
let Some(from_fact) = scan.file_facts.get(from_path) else {
|
|
72
|
+
continue;
|
|
73
|
+
};
|
|
74
|
+
let Some(to_fact) = scan.file_facts.get(to_path) else {
|
|
75
|
+
continue;
|
|
76
|
+
};
|
|
77
|
+
if to_fact.layers.is_empty() {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
for from_layer in &from_fact.layers {
|
|
81
|
+
let allowed_layers = scan
|
|
82
|
+
.config
|
|
83
|
+
.allowed_dependencies
|
|
84
|
+
.get(from_layer)
|
|
85
|
+
.map(Vec::as_slice)
|
|
86
|
+
.unwrap_or(&[]);
|
|
87
|
+
if to_fact
|
|
88
|
+
.layers
|
|
89
|
+
.iter()
|
|
90
|
+
.any(|to_layer| from_layer == to_layer || allowed_layers.contains(to_layer))
|
|
91
|
+
{
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
let target_layers = to_fact.layers.join(", ");
|
|
95
|
+
violations.push(ArchitectureViolation {
|
|
96
|
+
id: "arch.no_forbidden_layer_dependencies".to_string(),
|
|
97
|
+
severity: rule.severity,
|
|
98
|
+
violation_type: "forbidden_layer_dependency".to_string(),
|
|
99
|
+
message: format!(
|
|
100
|
+
"{} in layer {} imports {} in forbidden layer {}.",
|
|
101
|
+
from_path, from_layer, to_path, target_layers
|
|
102
|
+
),
|
|
103
|
+
from: Some(edge.from.clone()),
|
|
104
|
+
to: Some(edge.to.clone()),
|
|
105
|
+
path: Some(from_path.to_string()),
|
|
106
|
+
source_range: edge.metadata.source_range.clone(),
|
|
107
|
+
suggestion: format!(
|
|
108
|
+
"Move the dependency behind an allowed layer boundary or change naome.arch.yaml allowed_dependencies if this architecture is intentional. {} currently allows: {}.",
|
|
109
|
+
from_layer,
|
|
110
|
+
allowed_layers.join(", ")
|
|
111
|
+
),
|
|
112
|
+
agent_instruction: format!(
|
|
113
|
+
"Do not import {} from {}. Introduce an allowed boundary or invert the dependency before re-running architecture validation.",
|
|
114
|
+
target_layers, from_layer
|
|
115
|
+
),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
fn validate_max_file_lines(
|
|
122
|
+
scan: &ArchitectureScanReport,
|
|
123
|
+
violations: &mut Vec<ArchitectureViolation>,
|
|
124
|
+
rules_executed: &mut Vec<String>,
|
|
125
|
+
) {
|
|
126
|
+
let rule = scan.config.rule("max_file_lines");
|
|
127
|
+
if !rule.enabled {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
rules_executed.push("arch.max_file_lines".to_string());
|
|
131
|
+
let Some(limit) = rule.value else {
|
|
132
|
+
return;
|
|
133
|
+
};
|
|
134
|
+
for fact in scan.file_facts.values() {
|
|
135
|
+
if fact.line_count <= limit {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
violations.push(ArchitectureViolation {
|
|
139
|
+
id: "arch.max_file_lines".to_string(),
|
|
140
|
+
severity: rule.severity,
|
|
141
|
+
violation_type: "file_size_budget".to_string(),
|
|
142
|
+
message: format!(
|
|
143
|
+
"{} has {} lines, exceeding the configured budget of {}.",
|
|
144
|
+
fact.path, fact.line_count, limit
|
|
145
|
+
),
|
|
146
|
+
from: Some(format!("file:{}", fact.path)),
|
|
147
|
+
to: None,
|
|
148
|
+
path: Some(fact.path.clone()),
|
|
149
|
+
source_range: None,
|
|
150
|
+
suggestion: "Split the file into cohesive modules or move generated content behind an explicit ignore rule.".to_string(),
|
|
151
|
+
agent_instruction: format!(
|
|
152
|
+
"Reduce {} below {} lines or add a justified generated-code ignore rule if it is not manually owned.",
|
|
153
|
+
fact.path, limit
|
|
154
|
+
),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fn validate_generated_manual_boundary(
|
|
160
|
+
scan: &ArchitectureScanReport,
|
|
161
|
+
violations: &mut Vec<ArchitectureViolation>,
|
|
162
|
+
rules_executed: &mut Vec<String>,
|
|
163
|
+
) {
|
|
164
|
+
let rule = scan.config.rule("generated_manual_boundary");
|
|
165
|
+
if !rule.enabled {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
rules_executed.push("arch.generated_manual_boundary".to_string());
|
|
169
|
+
if scan.changed_paths.is_empty() {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for changed_path in &scan.changed_paths {
|
|
174
|
+
let Some(ignore_rule) = scan
|
|
175
|
+
.config
|
|
176
|
+
.ignore
|
|
177
|
+
.iter()
|
|
178
|
+
.find(|rule| paths::matches_any(changed_path, std::slice::from_ref(&rule.path)))
|
|
179
|
+
else {
|
|
180
|
+
continue;
|
|
181
|
+
};
|
|
182
|
+
violations.push(ArchitectureViolation {
|
|
183
|
+
id: "arch.generated_manual_boundary".to_string(),
|
|
184
|
+
severity: rule.severity,
|
|
185
|
+
violation_type: "generated_manual_boundary".to_string(),
|
|
186
|
+
message: format!(
|
|
187
|
+
"{} matches ignored architecture-owned boundary {}.",
|
|
188
|
+
changed_path, ignore_rule.path
|
|
189
|
+
),
|
|
190
|
+
from: Some(format!("file:{changed_path}")),
|
|
191
|
+
to: None,
|
|
192
|
+
path: Some(changed_path.clone()),
|
|
193
|
+
source_range: None,
|
|
194
|
+
suggestion: format!(
|
|
195
|
+
"Do not edit this generated path directly. Regenerate it from its source or remove the ignore only with an explicit reason. Current reason: {}",
|
|
196
|
+
ignore_rule.reason
|
|
197
|
+
),
|
|
198
|
+
agent_instruction: format!(
|
|
199
|
+
"Do not modify {} directly; regenerate it or explain and change naome.arch.yaml intentionally.",
|
|
200
|
+
changed_path
|
|
201
|
+
),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fn severity_rank(severity: Severity) -> u8 {
|
|
207
|
+
match severity {
|
|
208
|
+
Severity::Error => 0,
|
|
209
|
+
Severity::Warning => 1,
|
|
210
|
+
Severity::Info => 2,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
use serde_json::json;
|
|
2
|
+
|
|
3
|
+
use crate::architecture::model::{
|
|
4
|
+
ArchitectureEdge, ArchitectureEdgeKind, ArchitectureGraph, ArchitectureMetadata,
|
|
5
|
+
ArchitectureNode, ArchitectureNodeKind, SourceRange,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
pub(super) fn push_node(
|
|
9
|
+
graph: &mut ArchitectureGraph,
|
|
10
|
+
id: &str,
|
|
11
|
+
kind: ArchitectureNodeKind,
|
|
12
|
+
label: &str,
|
|
13
|
+
path: Option<String>,
|
|
14
|
+
language: Option<String>,
|
|
15
|
+
raw_origin: serde_json::Value,
|
|
16
|
+
) {
|
|
17
|
+
graph.nodes.push(ArchitectureNode {
|
|
18
|
+
id: id.to_string(),
|
|
19
|
+
kind,
|
|
20
|
+
label: label.to_string(),
|
|
21
|
+
metadata: metadata(path, language, raw_origin),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
pub(super) fn push_node_with_metadata(
|
|
26
|
+
graph: &mut ArchitectureGraph,
|
|
27
|
+
id: &str,
|
|
28
|
+
kind: ArchitectureNodeKind,
|
|
29
|
+
label: &str,
|
|
30
|
+
path: Option<String>,
|
|
31
|
+
language: Option<String>,
|
|
32
|
+
confidence: f32,
|
|
33
|
+
extractor: &str,
|
|
34
|
+
raw_origin: serde_json::Value,
|
|
35
|
+
) {
|
|
36
|
+
graph.nodes.push(ArchitectureNode {
|
|
37
|
+
id: id.to_string(),
|
|
38
|
+
kind,
|
|
39
|
+
label: label.to_string(),
|
|
40
|
+
metadata: ArchitectureMetadata {
|
|
41
|
+
path,
|
|
42
|
+
language,
|
|
43
|
+
source_range: None,
|
|
44
|
+
confidence,
|
|
45
|
+
extractor: extractor.to_string(),
|
|
46
|
+
raw_origin,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
pub(super) fn push_edge(
|
|
52
|
+
graph: &mut ArchitectureGraph,
|
|
53
|
+
from: &str,
|
|
54
|
+
to: &str,
|
|
55
|
+
kind: ArchitectureEdgeKind,
|
|
56
|
+
label: &str,
|
|
57
|
+
path: Option<String>,
|
|
58
|
+
) {
|
|
59
|
+
graph.edges.push(ArchitectureEdge {
|
|
60
|
+
id: format!("edge:{from}:{to}:{kind:?}"),
|
|
61
|
+
from: from.to_string(),
|
|
62
|
+
to: to.to_string(),
|
|
63
|
+
kind,
|
|
64
|
+
label: label.to_string(),
|
|
65
|
+
metadata: metadata(path, None, json!({ "extractor": "path" })),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pub(super) fn push_edge_with_metadata(
|
|
70
|
+
graph: &mut ArchitectureGraph,
|
|
71
|
+
from: &str,
|
|
72
|
+
to: &str,
|
|
73
|
+
kind: ArchitectureEdgeKind,
|
|
74
|
+
label: &str,
|
|
75
|
+
path: Option<String>,
|
|
76
|
+
language: Option<String>,
|
|
77
|
+
source_range: Option<SourceRange>,
|
|
78
|
+
confidence: f32,
|
|
79
|
+
extractor: &str,
|
|
80
|
+
raw_origin: serde_json::Value,
|
|
81
|
+
) {
|
|
82
|
+
graph.edges.push(ArchitectureEdge {
|
|
83
|
+
id: format!(
|
|
84
|
+
"edge:{from}:{to}:{kind:?}:{}",
|
|
85
|
+
raw_origin
|
|
86
|
+
.get("specifier")
|
|
87
|
+
.and_then(serde_json::Value::as_str)
|
|
88
|
+
.unwrap_or(label)
|
|
89
|
+
),
|
|
90
|
+
from: from.to_string(),
|
|
91
|
+
to: to.to_string(),
|
|
92
|
+
kind,
|
|
93
|
+
label: label.to_string(),
|
|
94
|
+
metadata: ArchitectureMetadata {
|
|
95
|
+
path,
|
|
96
|
+
language,
|
|
97
|
+
source_range,
|
|
98
|
+
confidence,
|
|
99
|
+
extractor: extractor.to_string(),
|
|
100
|
+
raw_origin,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fn metadata(
|
|
106
|
+
path: Option<String>,
|
|
107
|
+
language: Option<String>,
|
|
108
|
+
raw_origin: serde_json::Value,
|
|
109
|
+
) -> ArchitectureMetadata {
|
|
110
|
+
ArchitectureMetadata {
|
|
111
|
+
path,
|
|
112
|
+
language,
|
|
113
|
+
source_range: None,
|
|
114
|
+
confidence: 1.0,
|
|
115
|
+
extractor: "path".to_string(),
|
|
116
|
+
raw_origin,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
use std::collections::BTreeSet;
|
|
2
|
+
use std::path::Path;
|
|
3
|
+
|
|
4
|
+
use crate::architecture::config::ArchitectureConfig;
|
|
5
|
+
use crate::architecture::scan::FileFact;
|
|
6
|
+
use crate::paths;
|
|
7
|
+
|
|
8
|
+
pub(super) fn file_fact(path: &str, content: &str, config: &ArchitectureConfig) -> FileFact {
|
|
9
|
+
FileFact {
|
|
10
|
+
path: path.to_string(),
|
|
11
|
+
language: language_for_path(path),
|
|
12
|
+
line_count: content.lines().count(),
|
|
13
|
+
layers: matching_named_patterns(
|
|
14
|
+
path,
|
|
15
|
+
config
|
|
16
|
+
.layers
|
|
17
|
+
.iter()
|
|
18
|
+
.map(|(name, value)| (name, &value.paths)),
|
|
19
|
+
),
|
|
20
|
+
contexts: matching_named_patterns(
|
|
21
|
+
path,
|
|
22
|
+
config
|
|
23
|
+
.contexts
|
|
24
|
+
.iter()
|
|
25
|
+
.map(|(name, value)| (name, &value.paths)),
|
|
26
|
+
),
|
|
27
|
+
ignored: ignore_reason(path, config),
|
|
28
|
+
imports: Vec::new(),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub(super) fn collect_directories(path: &str, directories: &mut BTreeSet<String>) {
|
|
33
|
+
let mut current = Path::new(path).parent();
|
|
34
|
+
while let Some(dir) = current {
|
|
35
|
+
let normalized = normalize_path(dir);
|
|
36
|
+
if normalized.is_empty() {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
directories.insert(normalized);
|
|
40
|
+
current = dir.parent();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
pub(super) fn parent_node_id(path: &str) -> Option<String> {
|
|
45
|
+
Path::new(path)
|
|
46
|
+
.parent()
|
|
47
|
+
.map(normalize_path)
|
|
48
|
+
.filter(|path| !path.is_empty())
|
|
49
|
+
.map(|path| format!("directory:{path}"))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fn matching_named_patterns<'a>(
|
|
53
|
+
path: &str,
|
|
54
|
+
patterns: impl Iterator<Item = (&'a String, &'a Vec<String>)>,
|
|
55
|
+
) -> Vec<String> {
|
|
56
|
+
patterns
|
|
57
|
+
.filter(|(_, patterns)| paths::matches_any(path, patterns))
|
|
58
|
+
.map(|(name, _)| name.clone())
|
|
59
|
+
.collect()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fn ignore_reason(path: &str, config: &ArchitectureConfig) -> Option<String> {
|
|
63
|
+
config
|
|
64
|
+
.ignore
|
|
65
|
+
.iter()
|
|
66
|
+
.find(|rule| paths::matches_any(path, std::slice::from_ref(&rule.path)))
|
|
67
|
+
.map(|rule| rule.reason.clone())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fn language_for_path(path: &str) -> Option<String> {
|
|
71
|
+
let extension = Path::new(path).extension()?.to_str()?;
|
|
72
|
+
match extension {
|
|
73
|
+
"ts" | "tsx" => Some("typescript".to_string()),
|
|
74
|
+
"js" | "jsx" | "mjs" | "cjs" => Some("javascript".to_string()),
|
|
75
|
+
"rs" => Some("rust".to_string()),
|
|
76
|
+
"py" => Some("python".to_string()),
|
|
77
|
+
"go" => Some("go".to_string()),
|
|
78
|
+
"java" => Some("java".to_string()),
|
|
79
|
+
"kt" | "kts" => Some("kotlin".to_string()),
|
|
80
|
+
"swift" => Some("swift".to_string()),
|
|
81
|
+
_ => None,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fn normalize_path(path: impl AsRef<Path>) -> String {
|
|
86
|
+
path.as_ref().to_string_lossy().replace('\\', "/")
|
|
87
|
+
}
|