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