@lamentis/naome 1.3.10 → 1.3.12

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 (35) hide show
  1. package/Cargo.lock +2 -2
  2. package/README.md +5 -0
  3. package/crates/naome-cli/Cargo.toml +1 -1
  4. package/crates/naome-cli/src/architecture_commands.rs +3 -3
  5. package/crates/naome-cli/tests/architecture_cli.rs +60 -0
  6. package/crates/naome-core/Cargo.toml +1 -1
  7. package/crates/naome-core/src/architecture/config/parser/sections.rs +61 -1
  8. package/crates/naome-core/src/architecture/config/parser.rs +2 -0
  9. package/crates/naome-core/src/architecture/config.rs +47 -0
  10. package/crates/naome-core/src/architecture/output.rs +15 -1
  11. package/crates/naome-core/src/architecture/rules/budgets.rs +179 -0
  12. package/crates/naome-core/src/architecture/rules/context.rs +138 -0
  13. package/crates/naome-core/src/architecture/rules/cycles.rs +39 -0
  14. package/crates/naome-core/src/architecture/rules/external.rs +185 -0
  15. package/crates/naome-core/src/architecture/rules/graph.rs +177 -0
  16. package/crates/naome-core/src/architecture/rules/transitive.rs +89 -0
  17. package/crates/naome-core/src/architecture/rules.rs +73 -27
  18. package/crates/naome-core/src/architecture/scan/graph_builder/emit.rs +63 -1
  19. package/crates/naome-core/src/architecture/scan/graph_builder/facts.rs +2 -3
  20. package/crates/naome-core/src/architecture/scan/graph_builder.rs +78 -1
  21. package/crates/naome-core/src/architecture/scan/imports/extractors.rs +404 -0
  22. package/crates/naome-core/src/architecture/scan/imports/resolver.rs +316 -0
  23. package/crates/naome-core/src/architecture/scan/imports.rs +75 -0
  24. package/crates/naome-core/src/architecture/scan.rs +20 -0
  25. package/crates/naome-core/src/architecture.rs +1 -1
  26. package/crates/naome-core/src/lib.rs +1 -0
  27. package/crates/naome-core/tests/architecture.rs +380 -73
  28. package/crates/naome-core/tests/architecture_rules.rs +498 -0
  29. package/crates/naome-core/tests/architecture_support/mod.rs +78 -0
  30. package/installer/harness-files.js +3 -3
  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/manifest.json +2 -2
  35. package/templates/naome-root/docs/naome/architecture-fitness.md +62 -7
@@ -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
+ }
@@ -0,0 +1,404 @@
1
+ use super::language_for_path;
2
+ use crate::architecture::model::SourceRange;
3
+
4
+ #[derive(Debug, Clone)]
5
+ pub(super) struct RawImport {
6
+ pub specifier: String,
7
+ pub source_range: SourceRange,
8
+ pub confidence: f32,
9
+ pub extractor: String,
10
+ }
11
+
12
+ pub(super) fn extract_raw_imports(path: &str, content: &str) -> Vec<RawImport> {
13
+ match language_for_path(path) {
14
+ Some("typescript" | "javascript") => extract_javascript_like(content),
15
+ Some("rust") => extract_rust(content),
16
+ Some("python") => extract_python(content),
17
+ Some("go") => extract_go(content),
18
+ _ => Vec::new(),
19
+ }
20
+ }
21
+
22
+ fn extract_javascript_like(content: &str) -> Vec<RawImport> {
23
+ let mut imports = Vec::new();
24
+ let mut pending_statement: Option<(usize, String)> = None;
25
+ for (index, line) in content.lines().enumerate() {
26
+ let trimmed = line.trim();
27
+ if is_comment_only(trimmed) {
28
+ continue;
29
+ }
30
+ if let Some((start_index, statement)) = pending_statement.as_mut() {
31
+ if !statement.is_empty() {
32
+ statement.push(' ');
33
+ }
34
+ statement.push_str(trimmed);
35
+ if let Some(specifier) = javascript_statement_specifier(statement) {
36
+ imports.push(raw_range(
37
+ specifier,
38
+ *start_index,
39
+ index,
40
+ line,
41
+ 0.86,
42
+ "import:javascript",
43
+ ));
44
+ pending_statement = None;
45
+ } else if trimmed.ends_with(';') {
46
+ pending_statement = None;
47
+ }
48
+ continue;
49
+ }
50
+
51
+ let specifier = javascript_statement_specifier(trimmed);
52
+ if let Some(specifier) = specifier {
53
+ imports.push(raw(specifier, index, line, 0.86, "import:javascript"));
54
+ } else if starts_javascript_pending_import(trimmed) && !trimmed.ends_with(';') {
55
+ pending_statement = Some((index, trimmed.to_string()));
56
+ }
57
+ }
58
+ imports
59
+ }
60
+
61
+ fn extract_rust(content: &str) -> Vec<RawImport> {
62
+ let mut imports = Vec::new();
63
+ for (index, line) in content.lines().enumerate() {
64
+ let trimmed = line.trim();
65
+ if is_comment_only(trimmed) {
66
+ continue;
67
+ }
68
+ let specifiers = trimmed
69
+ .strip_prefix("use ")
70
+ .or_else(|| rust_visible_use_value(trimmed))
71
+ .map(rust_use_specifiers)
72
+ .or_else(|| {
73
+ trimmed
74
+ .strip_prefix("extern crate ")
75
+ .map(|value| vec![value.trim_end_matches(';').trim().to_string()])
76
+ })
77
+ .unwrap_or_default();
78
+ for specifier in specifiers.into_iter().filter(|value| !value.is_empty()) {
79
+ imports.push(raw(specifier, index, line, 0.82, "import:rust"));
80
+ }
81
+ }
82
+ imports
83
+ }
84
+
85
+ fn extract_python(content: &str) -> Vec<RawImport> {
86
+ let mut imports = Vec::new();
87
+ let mut pending_from: Option<(usize, String, String)> = None;
88
+ for (index, line) in content.lines().enumerate() {
89
+ let trimmed = line.trim();
90
+ if let Some((start_index, module, imports_text)) = pending_from.as_mut() {
91
+ imports_text.push_str(trimmed.trim_end_matches(')').trim());
92
+ imports_text.push(',');
93
+ if trimmed.contains(')') {
94
+ for specifier in python_from_import_specifiers_from_parts(module, imports_text) {
95
+ imports.push(raw_range(
96
+ specifier,
97
+ *start_index,
98
+ index,
99
+ line,
100
+ 0.82,
101
+ "import:python",
102
+ ));
103
+ }
104
+ pending_from = None;
105
+ }
106
+ continue;
107
+ }
108
+ let specifiers = if let Some(value) = trimmed.strip_prefix("from ") {
109
+ if let Some((module, imports_text)) = python_multiline_from_import_start(value) {
110
+ pending_from = Some((index, module, imports_text));
111
+ Vec::new()
112
+ } else {
113
+ python_from_import_specifiers(value)
114
+ }
115
+ } else if let Some(value) = trimmed.strip_prefix("import ") {
116
+ python_import_specifiers(value)
117
+ } else {
118
+ Vec::new()
119
+ };
120
+ for specifier in specifiers {
121
+ imports.push(raw(specifier, index, line, 0.82, "import:python"));
122
+ }
123
+ }
124
+ imports
125
+ }
126
+
127
+ fn extract_go(content: &str) -> Vec<RawImport> {
128
+ let mut imports = Vec::new();
129
+ let mut in_block = false;
130
+ for (index, line) in content.lines().enumerate() {
131
+ let trimmed = line.trim();
132
+ if is_comment_only(trimmed) {
133
+ continue;
134
+ }
135
+ if trimmed == "import (" {
136
+ in_block = true;
137
+ continue;
138
+ }
139
+ if in_block && trimmed == ")" {
140
+ in_block = false;
141
+ continue;
142
+ }
143
+ let specifier = if in_block {
144
+ first_quoted(trimmed)
145
+ } else if let Some(value) = trimmed.strip_prefix("import ") {
146
+ first_quoted(value)
147
+ } else {
148
+ None
149
+ };
150
+ if let Some(specifier) = specifier {
151
+ imports.push(raw(specifier, index, line, 0.9, "import:go"));
152
+ }
153
+ }
154
+ imports
155
+ }
156
+
157
+ fn quoted_after_keyword(line: &str, keyword: &str) -> Option<String> {
158
+ let start = line.find(keyword)? + keyword.len();
159
+ first_quoted(&line[start..])
160
+ }
161
+
162
+ fn javascript_static_specifier(line: &str) -> Option<String> {
163
+ quoted_after_keyword(line, " from ").or_else(|| first_quoted(line))
164
+ }
165
+
166
+ fn javascript_declaration_specifier(line: &str) -> Option<String> {
167
+ if line.starts_with("import ") {
168
+ javascript_static_specifier(line)
169
+ } else if line.starts_with("export ") {
170
+ quoted_after_keyword(line, " from ")
171
+ } else {
172
+ None
173
+ }
174
+ }
175
+
176
+ fn javascript_statement_specifier(line: &str) -> Option<String> {
177
+ if starts_javascript_static_import(line) {
178
+ javascript_declaration_specifier(line)
179
+ } else if line.contains("require(") || line.contains("import(") {
180
+ first_quoted(line)
181
+ } else {
182
+ None
183
+ }
184
+ }
185
+
186
+ fn starts_javascript_static_import(line: &str) -> bool {
187
+ line.starts_with("import ") || line.starts_with("export ")
188
+ }
189
+
190
+ fn starts_javascript_pending_import(line: &str) -> bool {
191
+ starts_javascript_static_import(line) || line.contains("import(") || line.contains("require(")
192
+ }
193
+
194
+ fn is_comment_only(line: &str) -> bool {
195
+ line.starts_with("//")
196
+ || line.starts_with("/*")
197
+ || line.starts_with('*')
198
+ || line.starts_with('#')
199
+ }
200
+
201
+ fn rust_visible_use_value(line: &str) -> Option<&str> {
202
+ let (visibility, value) = line.split_once(" use ")?;
203
+ if visibility == "pub" || visibility.starts_with("pub(") {
204
+ Some(value)
205
+ } else {
206
+ None
207
+ }
208
+ }
209
+
210
+ fn rust_use_specifiers(value: &str) -> Vec<String> {
211
+ let value = value.trim_end_matches(';').trim();
212
+ rust_expand_use_tree("", value)
213
+ .into_iter()
214
+ .map(|specifier| rust_strip_alias(&specifier))
215
+ .filter(|specifier| !specifier.is_empty())
216
+ .collect()
217
+ }
218
+
219
+ fn rust_expand_use_tree(prefix: &str, value: &str) -> Vec<String> {
220
+ split_top_level_commas(value)
221
+ .into_iter()
222
+ .flat_map(|item| {
223
+ let item = item.trim();
224
+ let Some(open) = top_level_char(item, '{') else {
225
+ return vec![rust_join_path(prefix, item)];
226
+ };
227
+ let Some(close) = matching_brace(item, open) else {
228
+ return vec![rust_join_path(prefix, item)];
229
+ };
230
+ let nested_prefix = rust_join_path(prefix, item[..open].trim());
231
+ rust_expand_use_tree(&nested_prefix, &item[open + 1..close])
232
+ })
233
+ .collect()
234
+ }
235
+
236
+ fn split_top_level_commas(value: &str) -> Vec<&str> {
237
+ let mut parts = Vec::new();
238
+ let mut depth = 0usize;
239
+ let mut start = 0usize;
240
+ for (index, character) in value.char_indices() {
241
+ match character {
242
+ '{' => depth += 1,
243
+ '}' => depth = depth.saturating_sub(1),
244
+ ',' if depth == 0 => {
245
+ parts.push(&value[start..index]);
246
+ start = index + 1;
247
+ }
248
+ _ => {}
249
+ }
250
+ }
251
+ parts.push(&value[start..]);
252
+ parts
253
+ }
254
+
255
+ fn top_level_char(value: &str, needle: char) -> Option<usize> {
256
+ let mut depth = 0usize;
257
+ for (index, character) in value.char_indices() {
258
+ match character {
259
+ '{' if depth == 0 && character == needle => return Some(index),
260
+ '{' => depth += 1,
261
+ '}' => depth = depth.saturating_sub(1),
262
+ _ => {}
263
+ }
264
+ }
265
+ None
266
+ }
267
+
268
+ fn matching_brace(value: &str, open: usize) -> Option<usize> {
269
+ let mut depth = 0usize;
270
+ for (index, character) in value[open..].char_indices() {
271
+ match character {
272
+ '{' => depth += 1,
273
+ '}' => {
274
+ depth = depth.saturating_sub(1);
275
+ if depth == 0 {
276
+ return Some(open + index);
277
+ }
278
+ }
279
+ _ => {}
280
+ }
281
+ }
282
+ None
283
+ }
284
+
285
+ fn rust_join_path(prefix: &str, item: &str) -> String {
286
+ let prefix = prefix.trim().trim_end_matches("::");
287
+ let item = item.trim().trim_start_matches("::");
288
+ match (prefix.is_empty(), item.is_empty()) {
289
+ (true, _) => item.to_string(),
290
+ (_, true) => prefix.to_string(),
291
+ _ => format!("{prefix}::{item}"),
292
+ }
293
+ }
294
+
295
+ fn rust_strip_alias(value: &str) -> String {
296
+ value
297
+ .split_once(" as ")
298
+ .map(|(specifier, _)| specifier)
299
+ .unwrap_or(value)
300
+ .trim()
301
+ .to_string()
302
+ }
303
+
304
+ fn python_from_import_specifiers(value: &str) -> Vec<String> {
305
+ let Some((module, imports)) = value.split_once(" import ") else {
306
+ return Vec::new();
307
+ };
308
+ python_from_import_specifiers_from_parts(module, imports)
309
+ }
310
+
311
+ fn python_from_import_specifiers_from_parts(module: &str, imports: &str) -> Vec<String> {
312
+ let module = module.trim();
313
+ python_imported_names(imports)
314
+ .into_iter()
315
+ .map(|name| {
316
+ if module.chars().all(|character| character == '.') {
317
+ format!("{module}{name}")
318
+ } else {
319
+ format!("{module}.{name}")
320
+ }
321
+ })
322
+ .filter(|specifier| !specifier.is_empty())
323
+ .collect()
324
+ }
325
+
326
+ fn python_multiline_from_import_start(value: &str) -> Option<(String, String)> {
327
+ let (module, imports) = value.split_once(" import ")?;
328
+ let imports = imports.trim();
329
+ if !imports.starts_with('(') || imports.contains(')') {
330
+ return None;
331
+ }
332
+ Some((
333
+ module.trim().to_string(),
334
+ imports.trim_start_matches('(').to_string(),
335
+ ))
336
+ }
337
+
338
+ fn python_import_specifiers(value: &str) -> Vec<String> {
339
+ python_imported_names(value)
340
+ }
341
+
342
+ fn python_imported_names(value: &str) -> Vec<String> {
343
+ value
344
+ .trim()
345
+ .trim_start_matches('(')
346
+ .trim_end_matches(')')
347
+ .split(',')
348
+ .filter_map(|part| {
349
+ part.split_whitespace()
350
+ .next()
351
+ .map(|name| name.trim())
352
+ .filter(|name| !name.is_empty() && *name != "*")
353
+ .map(ToString::to_string)
354
+ })
355
+ .collect()
356
+ }
357
+
358
+ fn first_quoted(value: &str) -> Option<String> {
359
+ for quote in ['"', '\'', '`'] {
360
+ let Some(start) = value.find(quote).map(|index| index + 1) else {
361
+ continue;
362
+ };
363
+ let Some(end) = value[start..].find(quote).map(|index| index + start) else {
364
+ continue;
365
+ };
366
+ if end > start {
367
+ return Some(value[start..end].to_string());
368
+ }
369
+ }
370
+ None
371
+ }
372
+
373
+ fn raw(
374
+ specifier: String,
375
+ line_index: usize,
376
+ line: &str,
377
+ confidence: f32,
378
+ extractor: &str,
379
+ ) -> RawImport {
380
+ raw_range(
381
+ specifier, line_index, line_index, line, confidence, extractor,
382
+ )
383
+ }
384
+
385
+ fn raw_range(
386
+ specifier: String,
387
+ start_line_index: usize,
388
+ end_line_index: usize,
389
+ end_line: &str,
390
+ confidence: f32,
391
+ extractor: &str,
392
+ ) -> RawImport {
393
+ RawImport {
394
+ specifier,
395
+ source_range: SourceRange {
396
+ start_line: start_line_index + 1,
397
+ start_column: 1,
398
+ end_line: end_line_index + 1,
399
+ end_column: end_line.len() + 1,
400
+ },
401
+ confidence,
402
+ extractor: extractor.to_string(),
403
+ }
404
+ }