@lamentis/naome 1.3.16 → 1.3.17

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 (40) 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 +5 -4
  4. package/crates/naome-cli/src/architecture_init/infer.rs +131 -0
  5. package/crates/naome-cli/src/architecture_init/render.rs +56 -0
  6. package/crates/naome-cli/src/architecture_init/repository.rs +59 -0
  7. package/crates/naome-cli/src/architecture_init.rs +17 -0
  8. package/crates/naome-cli/src/main.rs +1 -0
  9. package/crates/naome-cli/tests/architecture_cli.rs +75 -0
  10. package/crates/naome-core/Cargo.toml +1 -1
  11. package/crates/naome-core/src/architecture/config_findings/configuration/coverage.rs +81 -0
  12. package/crates/naome-core/src/architecture/config_findings/configuration/overlap.rs +117 -0
  13. package/crates/naome-core/src/architecture/config_findings/configuration.rs +12 -0
  14. package/crates/naome-core/src/architecture/config_findings/imports.rs +30 -0
  15. package/crates/naome-core/src/architecture/config_findings.rs +50 -0
  16. package/crates/naome-core/src/architecture/explain.rs +45 -0
  17. package/crates/naome-core/src/architecture/output.rs +22 -162
  18. package/crates/naome-core/src/architecture/rules.rs +4 -3
  19. package/crates/naome-core/src/architecture/scan/cache.rs +1 -1
  20. package/crates/naome-core/src/architecture/scan/imports/resolver/candidates.rs +71 -0
  21. package/crates/naome-core/src/architecture/scan/imports/resolver/js_ts_alias.rs +241 -0
  22. package/crates/naome-core/src/architecture/scan/imports/resolver.rs +162 -91
  23. package/crates/naome-core/src/architecture/scan.rs +20 -6
  24. package/crates/naome-core/src/architecture.rs +7 -3
  25. package/crates/naome-core/src/lib.rs +1 -0
  26. package/crates/naome-core/tests/architecture.rs +30 -0
  27. package/crates/naome-core/tests/architecture_acceptance.rs +304 -0
  28. package/crates/naome-core/tests/architecture_aliases.rs +101 -0
  29. package/crates/naome-core/tests/architecture_cache.rs +57 -0
  30. package/crates/naome-core/tests/architecture_config.rs +87 -0
  31. package/crates/naome-core/tests/architecture_rules.rs +32 -0
  32. package/crates/naome-core/tests/architecture_unresolved.rs +36 -0
  33. package/native/darwin-arm64/naome +0 -0
  34. package/native/linux-x64/naome +0 -0
  35. package/package.json +1 -1
  36. package/templates/naome-root/.naome/bin/check-harness-health.js +1 -0
  37. package/templates/naome-root/.naome/bin/check-task-state.js +1 -0
  38. package/templates/naome-root/.naome/manifest.json +1 -1
  39. package/templates/naome-root/.naome/verification.json +6 -1
  40. package/templates/naome-root/docs/naome/architecture-fitness.md +68 -51
@@ -0,0 +1,50 @@
1
+ mod configuration;
2
+ mod imports;
3
+
4
+ use super::output::{ArchitectureAgentFeedback, ArchitectureConfigFinding};
5
+ use super::scan::ArchitectureScanReport;
6
+
7
+ pub fn architecture_config_agent_feedback(
8
+ findings: &[ArchitectureConfigFinding],
9
+ ) -> Vec<ArchitectureAgentFeedback> {
10
+ findings
11
+ .iter()
12
+ .filter(|finding| finding.id == "arch.import.unresolved")
13
+ .map(|finding| ArchitectureAgentFeedback {
14
+ problem: finding.message.clone(),
15
+ repair: finding.suggestion.clone(),
16
+ files: finding
17
+ .subject
18
+ .strip_prefix("file:")
19
+ .map(|path| vec![path.to_string()])
20
+ .unwrap_or_default(),
21
+ must_not_do: vec![
22
+ "Do not leave unresolved imports hidden from architecture validation.".to_string(),
23
+ "Do not suppress architecture findings without an explicit ignore reason."
24
+ .to_string(),
25
+ ],
26
+ })
27
+ .collect()
28
+ }
29
+
30
+ pub fn config_findings_for(scan: &ArchitectureScanReport) -> Vec<ArchitectureConfigFinding> {
31
+ let mut findings = Vec::new();
32
+ findings.extend(configuration::findings(scan));
33
+ findings.extend(imports::findings(scan));
34
+ findings.sort_by(|left, right| {
35
+ (
36
+ left.id.as_str(),
37
+ left.subject.as_str(),
38
+ left.message.as_str(),
39
+ )
40
+ .cmp(&(
41
+ right.id.as_str(),
42
+ right.subject.as_str(),
43
+ right.message.as_str(),
44
+ ))
45
+ });
46
+ findings.dedup_by(|left, right| {
47
+ left.id == right.id && left.subject == right.subject && left.message == right.message
48
+ });
49
+ findings
50
+ }
@@ -0,0 +1,45 @@
1
+ use super::config_findings::config_findings_for;
2
+ use super::output::ARCHITECTURE_RULE_IDS;
3
+ use super::scan::ArchitectureScanReport;
4
+
5
+ pub fn format_architecture_explain(scan: &ArchitectureScanReport) -> String {
6
+ let findings = config_findings_for(scan);
7
+ let layers = scan
8
+ .config
9
+ .layers
10
+ .keys()
11
+ .cloned()
12
+ .collect::<Vec<_>>()
13
+ .join(", ");
14
+ let contexts = scan
15
+ .config
16
+ .contexts
17
+ .keys()
18
+ .cloned()
19
+ .collect::<Vec<_>>()
20
+ .join(", ");
21
+ let mut output = format!(
22
+ "NAOME Architecture Fitness\nrules: {}\nlayers: {}\ncontexts: {}\npath extractor: enabled for every repository\nimport extractors: typescript, javascript, rust, python, go, swift\n",
23
+ ARCHITECTURE_RULE_IDS.join(", "),
24
+ empty_label(&layers),
25
+ empty_label(&contexts)
26
+ );
27
+ if !findings.is_empty() {
28
+ output.push_str(&format!("configuration findings: {}\n", findings.len()));
29
+ for finding in findings.iter().take(5) {
30
+ output.push_str(&format!(
31
+ "- {} {}: {}\n",
32
+ finding.severity, finding.subject, finding.message
33
+ ));
34
+ }
35
+ }
36
+ output
37
+ }
38
+
39
+ fn empty_label(value: &str) -> &str {
40
+ if value.is_empty() {
41
+ "<none>"
42
+ } else {
43
+ value
44
+ }
45
+ }
@@ -86,138 +86,40 @@ pub fn architecture_agent_feedback(
86
86
  .map(|violation| ArchitectureAgentFeedback {
87
87
  problem: violation.message.clone(),
88
88
  repair: violation.suggestion.clone(),
89
- files: violation.path.iter().cloned().collect(),
90
- must_not_do: vec![
91
- "Do not suppress architecture rules without an explicit ignore reason.".to_string(),
92
- "Do not edit generated files unless the architecture config allows it.".to_string(),
93
- ],
89
+ files: feedback_files(violation),
90
+ must_not_do: feedback_must_not_do(violation),
94
91
  })
95
92
  .collect()
96
93
  }
97
94
 
98
- pub fn config_findings_for(scan: &ArchitectureScanReport) -> Vec<ArchitectureConfigFinding> {
99
- let mut findings = Vec::new();
100
- findings.extend(broad_layer_findings(scan));
101
- findings.extend(catch_all_context_findings(scan));
102
- findings.sort_by(|left, right| {
103
- (left.id.as_str(), left.subject.as_str()).cmp(&(right.id.as_str(), right.subject.as_str()))
104
- });
105
- findings.dedup_by(|left, right| left.id == right.id && left.subject == right.subject);
106
- findings
107
- }
108
-
109
- fn broad_layer_findings(scan: &ArchitectureScanReport) -> Vec<ArchitectureConfigFinding> {
110
- let mut findings = Vec::new();
111
- for (layer_name, layer) in &scan.config.layers {
112
- let Some(broad_pattern) = layer
113
- .paths
114
- .iter()
115
- .find(|pattern| is_broad_source_pattern(pattern))
95
+ fn feedback_files(violation: &ArchitectureViolation) -> Vec<String> {
96
+ let mut files = Vec::new();
97
+ if let Some(path) = &violation.path {
98
+ files.push(path.clone());
99
+ }
100
+ for endpoint in [&violation.from, &violation.to] {
101
+ let Some(path) = endpoint
102
+ .as_deref()
103
+ .and_then(|value| value.strip_prefix("file:"))
116
104
  else {
117
105
  continue;
118
106
  };
119
- let overlapping_layers = scan
120
- .config
121
- .layers
122
- .iter()
123
- .filter(|(other_name, other_layer)| {
124
- other_name.as_str() != layer_name.as_str()
125
- && other_layer
126
- .paths
127
- .iter()
128
- .any(|pattern| pattern_is_narrower_than(pattern, broad_pattern))
129
- })
130
- .map(|(name, _)| name.clone())
131
- .collect::<Vec<_>>();
132
- if overlapping_layers.is_empty() {
133
- continue;
107
+ if !files.iter().any(|existing| existing == path) {
108
+ files.push(path.to_string());
134
109
  }
135
- findings.push(ArchitectureConfigFinding {
136
- id: "arch.config.broad_layer_overlap".to_string(),
137
- severity: "warning".to_string(),
138
- subject: format!("layer:{layer_name}"),
139
- message: format!(
140
- "Layer {layer_name} uses broad path pattern {broad_pattern} while narrower layers also match inside it: {}.",
141
- overlapping_layers.join(", ")
142
- ),
143
- suggestion: format!(
144
- "Keep broad compatibility layers intentional and make allowed_dependencies explicit, or narrow {layer_name} so files do not inherit multiple architectural meanings by default."
145
- ),
146
- agent_instruction: format!(
147
- "Do not rely on broad layer {layer_name} to hide architecture boundaries; prefer narrower layer paths or explicit allow rules."
148
- ),
149
- });
150
110
  }
151
- findings
152
- }
153
-
154
- fn catch_all_context_findings(scan: &ArchitectureScanReport) -> Vec<ArchitectureConfigFinding> {
155
- if scan.config.contexts.len() <= 1 {
156
- return Vec::new();
157
- }
158
- scan.config
159
- .contexts
160
- .iter()
161
- .filter(|(_, context)| {
162
- context
163
- .paths
164
- .iter()
165
- .any(|pattern| is_broad_source_pattern(pattern))
166
- })
167
- .filter_map(|(context_name, context)| {
168
- let broad_pattern = context
169
- .paths
170
- .iter()
171
- .find(|pattern| is_broad_source_pattern(pattern))?;
172
- let specific_contexts = scan
173
- .config
174
- .contexts
175
- .iter()
176
- .filter(|(other_name, other_context)| {
177
- other_name.as_str() != context_name.as_str()
178
- && other_context
179
- .paths
180
- .iter()
181
- .any(|pattern| pattern_is_narrower_than(pattern, broad_pattern))
182
- })
183
- .map(|(name, _)| name.clone())
184
- .collect::<Vec<_>>();
185
- if specific_contexts.is_empty() {
186
- return None;
187
- }
188
- Some(ArchitectureConfigFinding {
189
- id: "arch.config.catch_all_context_with_specific_contexts".to_string(),
190
- severity: "warning".to_string(),
191
- subject: format!("context:{context_name}"),
192
- message: format!(
193
- "Context {context_name} uses catch-all path pattern {broad_pattern} alongside narrower contexts: {}.",
194
- specific_contexts.join(", ")
195
- ),
196
- suggestion: format!(
197
- "Treat {context_name} as a compatibility bucket only. Move shared code into explicit public APIs or replace the catch-all context with narrower bounded contexts."
198
- ),
199
- agent_instruction: format!(
200
- "Do not classify cross-context imports as safe only because {context_name} also matches them; model the real bounded context or public API."
201
- ),
202
- })
203
- })
204
- .collect()
111
+ files
205
112
  }
206
113
 
207
- fn is_broad_source_pattern(pattern: &str) -> bool {
208
- matches!(pattern, "**" | "**/*" | "src/**" | "packages/**/src/**")
209
- }
210
-
211
- fn pattern_is_narrower_than(pattern: &str, broad_pattern: &str) -> bool {
212
- if pattern == broad_pattern {
213
- return false;
214
- }
215
- match broad_pattern {
216
- "**" | "**/*" => true,
217
- "src/**" => pattern.starts_with("src/") && pattern != "src/**",
218
- "packages/**/src/**" => pattern.starts_with("packages/") && pattern.contains("/src/"),
219
- _ => false,
114
+ fn feedback_must_not_do(violation: &ArchitectureViolation) -> Vec<String> {
115
+ let mut items = vec![
116
+ "Do not suppress architecture rules without an explicit ignore reason.".to_string(),
117
+ "Do not edit generated files unless the architecture config allows it.".to_string(),
118
+ ];
119
+ if violation.id == "arch.no_forbidden_layer_dependencies" {
120
+ items.push(violation.agent_instruction.clone());
220
121
  }
122
+ items
221
123
  }
222
124
 
223
125
  pub fn summary_for(violations: &[ArchitectureViolation]) -> ViolationSummary {
@@ -314,45 +216,3 @@ pub fn format_architecture_scan(report: &ArchitectureScanReport) -> String {
314
216
  }
315
217
  output
316
218
  }
317
-
318
- pub fn format_architecture_explain(scan: &ArchitectureScanReport) -> String {
319
- let findings = config_findings_for(scan);
320
- let layers = scan
321
- .config
322
- .layers
323
- .keys()
324
- .cloned()
325
- .collect::<Vec<_>>()
326
- .join(", ");
327
- let contexts = scan
328
- .config
329
- .contexts
330
- .keys()
331
- .cloned()
332
- .collect::<Vec<_>>()
333
- .join(", ");
334
- let mut output = format!(
335
- "NAOME Architecture Fitness\nrules: {}\nlayers: {}\ncontexts: {}\npath extractor: enabled for every repository\nimport extractors: typescript, javascript, rust, python, go, swift\n",
336
- ARCHITECTURE_RULE_IDS.join(", "),
337
- empty_label(&layers),
338
- empty_label(&contexts)
339
- );
340
- if !findings.is_empty() {
341
- output.push_str(&format!("configuration findings: {}\n", findings.len()));
342
- for finding in findings.iter().take(5) {
343
- output.push_str(&format!(
344
- "- {} {}: {}\n",
345
- finding.severity, finding.subject, finding.message
346
- ));
347
- }
348
- }
349
- output
350
- }
351
-
352
- fn empty_label(value: &str) -> &str {
353
- if value.is_empty() {
354
- "<none>"
355
- } else {
356
- value
357
- }
358
- }
@@ -1,9 +1,9 @@
1
1
  use crate::paths;
2
2
 
3
+ use super::config_findings::{architecture_config_agent_feedback, config_findings_for};
3
4
  use super::model::Severity;
4
5
  use super::output::{
5
- architecture_agent_feedback, config_findings_for, summary_for, ArchitectureValidation,
6
- ArchitectureViolation,
6
+ architecture_agent_feedback, summary_for, ArchitectureValidation, ArchitectureViolation,
7
7
  };
8
8
  use super::scan::ArchitectureScanReport;
9
9
  use crate::architecture::model::ArchitectureEdgeKind;
@@ -44,8 +44,9 @@ pub fn validate_scan(scan: ArchitectureScanReport) -> ArchitectureValidation {
44
44
  });
45
45
  let summary = summary_for(&violations);
46
46
  let status = if summary.errors > 0 { "fail" } else { "pass" }.to_string();
47
- let agent_feedback = architecture_agent_feedback(&violations);
48
47
  let config_findings = config_findings_for(&scan);
48
+ let mut agent_feedback = architecture_agent_feedback(&violations);
49
+ agent_feedback.extend(architecture_config_agent_feedback(&config_findings));
49
50
 
50
51
  ArchitectureValidation {
51
52
  schema: "naome.arch.validation.v1".to_string(),
@@ -9,7 +9,7 @@ use super::FileFact;
9
9
  use crate::models::NaomeError;
10
10
 
11
11
  const CACHE_SCHEMA: &str = "naome.architecture-cache.v1";
12
- const EXTRACTOR_VERSION: &str = "architecture-cache-v1.3.14";
12
+ const EXTRACTOR_VERSION: &str = "architecture-cache-v1.3.17";
13
13
  const CACHE_PATH: &str = ".naome/cache/architecture/cache.json";
14
14
 
15
15
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -0,0 +1,71 @@
1
+ use std::collections::BTreeSet;
2
+
3
+ pub(super) fn resolve_candidates(
4
+ candidate: &str,
5
+ repository_files: &BTreeSet<String>,
6
+ language: Option<&str>,
7
+ ) -> Option<String> {
8
+ candidate_paths(candidate, language)
9
+ .into_iter()
10
+ .find(|path| repository_files.contains(path))
11
+ }
12
+
13
+ pub(super) fn resolve_progressively(
14
+ candidate: &str,
15
+ repository_files: &BTreeSet<String>,
16
+ language: Option<&str>,
17
+ separator: char,
18
+ ) -> Option<String> {
19
+ let mut current = candidate.to_string();
20
+ loop {
21
+ if let Some(path) = resolve_candidates(&current, repository_files, language) {
22
+ return Some(path);
23
+ }
24
+ let Some((parent, _)) = current.rsplit_once(separator) else {
25
+ return None;
26
+ };
27
+ current = parent.to_string();
28
+ }
29
+ }
30
+
31
+ fn candidate_paths(candidate: &str, language: Option<&str>) -> Vec<String> {
32
+ let extensions = extensions_for_language(language);
33
+ let mut candidates = extensions
34
+ .iter()
35
+ .map(|extension| format!("{candidate}{extension}"))
36
+ .collect::<Vec<_>>();
37
+ match language {
38
+ Some("rust") => candidates.push(format!("{candidate}/mod.rs")),
39
+ Some("python") => candidates.push(format!("{candidate}/__init__.py")),
40
+ Some("go") => {}
41
+ Some("typescript") | Some("javascript") => candidates.extend([
42
+ format!("{candidate}/index.ts"),
43
+ format!("{candidate}/index.tsx"),
44
+ format!("{candidate}/index.js"),
45
+ format!("{candidate}/index.jsx"),
46
+ ]),
47
+ _ => candidates.extend([
48
+ format!("{candidate}/index.ts"),
49
+ format!("{candidate}/index.tsx"),
50
+ format!("{candidate}/index.js"),
51
+ format!("{candidate}/index.jsx"),
52
+ format!("{candidate}/mod.rs"),
53
+ format!("{candidate}/__init__.py"),
54
+ ]),
55
+ }
56
+ candidates
57
+ }
58
+
59
+ fn extensions_for_language(language: Option<&str>) -> &'static [&'static str] {
60
+ match language {
61
+ Some("rust") => &["", ".rs"],
62
+ Some("python") => &["", ".py"],
63
+ Some("go") => &["", ".go"],
64
+ Some("swift") => &["", ".swift"],
65
+ Some("typescript") => &["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
66
+ Some("javascript") => &["", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"],
67
+ _ => &[
68
+ "", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs", ".py", ".go", ".swift",
69
+ ],
70
+ }
71
+ }
@@ -0,0 +1,241 @@
1
+ use std::collections::BTreeSet;
2
+ use std::collections::HashSet;
3
+ use std::fs;
4
+ use std::path::{Path, PathBuf};
5
+
6
+ use serde_json::Value;
7
+
8
+ pub(super) enum AliasResolution {
9
+ Resolved(String),
10
+ Unresolved,
11
+ }
12
+
13
+ pub(super) fn resolve(
14
+ root: &Path,
15
+ from_path: &str,
16
+ specifier: &str,
17
+ repository_files: &BTreeSet<String>,
18
+ language: Option<&str>,
19
+ ) -> Option<AliasResolution> {
20
+ for config_path in nearest_configs(from_path, repository_files) {
21
+ let mut seen = HashSet::new();
22
+ for config_path in config_with_extends(root, &config_path, &mut seen) {
23
+ let Some(resolution) =
24
+ resolve_from_config(root, &config_path, specifier, repository_files, language)
25
+ else {
26
+ continue;
27
+ };
28
+ match resolution {
29
+ AliasResolution::Resolved(_) => return Some(resolution),
30
+ AliasResolution::Unresolved => return Some(AliasResolution::Unresolved),
31
+ }
32
+ }
33
+ }
34
+ None
35
+ }
36
+
37
+ fn resolve_from_config(
38
+ root: &Path,
39
+ config_path: &str,
40
+ specifier: &str,
41
+ repository_files: &BTreeSet<String>,
42
+ language: Option<&str>,
43
+ ) -> Option<AliasResolution> {
44
+ let config_dir = Path::new(config_path)
45
+ .parent()
46
+ .unwrap_or_else(|| Path::new(""));
47
+ let raw = read_jsonc(root, config_path)?;
48
+ let compiler_options = raw.get("compilerOptions")?;
49
+ let base_url = compiler_options
50
+ .get("baseUrl")
51
+ .and_then(Value::as_str)
52
+ .unwrap_or(".");
53
+ let paths = compiler_options.get("paths")?.as_object()?;
54
+ let mut matches = paths
55
+ .iter()
56
+ .filter_map(|(alias_pattern, targets)| {
57
+ let capture = alias_capture(alias_pattern, specifier)?;
58
+ Some((
59
+ alias_specificity(alias_pattern),
60
+ alias_pattern,
61
+ capture,
62
+ targets,
63
+ ))
64
+ })
65
+ .collect::<Vec<_>>();
66
+ matches.sort_by(|left, right| {
67
+ right
68
+ .0
69
+ .cmp(&left.0)
70
+ .then_with(|| right.1.len().cmp(&left.1.len()))
71
+ .then_with(|| left.1.cmp(right.1))
72
+ });
73
+
74
+ let (_, _, capture, targets) = matches.into_iter().next()?;
75
+ let Some(targets) = targets.as_array() else {
76
+ return Some(AliasResolution::Unresolved);
77
+ };
78
+ for target in targets.iter().filter_map(Value::as_str) {
79
+ let expanded = target.replace('*', &capture);
80
+ let candidate = super::normalize(config_dir.join(base_url).join(expanded));
81
+ if let Some(path) = super::resolve_candidates(&candidate, repository_files, language) {
82
+ return Some(AliasResolution::Resolved(path));
83
+ }
84
+ }
85
+ Some(AliasResolution::Unresolved)
86
+ }
87
+
88
+ fn read_jsonc(root: &Path, config_path: &str) -> Option<Value> {
89
+ let content = fs::read_to_string(root.join(config_path)).ok()?;
90
+ serde_json::from_str::<Value>(&strip_jsonc(&content)).ok()
91
+ }
92
+
93
+ fn config_with_extends(root: &Path, config_path: &str, seen: &mut HashSet<String>) -> Vec<String> {
94
+ if !seen.insert(config_path.to_string()) {
95
+ return Vec::new();
96
+ }
97
+ let mut configs = vec![config_path.to_string()];
98
+ let Some(raw) = read_jsonc(root, config_path) else {
99
+ return configs;
100
+ };
101
+ let Some(extends) = raw.get("extends").and_then(Value::as_str) else {
102
+ return configs;
103
+ };
104
+ let Some(extended_path) = resolve_extends_path(config_path, extends) else {
105
+ return configs;
106
+ };
107
+ configs.extend(config_with_extends(root, &extended_path, seen));
108
+ configs
109
+ }
110
+
111
+ fn resolve_extends_path(config_path: &str, extends: &str) -> Option<String> {
112
+ if extends.is_empty()
113
+ || extends.starts_with('@')
114
+ || (!extends.starts_with('.') && !extends.starts_with('/'))
115
+ {
116
+ return None;
117
+ }
118
+ let config_dir = Path::new(config_path)
119
+ .parent()
120
+ .unwrap_or_else(|| Path::new(""));
121
+ let mut candidate = if Path::new(extends).is_absolute() {
122
+ PathBuf::from(extends.strip_prefix('/').unwrap_or(extends))
123
+ } else {
124
+ config_dir.join(extends)
125
+ };
126
+ if candidate.extension().is_none() {
127
+ candidate.set_extension("json");
128
+ }
129
+ Some(super::normalize(candidate))
130
+ }
131
+
132
+ fn strip_jsonc(content: &str) -> String {
133
+ let mut stripped = String::with_capacity(content.len());
134
+ let mut chars = content.chars().peekable();
135
+ let mut in_string = false;
136
+ let mut escaped = false;
137
+ while let Some(character) = chars.next() {
138
+ if in_string {
139
+ if character == '"' && !escaped {
140
+ in_string = false;
141
+ }
142
+ stripped.push(character);
143
+ escaped = character == '\\' && !escaped;
144
+ continue;
145
+ }
146
+ if character == '"' {
147
+ in_string = true;
148
+ stripped.push(character);
149
+ continue;
150
+ }
151
+ if character == '/' && chars.peek() == Some(&'/') {
152
+ chars.next();
153
+ for next in chars.by_ref() {
154
+ if next == '\n' {
155
+ stripped.push('\n');
156
+ break;
157
+ }
158
+ }
159
+ continue;
160
+ }
161
+ if character == '/' && chars.peek() == Some(&'*') {
162
+ chars.next();
163
+ let mut previous = '\0';
164
+ for next in chars.by_ref() {
165
+ if next == '\n' {
166
+ stripped.push('\n');
167
+ }
168
+ if previous == '*' && next == '/' {
169
+ break;
170
+ }
171
+ previous = next;
172
+ }
173
+ continue;
174
+ }
175
+ stripped.push(character);
176
+ }
177
+ remove_trailing_commas(&stripped)
178
+ }
179
+
180
+ fn remove_trailing_commas(content: &str) -> String {
181
+ let mut output = String::with_capacity(content.len());
182
+ let chars = content.chars().collect::<Vec<_>>();
183
+ let mut index = 0usize;
184
+ while index < chars.len() {
185
+ if chars[index] == ',' {
186
+ let mut next = index + 1;
187
+ while next < chars.len() && chars[next].is_whitespace() {
188
+ next += 1;
189
+ }
190
+ if next < chars.len() && matches!(chars[next], '}' | ']') {
191
+ index += 1;
192
+ continue;
193
+ }
194
+ }
195
+ output.push(chars[index]);
196
+ index += 1;
197
+ }
198
+ output
199
+ }
200
+
201
+ fn nearest_configs(from_path: &str, repository_files: &BTreeSet<String>) -> Vec<String> {
202
+ let mut configs = Vec::new();
203
+ let mut current = Path::new(from_path)
204
+ .parent()
205
+ .unwrap_or_else(|| Path::new(""));
206
+ loop {
207
+ for file_name in ["tsconfig.json", "jsconfig.json"] {
208
+ let candidate = super::normalize(current.join(file_name));
209
+ if repository_files.contains(&candidate) {
210
+ configs.push(candidate);
211
+ }
212
+ }
213
+ let Some(parent) = current.parent() else {
214
+ break;
215
+ };
216
+ if parent == current {
217
+ break;
218
+ }
219
+ current = parent;
220
+ }
221
+ configs
222
+ }
223
+
224
+ fn alias_capture(pattern: &str, specifier: &str) -> Option<String> {
225
+ let Some((prefix, suffix)) = pattern.split_once('*') else {
226
+ return (pattern == specifier).then(String::new);
227
+ };
228
+ Some(
229
+ specifier
230
+ .strip_prefix(prefix)?
231
+ .strip_suffix(suffix)?
232
+ .to_string(),
233
+ )
234
+ }
235
+
236
+ fn alias_specificity(pattern: &str) -> usize {
237
+ pattern
238
+ .split_once('*')
239
+ .map(|(prefix, _)| prefix.len())
240
+ .unwrap_or(pattern.len())
241
+ }