@lamentis/naome 1.3.12 → 1.3.14

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 (29) hide show
  1. package/Cargo.lock +2 -2
  2. package/crates/naome-cli/Cargo.toml +1 -1
  3. package/crates/naome-cli/src/architecture_commands.rs +1 -1
  4. package/crates/naome-core/Cargo.toml +1 -1
  5. package/crates/naome-core/src/architecture/output.rs +26 -4
  6. package/crates/naome-core/src/architecture/rules/external.rs +59 -0
  7. package/crates/naome-core/src/architecture/rules.rs +2 -0
  8. package/crates/naome-core/src/architecture/scan/cache.rs +145 -0
  9. package/crates/naome-core/src/architecture/scan/graph_builder.rs +159 -38
  10. package/crates/naome-core/src/architecture/scan/imports/extractors/swift.rs +48 -0
  11. package/crates/naome-core/src/architecture/scan/imports/extractors.rs +3 -0
  12. package/crates/naome-core/src/architecture/scan/imports/resolver.rs +41 -1
  13. package/crates/naome-core/src/architecture/scan/imports.rs +1 -0
  14. package/crates/naome-core/src/architecture/scan/manifest/common.rs +102 -0
  15. package/crates/naome-core/src/architecture/scan/manifest/parsers/json.rs +46 -0
  16. package/crates/naome-core/src/architecture/scan/manifest/parsers/other.rs +280 -0
  17. package/crates/naome-core/src/architecture/scan/manifest/parsers/toml.rs +184 -0
  18. package/crates/naome-core/src/architecture/scan/manifest/parsers.rs +3 -0
  19. package/crates/naome-core/src/architecture/scan/manifest.rs +33 -0
  20. package/crates/naome-core/src/architecture/scan.rs +254 -10
  21. package/crates/naome-core/tests/architecture_cache.rs +212 -0
  22. package/crates/naome-core/tests/architecture_manifests.rs +289 -0
  23. package/crates/naome-core/tests/architecture_support/mod.rs +2 -0
  24. package/crates/naome-core/tests/architecture_swift.rs +111 -0
  25. package/native/darwin-arm64/naome +0 -0
  26. package/native/linux-x64/naome +0 -0
  27. package/package.json +1 -1
  28. package/templates/naome-root/.naome/manifest.json +2 -2
  29. package/templates/naome-root/docs/naome/architecture-fitness.md +44 -13
@@ -0,0 +1,33 @@
1
+ use std::fs;
2
+ use std::path::Path;
3
+
4
+ use super::ManifestFact;
5
+
6
+ mod common;
7
+ mod parsers;
8
+
9
+ use common::{manifest_kind, ManifestKind};
10
+
11
+ pub(super) fn extract_manifests(root: &Path, files: &[String]) -> Vec<ManifestFact> {
12
+ let mut manifests = files
13
+ .iter()
14
+ .filter_map(|path| extract_manifest(root, path))
15
+ .collect::<Vec<_>>();
16
+ manifests.sort_by(|left, right| left.path.cmp(&right.path));
17
+ manifests
18
+ }
19
+
20
+ fn extract_manifest(root: &Path, path: &str) -> Option<ManifestFact> {
21
+ let kind = manifest_kind(path)?;
22
+ let content = fs::read_to_string(root.join(path)).ok()?;
23
+ match kind {
24
+ ManifestKind::PackageJson => parsers::json::package_json(path, &content),
25
+ ManifestKind::CargoToml => parsers::toml::cargo_toml(path, &content),
26
+ ManifestKind::PyprojectToml => parsers::toml::pyproject_toml(path, &content),
27
+ ManifestKind::GoMod => parsers::other::go_mod(path, &content),
28
+ ManifestKind::PomXml => parsers::other::pom_xml(path, &content),
29
+ ManifestKind::BuildGradle => parsers::other::build_gradle(path, &content),
30
+ ManifestKind::PackageSwift => parsers::other::package_swift(path, &content),
31
+ ManifestKind::XcodeProject => parsers::other::xcode_project(path, &content),
32
+ }
33
+ }
@@ -1,16 +1,22 @@
1
1
  use std::collections::BTreeMap;
2
2
  use std::path::{Path, PathBuf};
3
3
 
4
- use serde::Serialize;
4
+ use serde::{Deserialize, Serialize};
5
5
 
6
6
  use crate::git;
7
7
  use crate::models::NaomeError;
8
8
 
9
- use super::config::{read_architecture_config, ArchitectureConfig};
9
+ use cache::CacheStatus;
10
+
11
+ use super::config::{
12
+ default_architecture_config_text, read_architecture_config, ArchitectureConfig,
13
+ };
10
14
  use super::model::ArchitectureGraph;
11
15
 
16
+ mod cache;
12
17
  mod graph_builder;
13
18
  mod imports;
19
+ mod manifest;
14
20
  mod path_scan;
15
21
 
16
22
  #[derive(Debug, Clone, Default)]
@@ -27,14 +33,18 @@ pub struct ArchitectureScanReport {
27
33
  pub files_scanned: usize,
28
34
  pub changed_only_requested: bool,
29
35
  pub changed_only_degraded_to_full_scan: bool,
36
+ pub changed_only_mode: String,
37
+ pub changed_only_degradation_reason: Option<String>,
30
38
  pub changed_paths: Vec<String>,
31
39
  #[serde(skip_serializing)]
32
40
  pub config: ArchitectureConfig,
33
41
  #[serde(skip_serializing)]
34
42
  pub file_facts: BTreeMap<String, FileFact>,
43
+ #[serde(skip_serializing)]
44
+ pub manifest_facts: Vec<ManifestFact>,
35
45
  }
36
46
 
37
- #[derive(Debug, Clone, Serialize)]
47
+ #[derive(Debug, Clone, Serialize, Deserialize)]
38
48
  #[serde(rename_all = "camelCase")]
39
49
  pub struct FileFact {
40
50
  pub path: String,
@@ -46,7 +56,7 @@ pub struct FileFact {
46
56
  pub imports: Vec<ImportFact>,
47
57
  }
48
58
 
49
- #[derive(Debug, Clone, Serialize)]
59
+ #[derive(Debug, Clone, Serialize, Deserialize)]
50
60
  #[serde(rename_all = "camelCase")]
51
61
  pub struct ImportFact {
52
62
  pub specifier: String,
@@ -56,7 +66,7 @@ pub struct ImportFact {
56
66
  pub extractor: String,
57
67
  }
58
68
 
59
- #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
69
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60
70
  #[serde(rename_all = "camelCase")]
61
71
  pub enum ImportTarget {
62
72
  File(String),
@@ -64,6 +74,26 @@ pub enum ImportTarget {
64
74
  Unknown(String),
65
75
  }
66
76
 
77
+ #[derive(Debug, Clone, Serialize, Deserialize)]
78
+ #[serde(rename_all = "camelCase")]
79
+ pub struct ManifestFact {
80
+ pub path: String,
81
+ pub ecosystem: String,
82
+ pub package_name: String,
83
+ pub dependencies: Vec<ManifestDependency>,
84
+ pub confidence: f32,
85
+ pub extractor: String,
86
+ }
87
+
88
+ #[derive(Debug, Clone, Serialize, Deserialize)]
89
+ #[serde(rename_all = "camelCase")]
90
+ pub struct ManifestDependency {
91
+ pub name: String,
92
+ pub dependency_kind: String,
93
+ pub version: Option<String>,
94
+ pub confidence: f32,
95
+ }
96
+
67
97
  pub fn scan_architecture(
68
98
  root: &Path,
69
99
  options: ArchitectureScanOptions,
@@ -71,21 +101,235 @@ pub fn scan_architecture(
71
101
  let config = read_architecture_config(root, options.config_path.as_deref())?;
72
102
  let changed_paths = changed_paths(root, options.changed_only)?;
73
103
  let files = path_scan::repository_files(root, &config)?;
74
- let (mut graph, file_facts) = graph_builder::build_path_graph(root, files, &config);
104
+ let config_hash = cache::config_hash(
105
+ root,
106
+ options.config_path.as_deref(),
107
+ default_architecture_config_text(),
108
+ )?;
109
+ let file_list_hash = cache::file_list_hash(&files);
110
+ let manifest_facts = manifest::extract_manifests(root, &files);
111
+
112
+ let scan = if options.changed_only {
113
+ changed_only_scan(
114
+ root,
115
+ files,
116
+ &config,
117
+ &manifest_facts,
118
+ &changed_paths,
119
+ config_hash,
120
+ file_list_hash,
121
+ )?
122
+ } else {
123
+ full_scan(
124
+ root,
125
+ files,
126
+ &config,
127
+ &manifest_facts,
128
+ config_hash,
129
+ file_list_hash,
130
+ false,
131
+ None,
132
+ "full_scan",
133
+ )?
134
+ };
135
+
136
+ Ok(scan.with_changed_paths(changed_paths))
137
+ }
138
+
139
+ fn changed_only_scan(
140
+ root: &Path,
141
+ files: Vec<String>,
142
+ config: &ArchitectureConfig,
143
+ manifest_facts: &[ManifestFact],
144
+ changed_paths: &[String],
145
+ config_hash: String,
146
+ file_list_hash: String,
147
+ ) -> Result<ArchitectureScanReport, NaomeError> {
148
+ let Some(cache) = cache::read(root) else {
149
+ return degraded_full_scan(
150
+ root,
151
+ files,
152
+ config,
153
+ manifest_facts,
154
+ config_hash,
155
+ file_list_hash,
156
+ "cache_miss",
157
+ );
158
+ };
159
+ match cache.is_compatible(&config_hash, &file_list_hash) {
160
+ CacheStatus::Miss(reason) => degraded_full_scan(
161
+ root,
162
+ files,
163
+ config,
164
+ manifest_facts,
165
+ config_hash,
166
+ file_list_hash,
167
+ reason,
168
+ ),
169
+ CacheStatus::Hit => incremental_scan(
170
+ root,
171
+ files,
172
+ config,
173
+ manifest_facts,
174
+ changed_paths,
175
+ config_hash,
176
+ file_list_hash,
177
+ cache,
178
+ ),
179
+ }
180
+ }
181
+
182
+ fn incremental_scan(
183
+ root: &Path,
184
+ files: Vec<String>,
185
+ config: &ArchitectureConfig,
186
+ manifest_facts: &[ManifestFact],
187
+ changed_paths: &[String],
188
+ config_hash: String,
189
+ file_list_hash: String,
190
+ cache: cache::ArchitectureCache,
191
+ ) -> Result<ArchitectureScanReport, NaomeError> {
192
+ let file_set = files.iter().cloned().collect();
193
+ let changed = cache::changed_set(changed_paths);
194
+ if import_resolver_context_changed(changed_paths) {
195
+ return degraded_full_scan(
196
+ root,
197
+ files,
198
+ config,
199
+ manifest_facts,
200
+ config_hash,
201
+ file_list_hash,
202
+ "resolver_context_changed",
203
+ );
204
+ }
205
+ if cached_clean_content_changed(root, &files, &changed, &cache) {
206
+ return degraded_full_scan(
207
+ root,
208
+ files,
209
+ config,
210
+ manifest_facts,
211
+ config_hash,
212
+ file_list_hash,
213
+ "content_changed",
214
+ );
215
+ }
216
+ let mut file_facts = BTreeMap::new();
217
+ let mut scanned = 0;
218
+
219
+ for path in &files {
220
+ if changed.contains(path) || !cache.files.contains_key(path) {
221
+ let fact = graph_builder::scan_file_fact(root, path, config, &file_set);
222
+ scanned += 1;
223
+ file_facts.insert(path.clone(), fact);
224
+ } else if let Some(cached) = cache.files.get(path) {
225
+ file_facts.insert(path.clone(), cached.fact.clone());
226
+ }
227
+ }
75
228
 
229
+ let mut graph =
230
+ graph_builder::build_graph_from_facts(&files, &file_facts, config, manifest_facts);
76
231
  graph.sort_stable();
232
+ let _ = cache::write(root, config_hash, file_list_hash, &file_facts);
77
233
  Ok(ArchitectureScanReport {
78
234
  schema: "naome.arch.scan.v1".to_string(),
79
- files_scanned: file_facts.len(),
235
+ files_scanned: scanned,
80
236
  graph,
81
- changed_only_requested: options.changed_only,
82
- changed_only_degraded_to_full_scan: options.changed_only,
83
- changed_paths,
237
+ changed_only_requested: true,
238
+ changed_only_degraded_to_full_scan: false,
239
+ changed_only_mode: "incremental_cache".to_string(),
240
+ changed_only_degradation_reason: None,
241
+ changed_paths: Vec::new(),
242
+ config: config.clone(),
243
+ file_facts,
244
+ manifest_facts: manifest_facts.to_vec(),
245
+ })
246
+ }
247
+
248
+ fn cached_clean_content_changed(
249
+ root: &Path,
250
+ files: &[String],
251
+ changed: &std::collections::BTreeSet<String>,
252
+ cache: &cache::ArchitectureCache,
253
+ ) -> bool {
254
+ files.iter().any(|path| {
255
+ !changed.contains(path)
256
+ && cache.files.get(path).is_some_and(|cached| {
257
+ cache::content_hash(root, path)
258
+ .map(|current| current != cached.content_hash)
259
+ .unwrap_or(true)
260
+ })
261
+ })
262
+ }
263
+
264
+ fn import_resolver_context_changed(changed_paths: &[String]) -> bool {
265
+ changed_paths.iter().any(|path| {
266
+ Path::new(path)
267
+ .file_name()
268
+ .and_then(|value| value.to_str())
269
+ .is_some_and(|name| matches!(name, "go.mod" | "tsconfig.json" | "jsconfig.json"))
270
+ })
271
+ }
272
+
273
+ fn degraded_full_scan(
274
+ root: &Path,
275
+ files: Vec<String>,
276
+ config: &ArchitectureConfig,
277
+ manifest_facts: &[ManifestFact],
278
+ config_hash: String,
279
+ file_list_hash: String,
280
+ reason: &'static str,
281
+ ) -> Result<ArchitectureScanReport, NaomeError> {
282
+ full_scan(
283
+ root,
284
+ files,
84
285
  config,
286
+ manifest_facts,
287
+ config_hash,
288
+ file_list_hash,
289
+ true,
290
+ Some(reason),
291
+ "degraded_full_scan",
292
+ )
293
+ }
294
+
295
+ fn full_scan(
296
+ root: &Path,
297
+ files: Vec<String>,
298
+ config: &ArchitectureConfig,
299
+ manifest_facts: &[ManifestFact],
300
+ config_hash: String,
301
+ file_list_hash: String,
302
+ changed_only_degraded_to_full_scan: bool,
303
+ changed_only_degradation_reason: Option<&str>,
304
+ changed_only_mode: &str,
305
+ ) -> Result<ArchitectureScanReport, NaomeError> {
306
+ let (mut graph, file_facts) =
307
+ graph_builder::build_path_graph(root, files, config, manifest_facts);
308
+ graph.sort_stable();
309
+ let files_scanned = file_facts.len();
310
+ let _ = cache::write(root, config_hash, file_list_hash, &file_facts);
311
+ Ok(ArchitectureScanReport {
312
+ schema: "naome.arch.scan.v1".to_string(),
313
+ files_scanned,
314
+ graph,
315
+ changed_only_requested: changed_only_degraded_to_full_scan,
316
+ changed_only_degraded_to_full_scan,
317
+ changed_only_mode: changed_only_mode.to_string(),
318
+ changed_only_degradation_reason: changed_only_degradation_reason.map(str::to_string),
319
+ changed_paths: Vec::new(),
320
+ config: config.clone(),
85
321
  file_facts,
322
+ manifest_facts: manifest_facts.to_vec(),
86
323
  })
87
324
  }
88
325
 
326
+ impl ArchitectureScanReport {
327
+ fn with_changed_paths(mut self, changed_paths: Vec<String>) -> Self {
328
+ self.changed_paths = changed_paths;
329
+ self
330
+ }
331
+ }
332
+
89
333
  fn changed_paths(root: &Path, changed_only: bool) -> Result<Vec<String>, NaomeError> {
90
334
  if changed_only {
91
335
  git::changed_paths(root)
@@ -0,0 +1,212 @@
1
+ use naome_core::{validate_architecture, ArchitectureScanOptions};
2
+ use std::path::Path;
3
+ use std::process::Command;
4
+
5
+ mod architecture_support;
6
+
7
+ use architecture_support::FixtureRepo;
8
+
9
+ #[test]
10
+ fn changed_only_uses_incremental_cache_after_initial_safe_full_scan() {
11
+ let repo = FixtureRepo::new();
12
+ repo.write(
13
+ "naome.arch.yaml",
14
+ forbidden_domain_to_infrastructure_config(),
15
+ );
16
+ repo.write("src/domain/event.ts", "export const event = 1;\n");
17
+ repo.write("src/infrastructure/db.ts", "export const db = 1;\n");
18
+ repo.init_git();
19
+
20
+ let initial = validate_architecture(repo.path(), changed_only()).unwrap();
21
+
22
+ assert!(initial.changed_only_requested);
23
+ assert!(initial.changed_only_degraded_to_full_scan);
24
+ assert_eq!(initial.changed_only_mode, "degraded_full_scan");
25
+ assert_eq!(
26
+ initial.changed_only_degradation_reason.as_deref(),
27
+ Some("cache_miss")
28
+ );
29
+
30
+ repo.write(
31
+ "src/domain/event.ts",
32
+ "import { db } from '../infrastructure/db';\nexport const event = db;\n",
33
+ );
34
+
35
+ let incremental = validate_architecture(repo.path(), changed_only()).unwrap();
36
+
37
+ assert!(incremental.changed_only_requested);
38
+ assert!(!incremental.changed_only_degraded_to_full_scan);
39
+ assert_eq!(incremental.changed_only_mode, "incremental_cache");
40
+ assert_eq!(incremental.changed_only_degradation_reason, None);
41
+ assert_eq!(incremental.files_scanned, 1);
42
+ assert_eq!(incremental.summary.errors, 1);
43
+ assert_eq!(
44
+ incremental.violations[0].path.as_deref(),
45
+ Some("src/domain/event.ts")
46
+ );
47
+ }
48
+
49
+ #[test]
50
+ fn changed_only_degrades_when_architecture_config_changes() {
51
+ let repo = FixtureRepo::new();
52
+ repo.write(
53
+ "naome.arch.yaml",
54
+ forbidden_domain_to_infrastructure_config(),
55
+ );
56
+ repo.write("src/domain/event.ts", "export const event = 1;\n");
57
+ repo.write("src/infrastructure/db.ts", "export const db = 1;\n");
58
+ repo.init_git();
59
+ validate_architecture(repo.path(), changed_only()).unwrap();
60
+
61
+ repo.write(
62
+ "naome.arch.yaml",
63
+ "layers:\n application:\n paths:\n - \"src/**\"\n",
64
+ );
65
+
66
+ let report = validate_architecture(repo.path(), changed_only()).unwrap();
67
+
68
+ assert!(report.changed_only_degraded_to_full_scan);
69
+ assert_eq!(report.changed_only_mode, "degraded_full_scan");
70
+ assert_eq!(
71
+ report.changed_only_degradation_reason.as_deref(),
72
+ Some("config_changed")
73
+ );
74
+ }
75
+
76
+ #[test]
77
+ fn changed_only_degrades_when_repository_file_set_changes() {
78
+ let repo = FixtureRepo::new();
79
+ repo.write(
80
+ "naome.arch.yaml",
81
+ forbidden_domain_to_infrastructure_config(),
82
+ );
83
+ repo.write("src/domain/event.ts", "export const event = 1;\n");
84
+ repo.write("src/infrastructure/db.ts", "export const db = 1;\n");
85
+ repo.init_git();
86
+ validate_architecture(repo.path(), changed_only()).unwrap();
87
+
88
+ repo.write("src/domain/new.ts", "export const fresh = 1;\n");
89
+
90
+ let report = validate_architecture(repo.path(), changed_only()).unwrap();
91
+
92
+ assert!(report.changed_only_degraded_to_full_scan);
93
+ assert_eq!(report.changed_only_mode, "degraded_full_scan");
94
+ assert_eq!(
95
+ report.changed_only_degradation_reason.as_deref(),
96
+ Some("file_set_changed")
97
+ );
98
+ }
99
+
100
+ #[test]
101
+ fn changed_only_degrades_when_cached_clean_file_content_changed() {
102
+ let repo = FixtureRepo::new();
103
+ repo.write(
104
+ "naome.arch.yaml",
105
+ forbidden_domain_to_infrastructure_config(),
106
+ );
107
+ repo.write("src/domain/event.ts", "export const event = 1;\n");
108
+ repo.write("src/infrastructure/db.ts", "export const db = 1;\n");
109
+ repo.init_git();
110
+ validate_architecture(repo.path(), changed_only()).unwrap();
111
+
112
+ repo.write(
113
+ "src/domain/event.ts",
114
+ "import { db } from '../infrastructure/db';\nexport const event = db;\n",
115
+ );
116
+ commit_all(repo.path(), "content change after cache");
117
+
118
+ let report = validate_architecture(repo.path(), changed_only()).unwrap();
119
+
120
+ assert!(report.changed_only_requested);
121
+ assert!(report.changed_only_degraded_to_full_scan);
122
+ assert_eq!(report.changed_only_mode, "degraded_full_scan");
123
+ assert_eq!(
124
+ report.changed_only_degradation_reason.as_deref(),
125
+ Some("content_changed")
126
+ );
127
+ assert_eq!(report.summary.errors, 1);
128
+ }
129
+
130
+ #[test]
131
+ fn changed_only_degrades_when_go_module_context_changes() {
132
+ let repo = FixtureRepo::new();
133
+ repo.write(
134
+ "naome.arch.yaml",
135
+ forbidden_domain_to_infrastructure_config(),
136
+ );
137
+ repo.write("go.mod", "module example.com/old\n\ngo 1.22\n");
138
+ repo.write(
139
+ "src/domain/event.go",
140
+ "package domain\n\nimport \"example.com/new/src/infrastructure/db\"\n\nvar Event = db.Value\n",
141
+ );
142
+ repo.write(
143
+ "src/infrastructure/db/db.go",
144
+ "package db\n\nvar Value = 1\n",
145
+ );
146
+ repo.init_git();
147
+ let initial = validate_architecture(repo.path(), changed_only()).unwrap();
148
+ assert_eq!(initial.summary.errors, 0);
149
+
150
+ repo.write("go.mod", "module example.com/new\n\ngo 1.22\n");
151
+
152
+ let report = validate_architecture(repo.path(), changed_only()).unwrap();
153
+
154
+ assert!(report.changed_only_requested);
155
+ assert!(report.changed_only_degraded_to_full_scan);
156
+ assert_eq!(report.changed_only_mode, "degraded_full_scan");
157
+ assert_eq!(
158
+ report.changed_only_degradation_reason.as_deref(),
159
+ Some("resolver_context_changed")
160
+ );
161
+ assert_eq!(report.summary.errors, 1);
162
+ assert_eq!(
163
+ report.violations[0].path.as_deref(),
164
+ Some("src/domain/event.go")
165
+ );
166
+ }
167
+
168
+ fn changed_only() -> ArchitectureScanOptions {
169
+ ArchitectureScanOptions {
170
+ changed_only: true,
171
+ ..ArchitectureScanOptions::default()
172
+ }
173
+ }
174
+
175
+ fn forbidden_domain_to_infrastructure_config() -> &'static str {
176
+ r#"layers:
177
+ domain:
178
+ paths:
179
+ - "src/domain/**"
180
+ infrastructure:
181
+ paths:
182
+ - "src/infrastructure/**"
183
+ allowed_dependencies:
184
+ domain:
185
+ infrastructure:
186
+ - domain
187
+ rules:
188
+ no_forbidden_layer_dependencies:
189
+ enabled: true
190
+ severity: error
191
+ "#
192
+ }
193
+
194
+ fn commit_all(root: &Path, message: &str) {
195
+ run_git(root, &["add", "."]);
196
+ run_git(root, &["commit", "-m", message]);
197
+ }
198
+
199
+ fn run_git(root: &Path, args: &[&str]) {
200
+ let output = Command::new("git")
201
+ .args(args)
202
+ .current_dir(root)
203
+ .output()
204
+ .unwrap();
205
+ assert!(
206
+ output.status.success(),
207
+ "git {:?} failed: {}{}",
208
+ args,
209
+ String::from_utf8_lossy(&output.stdout),
210
+ String::from_utf8_lossy(&output.stderr)
211
+ );
212
+ }