@lamentis/naome 1.2.0 → 1.3.0

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 (139) hide show
  1. package/Cargo.lock +2 -2
  2. package/README.md +108 -47
  3. package/bin/naome-node.js +2 -1579
  4. package/bin/naome.js +34 -5
  5. package/crates/naome-cli/Cargo.toml +1 -1
  6. package/crates/naome-cli/src/dispatcher.rs +7 -2
  7. package/crates/naome-cli/src/main.rs +37 -22
  8. package/crates/naome-cli/src/quality_commands.rs +317 -10
  9. package/crates/naome-cli/src/workflow_commands.rs +21 -1
  10. package/crates/naome-core/Cargo.toml +1 -1
  11. package/crates/naome-core/src/decision/checks.rs +64 -0
  12. package/crates/naome-core/src/decision/idle.rs +67 -0
  13. package/crates/naome-core/src/decision/json.rs +36 -0
  14. package/crates/naome-core/src/decision/states.rs +165 -0
  15. package/crates/naome-core/src/decision.rs +131 -353
  16. package/crates/naome-core/src/git.rs +4 -2
  17. package/crates/naome-core/src/install_plan.rs +4 -0
  18. package/crates/naome-core/src/lib.rs +12 -6
  19. package/crates/naome-core/src/paths.rs +3 -1
  20. package/crates/naome-core/src/quality/adapter_support.rs +89 -0
  21. package/crates/naome-core/src/quality/adapters.rs +20 -67
  22. package/crates/naome-core/src/quality/baseline.rs +8 -0
  23. package/crates/naome-core/src/quality/cache.rs +153 -0
  24. package/crates/naome-core/src/quality/checks/duplicate_blocks.rs +25 -11
  25. package/crates/naome-core/src/quality/checks/near_duplicates.rs +4 -2
  26. package/crates/naome-core/src/quality/checks.rs +7 -8
  27. package/crates/naome-core/src/quality/cleanup.rs +48 -3
  28. package/crates/naome-core/src/quality/config.rs +8 -15
  29. package/crates/naome-core/src/quality/config_support.rs +24 -0
  30. package/crates/naome-core/src/quality/mod.rs +72 -6
  31. package/crates/naome-core/src/quality/scanner/analysis/normalize.rs +78 -0
  32. package/crates/naome-core/src/quality/scanner/analysis.rs +160 -0
  33. package/crates/naome-core/src/quality/scanner/repo_paths.rs +39 -3
  34. package/crates/naome-core/src/quality/scanner.rs +200 -215
  35. package/crates/naome-core/src/quality/semantic/checks.rs +134 -0
  36. package/crates/naome-core/src/quality/semantic/extract.rs +158 -0
  37. package/crates/naome-core/src/quality/semantic/model.rs +85 -0
  38. package/crates/naome-core/src/quality/semantic/route.rs +52 -0
  39. package/crates/naome-core/src/quality/semantic.rs +68 -0
  40. package/crates/naome-core/src/quality/structure/adapters.rs +84 -0
  41. package/crates/naome-core/src/quality/structure/checks/basic.rs +153 -0
  42. package/crates/naome-core/src/quality/structure/checks/directory.rs +134 -0
  43. package/crates/naome-core/src/quality/structure/checks/pairing.rs +63 -0
  44. package/crates/naome-core/src/quality/structure/checks.rs +124 -0
  45. package/crates/naome-core/src/quality/structure/classify/roles.rs +188 -0
  46. package/crates/naome-core/src/quality/structure/classify.rs +146 -0
  47. package/crates/naome-core/src/quality/structure/config.rs +89 -0
  48. package/crates/naome-core/src/quality/structure/defaults.rs +107 -0
  49. package/crates/naome-core/src/quality/structure/mod.rs +77 -0
  50. package/crates/naome-core/src/quality/structure/model.rs +131 -0
  51. package/crates/naome-core/src/quality/types.rs +43 -2
  52. package/crates/naome-core/src/route/builtin_checks.rs +141 -0
  53. package/crates/naome-core/src/route/builtin_context.rs +73 -0
  54. package/crates/naome-core/src/route/builtin_integrity.rs +49 -0
  55. package/crates/naome-core/src/route/builtin_require.rs +40 -0
  56. package/crates/naome-core/src/route/context.rs +180 -0
  57. package/crates/naome-core/src/route/execution.rs +96 -0
  58. package/crates/naome-core/src/route/execution_baselines.rs +146 -0
  59. package/crates/naome-core/src/route/execution_support.rs +57 -0
  60. package/crates/naome-core/src/route/execution_tasks.rs +71 -0
  61. package/crates/naome-core/src/route/git_ops.rs +72 -0
  62. package/crates/naome-core/src/route/quality_gate.rs +73 -0
  63. package/crates/naome-core/src/route/quality_gate_config.rs +126 -0
  64. package/crates/naome-core/src/route/quality_gate_snapshot.rs +69 -0
  65. package/crates/naome-core/src/route/worktree.rs +75 -0
  66. package/crates/naome-core/src/route/worktree_files.rs +32 -0
  67. package/crates/naome-core/src/route/worktree_plan.rs +131 -0
  68. package/crates/naome-core/src/route.rs +44 -1217
  69. package/crates/naome-core/src/verification.rs +1 -0
  70. package/crates/naome-core/src/workflow/doctor.rs +144 -0
  71. package/crates/naome-core/src/workflow/mod.rs +2 -0
  72. package/crates/naome-core/src/workflow/mutation.rs +1 -2
  73. package/crates/naome-core/tests/decision.rs +24 -118
  74. package/crates/naome-core/tests/harness_health.rs +2 -0
  75. package/crates/naome-core/tests/install_plan.rs +2 -0
  76. package/crates/naome-core/tests/quality.rs +26 -123
  77. package/crates/naome-core/tests/quality_performance.rs +231 -0
  78. package/crates/naome-core/tests/quality_structure.rs +116 -0
  79. package/crates/naome-core/tests/quality_structure_adapters.rs +98 -0
  80. package/crates/naome-core/tests/quality_structure_policy.rs +144 -0
  81. package/crates/naome-core/tests/quality_structure_support/mod.rs +249 -0
  82. package/crates/naome-core/tests/repo_support/mod.rs +16 -0
  83. package/crates/naome-core/tests/repo_support/repo.rs +113 -0
  84. package/crates/naome-core/tests/repo_support/repo_factories.rs +99 -0
  85. package/crates/naome-core/tests/repo_support/repo_helpers.rs +123 -0
  86. package/crates/naome-core/tests/repo_support/routes.rs +81 -0
  87. package/crates/naome-core/tests/repo_support/verification.rs +168 -0
  88. package/crates/naome-core/tests/repo_support/verification_values.rs +135 -0
  89. package/crates/naome-core/tests/route.rs +1 -1376
  90. package/crates/naome-core/tests/route_baseline.rs +86 -0
  91. package/crates/naome-core/tests/route_completion.rs +141 -0
  92. package/crates/naome-core/tests/route_harness_refresh.rs +135 -0
  93. package/crates/naome-core/tests/route_user_diff.rs +202 -0
  94. package/crates/naome-core/tests/route_worktree.rs +54 -0
  95. package/crates/naome-core/tests/semantic_legacy.rs +140 -0
  96. package/crates/naome-core/tests/task_state.rs +60 -432
  97. package/crates/naome-core/tests/task_state_compact_support/repo.rs +1 -1
  98. package/crates/naome-core/tests/task_state_support/mod.rs +163 -0
  99. package/crates/naome-core/tests/task_state_support/states.rs +84 -0
  100. package/crates/naome-core/tests/verification.rs +4 -45
  101. package/crates/naome-core/tests/verification_contract.rs +22 -78
  102. package/crates/naome-core/tests/workflow_doctor.rs +24 -0
  103. package/crates/naome-core/tests/workflow_policy.rs +6 -1
  104. package/crates/naome-core/tests/workflow_support/mod.rs +1 -1
  105. package/installer/agents.js +90 -0
  106. package/installer/context.js +67 -0
  107. package/installer/filesystem.js +166 -0
  108. package/installer/flows.js +84 -0
  109. package/installer/git-boundary.js +171 -0
  110. package/installer/git-hook-content.js +36 -0
  111. package/installer/git-hooks.js +134 -0
  112. package/installer/git-local.js +2 -0
  113. package/installer/git-shared.js +35 -0
  114. package/installer/harness-file-ops.js +140 -0
  115. package/installer/harness-files.js +56 -0
  116. package/installer/harness-verification.js +123 -0
  117. package/installer/install-plan.js +66 -0
  118. package/installer/main.js +25 -0
  119. package/installer/manifest-state.js +167 -0
  120. package/installer/native-build.js +24 -0
  121. package/installer/native-format.js +6 -0
  122. package/installer/native.js +162 -0
  123. package/installer/output.js +131 -0
  124. package/installer/version.js +32 -0
  125. package/native/darwin-arm64/naome +0 -0
  126. package/native/linux-x64/naome +0 -0
  127. package/package.json +2 -1
  128. package/templates/naome-root/.naome/bin/check-harness-health.js +3 -3
  129. package/templates/naome-root/.naome/bin/check-task-state.js +3 -3
  130. package/templates/naome-root/.naome/bin/naome.js +32 -21
  131. package/templates/naome-root/.naome/manifest.json +5 -3
  132. package/templates/naome-root/.naome/repository-structure.json +90 -0
  133. package/templates/naome-root/.naome/verification.json +1 -0
  134. package/templates/naome-root/.naomeignore +1 -0
  135. package/templates/naome-root/docs/naome/agent-workflow.md +16 -14
  136. package/templates/naome-root/docs/naome/index.md +4 -3
  137. package/templates/naome-root/docs/naome/repository-quality.md +66 -4
  138. package/templates/naome-root/docs/naome/repository-structure.md +51 -0
  139. package/templates/naome-root/docs/naome/testing.md +2 -1
@@ -0,0 +1,158 @@
1
+ use std::collections::BTreeSet;
2
+
3
+ use super::model::ObjectCandidate;
4
+ use crate::quality::scanner::{stable_fingerprint, FileAnalysis, SymbolAnalysis};
5
+
6
+ pub(super) fn extract_object_candidates(file: &FileAnalysis) -> Vec<ObjectCandidate> {
7
+ let mut candidates = Vec::new();
8
+ let mut start: Option<usize> = None;
9
+ let mut depth = 0isize;
10
+
11
+ for (index, line) in file.raw_lines.iter().enumerate() {
12
+ let delta = brace_delta(line);
13
+ if start.is_none() && line.contains('{') && looks_like_object_start(line) {
14
+ start = Some(index);
15
+ }
16
+ if start.is_some() {
17
+ depth += delta;
18
+ if depth <= 0 {
19
+ if let Some(start_index) = start.take() {
20
+ push_candidate(file, start_index, index, &mut candidates);
21
+ }
22
+ depth = 0;
23
+ }
24
+ }
25
+ }
26
+
27
+ candidates
28
+ }
29
+
30
+ fn push_candidate(
31
+ file: &FileAnalysis,
32
+ start_index: usize,
33
+ end_index: usize,
34
+ candidates: &mut Vec<ObjectCandidate>,
35
+ ) {
36
+ let line_count = end_index.saturating_sub(start_index) + 1;
37
+ if line_count < 8 {
38
+ return;
39
+ }
40
+ let keys = extract_keys(&file.raw_lines[start_index..=end_index]);
41
+ if keys.len() < 3 {
42
+ return;
43
+ }
44
+ let shape = keys.iter().cloned().collect::<Vec<_>>().join("|");
45
+ let shape_hash = stable_fingerprint(&["semantic-shape", &shape]);
46
+ let start_line = start_index + 1;
47
+ candidates.push(ObjectCandidate {
48
+ path: file.path.clone(),
49
+ start_line,
50
+ end_line: end_index + 1,
51
+ symbol: nearest_symbol(&file.symbols, start_line),
52
+ keys,
53
+ shape_hash,
54
+ line_count,
55
+ in_test_context: is_test_context(&file.path),
56
+ });
57
+ }
58
+
59
+ fn extract_keys(lines: &[String]) -> BTreeSet<String> {
60
+ lines
61
+ .iter()
62
+ .filter_map(|line| key_from_line(line))
63
+ .collect::<BTreeSet<_>>()
64
+ }
65
+
66
+ fn key_from_line(line: &str) -> Option<String> {
67
+ let trimmed = line.trim().trim_start_matches(['{', ',', '[']);
68
+ if trimmed.starts_with("//") || trimmed.starts_with('#') {
69
+ return None;
70
+ }
71
+ let (raw_key, _) = trimmed.split_once(':')?;
72
+ let key = raw_key.trim().trim_matches('"').trim_matches('\'').trim();
73
+ let valid = !key.is_empty()
74
+ && key.len() <= 64
75
+ && key.chars().all(|character| {
76
+ character.is_ascii_alphanumeric() || character == '_' || character == '-'
77
+ });
78
+ valid.then(|| key.to_ascii_lowercase())
79
+ }
80
+
81
+ fn brace_delta(line: &str) -> isize {
82
+ let mut delta = 0isize;
83
+ let mut in_string = false;
84
+ let mut quote = '\0';
85
+ let mut escaped = false;
86
+ for character in line.chars() {
87
+ if in_string {
88
+ if escaped {
89
+ escaped = false;
90
+ } else if character == '\\' {
91
+ escaped = true;
92
+ } else if character == quote {
93
+ in_string = false;
94
+ }
95
+ continue;
96
+ }
97
+ if character == '"' || character == '\'' || character == '`' {
98
+ in_string = true;
99
+ quote = character;
100
+ } else if character == '{' {
101
+ delta += 1;
102
+ } else if character == '}' {
103
+ delta -= 1;
104
+ }
105
+ }
106
+ delta
107
+ }
108
+
109
+ fn looks_like_object_start(line: &str) -> bool {
110
+ let trimmed = line.trim();
111
+ if starts_code_block(trimmed) {
112
+ return false;
113
+ }
114
+ trimmed.starts_with('{')
115
+ || trimmed.ends_with('{')
116
+ || trimmed.contains("= {")
117
+ || trimmed.contains(": {")
118
+ || trimmed.contains("({")
119
+ }
120
+
121
+ fn starts_code_block(trimmed: &str) -> bool {
122
+ [
123
+ "fn ",
124
+ "pub fn ",
125
+ "function ",
126
+ "async function ",
127
+ "if ",
128
+ "for ",
129
+ "while ",
130
+ "match ",
131
+ "impl ",
132
+ "mod ",
133
+ "class ",
134
+ "struct ",
135
+ "enum ",
136
+ ]
137
+ .iter()
138
+ .any(|prefix| trimmed.starts_with(prefix))
139
+ }
140
+
141
+ fn nearest_symbol(symbols: &[SymbolAnalysis], line: usize) -> Option<String> {
142
+ symbols
143
+ .iter()
144
+ .filter(|symbol| symbol.start_line <= line)
145
+ .max_by_key(|symbol| symbol.start_line)
146
+ .map(|symbol| symbol.name.clone())
147
+ .filter(|name| !name.is_empty())
148
+ }
149
+
150
+ fn is_test_context(path: &str) -> bool {
151
+ let lower = path.to_ascii_lowercase();
152
+ lower.contains("/test")
153
+ || lower.contains("test/")
154
+ || lower.contains(".test.")
155
+ || lower.contains(".spec.")
156
+ || lower.contains("fixture")
157
+ || lower.contains("support")
158
+ }
@@ -0,0 +1,85 @@
1
+ use std::collections::BTreeSet;
2
+
3
+ use serde::Serialize;
4
+
5
+ #[derive(Debug, Clone, Serialize)]
6
+ #[serde(rename_all = "camelCase")]
7
+ pub struct SemanticReport {
8
+ pub schema: String,
9
+ pub mode: String,
10
+ pub ok: bool,
11
+ pub changed_paths: Vec<String>,
12
+ pub summary: SemanticSummary,
13
+ pub findings: Vec<SemanticFinding>,
14
+ }
15
+
16
+ #[derive(Debug, Clone, Serialize)]
17
+ #[serde(rename_all = "camelCase")]
18
+ pub struct SemanticSummary {
19
+ pub scanned_files: usize,
20
+ pub scanned_path_count: usize,
21
+ pub finding_count: usize,
22
+ pub blocking_finding_count: usize,
23
+ pub truncated: bool,
24
+ pub reason_codes: Vec<String>,
25
+ }
26
+
27
+ #[derive(Debug, Clone, Serialize)]
28
+ #[serde(rename_all = "camelCase")]
29
+ pub struct SemanticFinding {
30
+ pub id: String,
31
+ pub kind: String,
32
+ pub confidence: f64,
33
+ pub severity: String,
34
+ pub mode: String,
35
+ pub summary: String,
36
+ pub occurrences: Vec<SemanticOccurrence>,
37
+ pub cleanup_route: SemanticCleanupRoute,
38
+ }
39
+
40
+ #[derive(Debug, Clone, Serialize)]
41
+ #[serde(rename_all = "camelCase")]
42
+ pub struct SemanticOccurrence {
43
+ pub path: String,
44
+ pub start_line: usize,
45
+ pub end_line: usize,
46
+ pub symbol: Option<String>,
47
+ pub shape_hash: String,
48
+ pub key_count: usize,
49
+ pub line_count: usize,
50
+ }
51
+
52
+ #[derive(Debug, Clone, Serialize)]
53
+ #[serde(rename_all = "camelCase")]
54
+ pub struct SemanticCleanupRoute {
55
+ pub intent: String,
56
+ pub target_suggestion: String,
57
+ pub agent_instructions: Vec<String>,
58
+ pub required_checks: Vec<String>,
59
+ }
60
+
61
+ #[derive(Debug, Clone)]
62
+ pub(super) struct ObjectCandidate {
63
+ pub path: String,
64
+ pub start_line: usize,
65
+ pub end_line: usize,
66
+ pub symbol: Option<String>,
67
+ pub keys: BTreeSet<String>,
68
+ pub shape_hash: String,
69
+ pub line_count: usize,
70
+ pub in_test_context: bool,
71
+ }
72
+
73
+ impl ObjectCandidate {
74
+ pub(super) fn occurrence(&self) -> SemanticOccurrence {
75
+ SemanticOccurrence {
76
+ path: self.path.clone(),
77
+ start_line: self.start_line,
78
+ end_line: self.end_line,
79
+ symbol: self.symbol.clone(),
80
+ shape_hash: self.shape_hash.clone(),
81
+ key_count: self.keys.len(),
82
+ line_count: self.line_count,
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,52 @@
1
+ use super::model::{SemanticCleanupRoute, SemanticOccurrence};
2
+ use crate::quality::scanner::QualityContext;
3
+ use crate::quality::types::QualityMode;
4
+
5
+ pub(super) fn finding_mode(context: &QualityContext) -> &'static str {
6
+ match context.mode {
7
+ QualityMode::ChangedFast => "changed-blocking",
8
+ QualityMode::Report => "report",
9
+ QualityMode::DeepReport => "deep-report",
10
+ }
11
+ }
12
+
13
+ pub(super) fn cleanup_route(
14
+ intent: &str,
15
+ occurrences: &[SemanticOccurrence],
16
+ first_instruction: &str,
17
+ ) -> SemanticCleanupRoute {
18
+ let target_suggestion = target_suggestion(occurrences);
19
+ SemanticCleanupRoute {
20
+ intent: intent.to_string(),
21
+ target_suggestion,
22
+ agent_instructions: vec![
23
+ first_instruction.to_string(),
24
+ "Update every occurrence listed in this finding group; do not leave parallel inline copies behind.".to_string(),
25
+ "Preserve behavior and keep version-specific or schema-specific differences explicit as parameters or named fixtures.".to_string(),
26
+ "Do not auto-remove compatibility coverage unless focused tests prove the behavior remains covered.".to_string(),
27
+ ],
28
+ required_checks: vec![
29
+ "naome semantic check --changed".to_string(),
30
+ "naome quality check --changed".to_string(),
31
+ "git diff --check".to_string(),
32
+ ],
33
+ }
34
+ }
35
+
36
+ fn target_suggestion(occurrences: &[SemanticOccurrence]) -> String {
37
+ let Some(first) = occurrences.first() else {
38
+ return "test-support".to_string();
39
+ };
40
+ let directory = first
41
+ .path
42
+ .rsplit_once('/')
43
+ .map(|(directory, _)| directory)
44
+ .unwrap_or(".");
45
+ if directory == "." {
46
+ "test-support".to_string()
47
+ } else if directory.contains("script") {
48
+ format!("{directory}/test-support")
49
+ } else {
50
+ format!("{directory}/fixtures")
51
+ }
52
+ }
@@ -0,0 +1,68 @@
1
+ mod checks;
2
+ mod extract;
3
+ mod model;
4
+ mod route;
5
+
6
+ use checks::{copied_config_findings, inline_legacy_fixture_findings};
7
+ use extract::extract_object_candidates;
8
+ pub use model::{SemanticFinding, SemanticReport};
9
+
10
+ use super::scanner::QualityContext;
11
+ use model::SemanticSummary;
12
+
13
+ pub fn run_semantic_checks(context: &QualityContext) -> SemanticReport {
14
+ let mut candidates = context
15
+ .files
16
+ .iter()
17
+ .flat_map(extract_object_candidates)
18
+ .collect::<Vec<_>>();
19
+ candidates.sort_by(|left, right| {
20
+ left.path
21
+ .cmp(&right.path)
22
+ .then(left.start_line.cmp(&right.start_line))
23
+ .then(left.shape_hash.cmp(&right.shape_hash))
24
+ });
25
+
26
+ let mut findings = Vec::new();
27
+ findings.extend(copied_config_findings(context, &candidates));
28
+ findings.extend(inline_legacy_fixture_findings(context, &candidates));
29
+ findings.sort_by(|left, right| {
30
+ left.kind
31
+ .cmp(&right.kind)
32
+ .then(left.occurrences[0].path.cmp(&right.occurrences[0].path))
33
+ .then(
34
+ left.occurrences[0]
35
+ .start_line
36
+ .cmp(&right.occurrences[0].start_line),
37
+ )
38
+ .then(left.id.cmp(&right.id))
39
+ });
40
+
41
+ let blocking_finding_count = findings.len();
42
+ SemanticReport {
43
+ schema: "naome.semantic-legacy-report.v1".to_string(),
44
+ mode: context.mode.as_str().to_string(),
45
+ ok: blocking_finding_count == 0,
46
+ changed_paths: context.changed_paths.clone(),
47
+ summary: SemanticSummary {
48
+ scanned_files: context.files.len(),
49
+ scanned_path_count: context.scanned_paths().len(),
50
+ finding_count: findings.len(),
51
+ blocking_finding_count,
52
+ truncated: context.truncated,
53
+ reason_codes: context.reason_codes.clone(),
54
+ },
55
+ findings,
56
+ }
57
+ }
58
+
59
+ pub fn semantic_route_for_finding(
60
+ report: &SemanticReport,
61
+ finding_id: &str,
62
+ ) -> Option<SemanticFinding> {
63
+ report
64
+ .findings
65
+ .iter()
66
+ .find(|finding| finding.id == finding_id)
67
+ .cloned()
68
+ }
@@ -0,0 +1,84 @@
1
+ use crate::models::NaomeError;
2
+
3
+ use crate::quality::adapter_support::{
4
+ detected_ids, detects_javascript_typescript_project, detects_rust_project, extend_unique,
5
+ find_adapter_by_id, validate_ids, AdapterDescriptor, RepoSignals,
6
+ };
7
+
8
+ use super::model::RepositoryStructureConfig;
9
+
10
+ const CONFIG_PATH: &str = ".naome/repository-structure.json";
11
+
12
+ struct StructureAdapter {
13
+ id: &'static str,
14
+ detect: fn(&RepoSignals<'_>) -> bool,
15
+ source_roots: &'static [&'static str],
16
+ test_roots: &'static [&'static str],
17
+ module_roots: &'static [&'static str],
18
+ allowed_root_files: &'static [&'static str],
19
+ }
20
+
21
+ impl AdapterDescriptor for StructureAdapter {
22
+ fn id(&self) -> &'static str {
23
+ self.id
24
+ }
25
+
26
+ fn detects(&self, signals: &RepoSignals<'_>) -> bool {
27
+ (self.detect)(signals)
28
+ }
29
+ }
30
+
31
+ pub fn detected_structure_adapter_ids(paths: &[String]) -> Vec<String> {
32
+ detected_ids(paths, registry())
33
+ }
34
+
35
+ pub fn apply_structure_adapters(
36
+ mut config: RepositoryStructureConfig,
37
+ ) -> Result<RepositoryStructureConfig, NaomeError> {
38
+ validate_structure_adapter_ids(&config.enabled_adapters)?;
39
+ for adapter_id in config.enabled_adapters.clone() {
40
+ let adapter = find_adapter_by_id(registry(), &adapter_id, CONFIG_PATH)?;
41
+ extend_unique(&mut config.source_roots, adapter.source_roots);
42
+ extend_unique(&mut config.test_roots, adapter.test_roots);
43
+ extend_unique(&mut config.module_roots, adapter.module_roots);
44
+ extend_unique(&mut config.allowed_root_files, adapter.allowed_root_files);
45
+ }
46
+ Ok(config)
47
+ }
48
+
49
+ pub fn validate_structure_adapter_ids(ids: &[String]) -> Result<(), NaomeError> {
50
+ validate_ids(ids, registry(), CONFIG_PATH)
51
+ }
52
+
53
+ fn registry() -> &'static [StructureAdapter] {
54
+ &[
55
+ StructureAdapter {
56
+ id: "rust",
57
+ detect: detects_rust_project,
58
+ source_roots: &["src/**", "crates/*/src/**", "**/crates/*/src/**"],
59
+ test_roots: &["tests/**", "crates/*/tests/**", "**/crates/*/tests/**"],
60
+ module_roots: &["src/**", "crates/*/src/**", "**/crates/*/src/**"],
61
+ allowed_root_files: &["Cargo.toml", "Cargo.lock"],
62
+ },
63
+ StructureAdapter {
64
+ id: "javascript-typescript",
65
+ detect: detects_javascript_typescript_project,
66
+ source_roots: &[
67
+ "src/**",
68
+ "app/**",
69
+ "pages/**",
70
+ "components/**",
71
+ "packages/*/src/**",
72
+ "**/packages/*/src/**",
73
+ ],
74
+ test_roots: &["test/**", "tests/**", "__tests__/**", "packages/*/tests/**"],
75
+ module_roots: &["src/**", "app/**", "packages/*/src/**"],
76
+ allowed_root_files: &[
77
+ "package.json",
78
+ "tsconfig.json",
79
+ "vite.config.ts",
80
+ "next.config.js",
81
+ ],
82
+ },
83
+ ]
84
+ }
@@ -0,0 +1,153 @@
1
+ use crate::paths;
2
+ use crate::quality::types::{QualityMode, QualityViolation};
3
+
4
+ use super::{applies, push};
5
+ use crate::quality::structure::model::{DirectoryRoleRule, RepositoryStructureModel};
6
+
7
+ pub(super) fn root_file_sprawl(
8
+ model: &RepositoryStructureModel,
9
+ mode: QualityMode,
10
+ violations: &mut Vec<QualityViolation>,
11
+ ) {
12
+ for path in model
13
+ .paths
14
+ .iter()
15
+ .filter(|path| applies(path, mode) && path.segments.len() == 1)
16
+ {
17
+ if paths::matches_any(&path.explanation.path, &model.config.allowed_root_files) {
18
+ continue;
19
+ }
20
+ push_single_path(
21
+ "root-file-sprawl",
22
+ &path.explanation.path,
23
+ format!(
24
+ "{} is a root-level file without a recognized root role; place new work inside a module, docs, config, or script directory.",
25
+ path.explanation.path
26
+ ),
27
+ violations,
28
+ );
29
+ }
30
+ }
31
+
32
+ pub(super) fn misplaced_files(
33
+ model: &RepositoryStructureModel,
34
+ mode: QualityMode,
35
+ violations: &mut Vec<QualityViolation>,
36
+ ) {
37
+ for path in model.paths.iter().filter(|path| applies(path, mode)) {
38
+ let role = path.explanation.role.as_str();
39
+ let misplaced_test =
40
+ role == "test" && !paths::matches_any(&path.explanation.path, &model.config.test_roots);
41
+ if misplaced_test {
42
+ push_single_path(
43
+ "misplaced-file-role",
44
+ &path.explanation.path,
45
+ format!(
46
+ "{} is test code outside a configured test root.",
47
+ path.explanation.path
48
+ ),
49
+ violations,
50
+ );
51
+ }
52
+ }
53
+ }
54
+
55
+ pub(super) fn directory_role_mixing(
56
+ model: &RepositoryStructureModel,
57
+ mode: QualityMode,
58
+ violations: &mut Vec<QualityViolation>,
59
+ ) {
60
+ for path in model.paths.iter().filter(|path| {
61
+ applies(path, mode)
62
+ && matches!(path.explanation.role.as_str(), "generated" | "artifact")
63
+ && paths::matches_any(&path.explanation.path, &model.config.source_roots)
64
+ }) {
65
+ if role_rule_allows_path(model, &path.explanation.path, &path.explanation.role) {
66
+ continue;
67
+ }
68
+ push_single_path(
69
+ "directory-role-mixing",
70
+ &path.explanation.path,
71
+ format!(
72
+ "{} is {} content under a source root.",
73
+ path.explanation.path, path.explanation.role
74
+ ),
75
+ violations,
76
+ );
77
+ }
78
+
79
+ for directory in &model.directories {
80
+ let rule = directory_role_rule_for(model, &directory.path);
81
+ let allowed_by_rule = rule
82
+ .filter(|rule| !rule.allowed_roles.is_empty())
83
+ .is_some_and(|rule| {
84
+ directory
85
+ .roles
86
+ .iter()
87
+ .all(|role| rule.allowed_roles.iter().any(|allowed| allowed == role))
88
+ });
89
+ let max_roles = rule
90
+ .and_then(|rule| rule.max_roles)
91
+ .unwrap_or(model.config.limits.max_directory_roles);
92
+ let incompatible = directory.roles.contains("source")
93
+ && (directory.roles.contains("generated")
94
+ || directory.roles.contains("artifact")
95
+ || directory.roles.contains("dependency/vendor"));
96
+ let too_many_roles = directory.roles.len() > max_roles
97
+ && (directory.roles.contains("source")
98
+ || directory.roles.contains("generated")
99
+ || directory.roles.contains("artifact"));
100
+ if (!incompatible || allowed_by_rule) && !too_many_roles {
101
+ continue;
102
+ }
103
+ for path in model.paths.iter().filter(|path| {
104
+ path.explanation.directory == directory.path
105
+ && applies(path, mode)
106
+ && path.explanation.role != "source"
107
+ }) {
108
+ push(
109
+ "directory-role-mixing",
110
+ &path.explanation.path,
111
+ format!(
112
+ "{} mixes incompatible directory roles: {}.",
113
+ directory.path,
114
+ directory
115
+ .roles
116
+ .iter()
117
+ .cloned()
118
+ .collect::<Vec<_>>()
119
+ .join(", ")
120
+ ),
121
+ vec![],
122
+ violations,
123
+ );
124
+ }
125
+ }
126
+ }
127
+
128
+ fn role_rule_allows_path(model: &RepositoryStructureModel, path: &str, role: &str) -> bool {
129
+ model.config.directory_role_rules.iter().any(|rule| {
130
+ !rule.allowed_roles.is_empty()
131
+ && paths::matches_any(path, &rule.paths)
132
+ && rule.allowed_roles.iter().any(|allowed| allowed == role)
133
+ })
134
+ }
135
+
136
+ fn directory_role_rule_for<'a>(
137
+ model: &'a RepositoryStructureModel,
138
+ directory: &str,
139
+ ) -> Option<&'a DirectoryRoleRule> {
140
+ model.config.directory_role_rules.iter().find(|rule| {
141
+ paths::matches_any(directory, &rule.paths)
142
+ || paths::matches_any(&format!("{directory}/_"), &rule.paths)
143
+ })
144
+ }
145
+
146
+ fn push_single_path(
147
+ check_id: &str,
148
+ path: &str,
149
+ message: String,
150
+ violations: &mut Vec<QualityViolation>,
151
+ ) {
152
+ push(check_id, path, message, vec![], violations);
153
+ }