@lamentis/naome 1.3.11 → 1.3.13

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/crates/naome-cli/Cargo.toml +1 -1
  3. package/crates/naome-cli/src/architecture_commands.rs +2 -6
  4. package/crates/naome-cli/tests/architecture_cli.rs +60 -0
  5. package/crates/naome-core/Cargo.toml +1 -1
  6. package/crates/naome-core/src/architecture/config/parser/sections.rs +44 -1
  7. package/crates/naome-core/src/architecture/config/parser.rs +1 -0
  8. package/crates/naome-core/src/architecture/config.rs +35 -0
  9. package/crates/naome-core/src/architecture/output.rs +15 -1
  10. package/crates/naome-core/src/architecture/rules/budgets.rs +179 -0
  11. package/crates/naome-core/src/architecture/rules/context.rs +138 -0
  12. package/crates/naome-core/src/architecture/rules/cycles.rs +39 -0
  13. package/crates/naome-core/src/architecture/rules/external.rs +244 -0
  14. package/crates/naome-core/src/architecture/rules/graph.rs +177 -0
  15. package/crates/naome-core/src/architecture/rules/transitive.rs +89 -0
  16. package/crates/naome-core/src/architecture/rules.rs +13 -39
  17. package/crates/naome-core/src/architecture/scan/graph_builder.rs +130 -30
  18. package/crates/naome-core/src/architecture/scan/imports/extractors/swift.rs +48 -0
  19. package/crates/naome-core/src/architecture/scan/imports/extractors.rs +7 -7
  20. package/crates/naome-core/src/architecture/scan/imports/resolver.rs +44 -22
  21. package/crates/naome-core/src/architecture/scan/imports.rs +17 -0
  22. package/crates/naome-core/src/architecture/scan/manifest/common.rs +102 -0
  23. package/crates/naome-core/src/architecture/scan/manifest/parsers/json.rs +46 -0
  24. package/crates/naome-core/src/architecture/scan/manifest/parsers/other.rs +280 -0
  25. package/crates/naome-core/src/architecture/scan/manifest/parsers/toml.rs +184 -0
  26. package/crates/naome-core/src/architecture/scan/manifest/parsers.rs +3 -0
  27. package/crates/naome-core/src/architecture/scan/manifest.rs +33 -0
  28. package/crates/naome-core/src/architecture/scan.rs +27 -1
  29. package/crates/naome-core/src/architecture.rs +1 -1
  30. package/crates/naome-core/src/lib.rs +1 -0
  31. package/crates/naome-core/tests/architecture.rs +53 -85
  32. package/crates/naome-core/tests/architecture_manifests.rs +289 -0
  33. package/crates/naome-core/tests/architecture_rules.rs +498 -0
  34. package/crates/naome-core/tests/architecture_support/mod.rs +80 -0
  35. package/crates/naome-core/tests/architecture_swift.rs +111 -0
  36. package/installer/harness-files.js +3 -3
  37. package/native/darwin-arm64/naome +0 -0
  38. package/native/linux-x64/naome +0 -0
  39. package/package.json +1 -1
  40. package/templates/naome-root/.naome/manifest.json +2 -2
  41. package/templates/naome-root/docs/naome/architecture-fitness.md +61 -8
@@ -0,0 +1,498 @@
1
+ use std::fs;
2
+ use std::path::{Path, PathBuf};
3
+ use std::sync::atomic::{AtomicU64, Ordering};
4
+ use std::time::{SystemTime, UNIX_EPOCH};
5
+
6
+ use naome_core::{validate_architecture, ArchitectureScanOptions};
7
+
8
+ static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
9
+ const BILLING_INTERNAL_DB_IMPORT: &str = "import { db } from '../src/billing/internal/db';\n";
10
+ const BILLING_INTERNAL_DB_EXPORT: &str = "export const db = 1;\n";
11
+ const RUST_PARENT_MODULE_ENTRY: &str = "mod parent;\nuse parent::run;\npub fn main() { run(); }\n";
12
+ const RUST_PARENT_MODULE_SOURCE: &str =
13
+ "mod child;\nuse self::child::call;\npub fn helper() {}\npub fn run() { call(); }\n";
14
+ const RUST_CHILD_MODULE_SOURCE: &str = "use super::helper;\npub fn call() { helper(); }\n";
15
+ const RUST_CRATE_PARENT_MODULE_SOURCE: &str =
16
+ "mod child;\nuse crate::parent::child::call;\npub fn helper() {}\npub fn run() { call(); }\n";
17
+ const RUST_CRATE_CHILD_MODULE_SOURCE: &str =
18
+ "use crate::parent::helper;\npub fn call() { helper(); }\n";
19
+ const TRANSITIVE_LAYER_POLICY_CONFIG: &str = "layers:\n domain:\n paths:\n - \"src/domain/**\"\n shared:\n paths:\n - \"src/shared/**\"\n infrastructure:\n paths:\n - \"src/infrastructure/**\"\nallowed_dependencies:\n domain:\n - shared\n shared:\n infrastructure:\n - domain\n - shared\nexternal_dependencies:\n domain:\n allow: []\n infrastructure:\n allow:\n - \"stripe\"\n - \"@supabase/*\"\nrules:\n no_transitive_forbidden_layer_dependencies:\n enabled: true\n severity: error\n external_dependency_policy:\n enabled: true\n severity: error\n";
20
+ const GO_EXTERNAL_POLICY_CONFIG: &str = "layers:\n domain:\n paths:\n - \"domain/**\"\nexternal_dependencies:\n domain:\n allow:\n - \"github.com/acme/payments\"\nrules:\n external_dependency_policy:\n enabled: true\n severity: error\n";
21
+ const PYTHON_RUST_EXTERNAL_POLICY_CONFIG: &str = "layers:\n domain:\n paths:\n - \"src/domain/**\"\nexternal_dependencies:\n domain:\n allow:\n - \"requests\"\n - \"serde\"\nrules:\n external_dependency_policy:\n enabled: true\n severity: error\n";
22
+ const STDLIB_EXTERNAL_POLICY_CONFIG: &str = "layers:\n domain:\n paths:\n - \"src/domain/**\"\n - \"domain/**\"\nexternal_dependencies:\n domain:\n allow: []\nrules:\n external_dependency_policy:\n enabled: true\n severity: error\n";
23
+
24
+ #[test]
25
+ fn context_public_api_cycles_and_budgets_report_deterministic_violations() {
26
+ let repo = fixture_root();
27
+ write_context_boundary_config(
28
+ &repo,
29
+ false,
30
+ " no_cycles:\n enabled: true\n severity: error\n max_imports_per_file:\n enabled: true\n value: 1\n severity: warning\n max_fan_out:\n enabled: true\n value: 1\n severity: warning\n",
31
+ );
32
+ fixture_write(
33
+ &repo,
34
+ "src/billing/use_ticket.ts",
35
+ "import { internal } from '../ticketing/internal/service';\nimport { helper } from './helper';\n",
36
+ );
37
+ fixture_write(&repo, "src/billing/helper.ts", "export const helper = 1;\n");
38
+ fixture_write(
39
+ &repo,
40
+ "src/ticketing/internal/service.ts",
41
+ "export const internal = 1;\n",
42
+ );
43
+ fixture_write(
44
+ &repo,
45
+ "src/billing/a.ts",
46
+ "import { b } from './b';\nexport const a = b;\n",
47
+ );
48
+ fixture_write(
49
+ &repo,
50
+ "src/billing/b.ts",
51
+ "import { a } from './a';\nexport const b = a;\n",
52
+ );
53
+
54
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
55
+ let ids = report
56
+ .violations
57
+ .iter()
58
+ .map(|violation| violation.id.as_str())
59
+ .collect::<Vec<_>>();
60
+
61
+ assert!(ids.contains(&"arch.no_cross_context_internal_imports"));
62
+ assert!(ids.contains(&"arch.public_api_boundary"));
63
+ assert!(ids.contains(&"arch.no_cycles"));
64
+ assert!(ids.contains(&"arch.max_imports_per_file"));
65
+ assert!(ids.contains(&"arch.max_fan_out"));
66
+ assert!(report
67
+ .violations
68
+ .iter()
69
+ .any(|violation| violation.agent_instruction.contains("public API")));
70
+ }
71
+
72
+ #[test]
73
+ fn catch_all_contexts_do_not_hide_specific_context_boundaries() {
74
+ let repo = fixture_root();
75
+ write_context_boundary_config(&repo, true, "");
76
+ fixture_write(
77
+ &repo,
78
+ "src/billing/use_ticket.ts",
79
+ "import { internal } from '../ticketing/internal/service';\n",
80
+ );
81
+ fixture_write(
82
+ &repo,
83
+ "src/ticketing/internal/service.ts",
84
+ "export const internal = 1;\n",
85
+ );
86
+
87
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
88
+
89
+ assert!(report
90
+ .violations
91
+ .iter()
92
+ .any(|violation| violation.id == "arch.no_cross_context_internal_imports"));
93
+ assert!(report
94
+ .violations
95
+ .iter()
96
+ .any(|violation| violation.id == "arch.public_api_boundary"));
97
+ }
98
+
99
+ #[test]
100
+ fn catch_all_contexts_do_not_turn_shared_helpers_into_context_targets() {
101
+ let repo = fixture_root();
102
+ write_context_boundary_config(&repo, true, "");
103
+ fixture_write(
104
+ &repo,
105
+ "src/billing/use_logger.ts",
106
+ "import { logger } from '../shared/logger';\n",
107
+ );
108
+ fixture_write(
109
+ &repo,
110
+ "src/shared/logger.ts",
111
+ "export const logger = console;\n",
112
+ );
113
+
114
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
115
+
116
+ assert!(
117
+ report.violations.iter().all(|violation| !matches!(
118
+ violation.id.as_str(),
119
+ "arch.no_cross_context_internal_imports" | "arch.public_api_boundary"
120
+ )),
121
+ "{:?}",
122
+ report.violations
123
+ );
124
+ }
125
+
126
+ fn write_context_boundary_config(repo: &Path, include_default: bool, extra_rules: &str) {
127
+ let default_context = if include_default {
128
+ " default:\n paths:\n - \"src/**\"\n public_api:\n - \"src/index.ts\"\n"
129
+ } else {
130
+ ""
131
+ };
132
+ fixture_write(
133
+ repo,
134
+ "naome.arch.yaml",
135
+ &format!(
136
+ "contexts:\n{default_context} billing:\n paths:\n - \"src/billing/**\"\n public_api:\n - \"src/billing/index.ts\"\n ticketing:\n paths:\n - \"src/ticketing/**\"\n public_api:\n - \"src/ticketing/index.ts\"\nrules:\n no_cross_context_internal_imports:\n enabled: true\n severity: error\n public_api_boundary:\n enabled: true\n severity: error\n{extra_rules}"
137
+ ),
138
+ );
139
+ }
140
+
141
+ #[test]
142
+ fn transitive_layer_and_external_dependency_policies_are_enforced() {
143
+ let repo = fixture_root();
144
+ fixture_write(&repo, "naome.arch.yaml", TRANSITIVE_LAYER_POLICY_CONFIG);
145
+ fixture_write(
146
+ &repo,
147
+ "src/domain/event.ts",
148
+ "import { shared } from '../shared/shared';\nimport Stripe from 'stripe';\n",
149
+ );
150
+ fixture_write(
151
+ &repo,
152
+ "src/shared/shared.ts",
153
+ "import { db } from '../infrastructure/db';\nexport const shared = db;\n",
154
+ );
155
+ fixture_write(&repo, "src/infrastructure/db.ts", "export const db = 1;\n");
156
+ fixture_write(
157
+ &repo,
158
+ "src/infrastructure/payments.ts",
159
+ "import Stripe from 'stripe';\nexport const payments = Stripe;\n",
160
+ );
161
+
162
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
163
+
164
+ assert!(report.violations.iter().any(|violation| {
165
+ violation.id == "arch.no_transitive_forbidden_layer_dependencies"
166
+ && violation.path.as_deref() == Some("src/domain/event.ts")
167
+ }));
168
+ assert!(report.violations.iter().any(|violation| {
169
+ violation.id == "arch.external_dependency_policy"
170
+ && violation.path.as_deref() == Some("src/domain/event.ts")
171
+ && violation.to.as_deref() == Some("external:stripe")
172
+ }));
173
+ assert!(!report.violations.iter().any(|violation| {
174
+ violation.id == "arch.external_dependency_policy"
175
+ && violation.path.as_deref() == Some("src/infrastructure/payments.ts")
176
+ }));
177
+ }
178
+
179
+ #[test]
180
+ fn go_external_dependency_policy_matches_full_module_paths() {
181
+ let repo = fixture_root();
182
+ fixture_write(&repo, "naome.arch.yaml", GO_EXTERNAL_POLICY_CONFIG);
183
+ fixture_write(
184
+ &repo,
185
+ "domain/payments.go",
186
+ "package domain\nimport \"github.com/acme/payments\"\n",
187
+ );
188
+ fixture_write(
189
+ &repo,
190
+ "domain/storage.go",
191
+ "package domain\nimport \"github.com/acme/storage\"\n",
192
+ );
193
+
194
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
195
+
196
+ assert!(!report.violations.iter().any(|violation| {
197
+ violation.id == "arch.external_dependency_policy"
198
+ && violation.path.as_deref() == Some("domain/payments.go")
199
+ }));
200
+ assert!(report.violations.iter().any(|violation| {
201
+ violation.id == "arch.external_dependency_policy"
202
+ && violation.path.as_deref() == Some("domain/storage.go")
203
+ && violation.to.as_deref() == Some("external:github.com/acme/storage")
204
+ }));
205
+ }
206
+
207
+ #[test]
208
+ fn external_dependency_policy_normalizes_python_members_and_rust_aliases() {
209
+ let repo = fixture_root();
210
+ fixture_write(&repo, "naome.arch.yaml", PYTHON_RUST_EXTERNAL_POLICY_CONFIG);
211
+ fixture_write(
212
+ &repo,
213
+ "src/domain/http.py",
214
+ "from requests import Session\n",
215
+ );
216
+ fixture_write(
217
+ &repo,
218
+ "src/domain/lib.rs",
219
+ "extern crate serde as serde_json;\n",
220
+ );
221
+
222
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
223
+
224
+ assert!(
225
+ report
226
+ .violations
227
+ .iter()
228
+ .all(|violation| violation.id != "arch.external_dependency_policy"),
229
+ "{:?}",
230
+ report.violations
231
+ );
232
+ }
233
+
234
+ #[test]
235
+ fn external_dependency_policy_ignores_language_standard_libraries() {
236
+ let repo = fixture_root();
237
+ fixture_write(&repo, "naome.arch.yaml", STDLIB_EXTERNAL_POLICY_CONFIG);
238
+ fixture_write(
239
+ &repo,
240
+ "src/domain/node.js",
241
+ "import fs from 'node:fs';\nimport express from 'express';\n",
242
+ );
243
+ fixture_write(
244
+ &repo,
245
+ "src/domain/service.py",
246
+ "from os import path\nimport requests\n",
247
+ );
248
+ fixture_write(
249
+ &repo,
250
+ "src/domain/lib.rs",
251
+ "use std::fs;\nextern crate serde;\n",
252
+ );
253
+ fixture_write(
254
+ &repo,
255
+ "domain/main.go",
256
+ "package domain\nimport (\n \"fmt\"\n \"github.com/acme/storage\"\n)\n",
257
+ );
258
+
259
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
260
+ let external_targets = report
261
+ .violations
262
+ .iter()
263
+ .filter(|violation| violation.id == "arch.external_dependency_policy")
264
+ .filter_map(|violation| violation.to.as_deref())
265
+ .collect::<Vec<_>>();
266
+
267
+ assert_eq!(
268
+ external_targets,
269
+ vec![
270
+ "external:github.com/acme/storage",
271
+ "external:serde",
272
+ "external:express",
273
+ "external:requests"
274
+ ]
275
+ );
276
+ }
277
+
278
+ #[test]
279
+ fn uncontexted_callers_must_use_context_public_apis() {
280
+ let repo = fixture_root();
281
+ fixture_write(
282
+ &repo,
283
+ "naome.arch.yaml",
284
+ r#"
285
+ contexts:
286
+ billing:
287
+ paths:
288
+ - "src/billing/**"
289
+ public_api:
290
+ - "src/billing/index.ts"
291
+ rules:
292
+ no_cross_context_internal_imports:
293
+ enabled: true
294
+ severity: error
295
+ public_api_boundary:
296
+ enabled: true
297
+ severity: error
298
+ "#,
299
+ );
300
+ fixture_write(&repo, "tests/billing_test.ts", BILLING_INTERNAL_DB_IMPORT);
301
+ fixture_write(
302
+ &repo,
303
+ "src/billing/internal/db.ts",
304
+ BILLING_INTERNAL_DB_EXPORT,
305
+ );
306
+
307
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
308
+
309
+ assert!(report
310
+ .violations
311
+ .iter()
312
+ .any(|violation| violation.id == "arch.no_cross_context_internal_imports"));
313
+ assert!(report
314
+ .violations
315
+ .iter()
316
+ .any(|violation| violation.id == "arch.public_api_boundary"));
317
+ }
318
+
319
+ #[test]
320
+ fn non_public_cross_context_imports_do_not_count_as_internal_imports() {
321
+ let repo = fixture_root();
322
+ write_context_boundary_config(&repo, false, "");
323
+ fixture_write(
324
+ &repo,
325
+ "src/billing/use_ticket.ts",
326
+ "import { helper } from '../ticketing/helpers';\n",
327
+ );
328
+ fixture_write(
329
+ &repo,
330
+ "src/ticketing/helpers.ts",
331
+ "export const helper = 1;\n",
332
+ );
333
+
334
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
335
+
336
+ assert!(!report
337
+ .violations
338
+ .iter()
339
+ .any(|violation| violation.id == "arch.no_cross_context_internal_imports"));
340
+ assert!(report
341
+ .violations
342
+ .iter()
343
+ .any(|violation| violation.id == "arch.public_api_boundary"));
344
+ }
345
+
346
+ #[test]
347
+ fn rust_module_declarations_do_not_create_import_cycles() {
348
+ let repo = fixture_root();
349
+ write_cycle_config(&repo);
350
+ fixture_write(&repo, "src/lib.rs", RUST_PARENT_MODULE_ENTRY);
351
+ fixture_write(&repo, "src/parent.rs", RUST_PARENT_MODULE_SOURCE);
352
+ fixture_write(&repo, "src/parent/child.rs", RUST_CHILD_MODULE_SOURCE);
353
+
354
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
355
+
356
+ assert!(
357
+ report
358
+ .violations
359
+ .iter()
360
+ .all(|violation| violation.id != "arch.no_cycles"),
361
+ "{:?}",
362
+ report.violations
363
+ );
364
+ }
365
+
366
+ #[test]
367
+ fn rust_crate_qualified_parent_child_modules_do_not_create_import_cycles() {
368
+ let repo = fixture_root();
369
+ write_cycle_config(&repo);
370
+ fixture_write(&repo, "src/lib.rs", "mod parent;\n");
371
+ fixture_write(&repo, "src/parent.rs", RUST_CRATE_PARENT_MODULE_SOURCE);
372
+ fixture_write(&repo, "src/parent/child.rs", RUST_CRATE_CHILD_MODULE_SOURCE);
373
+
374
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
375
+
376
+ assert!(
377
+ report
378
+ .violations
379
+ .iter()
380
+ .all(|violation| violation.id != "arch.no_cycles"),
381
+ "{:?}",
382
+ report.violations
383
+ );
384
+ }
385
+
386
+ #[test]
387
+ fn self_imports_are_reported_as_cycles() {
388
+ let repo = fixture_root();
389
+ write_cycle_config(&repo);
390
+ fixture_write(
391
+ &repo,
392
+ "src/a.ts",
393
+ "import { a } from './a';\nexport const a = 1;\n",
394
+ );
395
+
396
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
397
+
398
+ assert!(
399
+ report
400
+ .violations
401
+ .iter()
402
+ .any(|violation| violation.id == "arch.no_cycles"),
403
+ "{:?}",
404
+ report.violations
405
+ );
406
+ }
407
+
408
+ #[test]
409
+ fn rust_sibling_module_import_cycles_are_still_reported() {
410
+ let repo = rust_sibling_cycle_repo(
411
+ "use crate::parent::right::right;\npub fn left() { right(); }\n",
412
+ "use crate::parent::left::left;\npub fn right() { left(); }\n",
413
+ );
414
+
415
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
416
+
417
+ assert!(
418
+ report
419
+ .violations
420
+ .iter()
421
+ .any(|violation| violation.id == "arch.no_cycles"),
422
+ "{:?}",
423
+ report.violations
424
+ );
425
+ }
426
+
427
+ #[test]
428
+ fn rust_relative_sibling_module_references_are_module_internal() {
429
+ let repo = rust_sibling_cycle_repo(
430
+ "use super::right::right;\npub fn left() { right(); }\n",
431
+ "use super::left::left;\npub fn right() { left(); }\n",
432
+ );
433
+
434
+ let report = validate_architecture(&repo, ArchitectureScanOptions::default()).unwrap();
435
+
436
+ assert!(
437
+ report
438
+ .violations
439
+ .iter()
440
+ .any(|violation| violation.id == "arch.no_cycles"),
441
+ "{:?}",
442
+ report.violations
443
+ );
444
+ }
445
+
446
+ fn rust_sibling_cycle_repo(left_source: &str, right_source: &str) -> PathBuf {
447
+ let repo = fixture_root();
448
+ write_cycle_config(&repo);
449
+ fixture_write(&repo, "src/lib.rs", "mod parent;\n");
450
+ fixture_write(&repo, "src/parent.rs", "pub mod left;\npub mod right;\n");
451
+ fixture_write(&repo, "src/parent/left.rs", left_source);
452
+ fixture_write(&repo, "src/parent/right.rs", right_source);
453
+ repo
454
+ }
455
+
456
+ fn write_cycle_config(repo: &Path) {
457
+ fixture_write(
458
+ repo,
459
+ "naome.arch.yaml",
460
+ r#"
461
+ rules:
462
+ no_cycles:
463
+ enabled: true
464
+ severity: error
465
+ "#,
466
+ );
467
+ }
468
+
469
+ fn fixture_root() -> PathBuf {
470
+ let nonce = SystemTime::now()
471
+ .duration_since(UNIX_EPOCH)
472
+ .unwrap()
473
+ .as_nanos();
474
+ let counter = FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed);
475
+ let mut root = std::env::temp_dir();
476
+ root.push(format!(
477
+ "naome-arch-rules-fixture-{}-{nonce}-{counter}",
478
+ std::process::id()
479
+ ));
480
+ fs::create_dir_all(&root).unwrap();
481
+ fs::write(
482
+ root.join(".naomeignore"),
483
+ [".naome/archive/", ".naome/tasks/", ""].join("\n"),
484
+ )
485
+ .unwrap();
486
+ root
487
+ }
488
+
489
+ fn fixture_write(root: &Path, relative_path: &str, content: &str) {
490
+ let path = relative_path
491
+ .split('/')
492
+ .fold(root.to_path_buf(), |mut path, segment| {
493
+ path.push(segment);
494
+ path
495
+ });
496
+ fs::create_dir_all(path.parent().unwrap()).unwrap();
497
+ fs::write(path, content).unwrap();
498
+ }
@@ -0,0 +1,80 @@
1
+ #![allow(dead_code)]
2
+
3
+ use std::fs;
4
+ use std::path::{Path, PathBuf};
5
+ use std::process::Command;
6
+ use std::sync::atomic::{AtomicU64, Ordering};
7
+ use std::time::{SystemTime, UNIX_EPOCH};
8
+
9
+ use naome_core::{ArchitectureEdgeKind, ArchitectureScanReport};
10
+
11
+ static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
12
+
13
+ pub struct FixtureRepo {
14
+ root: PathBuf,
15
+ }
16
+
17
+ impl FixtureRepo {
18
+ pub fn new() -> Self {
19
+ let nonce = SystemTime::now()
20
+ .duration_since(UNIX_EPOCH)
21
+ .unwrap()
22
+ .as_nanos();
23
+ let counter = FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed);
24
+ let root = std::env::temp_dir().join(format!(
25
+ "naome-arch-fixture-{}-{nonce}-{counter}",
26
+ std::process::id()
27
+ ));
28
+ fs::create_dir_all(&root).unwrap();
29
+ fs::write(
30
+ root.join(".naomeignore"),
31
+ ".naome/archive/\n.naome/tasks/\n",
32
+ )
33
+ .unwrap();
34
+ Self { root }
35
+ }
36
+
37
+ pub fn path(&self) -> &Path {
38
+ &self.root
39
+ }
40
+
41
+ pub fn write(&self, relative_path: &str, content: &str) {
42
+ let fixture_path = self.root.join(relative_path);
43
+ if let Some(parent) = fixture_path.parent() {
44
+ if !parent.exists() {
45
+ fs::create_dir_all(parent).unwrap();
46
+ }
47
+ }
48
+ fs::write(&fixture_path, content.as_bytes()).unwrap();
49
+ }
50
+
51
+ pub fn init_git(&self) {
52
+ run_git(&self.root, &["init"]);
53
+ run_git(&self.root, &["config", "user.email", "naome@example.com"]);
54
+ run_git(&self.root, &["config", "user.name", "NAOME Test"]);
55
+ self.write("README.md", "# Fixture\n");
56
+ run_git(&self.root, &["add", "."]);
57
+ run_git(&self.root, &["commit", "-m", "baseline"]);
58
+ }
59
+ }
60
+
61
+ pub fn has_import_edge(scan: &ArchitectureScanReport, from: &str, to: &str) -> bool {
62
+ scan.graph.edges.iter().any(|edge| {
63
+ edge.kind == ArchitectureEdgeKind::Imports && edge.from == from && edge.to == to
64
+ })
65
+ }
66
+
67
+ fn run_git(root: &Path, args: &[&str]) {
68
+ let result = Command::new("git")
69
+ .args(args)
70
+ .current_dir(root)
71
+ .output()
72
+ .unwrap();
73
+ assert!(
74
+ result.status.success(),
75
+ "git {:?} failed: {}{}",
76
+ args,
77
+ String::from_utf8_lossy(&result.stdout),
78
+ String::from_utf8_lossy(&result.stderr)
79
+ );
80
+ }
@@ -0,0 +1,111 @@
1
+ use naome_core::{scan_architecture, validate_architecture, ArchitectureScanOptions};
2
+
3
+ mod architecture_support;
4
+
5
+ use architecture_support::FixtureRepo;
6
+
7
+ #[test]
8
+ fn swift_imports_emit_external_dependency_edges_for_ios_apps() {
9
+ let repo = FixtureRepo::new();
10
+ repo.write(
11
+ "Sources/Tickets/AppView.swift",
12
+ "import SwiftUI\nimport UIKit\n@testable import TicketsCore\n@preconcurrency import Alamofire\n@_implementationOnly import Firebase\n",
13
+ );
14
+
15
+ let scan = scan_architecture(repo.path(), ArchitectureScanOptions::default()).unwrap();
16
+ let fact = scan
17
+ .file_facts
18
+ .get("Sources/Tickets/AppView.swift")
19
+ .unwrap();
20
+
21
+ assert_eq!(fact.language.as_deref(), Some("swift"));
22
+ for package in ["SwiftUI", "UIKit", "TicketsCore", "Alamofire", "Firebase"] {
23
+ assert!(
24
+ scan.graph
25
+ .nodes
26
+ .iter()
27
+ .any(|node| { node.id == format!("external:{package}") && node.label == package }),
28
+ "missing external node for {package}"
29
+ );
30
+ }
31
+ }
32
+
33
+ #[test]
34
+ fn swift_apple_frameworks_do_not_violate_external_dependency_policy() {
35
+ let repo = FixtureRepo::new();
36
+ repo.write(
37
+ "naome.arch.yaml",
38
+ r#"
39
+ layers:
40
+ domain:
41
+ paths:
42
+ - "Sources/Domain/**"
43
+ rules:
44
+ external_dependency_policy:
45
+ enabled: true
46
+ severity: error
47
+ external_dependencies:
48
+ domain:
49
+ allow: []
50
+ "#,
51
+ );
52
+ repo.write(
53
+ "Sources/Domain/EventView.swift",
54
+ "import Foundation\nimport SwiftUI\nimport UIKit\nimport CoreBluetooth\nimport CoreImage\nimport CoreML\nimport Network\nimport Vision\nimport Alamofire\n",
55
+ );
56
+
57
+ let report = validate_architecture(repo.path(), ArchitectureScanOptions::default()).unwrap();
58
+
59
+ assert_eq!(report.summary.errors, 1);
60
+ assert_eq!(
61
+ report.violations[0].to.as_deref(),
62
+ Some("external:Alamofire")
63
+ );
64
+ }
65
+
66
+ #[test]
67
+ fn swiftpm_target_imports_resolve_to_local_target_files() {
68
+ let repo = FixtureRepo::new();
69
+ repo.write(
70
+ "naome.arch.yaml",
71
+ r#"
72
+ layers:
73
+ ui:
74
+ paths:
75
+ - "Sources/App/**"
76
+ - "Tests/**"
77
+ rules:
78
+ external_dependency_policy:
79
+ enabled: true
80
+ severity: error
81
+ external_dependencies:
82
+ ui:
83
+ allow: []
84
+ "#,
85
+ );
86
+ repo.write(
87
+ "Sources/App/AppView.swift",
88
+ "import SwiftUI\nimport Core\nimport Alamofire\n",
89
+ );
90
+ repo.write("Sources/Core/Model.swift", "public struct Model {}\n");
91
+ repo.write(
92
+ "Tests/CoreTests/CoreTests.swift",
93
+ "import XCTest\n@testable import Core\n",
94
+ );
95
+
96
+ let report = validate_architecture(repo.path(), ArchitectureScanOptions::default()).unwrap();
97
+ let scan = scan_architecture(repo.path(), ArchitectureScanOptions::default()).unwrap();
98
+
99
+ assert!(scan.graph.edges.iter().any(|edge| {
100
+ edge.from == "file:Sources/App/AppView.swift" && edge.to == "file:Sources/Core/Model.swift"
101
+ }));
102
+ assert!(scan.graph.edges.iter().any(|edge| {
103
+ edge.from == "file:Tests/CoreTests/CoreTests.swift"
104
+ && edge.to == "file:Sources/Core/Model.swift"
105
+ }));
106
+ assert_eq!(report.summary.errors, 1);
107
+ assert_eq!(
108
+ report.violations[0].to.as_deref(),
109
+ Some("external:Alamofire")
110
+ );
111
+ }