@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.
Files changed (41) hide show
  1. package/Cargo.lock +2 -2
  2. package/README.md +10 -0
  3. package/bin/naome.js +1 -1
  4. package/crates/naome-cli/Cargo.toml +1 -1
  5. package/crates/naome-cli/src/architecture_commands.rs +127 -0
  6. package/crates/naome-cli/src/cli_args.rs +4 -0
  7. package/crates/naome-cli/src/dispatcher.rs +2 -0
  8. package/crates/naome-cli/src/main.rs +6 -0
  9. package/crates/naome-core/Cargo.toml +1 -1
  10. package/crates/naome-core/src/architecture/config/parser/scalar.rs +26 -0
  11. package/crates/naome-core/src/architecture/config/parser/sections.rs +154 -0
  12. package/crates/naome-core/src/architecture/config/parser.rs +97 -0
  13. package/crates/naome-core/src/architecture/config.rs +126 -0
  14. package/crates/naome-core/src/architecture/model.rs +80 -0
  15. package/crates/naome-core/src/architecture/output.rs +178 -0
  16. package/crates/naome-core/src/architecture/rules.rs +212 -0
  17. package/crates/naome-core/src/architecture/scan/graph_builder/emit.rs +118 -0
  18. package/crates/naome-core/src/architecture/scan/graph_builder/facts.rs +87 -0
  19. package/crates/naome-core/src/architecture/scan/graph_builder.rs +211 -0
  20. package/crates/naome-core/src/architecture/scan/imports/extractors.rs +407 -0
  21. package/crates/naome-core/src/architecture/scan/imports/resolver.rs +334 -0
  22. package/crates/naome-core/src/architecture/scan/imports.rs +59 -0
  23. package/crates/naome-core/src/architecture/scan/path_scan.rs +92 -0
  24. package/crates/naome-core/src/architecture/scan.rs +95 -0
  25. package/crates/naome-core/src/architecture.rs +31 -0
  26. package/crates/naome-core/src/install_plan.rs +2 -0
  27. package/crates/naome-core/src/lib.rs +16 -8
  28. package/crates/naome-core/tests/architecture.rs +548 -0
  29. package/crates/naome-core/tests/harness_health.rs +1 -0
  30. package/installer/harness-files.js +3 -0
  31. package/native/darwin-arm64/naome +0 -0
  32. package/native/linux-x64/naome +0 -0
  33. package/package.json +1 -1
  34. package/templates/naome-root/.naome/bin/check-harness-health.js +7 -7
  35. package/templates/naome-root/.naome/bin/check-task-state.js +7 -7
  36. package/templates/naome-root/.naome/bin/naome.js +2 -2
  37. package/templates/naome-root/.naome/manifest.json +10 -8
  38. package/templates/naome-root/.naome/verification.json +15 -1
  39. package/templates/naome-root/docs/naome/architecture-fitness.md +109 -0
  40. package/templates/naome-root/docs/naome/index.md +4 -3
  41. 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
+ }