@lamentis/naome 1.3.10 → 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 CHANGED
@@ -76,7 +76,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
76
76
 
77
77
  [[package]]
78
78
  name = "naome-cli"
79
- version = "1.3.10"
79
+ version = "1.3.11"
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.10"
87
+ version = "1.3.11"
88
88
  dependencies = [
89
89
  "serde",
90
90
  "serde_json",
package/README.md CHANGED
@@ -88,6 +88,11 @@ naome task render-state --write --json
88
88
  naome commit -m "type(scope): summary"
89
89
  ```
90
90
 
91
+ Architecture fitness builds a language-agnostic graph, extracts direct imports
92
+ for TypeScript, JavaScript, Rust, Python, and Go, and can enforce configured
93
+ layer dependency rules such as keeping domain code independent from
94
+ infrastructure adapters.
95
+
91
96
  `naome sync` installs or repairs the local harness files. It does not run a
92
97
  hidden full-repository quality scan. It also migrates any active legacy
93
98
  task-state into the local task ledger automatically and untracks local
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-cli"
3
- version = "1.3.10"
3
+ version = "1.3.11"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -52,8 +52,12 @@ fn run_arch_explain(root: &Path, args: &[String]) -> Result<(), Box<dyn std::err
52
52
  "schema": "naome.arch.explain.v1",
53
53
  "layers": scan.config.layers.keys().collect::<Vec<_>>(),
54
54
  "contexts": scan.config.contexts.keys().collect::<Vec<_>>(),
55
- "rules": ["arch.max_file_lines", "arch.generated_manual_boundary"],
56
- "extractors": ["path"]
55
+ "rules": [
56
+ "arch.max_file_lines",
57
+ "arch.generated_manual_boundary",
58
+ "arch.no_forbidden_layer_dependencies"
59
+ ],
60
+ "extractors": ["path", "typescript", "javascript", "rust", "python", "go"]
57
61
  }))?
58
62
  );
59
63
  } else {
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-core"
3
- version = "1.3.10"
3
+ version = "1.3.11"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -36,6 +36,23 @@ pub(super) fn parse_contexts(parser: &mut ConfigParser<'_>) -> Result<(), NaomeE
36
36
  Ok(())
37
37
  }
38
38
 
39
+ pub(super) fn parse_allowed_dependencies(parser: &mut ConfigParser<'_>) -> Result<(), NaomeError> {
40
+ while let Some((_, line)) = parser.peek_line() {
41
+ if indent(line) == 0 {
42
+ break;
43
+ }
44
+ let (line_number, line) = parser.next_line().unwrap();
45
+ let name = section_name(line, 2)
46
+ .ok_or_else(|| parser.error(line_number, "expected dependency source".to_string()))?;
47
+ let dependencies = parser.parse_list(4)?;
48
+ parser
49
+ .config
50
+ .allowed_dependencies
51
+ .insert(name.to_string(), dependencies);
52
+ }
53
+ Ok(())
54
+ }
55
+
39
56
  fn parse_context(parser: &mut ConfigParser<'_>) -> Result<ContextConfig, NaomeError> {
40
57
  let mut context = ContextConfig::default();
41
58
  while let Some((_, child)) = parser.peek_line() {
@@ -40,6 +40,7 @@ impl<'a> ConfigParser<'a> {
40
40
  match line.trim() {
41
41
  "layers:" => sections::parse_layers(self)?,
42
42
  "contexts:" => sections::parse_contexts(self)?,
43
+ "allowed_dependencies:" => sections::parse_allowed_dependencies(self)?,
43
44
  "rules:" => sections::parse_rules(self)?,
44
45
  "ignore:" => sections::parse_ignore(self)?,
45
46
  other => {
@@ -12,6 +12,7 @@ mod parser;
12
12
  pub struct ArchitectureConfig {
13
13
  pub layers: BTreeMap<String, LayerConfig>,
14
14
  pub contexts: BTreeMap<String, ContextConfig>,
15
+ pub allowed_dependencies: BTreeMap<String, Vec<String>>,
15
16
  pub rules: BTreeMap<String, RuleConfig>,
16
17
  pub ignore: Vec<IgnoreRule>,
17
18
  }
@@ -77,6 +78,14 @@ layers:
77
78
  paths:
78
79
  - "src/infrastructure/**"
79
80
 
81
+ allowed_dependencies:
82
+ application:
83
+ - domain
84
+ - infrastructure
85
+ domain:
86
+ infrastructure:
87
+ - domain
88
+
80
89
  contexts:
81
90
  default:
82
91
  paths:
@@ -92,6 +101,9 @@ rules:
92
101
  generated_manual_boundary:
93
102
  enabled: true
94
103
  severity: error
104
+ no_forbidden_layer_dependencies:
105
+ enabled: true
106
+ severity: error
95
107
 
96
108
  ignore:
97
109
  - path: "generated/**"
@@ -163,7 +163,7 @@ pub fn format_architecture_explain(scan: &ArchitectureScanReport) -> String {
163
163
  .collect::<Vec<_>>()
164
164
  .join(", ");
165
165
  format!(
166
- "NAOME Architecture Fitness\nrules: max_file_lines, generated_manual_boundary\nlayers: {}\ncontexts: {}\npath extractor: enabled for every repository\n",
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
167
  empty_label(&layers),
168
168
  empty_label(&contexts)
169
169
  )
@@ -5,6 +5,7 @@ use super::output::{
5
5
  Severity,
6
6
  };
7
7
  use super::scan::ArchitectureScanReport;
8
+ use crate::architecture::model::ArchitectureEdgeKind;
8
9
 
9
10
  pub fn validate_scan(scan: ArchitectureScanReport) -> ArchitectureValidation {
10
11
  let mut violations = Vec::new();
@@ -12,6 +13,7 @@ pub fn validate_scan(scan: ArchitectureScanReport) -> ArchitectureValidation {
12
13
 
13
14
  validate_max_file_lines(&scan, &mut violations, &mut rules_executed);
14
15
  validate_generated_manual_boundary(&scan, &mut violations, &mut rules_executed);
16
+ validate_forbidden_layer_dependencies(&scan, &mut violations, &mut rules_executed);
15
17
 
16
18
  violations.sort_by(|left, right| {
17
19
  (
@@ -46,6 +48,76 @@ pub fn validate_scan(scan: ArchitectureScanReport) -> ArchitectureValidation {
46
48
  }
47
49
  }
48
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
+
49
121
  fn validate_max_file_lines(
50
122
  scan: &ArchitectureScanReport,
51
123
  violations: &mut Vec<ArchitectureViolation>,
@@ -2,7 +2,7 @@ use serde_json::json;
2
2
 
3
3
  use crate::architecture::model::{
4
4
  ArchitectureEdge, ArchitectureEdgeKind, ArchitectureGraph, ArchitectureMetadata,
5
- ArchitectureNode, ArchitectureNodeKind,
5
+ ArchitectureNode, ArchitectureNodeKind, SourceRange,
6
6
  };
7
7
 
8
8
  pub(super) fn push_node(
@@ -22,6 +22,32 @@ pub(super) fn push_node(
22
22
  });
23
23
  }
24
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
+
25
51
  pub(super) fn push_edge(
26
52
  graph: &mut ArchitectureGraph,
27
53
  from: &str,
@@ -40,6 +66,42 @@ pub(super) fn push_edge(
40
66
  });
41
67
  }
42
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
+
43
105
  fn metadata(
44
106
  path: Option<String>,
45
107
  language: Option<String>,
@@ -1,13 +1,11 @@
1
1
  use std::collections::BTreeSet;
2
- use std::fs;
3
2
  use std::path::Path;
4
3
 
5
4
  use crate::architecture::config::ArchitectureConfig;
6
5
  use crate::architecture::scan::FileFact;
7
6
  use crate::paths;
8
7
 
9
- pub(super) fn file_fact(root: &Path, path: &str, config: &ArchitectureConfig) -> FileFact {
10
- let content = fs::read_to_string(root.join(path)).unwrap_or_default();
8
+ pub(super) fn file_fact(path: &str, content: &str, config: &ArchitectureConfig) -> FileFact {
11
9
  FileFact {
12
10
  path: path.to_string(),
13
11
  language: language_for_path(path),
@@ -27,6 +25,7 @@ pub(super) fn file_fact(root: &Path, path: &str, config: &ArchitectureConfig) ->
27
25
  .map(|(name, value)| (name, &value.paths)),
28
26
  ),
29
27
  ignored: ignore_reason(path, config),
28
+ imports: Vec::new(),
30
29
  }
31
30
  }
32
31
 
@@ -1,8 +1,10 @@
1
1
  use std::collections::{BTreeMap, BTreeSet};
2
+ use std::fs;
2
3
  use std::path::Path;
3
4
 
4
5
  use serde_json::json;
5
6
 
7
+ use super::imports;
6
8
  use super::FileFact;
7
9
  use crate::architecture::config::ArchitectureConfig;
8
10
  use crate::architecture::model::{ArchitectureEdgeKind, ArchitectureGraph, ArchitectureNodeKind};
@@ -17,16 +19,20 @@ pub(super) fn build_path_graph(
17
19
  ) -> (ArchitectureGraph, BTreeMap<String, FileFact>) {
18
20
  let mut graph = ArchitectureGraph::default();
19
21
  let mut file_facts = BTreeMap::new();
22
+ let file_set = files.iter().cloned().collect::<BTreeSet<_>>();
20
23
 
21
24
  push_repository_and_policy_nodes(&mut graph, config);
22
25
  push_directories(&mut graph, &files);
23
26
 
24
27
  for path in files {
25
- let fact = facts::file_fact(root, &path, config);
28
+ let content = fs::read_to_string(root.join(&path)).unwrap_or_default();
29
+ let mut fact = facts::file_fact(&path, &content, config);
30
+ fact.imports = imports::extract_imports(root, &path, &content, &file_set);
26
31
  push_file(&mut graph, &fact);
27
32
  file_facts.insert(path, fact);
28
33
  }
29
34
 
35
+ push_imports(&mut graph, &file_facts);
30
36
  (graph, file_facts)
31
37
  }
32
38
 
@@ -115,6 +121,64 @@ fn push_file(graph: &mut ArchitectureGraph, fact: &FileFact) {
115
121
  push_membership_edges(graph, path, "context", &fact.contexts);
116
122
  }
117
123
 
124
+ fn push_imports(graph: &mut ArchitectureGraph, file_facts: &BTreeMap<String, FileFact>) {
125
+ let mut emitted_target_nodes = BTreeSet::new();
126
+ for fact in file_facts.values() {
127
+ for import in &fact.imports {
128
+ let target_id = match &import.target {
129
+ super::ImportTarget::File(path) => format!("file:{path}"),
130
+ super::ImportTarget::ExternalDependency(name) => {
131
+ let id = format!("external:{name}");
132
+ if emitted_target_nodes.insert(id.clone()) {
133
+ emit::push_node_with_metadata(
134
+ graph,
135
+ &id,
136
+ ArchitectureNodeKind::ExternalDependency,
137
+ name,
138
+ None,
139
+ None,
140
+ import.confidence,
141
+ &import.extractor,
142
+ json!({ "specifier": import.specifier, "resolvedAs": "external" }),
143
+ );
144
+ }
145
+ id
146
+ }
147
+ super::ImportTarget::Unknown(specifier) => {
148
+ let id = format!("unknown-import:{}", stable_fragment(specifier));
149
+ if emitted_target_nodes.insert(id.clone()) {
150
+ emit::push_node_with_metadata(
151
+ graph,
152
+ &id,
153
+ ArchitectureNodeKind::ExternalDependency,
154
+ specifier,
155
+ None,
156
+ None,
157
+ import.confidence,
158
+ &import.extractor,
159
+ json!({ "specifier": specifier, "resolvedAs": "unknown" }),
160
+ );
161
+ }
162
+ id
163
+ }
164
+ };
165
+ emit::push_edge_with_metadata(
166
+ graph,
167
+ &format!("file:{}", fact.path),
168
+ &target_id,
169
+ ArchitectureEdgeKind::Imports,
170
+ "imports",
171
+ Some(fact.path.clone()),
172
+ fact.language.clone(),
173
+ import.source_range.clone(),
174
+ import.confidence,
175
+ &import.extractor,
176
+ json!({ "specifier": import.specifier, "target": import.target }),
177
+ );
178
+ }
179
+ }
180
+ }
181
+
118
182
  fn push_membership_edges(
119
183
  graph: &mut ArchitectureGraph,
120
184
  path: &str,
@@ -132,3 +196,16 @@ fn push_membership_edges(
132
196
  );
133
197
  }
134
198
  }
199
+
200
+ fn stable_fragment(value: &str) -> String {
201
+ value
202
+ .chars()
203
+ .map(|character| {
204
+ if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '/') {
205
+ character
206
+ } else {
207
+ '_'
208
+ }
209
+ })
210
+ .collect()
211
+ }