@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
package/Cargo.lock CHANGED
@@ -76,7 +76,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
76
76
 
77
77
  [[package]]
78
78
  name = "naome-cli"
79
- version = "1.3.12"
79
+ version = "1.3.14"
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.12"
87
+ version = "1.3.14"
88
88
  dependencies = [
89
89
  "serde",
90
90
  "serde_json",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-cli"
3
- version = "1.3.12"
3
+ version = "1.3.14"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -53,7 +53,7 @@ fn run_arch_explain(root: &Path, args: &[String]) -> Result<(), Box<dyn std::err
53
53
  "layers": scan.config.layers.keys().collect::<Vec<_>>(),
54
54
  "contexts": scan.config.contexts.keys().collect::<Vec<_>>(),
55
55
  "rules": ARCHITECTURE_RULE_IDS,
56
- "extractors": ["path", "typescript", "javascript", "rust", "python", "go"]
56
+ "extractors": ["path", "typescript", "javascript", "rust", "python", "go", "swift"]
57
57
  }))?
58
58
  );
59
59
  } else {
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-core"
3
- version = "1.3.12"
3
+ version = "1.3.14"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -54,6 +54,8 @@ pub struct ArchitectureValidation {
54
54
  pub rules_executed: Vec<String>,
55
55
  pub changed_only_requested: bool,
56
56
  pub changed_only_degraded_to_full_scan: bool,
57
+ pub changed_only_mode: String,
58
+ pub changed_only_degradation_reason: Option<String>,
57
59
  pub violations: Vec<ArchitectureViolation>,
58
60
  #[serde(rename = "agent_feedback")]
59
61
  pub agent_feedback: Vec<ArchitectureAgentFeedback>,
@@ -134,7 +136,15 @@ pub fn format_architecture_validation(report: &ArchitectureValidation) -> String
134
136
  ];
135
137
 
136
138
  if report.changed_only_degraded_to_full_scan {
137
- lines.push("changed-only requested: degraded to full scan for soundness".to_string());
139
+ let reason = report
140
+ .changed_only_degradation_reason
141
+ .as_deref()
142
+ .unwrap_or("soundness");
143
+ lines.push(format!(
144
+ "changed-only requested: degraded to full scan for soundness ({reason})"
145
+ ));
146
+ } else if report.changed_only_requested {
147
+ lines.push("changed-only requested: using incremental architecture cache".to_string());
138
148
  }
139
149
 
140
150
  for violation in report.violations.iter().take(10) {
@@ -152,12 +162,24 @@ pub fn format_architecture_validation(report: &ArchitectureValidation) -> String
152
162
  }
153
163
 
154
164
  pub fn format_architecture_scan(report: &ArchitectureScanReport) -> String {
155
- format!(
165
+ let mut output = format!(
156
166
  "NAOME architecture scan\nfiles scanned: {}\ngraph nodes: {}\ngraph edges: {}\n",
157
167
  report.files_scanned,
158
168
  report.graph.nodes.len(),
159
169
  report.graph.edges.len()
160
- )
170
+ );
171
+ if report.changed_only_degraded_to_full_scan {
172
+ let reason = report
173
+ .changed_only_degradation_reason
174
+ .as_deref()
175
+ .unwrap_or("soundness");
176
+ output.push_str(&format!(
177
+ "changed-only requested: degraded to full scan for soundness ({reason})\n"
178
+ ));
179
+ } else if report.changed_only_requested {
180
+ output.push_str("changed-only requested: using incremental architecture cache\n");
181
+ }
182
+ output
161
183
  }
162
184
 
163
185
  pub fn format_architecture_explain(scan: &ArchitectureScanReport) -> String {
@@ -176,7 +198,7 @@ pub fn format_architecture_explain(scan: &ArchitectureScanReport) -> String {
176
198
  .collect::<Vec<_>>()
177
199
  .join(", ");
178
200
  format!(
179
- "NAOME Architecture Fitness\nrules: {}\nlayers: {}\ncontexts: {}\npath extractor: enabled for every repository\nimport extractors: typescript, javascript, rust, python, go\n",
201
+ "NAOME Architecture Fitness\nrules: {}\nlayers: {}\ncontexts: {}\npath extractor: enabled for every repository\nimport extractors: typescript, javascript, rust, python, go, swift\n",
180
202
  ARCHITECTURE_RULE_IDS.join(", "),
181
203
  empty_label(&layers),
182
204
  empty_label(&contexts)
@@ -73,6 +73,7 @@ fn is_standard_library_dependency(language: Option<&str>, package: &str) -> bool
73
73
  Some("javascript") | Some("typescript") => NODE_BUILTINS.contains(&root),
74
74
  Some("python") => PYTHON_STDLIB.contains(&root),
75
75
  Some("rust") => matches!(root, "std" | "core" | "alloc" | "proc_macro" | "test"),
76
+ Some("swift") => SWIFT_APPLE_FRAMEWORKS.contains(&root),
76
77
  _ => false,
77
78
  }
78
79
  }
@@ -183,3 +184,61 @@ const PYTHON_STDLIB: &[&str] = &[
183
184
  "xml",
184
185
  "zipfile",
185
186
  ];
187
+
188
+ const SWIFT_APPLE_FRAMEWORKS: &[&str] = &[
189
+ "Accelerate",
190
+ "AppIntents",
191
+ "AppKit",
192
+ "ARKit",
193
+ "AVFoundation",
194
+ "CloudKit",
195
+ "Combine",
196
+ "CoreData",
197
+ "CoreFoundation",
198
+ "CoreBluetooth",
199
+ "CoreGraphics",
200
+ "CoreImage",
201
+ "CoreLocation",
202
+ "CoreML",
203
+ "CoreMotion",
204
+ "CoreNFC",
205
+ "CoreSpotlight",
206
+ "CoreTelephony",
207
+ "CoreText",
208
+ "CryptoKit",
209
+ "Dispatch",
210
+ "EventKit",
211
+ "Foundation",
212
+ "GameKit",
213
+ "HealthKit",
214
+ "LocalAuthentication",
215
+ "MapKit",
216
+ "MessageUI",
217
+ "Metal",
218
+ "MetalKit",
219
+ "NaturalLanguage",
220
+ "Network",
221
+ "Observation",
222
+ "PassKit",
223
+ "PencilKit",
224
+ "Photos",
225
+ "QuartzCore",
226
+ "RealityKit",
227
+ "SafariServices",
228
+ "SceneKit",
229
+ "Security",
230
+ "SpriteKit",
231
+ "StoreKit",
232
+ "Swift",
233
+ "SwiftData",
234
+ "SwiftUI",
235
+ "ThreadNetwork",
236
+ "UIKit",
237
+ "UniformTypeIdentifiers",
238
+ "UserNotifications",
239
+ "Vision",
240
+ "WatchKit",
241
+ "WebKit",
242
+ "WidgetKit",
243
+ "XCTest",
244
+ ];
@@ -55,6 +55,8 @@ pub fn validate_scan(scan: ArchitectureScanReport) -> ArchitectureValidation {
55
55
  rules_executed,
56
56
  changed_only_requested: scan.changed_only_requested,
57
57
  changed_only_degraded_to_full_scan: scan.changed_only_degraded_to_full_scan,
58
+ changed_only_mode: scan.changed_only_mode,
59
+ changed_only_degradation_reason: scan.changed_only_degradation_reason,
58
60
  violations,
59
61
  agent_feedback,
60
62
  }
@@ -0,0 +1,145 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+ use std::fs;
3
+ use std::path::Path;
4
+
5
+ use serde::{Deserialize, Serialize};
6
+ use sha2::{Digest, Sha256};
7
+
8
+ use super::FileFact;
9
+ use crate::models::NaomeError;
10
+
11
+ const CACHE_SCHEMA: &str = "naome.architecture-cache.v1";
12
+ const EXTRACTOR_VERSION: &str = "architecture-cache-v1.3.14";
13
+ const CACHE_PATH: &str = ".naome/cache/architecture/cache.json";
14
+
15
+ #[derive(Debug, Clone, Serialize, Deserialize)]
16
+ #[serde(rename_all = "camelCase")]
17
+ pub(super) struct ArchitectureCache {
18
+ pub schema: String,
19
+ pub extractor_version: String,
20
+ pub config_hash: String,
21
+ pub file_list_hash: String,
22
+ pub files: BTreeMap<String, CachedFileFact>,
23
+ }
24
+
25
+ #[derive(Debug, Clone, Serialize, Deserialize)]
26
+ #[serde(rename_all = "camelCase")]
27
+ pub(super) struct CachedFileFact {
28
+ pub content_hash: String,
29
+ pub fact: FileFact,
30
+ }
31
+
32
+ impl ArchitectureCache {
33
+ pub(super) fn is_compatible(&self, config_hash: &str, file_list_hash: &str) -> CacheStatus {
34
+ if self.schema != CACHE_SCHEMA || self.extractor_version != EXTRACTOR_VERSION {
35
+ return CacheStatus::Miss("cache_miss");
36
+ }
37
+ if self.config_hash != config_hash {
38
+ return CacheStatus::Miss("config_changed");
39
+ }
40
+ if self.file_list_hash != file_list_hash {
41
+ return CacheStatus::Miss("file_set_changed");
42
+ }
43
+ CacheStatus::Hit
44
+ }
45
+ }
46
+
47
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
48
+ pub(super) enum CacheStatus {
49
+ Hit,
50
+ Miss(&'static str),
51
+ }
52
+
53
+ pub(super) fn read(root: &Path) -> Option<ArchitectureCache> {
54
+ let content = fs::read_to_string(root.join(CACHE_PATH)).ok()?;
55
+ serde_json::from_str(&content).ok()
56
+ }
57
+
58
+ pub(super) fn write(
59
+ root: &Path,
60
+ config_hash: String,
61
+ file_list_hash: String,
62
+ file_facts: &BTreeMap<String, FileFact>,
63
+ ) -> Result<(), NaomeError> {
64
+ let cache = ArchitectureCache {
65
+ schema: CACHE_SCHEMA.to_string(),
66
+ extractor_version: EXTRACTOR_VERSION.to_string(),
67
+ config_hash,
68
+ file_list_hash,
69
+ files: file_facts
70
+ .iter()
71
+ .filter_map(|(path, fact)| {
72
+ let content_hash = content_hash(root, path).ok()?;
73
+ Some((
74
+ path.clone(),
75
+ CachedFileFact {
76
+ content_hash,
77
+ fact: fact.clone(),
78
+ },
79
+ ))
80
+ })
81
+ .collect(),
82
+ };
83
+ let path = root.join(CACHE_PATH);
84
+ if let Some(parent) = path.parent() {
85
+ fs::create_dir_all(parent)?;
86
+ }
87
+ fs::write(path, serde_json::to_string_pretty(&cache)?)?;
88
+ Ok(())
89
+ }
90
+
91
+ pub(super) fn config_hash(
92
+ root: &Path,
93
+ explicit_path: Option<&Path>,
94
+ default_content: &str,
95
+ ) -> Result<String, NaomeError> {
96
+ let path = explicit_path
97
+ .map(Path::to_path_buf)
98
+ .unwrap_or_else(|| root.join("naome.arch.yaml"));
99
+ let source = if path.exists() {
100
+ fs::read_to_string(&path)?
101
+ } else {
102
+ default_content.to_string()
103
+ };
104
+ let label = display_path(root, &path);
105
+ Ok(stable_hash([
106
+ b"architecture-config-v1".as_slice(),
107
+ label.as_bytes(),
108
+ source.as_bytes(),
109
+ ]))
110
+ }
111
+
112
+ pub(super) fn file_list_hash(files: &[String]) -> String {
113
+ let mut hasher = Sha256::new();
114
+ hasher.update(b"architecture-file-list-v1");
115
+ for path in files {
116
+ hasher.update([0]);
117
+ hasher.update(path.as_bytes());
118
+ }
119
+ format!("sha256:{:x}", hasher.finalize())
120
+ }
121
+
122
+ pub(super) fn content_hash(root: &Path, path: &str) -> Result<String, NaomeError> {
123
+ let bytes = fs::read(root.join(path))?;
124
+ Ok(stable_hash([b"architecture-file-v1".as_slice(), &bytes]))
125
+ }
126
+
127
+ pub(super) fn changed_set(changed_paths: &[String]) -> BTreeSet<String> {
128
+ changed_paths.iter().cloned().collect()
129
+ }
130
+
131
+ fn stable_hash<'a>(parts: impl IntoIterator<Item = &'a [u8]>) -> String {
132
+ let mut hasher = Sha256::new();
133
+ for part in parts {
134
+ hasher.update(part);
135
+ hasher.update([0]);
136
+ }
137
+ format!("sha256:{:x}", hasher.finalize())
138
+ }
139
+
140
+ fn display_path(root: &Path, path: &Path) -> String {
141
+ path.strip_prefix(root)
142
+ .unwrap_or(path)
143
+ .to_string_lossy()
144
+ .replace('\\', "/")
145
+ }
@@ -5,7 +5,7 @@ use std::path::Path;
5
5
  use serde_json::json;
6
6
 
7
7
  use super::imports;
8
- use super::FileFact;
8
+ use super::{FileFact, ManifestFact};
9
9
  use crate::architecture::config::ArchitectureConfig;
10
10
  use crate::architecture::model::{ArchitectureEdgeKind, ArchitectureGraph, ArchitectureNodeKind};
11
11
 
@@ -16,24 +16,48 @@ pub(super) fn build_path_graph(
16
16
  root: &Path,
17
17
  files: Vec<String>,
18
18
  config: &ArchitectureConfig,
19
+ manifests: &[ManifestFact],
19
20
  ) -> (ArchitectureGraph, BTreeMap<String, FileFact>) {
20
- let mut graph = ArchitectureGraph::default();
21
21
  let mut file_facts = BTreeMap::new();
22
22
  let file_set = files.iter().cloned().collect::<BTreeSet<_>>();
23
+ for path in &files {
24
+ file_facts.insert(path.clone(), scan_file_fact(root, path, config, &file_set));
25
+ }
26
+ let graph = build_graph_from_facts(&files, &file_facts, config, manifests);
27
+ (graph, file_facts)
28
+ }
29
+
30
+ pub(super) fn scan_file_fact(
31
+ root: &Path,
32
+ path: &str,
33
+ config: &ArchitectureConfig,
34
+ file_set: &BTreeSet<String>,
35
+ ) -> FileFact {
36
+ let content = fs::read_to_string(root.join(path)).unwrap_or_default();
37
+ let mut fact = facts::file_fact(path, &content, config);
38
+ fact.imports = imports::extract_imports(root, path, &content, file_set);
39
+ fact
40
+ }
41
+
42
+ pub(super) fn build_graph_from_facts(
43
+ files: &[String],
44
+ file_facts: &BTreeMap<String, FileFact>,
45
+ config: &ArchitectureConfig,
46
+ manifests: &[ManifestFact],
47
+ ) -> ArchitectureGraph {
48
+ let mut graph = ArchitectureGraph::default();
49
+ let mut emitted_external_nodes = BTreeSet::new();
23
50
 
24
51
  push_repository_and_policy_nodes(&mut graph, config);
25
- push_directories(&mut graph, &files);
52
+ push_directories(&mut graph, files);
53
+ push_manifests(&mut graph, manifests, &mut emitted_external_nodes);
26
54
 
27
- for path in files {
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);
55
+ for fact in file_facts.values() {
31
56
  push_file(&mut graph, &fact);
32
- file_facts.insert(path, fact);
33
57
  }
34
58
 
35
- push_imports(&mut graph, &file_facts);
36
- (graph, file_facts)
59
+ push_imports(&mut graph, file_facts, &mut emitted_external_nodes);
60
+ graph
37
61
  }
38
62
 
39
63
  fn push_repository_and_policy_nodes(graph: &mut ArchitectureGraph, config: &ArchitectureConfig) {
@@ -121,44 +145,117 @@ fn push_file(graph: &mut ArchitectureGraph, fact: &FileFact) {
121
145
  push_membership_edges(graph, path, "context", &fact.contexts);
122
146
  }
123
147
 
124
- fn push_imports(graph: &mut ArchitectureGraph, file_facts: &BTreeMap<String, FileFact>) {
125
- let mut emitted_target_nodes = BTreeSet::new();
148
+ fn push_manifests(
149
+ graph: &mut ArchitectureGraph,
150
+ manifests: &[ManifestFact],
151
+ emitted_external_nodes: &mut BTreeSet<String>,
152
+ ) {
153
+ for manifest in manifests {
154
+ let package_id = manifest_package_id(manifest);
155
+ emit::push_node_with_metadata(
156
+ graph,
157
+ &package_id,
158
+ ArchitectureNodeKind::Package,
159
+ &manifest.package_name,
160
+ Some(manifest.path.clone()),
161
+ None,
162
+ manifest.confidence,
163
+ &manifest.extractor,
164
+ json!({
165
+ "path": manifest.path,
166
+ "ecosystem": manifest.ecosystem,
167
+ "packageName": manifest.package_name
168
+ }),
169
+ );
170
+ emit::push_edge(
171
+ graph,
172
+ facts::parent_node_id(&manifest.path)
173
+ .as_deref()
174
+ .unwrap_or("repository:."),
175
+ &package_id,
176
+ ArchitectureEdgeKind::Contains,
177
+ "contains",
178
+ Some(manifest.path.clone()),
179
+ );
180
+ for dependency in &manifest.dependencies {
181
+ let external_id = format!("external:{}", dependency.name);
182
+ push_external_node(
183
+ graph,
184
+ emitted_external_nodes,
185
+ &external_id,
186
+ &dependency.name,
187
+ dependency.confidence,
188
+ &manifest.extractor,
189
+ json!({
190
+ "manifestPath": manifest.path,
191
+ "ecosystem": manifest.ecosystem,
192
+ "dependencyKind": dependency.dependency_kind,
193
+ "version": dependency.version
194
+ }),
195
+ );
196
+ emit::push_edge_with_metadata(
197
+ graph,
198
+ &package_id,
199
+ &external_id,
200
+ ArchitectureEdgeKind::DependsOn,
201
+ "depends_on",
202
+ Some(manifest.path.clone()),
203
+ None,
204
+ None,
205
+ dependency.confidence,
206
+ &manifest.extractor,
207
+ json!({
208
+ "specifier": dependency.name,
209
+ "manifestPath": manifest.path,
210
+ "dependencyKind": dependency.dependency_kind,
211
+ "version": dependency.version
212
+ }),
213
+ );
214
+ }
215
+ }
216
+ }
217
+
218
+ fn manifest_package_id(manifest: &ManifestFact) -> String {
219
+ format!(
220
+ "package:{}:{}",
221
+ stable_fragment(&manifest.ecosystem),
222
+ stable_fragment(&manifest.path)
223
+ )
224
+ }
225
+
226
+ fn push_imports(
227
+ graph: &mut ArchitectureGraph,
228
+ file_facts: &BTreeMap<String, FileFact>,
229
+ emitted_external_nodes: &mut BTreeSet<String>,
230
+ ) {
126
231
  for fact in file_facts.values() {
127
232
  for import in &fact.imports {
128
233
  let target_id = match &import.target {
129
234
  super::ImportTarget::File(path) => format!("file:{path}"),
130
235
  super::ImportTarget::ExternalDependency(name) => {
131
236
  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
- }
237
+ push_external_node(
238
+ graph,
239
+ emitted_external_nodes,
240
+ &id,
241
+ name,
242
+ import.confidence,
243
+ &import.extractor,
244
+ json!({ "specifier": import.specifier, "resolvedAs": "external" }),
245
+ );
145
246
  id
146
247
  }
147
248
  super::ImportTarget::Unknown(specifier) => {
148
249
  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
- }
250
+ push_external_node(
251
+ graph,
252
+ emitted_external_nodes,
253
+ &id,
254
+ specifier,
255
+ import.confidence,
256
+ &import.extractor,
257
+ json!({ "specifier": specifier, "resolvedAs": "unknown" }),
258
+ );
162
259
  id
163
260
  }
164
261
  };
@@ -179,6 +276,30 @@ fn push_imports(graph: &mut ArchitectureGraph, file_facts: &BTreeMap<String, Fil
179
276
  }
180
277
  }
181
278
 
279
+ fn push_external_node(
280
+ graph: &mut ArchitectureGraph,
281
+ emitted_external_nodes: &mut BTreeSet<String>,
282
+ id: &str,
283
+ label: &str,
284
+ confidence: f32,
285
+ extractor: &str,
286
+ raw_origin: serde_json::Value,
287
+ ) {
288
+ if emitted_external_nodes.insert(id.to_string()) {
289
+ emit::push_node_with_metadata(
290
+ graph,
291
+ id,
292
+ ArchitectureNodeKind::ExternalDependency,
293
+ label,
294
+ None,
295
+ None,
296
+ confidence,
297
+ extractor,
298
+ raw_origin,
299
+ );
300
+ }
301
+ }
302
+
182
303
  fn push_membership_edges(
183
304
  graph: &mut ArchitectureGraph,
184
305
  path: &str,
@@ -0,0 +1,48 @@
1
+ use super::{is_comment_only, raw, RawImport};
2
+
3
+ pub(super) fn extract(content: &str) -> Vec<RawImport> {
4
+ let mut imports = Vec::new();
5
+ for (index, line) in content.lines().enumerate() {
6
+ let trimmed = line.trim();
7
+ if is_comment_only(trimmed) {
8
+ continue;
9
+ }
10
+ let Some(import_value) = swift_import_value(trimmed) else {
11
+ continue;
12
+ };
13
+ if let Some(specifier) = swift_import_specifier(import_value) {
14
+ imports.push(raw(specifier, index, line, 0.88, "import:swift"));
15
+ }
16
+ }
17
+ imports
18
+ }
19
+
20
+ fn swift_import_value(line: &str) -> Option<&str> {
21
+ let mut remaining = line.trim();
22
+ while remaining.starts_with('@') {
23
+ let Some((_, after_attribute)) = remaining.split_once(' ') else {
24
+ return None;
25
+ };
26
+ remaining = after_attribute.trim_start();
27
+ }
28
+ remaining.strip_prefix("import ")
29
+ }
30
+
31
+ fn swift_import_specifier(value: &str) -> Option<String> {
32
+ let mut parts = value.split_whitespace();
33
+ let first = parts.next()?;
34
+ let module = if matches!(
35
+ first,
36
+ "class" | "struct" | "enum" | "protocol" | "func" | "var" | "typealias"
37
+ ) {
38
+ parts.next()?
39
+ } else {
40
+ first
41
+ };
42
+ module
43
+ .split('.')
44
+ .next()
45
+ .map(str::trim)
46
+ .filter(|value| !value.is_empty())
47
+ .map(ToString::to_string)
48
+ }
@@ -1,6 +1,8 @@
1
1
  use super::language_for_path;
2
2
  use crate::architecture::model::SourceRange;
3
3
 
4
+ mod swift;
5
+
4
6
  #[derive(Debug, Clone)]
5
7
  pub(super) struct RawImport {
6
8
  pub specifier: String,
@@ -15,6 +17,7 @@ pub(super) fn extract_raw_imports(path: &str, content: &str) -> Vec<RawImport> {
15
17
  Some("rust") => extract_rust(content),
16
18
  Some("python") => extract_python(content),
17
19
  Some("go") => extract_go(content),
20
+ Some("swift") => swift::extract(content),
18
21
  _ => Vec::new(),
19
22
  }
20
23
  }