Skip to main content

fallow_config/config/
parsing.rs

1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use fallow_types::path_util::is_absolute_path_any_platform;
5use rustc_hash::FxHashSet;
6
7use super::FallowConfig;
8
9/// Supported config file names in priority order.
10pub(super) const CONFIG_NAMES: &[&str] = &[
11    ".fallowrc.json",
12    ".fallowrc.jsonc",
13    "fallow.toml",
14    ".fallow.toml",
15];
16
17pub(super) const MAX_EXTENDS_DEPTH: usize = 10;
18
19/// Prefix for npm package specifiers in the `extends` field.
20const NPM_PREFIX: &str = "npm:";
21
22/// Prefix for HTTPS URL specifiers in the `extends` field.
23const HTTPS_PREFIX: &str = "https://";
24
25/// Prefix for HTTP URL specifiers (rejected with a clear error).
26const HTTP_PREFIX: &str = "http://";
27
28/// Default timeout for fetching remote configs via URL extends.
29const DEFAULT_URL_TIMEOUT_SECS: u64 = 5;
30
31/// Detect config format from file extension.
32pub(super) enum ConfigFormat {
33    Toml,
34    Json,
35}
36
37impl ConfigFormat {
38    pub(super) fn from_path(path: &Path) -> Self {
39        match path.extension().and_then(|e| e.to_str()) {
40            Some("json" | "jsonc") => Self::Json,
41            _ => Self::Toml,
42        }
43    }
44}
45
46/// Deep-merge two JSON values. `base` is lower-priority, `overlay` is higher.
47/// Objects: merge field by field. Arrays/scalars: overlay replaces base.
48pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
49    match (base, overlay) {
50        (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
51            for (key, value) in overlay_map {
52                if let Some(base_value) = base_map.get_mut(&key) {
53                    deep_merge_json(base_value, value);
54                } else {
55                    base_map.insert(key, value);
56                }
57            }
58        }
59        (base, overlay) => {
60            *base = overlay;
61        }
62    }
63}
64
65pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
66    let content = std::fs::read_to_string(path)
67        .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
68    let content = content.trim_start_matches('\u{FEFF}');
69
70    match ConfigFormat::from_path(path) {
71        ConfigFormat::Toml => {
72            let toml_value: toml::Value = toml::from_str(content).map_err(|e| {
73                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
74            })?;
75            serde_json::to_value(toml_value).map_err(|e| {
76                miette::miette!(
77                    "Failed to convert TOML to JSON for {}: {}",
78                    path.display(),
79                    e
80                )
81            })
82        }
83        ConfigFormat::Json => crate::jsonc::parse_to_value(content)
84            .map_err(|e| miette::miette!("Failed to parse config file {}: {}", path.display(), e)),
85    }
86}
87
88fn is_repo_root(dir: &Path) -> bool {
89    dir.join(".git").exists() || dir.join(".hg").exists() || dir.join(".svn").exists()
90}
91
92fn resolve_confined(
93    base_dir: &Path,
94    resolved: &Path,
95    context: &str,
96    source_config: &Path,
97) -> Result<PathBuf, miette::Report> {
98    let canonical_base = dunce::canonicalize(base_dir)
99        .map_err(|e| miette::miette!("Failed to resolve base dir {}: {}", base_dir.display(), e))?;
100    let canonical_file = dunce::canonicalize(resolved).map_err(|e| {
101        miette::miette!(
102            "Config file not found: {} ({}, referenced from {}): {}",
103            resolved.display(),
104            context,
105            source_config.display(),
106            e
107        )
108    })?;
109    if !canonical_file.starts_with(&canonical_base) {
110        return Err(miette::miette!(
111            "Path traversal detected: {} escapes package directory {} ({}, referenced from {})",
112            resolved.display(),
113            base_dir.display(),
114            context,
115            source_config.display()
116        ));
117    }
118    Ok(canonical_file)
119}
120
121fn validate_npm_package_name(name: &str, source_config: &Path) -> Result<(), miette::Report> {
122    if name.starts_with('@') && !name.contains('/') {
123        return Err(miette::miette!(
124            "Invalid scoped npm package name '{}': must be '@scope/name' (referenced from {})",
125            name,
126            source_config.display()
127        ));
128    }
129    if name.split('/').any(|c| c == ".." || c == ".") {
130        return Err(miette::miette!(
131            "Invalid npm package name '{}': path traversal components not allowed (referenced from {})",
132            name,
133            source_config.display()
134        ));
135    }
136    Ok(())
137}
138
139fn parse_npm_specifier(specifier: &str) -> (&str, Option<&str>) {
140    if specifier.starts_with('@') {
141        let mut slashes = 0;
142        for (i, ch) in specifier.char_indices() {
143            if ch == '/' {
144                slashes += 1;
145                if slashes == 2 {
146                    return (&specifier[..i], Some(&specifier[i + 1..]));
147                }
148            }
149        }
150        (specifier, None)
151    } else if let Some(slash) = specifier.find('/') {
152        (&specifier[..slash], Some(&specifier[slash + 1..]))
153    } else {
154        (specifier, None)
155    }
156}
157
158fn resolve_package_exports(pkg: &serde_json::Value, package_dir: &Path) -> Option<PathBuf> {
159    let exports = pkg.get("exports")?;
160    match exports {
161        serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
162        serde_json::Value::Object(map) => {
163            let dot_export = map.get(".")?;
164            match dot_export {
165                serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
166                serde_json::Value::Object(conditions) => {
167                    for key in ["default", "node", "import", "require"] {
168                        if let Some(serde_json::Value::String(s)) = conditions.get(key) {
169                            return Some(package_dir.join(s.as_str()));
170                        }
171                    }
172                    None
173                }
174                _ => None,
175            }
176        }
177        _ => None,
178    }
179}
180
181fn find_config_in_npm_package(
182    package_dir: &Path,
183    source_config: &Path,
184) -> Result<PathBuf, miette::Report> {
185    let pkg_json_path = package_dir.join("package.json");
186    if pkg_json_path.exists() {
187        let content = std::fs::read_to_string(&pkg_json_path)
188            .map_err(|e| miette::miette!("Failed to read {}: {}", pkg_json_path.display(), e))?;
189        let pkg: serde_json::Value = serde_json::from_str(&content)
190            .map_err(|e| miette::miette!("Failed to parse {}: {}", pkg_json_path.display(), e))?;
191        if let Some(config_path) = resolve_package_exports(&pkg, package_dir)
192            && config_path.exists()
193        {
194            return resolve_confined(
195                package_dir,
196                &config_path,
197                "package.json exports",
198                source_config,
199            );
200        }
201        if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
202            let main_path = package_dir.join(main);
203            if main_path.exists() {
204                return resolve_confined(
205                    package_dir,
206                    &main_path,
207                    "package.json main",
208                    source_config,
209                );
210            }
211        }
212    }
213
214    for config_name in CONFIG_NAMES {
215        let config_path = package_dir.join(config_name);
216        if config_path.exists() {
217            return resolve_confined(
218                package_dir,
219                &config_path,
220                "config name fallback",
221                source_config,
222            );
223        }
224    }
225
226    Err(miette::miette!(
227        "No fallow config found in npm package at {}. \
228         Expected package.json with main/exports pointing to a config file, \
229         or one of: {}",
230        package_dir.display(),
231        CONFIG_NAMES.join(", ")
232    ))
233}
234
235fn resolve_npm_package(
236    config_dir: &Path,
237    specifier: &str,
238    source_config: &Path,
239) -> Result<PathBuf, miette::Report> {
240    let specifier = specifier.trim();
241    if specifier.is_empty() {
242        return Err(miette::miette!(
243            "Empty npm specifier in extends (in {})",
244            source_config.display()
245        ));
246    }
247
248    let (package_name, subpath) = parse_npm_specifier(specifier);
249    validate_npm_package_name(package_name, source_config)?;
250
251    let mut dir = Some(config_dir);
252    while let Some(d) = dir {
253        let candidate = d.join("node_modules").join(package_name);
254        if candidate.is_dir() {
255            return if let Some(sub) = subpath {
256                let file = candidate.join(sub);
257                if file.exists() {
258                    resolve_confined(
259                        &candidate,
260                        &file,
261                        &format!("subpath '{sub}'"),
262                        source_config,
263                    )
264                } else {
265                    Err(miette::miette!(
266                        "File not found in npm package: {} (looked for '{}' in {}, referenced from {})",
267                        file.display(),
268                        sub,
269                        candidate.display(),
270                        source_config.display()
271                    ))
272                }
273            } else {
274                find_config_in_npm_package(&candidate, source_config)
275            };
276        }
277        dir = d.parent();
278    }
279
280    Err(miette::miette!(
281        "npm package '{}' not found. \
282         Searched for node_modules/{} in ancestor directories of {} (referenced from {}). \
283         If this package should be available, install it and ensure it is listed in your project's dependencies",
284        package_name,
285        package_name,
286        config_dir.display(),
287        source_config.display()
288    ))
289}
290
291/// Normalize a URL for deduplication.
292fn normalize_url_for_dedup(url: &str) -> String {
293    let Some((scheme, rest)) = url.split_once("://") else {
294        return url.to_string();
295    };
296    let scheme = scheme.to_ascii_lowercase();
297
298    let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
299    let authority = authority.to_ascii_lowercase();
300
301    let authority = authority.strip_suffix(":443").unwrap_or(&authority);
302
303    let path = path.split_once('#').map_or(path, |(p, _)| p);
304    let path = path.split_once('?').map_or(path, |(p, _)| p);
305    let path = path.strip_suffix('/').unwrap_or(path);
306
307    if path.is_empty() {
308        format!("{scheme}://{authority}")
309    } else {
310        format!("{scheme}://{authority}/{path}")
311    }
312}
313
314/// Read the `FALLOW_EXTENDS_TIMEOUT_SECS` env var, falling back to [`DEFAULT_URL_TIMEOUT_SECS`].
315fn url_timeout() -> Duration {
316    std::env::var("FALLOW_EXTENDS_TIMEOUT_SECS")
317        .ok()
318        .and_then(|v| v.parse::<u64>().ok().filter(|&n| n > 0))
319        .map_or(
320            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
321            Duration::from_secs,
322        )
323}
324
325/// Maximum response body size for fetched config files.
326const MAX_URL_CONFIG_BYTES: u64 = 1024 * 1024;
327
328/// Fetch a remote JSON config from an HTTPS URL.
329fn fetch_url_config(url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
330    let timeout = url_timeout();
331    let agent = ureq::Agent::config_builder()
332        .timeout_global(Some(timeout))
333        .https_only(true)
334        .build()
335        .new_agent();
336
337    let mut response = agent.get(url).call().map_err(|e| {
338        miette::miette!(
339            "Failed to fetch remote config from {url} (referenced from {source}): {e}. \
340             If this URL is unavailable, use a local path or npm: specifier instead"
341        )
342    })?;
343
344    let body = response
345        .body_mut()
346        .with_config()
347        .limit(MAX_URL_CONFIG_BYTES)
348        .read_to_string()
349        .map_err(|e| {
350            miette::miette!(
351                "Failed to read response body from {url} (referenced from {source}): {e}"
352            )
353        })?;
354
355    crate::jsonc::parse_to_value(&body).map_err(|e| {
356        miette::miette!(
357            "Failed to parse remote config as JSON from {url} (referenced from {source}): {e}. \
358             Only JSON/JSONC is supported for URL-sourced configs"
359        )
360    })
361}
362
363/// Extract the `extends` array from a parsed JSON config value.
364fn extract_extends(value: &mut serde_json::Value) -> Vec<String> {
365    value
366        .as_object_mut()
367        .and_then(|obj| obj.remove("extends"))
368        .and_then(|v| match v {
369            serde_json::Value::Array(arr) => Some(
370                arr.into_iter()
371                    .filter_map(|v| v.as_str().map(String::from))
372                    .collect::<Vec<_>>(),
373            ),
374            serde_json::Value::String(s) => Some(vec![s]),
375            _ => None,
376        })
377        .unwrap_or_default()
378}
379
380/// Resolve extends entries from a URL-sourced config.
381fn resolve_url_extends(
382    url: &str,
383    visited: &mut FxHashSet<String>,
384    depth: usize,
385) -> Result<serde_json::Value, miette::Report> {
386    if depth >= MAX_EXTENDS_DEPTH {
387        return Err(miette::miette!(
388            "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {url}"
389        ));
390    }
391
392    let normalized = normalize_url_for_dedup(url);
393    if !visited.insert(normalized) {
394        return Err(miette::miette!(
395            "Circular extends detected: {url} was already visited in the extends chain"
396        ));
397    }
398
399    let mut value = fetch_url_config(url, url)?;
400    let extends = extract_extends(&mut value);
401
402    if extends.is_empty() {
403        return Ok(value);
404    }
405
406    let mut merged = serde_json::Value::Object(serde_json::Map::new());
407
408    for entry in &extends {
409        let base = if entry.starts_with(HTTPS_PREFIX) {
410            resolve_url_extends(entry, visited, depth + 1)?
411        } else if entry.starts_with(HTTP_PREFIX) {
412            return Err(miette::miette!(
413                "URL extends must use https://, got http:// URL '{}' (in remote config {}). \
414                 Change the URL to use https:// instead",
415                entry,
416                url
417            ));
418        } else if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
419            let cwd = std::env::current_dir().map_err(|e| {
420                miette::miette!(
421                    "Cannot resolve npm: specifier from URL-sourced config: \
422                     failed to determine current directory: {e}"
423                )
424            })?;
425            let path_placeholder = PathBuf::from(url);
426            let npm_path = resolve_npm_package(&cwd, npm_specifier, &path_placeholder)?;
427            resolve_extends_file(&npm_path, visited, depth + 1)?
428        } else {
429            return Err(miette::miette!(
430                "Relative paths in 'extends' are not supported when the base config was \
431                 fetched from a URL ('{url}'). Use another https:// URL or npm: reference \
432                 instead. Got: '{entry}'"
433            ));
434        };
435        deep_merge_json(&mut merged, base);
436    }
437
438    deep_merge_json(&mut merged, value);
439    Ok(merged)
440}
441
442/// Resolve extends from a local config file.
443fn resolve_extends_file(
444    path: &Path,
445    visited: &mut FxHashSet<String>,
446    depth: usize,
447) -> Result<serde_json::Value, miette::Report> {
448    if depth >= MAX_EXTENDS_DEPTH {
449        return Err(miette::miette!(
450            "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
451            path.display()
452        ));
453    }
454
455    record_extends_visit(path, visited)?;
456
457    let mut value = parse_config_to_value(path)?;
458    let extends = extract_extends(&mut value);
459
460    if extends.is_empty() {
461        return Ok(value);
462    }
463
464    let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
465    let sealed = value
466        .get("sealed")
467        .and_then(serde_json::Value::as_bool)
468        .unwrap_or(false);
469    let sealed_dir_canonical = sealed_config_dir(config_dir, sealed)?;
470    let mut merged = serde_json::Value::Object(serde_json::Map::new());
471
472    for extend_path_str in &extends {
473        let base = resolve_extends_file_entry(
474            path,
475            config_dir,
476            extend_path_str,
477            sealed,
478            sealed_dir_canonical.as_deref(),
479            visited,
480            depth,
481        )?;
482        deep_merge_json(&mut merged, base);
483    }
484
485    deep_merge_json(&mut merged, value);
486    Ok(merged)
487}
488
489fn record_extends_visit(
490    path: &Path,
491    visited: &mut FxHashSet<String>,
492) -> Result<(), miette::Report> {
493    let canonical = dunce::canonicalize(path).map_err(|e| {
494        miette::miette!(
495            "Config file not found or unresolvable: {}: {}",
496            path.display(),
497            e
498        )
499    })?;
500
501    if visited.insert(canonical.to_string_lossy().into_owned()) {
502        Ok(())
503    } else {
504        Err(miette::miette!(
505            "Circular extends detected: {} was already visited in the extends chain",
506            path.display()
507        ))
508    }
509}
510
511fn sealed_config_dir(config_dir: &Path, sealed: bool) -> Result<Option<PathBuf>, miette::Report> {
512    if !sealed {
513        return Ok(None);
514    }
515    dunce::canonicalize(config_dir).map(Some).map_err(|e| {
516        miette::miette!(
517            "Sealed config directory '{}' could not be canonicalized: {e}",
518            config_dir.display()
519        )
520    })
521}
522
523fn resolve_extends_file_entry(
524    path: &Path,
525    config_dir: &Path,
526    entry: &str,
527    sealed: bool,
528    sealed_dir_canonical: Option<&Path>,
529    visited: &mut FxHashSet<String>,
530    depth: usize,
531) -> Result<serde_json::Value, miette::Report> {
532    if entry.starts_with(HTTPS_PREFIX) {
533        reject_sealed_remote_extends(path, entry, sealed, "URL")?;
534        return resolve_url_extends(entry, visited, depth + 1);
535    }
536    if entry.starts_with(HTTP_PREFIX) {
537        return Err(miette::miette!(
538            "URL extends must use https://, got http:// URL '{}' (in {}). \
539             Change the URL to use https:// instead",
540            entry,
541            path.display()
542        ));
543    }
544    if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
545        reject_sealed_remote_extends(path, entry, sealed, "npm")?;
546        let npm_path = resolve_npm_package(config_dir, npm_specifier, path)?;
547        return resolve_extends_file(&npm_path, visited, depth + 1);
548    }
549    resolve_relative_extends_file(
550        path,
551        config_dir,
552        entry,
553        sealed_dir_canonical,
554        visited,
555        depth,
556    )
557}
558
559fn reject_sealed_remote_extends(
560    path: &Path,
561    entry: &str,
562    sealed: bool,
563    kind: &str,
564) -> Result<(), miette::Report> {
565    if sealed {
566        Err(miette::miette!(
567            "'sealed: true' config at {} rejects {} extends '{}'. \
568             Sealed configs only allow file-relative extends within \
569             the config's directory",
570            path.display(),
571            kind,
572            entry
573        ))
574    } else {
575        Ok(())
576    }
577}
578
579fn resolve_relative_extends_file(
580    path: &Path,
581    config_dir: &Path,
582    entry: &str,
583    sealed_dir_canonical: Option<&Path>,
584    visited: &mut FxHashSet<String>,
585    depth: usize,
586) -> Result<serde_json::Value, miette::Report> {
587    if is_absolute_path_any_platform(Path::new(entry)) {
588        return Err(miette::miette!(
589            "extends paths must be relative, got absolute path: {} (in {})",
590            entry,
591            path.display()
592        ));
593    }
594    let p = config_dir.join(entry);
595    if !p.exists() {
596        return Err(miette::miette!(
597            "Extended config file not found: {} (referenced from {})",
598            p.display(),
599            path.display()
600        ));
601    }
602    validate_sealed_relative_extends(path, entry, &p, sealed_dir_canonical)?;
603    resolve_extends_file(&p, visited, depth + 1)
604}
605
606fn validate_sealed_relative_extends(
607    path: &Path,
608    entry: &str,
609    resolved_path: &Path,
610    sealed_dir_canonical: Option<&Path>,
611) -> Result<(), miette::Report> {
612    let Some(dir_canonical) = sealed_dir_canonical else {
613        return Ok(());
614    };
615    let p_canonical = dunce::canonicalize(resolved_path).map_err(|e| {
616        miette::miette!(
617            "Sealed config extends path '{}' could not be canonicalized: {e}",
618            resolved_path.display()
619        )
620    })?;
621    if p_canonical.starts_with(dir_canonical) {
622        Ok(())
623    } else {
624        Err(miette::miette!(
625            "'sealed: true' config at {} rejects extends '{}' which resolves \
626             outside the config's directory ({}). Sealed configs only allow \
627             extends within the config's directory",
628            path.display(),
629            entry,
630            p_canonical.display()
631        ))
632    }
633}
634
635/// Public entry point: resolve a config file with all its extends chain.
636///
637/// Delegates to [`resolve_extends_file`] with a fresh visited set.
638pub(super) fn resolve_extends(
639    path: &Path,
640    visited: &mut FxHashSet<String>,
641    depth: usize,
642) -> Result<serde_json::Value, miette::Report> {
643    resolve_extends_file(path, visited, depth)
644}
645
646/// Collect every unknown key under `rules` or `overrides[].rules` in a merged
647/// config value (issue #467, phase 1).
648///
649/// Today `RulesConfig` / `PartialRulesConfig` carry serde aliases but NOT
650/// `deny_unknown_fields`, so typos like `unsued-files` are silently dropped and
651/// the user's intent is lost. This pass walks the merged value before
652/// deserialization and surfaces every unknown key, with a Levenshtein-distance
653/// suggestion when the typo is close to a known name.
654///
655/// Returns the findings so the caller can render them; tests can assert
656/// against the list without subscribing to tracing output.
657///
658/// Phase 2 (a future minor release) flips both structs to
659/// `#[serde(deny_unknown_fields)]` and the warning becomes a hard error.
660pub(super) fn collect_unknown_rule_keys(
661    merged: &serde_json::Value,
662) -> Vec<super::rules::UnknownRuleKey> {
663    use super::rules::find_unknown_rule_keys;
664
665    let mut findings = Vec::new();
666
667    if let Some(rules) = merged.get("rules") {
668        findings.extend(find_unknown_rule_keys(rules, "rules"));
669    }
670
671    if let Some(overrides) = merged.get("overrides").and_then(|v| v.as_array()) {
672        for (i, entry) in overrides.iter().enumerate() {
673            if let Some(rules) = entry.get("rules") {
674                let context = format!("overrides[{i}].rules");
675                findings.extend(find_unknown_rule_keys(rules, &context));
676            }
677        }
678    }
679
680    findings
681}
682
683thread_local! {
684    /// Per-thread capture of unknown-rule findings, for the wiring regression
685    /// test in this module. Each test installs a fresh capture via
686    /// [`capture_unknown_rule_warnings`], runs `FallowConfig::load`, and reads
687    /// back the findings. Thread-local so parallel test execution does not
688    /// race; bypassed entirely in production code (`UnknownRuleCapture::None`).
689    #[cfg(test)]
690    static UNKNOWN_RULE_CAPTURE: std::cell::RefCell<Option<Vec<super::rules::UnknownRuleKey>>> =
691        const { std::cell::RefCell::new(None) };
692}
693
694/// Install a thread-local capture buffer and run `body`. Returns the findings
695/// emitted by every `warn_on_unknown_rule_keys` call within `body`'s call tree
696/// on the current thread, in order. Test-only.
697#[cfg(test)]
698pub(super) fn capture_unknown_rule_warnings<F: FnOnce() -> R, R>(
699    body: F,
700) -> (R, Vec<super::rules::UnknownRuleKey>) {
701    UNKNOWN_RULE_CAPTURE.with(|cell| {
702        *cell.borrow_mut() = Some(Vec::new());
703    });
704    let result = body();
705    let findings = UNKNOWN_RULE_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
706    (result, findings)
707}
708
709/// Emit a `tracing::warn!` per finding from [`collect_unknown_rule_keys`].
710///
711/// `config_path` is the file the merged value originated from; it appears in
712/// the warning text AND in the dedupe key so two different config files with
713/// the same typo each warn once instead of the second one being silenced.
714///
715/// Deduplicates within the process: `FallowConfig::load` runs multiple times
716/// per analysis (combined mode runs check + dupes + health, each through the
717/// same config load path), so without a dedupe the same typo emits 3+ warnings
718/// per run.
719fn warn_on_unknown_rule_keys(config_path: &Path, merged: &serde_json::Value) {
720    use std::sync::{Mutex, OnceLock};
721
722    static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
723    let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
724
725    let path_display = config_path.display().to_string();
726
727    for finding in collect_unknown_rule_keys(merged) {
728        let dedupe_key = format!("{path_display}::{}::{}", finding.context, finding.key);
729        if let Ok(mut set) = warned.lock()
730            && !set.insert(dedupe_key)
731        {
732            continue;
733        }
734
735        #[cfg(test)]
736        UNKNOWN_RULE_CAPTURE.with(|cell| {
737            if let Some(buf) = cell.borrow_mut().as_mut() {
738                buf.push(finding.clone());
739            }
740        });
741
742        if let Some(suggestion) = finding.suggestion {
743            tracing::warn!(
744                "unknown rule '{key}' in {context} of {path} (did you mean '{suggestion}'?); \
745                 the rule will be ignored. A future release will reject unknown rule names.",
746                key = finding.key,
747                context = finding.context,
748                path = path_display,
749            );
750        } else {
751            tracing::warn!(
752                "unknown rule '{key}' in {context} of {path}; the rule will be ignored. \
753                 A future release will reject unknown rule names.",
754                key = finding.key,
755                context = finding.context,
756                path = path_display,
757            );
758        }
759    }
760}
761
762/// Return the lower-precedence config names from [`CONFIG_NAMES`] that ALSO
763/// exist in `dir`, given that `chosen_index` is the index of the first-match
764/// (winning) name.
765///
766/// Only indices after `chosen_index` are scanned: a higher-precedence name
767/// cannot coexist undetected, because it would have been the first match.
768fn shadowed_config_names(dir: &Path, chosen_index: usize) -> Vec<&'static str> {
769    CONFIG_NAMES
770        .iter()
771        .skip(chosen_index + 1)
772        .filter(|name| dir.join(name).exists())
773        .copied()
774        .collect()
775}
776
777/// A captured coexistence warning: `(chosen file name, shadowed file names)`.
778/// Test-only; populated by `warn_on_coexisting_configs` under capture.
779#[cfg(test)]
780type CoexistWarning = (String, Vec<String>);
781
782thread_local! {
783    /// Per-thread capture of coexisting-config warnings, for the wiring
784    /// regression test in this module. Mirrors [`UNKNOWN_RULE_CAPTURE`]: each
785    /// test installs a fresh capture via
786    /// [`capture_coexisting_config_warnings`], runs `find_and_load`, and reads
787    /// back the `(chosen, shadowed)` pairs. Thread-local so parallel test
788    /// execution does not race; bypassed entirely in production code.
789    #[cfg(test)]
790    static COEXIST_CAPTURE: std::cell::RefCell<Option<Vec<CoexistWarning>>> =
791        const { std::cell::RefCell::new(None) };
792}
793
794/// Install a thread-local capture buffer and run `body`. Returns every
795/// `(chosen, shadowed)` pair emitted by `warn_on_coexisting_configs` within
796/// `body`'s call tree on the current thread, in order. Test-only.
797#[cfg(test)]
798pub(super) fn capture_coexisting_config_warnings<F: FnOnce() -> R, R>(
799    body: F,
800) -> (R, Vec<CoexistWarning>) {
801    COEXIST_CAPTURE.with(|cell| {
802        *cell.borrow_mut() = Some(Vec::new());
803    });
804    let result = body();
805    let findings = COEXIST_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
806    (result, findings)
807}
808
809/// Emit a `tracing::warn!` when `find_and_load` picked `chosen_path` while one
810/// or more lower-precedence config files (`shadowed`) coexist in the same
811/// directory. Silent precedence is the worst class of config bug: the user
812/// sees correct-looking output produced from the wrong source (#458).
813///
814/// `chosen_path` is the absolute candidate path of the winning config;
815/// `shadowed` are the bare names of the lower-precedence files that also exist.
816///
817/// Deduplicates within the process keyed on the canonical directory, because
818/// `find_and_load` runs multiple times per analysis (combined mode loads config
819/// for check + dupes + health); without the dedupe the same directory would
820/// warn 3+ times per run. Two different directories with coexisting configs
821/// warn independently.
822fn warn_on_coexisting_configs(chosen_path: &Path, shadowed: &[&str]) {
823    use std::sync::{Mutex, OnceLock};
824
825    if shadowed.is_empty() {
826        return;
827    }
828
829    let chosen_name = chosen_path.file_name().map_or_else(
830        || chosen_path.display().to_string(),
831        |n| n.to_string_lossy().into_owned(),
832    );
833    let dir = chosen_path.parent().unwrap_or(chosen_path);
834
835    #[cfg(test)]
836    COEXIST_CAPTURE.with(|cell| {
837        if let Some(buf) = cell.borrow_mut().as_mut() {
838            buf.push((
839                chosen_name.clone(),
840                shadowed.iter().map(|s| (*s).to_owned()).collect(),
841            ));
842        }
843    });
844
845    static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
846    let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
847    let dedupe_key = std::fs::canonicalize(dir)
848        .unwrap_or_else(|_| dir.to_path_buf())
849        .display()
850        .to_string();
851    if let Ok(mut set) = warned.lock()
852        && !set.insert(dedupe_key)
853    {
854        return;
855    }
856
857    tracing::warn!(
858        "multiple fallow config files in {dir}: loaded '{chosen}', ignoring '{shadowed}'. \
859         fallow uses the first match in precedence order \
860         (.fallowrc.json > .fallowrc.jsonc > fallow.toml > .fallow.toml); \
861         remove the unused file(s) to silence this warning.",
862        dir = dir.display(),
863        chosen = chosen_name,
864        shadowed = shadowed.join(", "),
865    );
866}
867
868impl FallowConfig {
869    /// Load config from a fallow config file (TOML or JSON/JSONC).
870    ///
871    /// The format is detected from the file extension:
872    /// - `.toml` → TOML
873    /// - `.json` → JSON (with JSONC comment stripping)
874    ///
875    /// Supports `extends` for config inheritance. Extended configs are loaded
876    /// and deep-merged before this config's values are applied.
877    ///
878    /// User-supplied glob patterns (`entry`, `ignorePatterns`,
879    /// `dynamicallyLoaded`, `duplicates.ignore`, `health.ignore`,
880    /// `health.thresholdOverrides[].files`, `boundaries.zones[].patterns`, `overrides[].files`,
881    /// `ignoreExports[].file`, `ignoreCatalogReferences[].consumer`) are
882    /// validated against absolute paths, `..` traversal segments, and invalid
883    /// glob syntax. Loading fails loud on any rejection so silent no-match
884    /// configs surface to the user. See issue #463.
885    ///
886    /// # Errors
887    ///
888    /// Returns an error when the config file cannot be read, merged, or
889    /// deserialized, or when any user-supplied glob pattern is rejected.
890    pub fn load(path: &Path) -> Result<Self, miette::Report> {
891        let mut visited = FxHashSet::default();
892        let merged = resolve_extends(path, &mut visited, 0)?;
893
894        warn_on_unknown_rule_keys(path, &merged);
895
896        let config: Self = serde_json::from_value(merged).map_err(|e| {
897            miette::miette!(
898                "Failed to deserialize config from {}: {}",
899                path.display(),
900                e
901            )
902        })?;
903
904        config.validate_user_globs().map_err(|errors| {
905            let joined = errors
906                .iter()
907                .map(ToString::to_string)
908                .collect::<Vec<_>>()
909                .join("\n  - ");
910            miette::miette!("invalid config:\n  - {}", joined)
911        })?;
912        if !config.security.request_receivers_are_valid() {
913            return Err(miette::miette!(
914                "invalid config:\n  - security.requestReceivers entries must be non-empty strings"
915            ));
916        }
917        let threshold_override_errors = config.health.threshold_override_errors();
918        if !threshold_override_errors.is_empty() {
919            return Err(miette::miette!(
920                "invalid config:\n  - {}",
921                threshold_override_errors.join("\n  - ")
922            ));
923        }
924
925        Ok(config)
926    }
927
928    /// Validate all user-supplied glob patterns and directory paths in this config.
929    ///
930    /// Accumulates errors from every glob- or path-bearing field so the user
931    /// sees ALL offending values in one run rather than fixing them one at a
932    /// time.
933    ///
934    /// Covered filesystem glob fields: `entry`, `ignorePatterns`,
935    /// `dynamicallyLoaded`, `duplicates.ignore`, `health.ignore`,
936    /// `health.thresholdOverrides[].files`, `overrides[].files`, `ignoreExports[].file`,
937    /// `ignoreCatalogReferences[].consumer`, `boundaries.zones[].patterns`,
938    /// `boundaries.coverage.allowUnmatched`,
939    /// plus every glob-bearing field on inline `framework[]` plugin
940    /// definitions (entry points, always-used, config patterns, used-exports
941    /// patterns, and `fileExists` detection patterns; the last reaches
942    /// `glob::glob` on disk so a `..` segment there is a real path traversal).
943    ///
944    /// Covered specifier glob fields: `ignoreUnresolvedImports`. These match
945    /// raw import strings, so parent-relative specifiers like `../generated/**`
946    /// are valid and only glob syntax is checked.
947    ///
948    /// Covered directory-path fields: `boundaries.zones[].root` and
949    /// `boundaries.zones[].autoDiscover`. These are literal paths (not
950    /// globs), so only the absolute-path + traversal checks apply.
951    ///
952    /// # Errors
953    ///
954    /// Returns a non-empty `Vec` of
955    /// [`glob_validation::GlobValidationError`](super::glob_validation::GlobValidationError)
956    /// when any field contains a rejected value.
957    pub fn validate_user_globs(
958        &self,
959    ) -> Result<(), Vec<super::glob_validation::GlobValidationError>> {
960        use super::glob_validation::{
961            compile_user_glob, validate_user_globs, validate_user_path, validate_user_paths,
962            validate_user_specifier_globs,
963        };
964
965        let mut errors = Vec::new();
966
967        validate_user_globs(&self.entry, "entry", &mut errors);
968        validate_user_globs(&self.ignore_patterns, "ignorePatterns", &mut errors);
969        validate_user_globs(&self.dynamically_loaded, "dynamicallyLoaded", &mut errors);
970        validate_user_specifier_globs(
971            &self.ignore_unresolved_imports,
972            "ignoreUnresolvedImports",
973            &mut errors,
974        );
975        validate_user_globs(&self.duplicates.ignore, "duplicates.ignore", &mut errors);
976        validate_user_globs(&self.health.ignore, "health.ignore", &mut errors);
977        for override_entry in &self.health.threshold_overrides {
978            validate_user_globs(
979                &override_entry.files,
980                "health.thresholdOverrides[].files",
981                &mut errors,
982            );
983        }
984
985        for override_entry in &self.overrides {
986            validate_user_globs(&override_entry.files, "overrides[].files", &mut errors);
987        }
988
989        for rule in &self.ignore_exports {
990            if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
991                errors.push(e);
992            }
993        }
994
995        for rule in &self.ignore_catalog_references {
996            if let Some(consumer) = &rule.consumer
997                && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
998            {
999                errors.push(e);
1000            }
1001        }
1002
1003        for zone in &self.boundaries.zones {
1004            validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", &mut errors);
1005            if let Some(root) = &zone.root
1006                && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
1007            {
1008                errors.push(e);
1009            }
1010            validate_user_paths(
1011                &zone.auto_discover,
1012                "boundaries.zones[].autoDiscover",
1013                &mut errors,
1014            );
1015        }
1016        validate_user_globs(
1017            &self.boundaries.coverage.allow_unmatched,
1018            "boundaries.coverage.allowUnmatched",
1019            &mut errors,
1020        );
1021
1022        for plugin in &self.framework {
1023            if let Err(mut plugin_errors) = plugin.validate_user_globs() {
1024                errors.append(&mut plugin_errors);
1025            }
1026        }
1027
1028        if errors.is_empty() {
1029            Ok(())
1030        } else {
1031            Err(errors)
1032        }
1033    }
1034
1035    /// Find the config file path without loading it.
1036    /// Searches the same locations as `find_and_load`.
1037    #[must_use]
1038    pub fn find_config_path(start: &Path) -> Option<PathBuf> {
1039        let mut dir = start;
1040        loop {
1041            for name in CONFIG_NAMES {
1042                let candidate = dir.join(name);
1043                if candidate.exists() {
1044                    return Some(candidate);
1045                }
1046            }
1047            if is_repo_root(dir) {
1048                break;
1049            }
1050            dir = dir.parent()?;
1051        }
1052        None
1053    }
1054
1055    /// Find and load config, searching from `start` up to the project root.
1056    ///
1057    /// # Errors
1058    ///
1059    /// Returns an error if a config file is found but cannot be read or parsed.
1060    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
1061        let mut dir = start;
1062        loop {
1063            for (idx, name) in CONFIG_NAMES.iter().enumerate() {
1064                let candidate = dir.join(name);
1065                if candidate.exists() {
1066                    warn_on_coexisting_configs(&candidate, &shadowed_config_names(dir, idx));
1067                    match Self::load(&candidate) {
1068                        Ok(config) => return Ok(Some((config, candidate))),
1069                        Err(e) => {
1070                            return Err(format!("Failed to parse {}: {e}", candidate.display()));
1071                        }
1072                    }
1073                }
1074            }
1075            if is_repo_root(dir) {
1076                break;
1077            }
1078            dir = match dir.parent() {
1079                Some(parent) => parent,
1080                None => break,
1081            };
1082        }
1083        Ok(None)
1084    }
1085
1086    /// Generate JSON Schema for the configuration format.
1087    #[must_use]
1088    pub fn json_schema() -> serde_json::Value {
1089        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
1090    }
1091
1092    /// Validate boundary zone references and zone-root-prefix conflicts AFTER
1093    /// preset and auto-discover expansion.
1094    ///
1095    /// Runs the same expand sequence as [`FallowConfig::resolve`] (preset
1096    /// expansion gated on tsconfig `rootDir`, then `expand_auto_discover`)
1097    /// before invoking
1098    /// [`BoundaryConfig::validate_zone_references`](super::boundaries::BoundaryConfig::validate_zone_references)
1099    /// and
1100    /// [`BoundaryConfig::validate_root_prefixes`](super::boundaries::BoundaryConfig::validate_root_prefixes),
1101    /// so Bulletproof-style presets whose authored rule references logical
1102    /// groups (`features`) still load cleanly.
1103    ///
1104    /// Call sites (`runtime_support::load_config_for_analysis` in the CLI,
1105    /// `core::lib::config_for_project` for LSP and programmatic embedders)
1106    /// surface every collected error in a single rendered diagnostic, then
1107    /// exit with code 2. Previously these failures emitted `tracing::error!`
1108    /// and continued, producing a flood of false-positive boundary violations
1109    /// at analysis time (#468).
1110    ///
1111    /// `root` is the project root used by `expand_auto_discover` to scan for
1112    /// child directories. Caller is responsible for passing the same root it
1113    /// later hands to `resolve()`.
1114    ///
1115    /// # Errors
1116    ///
1117    /// Returns a non-empty `Vec<ZoneValidationError>` aggregating every
1118    /// offending zone reference and redundant-root-prefix pattern; the empty
1119    /// case becomes `Ok(())`.
1120    pub fn validate_resolved_boundaries(
1121        &self,
1122        root: &Path,
1123    ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1124        use super::boundaries::ZoneValidationError;
1125
1126        let mut boundaries = self.boundaries.clone();
1127        if boundaries.preset.is_some() {
1128            let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1129                .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1130                .unwrap_or_else(|| "src".to_owned());
1131            boundaries.expand(&source_root);
1132        }
1133        let _logical_groups = boundaries.expand_auto_discover(root);
1134
1135        let mut errors: Vec<ZoneValidationError> = boundaries
1136            .validate_zone_references()
1137            .into_iter()
1138            .map(ZoneValidationError::UnknownZoneReference)
1139            .collect();
1140        errors.extend(
1141            boundaries
1142                .validate_root_prefixes()
1143                .into_iter()
1144                .map(ZoneValidationError::RedundantRootPrefix),
1145        );
1146        errors.extend(
1147            boundaries
1148                .validate_call_rules()
1149                .into_iter()
1150                .map(ZoneValidationError::InvalidForbiddenCallee),
1151        );
1152
1153        if errors.is_empty() {
1154            Ok(())
1155        } else {
1156            Err(errors)
1157        }
1158    }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163    use super::*;
1164    use crate::CacheConfig;
1165    use crate::PackageJson;
1166    use crate::config::format::OutputFormat;
1167    use crate::config::rules::Severity;
1168
1169    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
1170    fn test_dir(_name: &str) -> tempfile::TempDir {
1171        tempfile::tempdir().expect("create temp dir")
1172    }
1173
1174    #[test]
1175    fn fallow_config_deserialize_minimal() {
1176        let toml_str = r#"
1177entry = ["src/main.ts"]
1178"#;
1179        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1180        assert_eq!(config.entry, vec!["src/main.ts"]);
1181        assert!(config.ignore_patterns.is_empty());
1182    }
1183
1184    #[test]
1185    fn fallow_config_deserialize_ignore_exports() {
1186        let toml_str = r#"
1187[[ignoreExports]]
1188file = "src/types/*.ts"
1189exports = ["*"]
1190
1191[[ignoreExports]]
1192file = "src/constants.ts"
1193exports = ["FOO", "BAR"]
1194"#;
1195        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1196        assert_eq!(config.ignore_exports.len(), 2);
1197        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1198        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1199        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1200    }
1201
1202    #[test]
1203    fn fallow_config_deserialize_ignore_dependencies() {
1204        let toml_str = r#"
1205ignoreDependencies = ["autoprefixer", "postcss"]
1206"#;
1207        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1208        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1209    }
1210
1211    #[test]
1212    fn fallow_config_deserialize_ignore_unresolved_imports() {
1213        let toml_str = r#"
1214ignoreUnresolvedImports = ["@example/icons", "@example/icons/**", "../generated/**"]
1215"#;
1216        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1217        assert_eq!(
1218            config.ignore_unresolved_imports,
1219            vec!["@example/icons", "@example/icons/**", "../generated/**"]
1220        );
1221    }
1222
1223    #[test]
1224    fn fallow_config_resolve_default_ignores() {
1225        let config = FallowConfig::default();
1226        let resolved = config.resolve(
1227            PathBuf::from("/tmp/test"),
1228            OutputFormat::Human,
1229            4,
1230            true,
1231            true,
1232            None,
1233        );
1234
1235        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1236        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1237        assert!(resolved.ignore_patterns.is_match("build/output.js"));
1238        assert!(resolved.ignore_patterns.is_match(".git/config"));
1239        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1240        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1241        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1242    }
1243
1244    #[test]
1245    fn fallow_config_resolve_custom_ignores() {
1246        let config = FallowConfig {
1247            entry: vec!["src/**/*.ts".to_string()],
1248            ignore_patterns: vec!["**/*.generated.ts".to_string()],
1249            ..Default::default()
1250        };
1251        let resolved = config.resolve(
1252            PathBuf::from("/tmp/test"),
1253            OutputFormat::Json,
1254            4,
1255            false,
1256            true,
1257            None,
1258        );
1259
1260        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1261        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1262        assert!(matches!(resolved.output, OutputFormat::Json));
1263        assert!(!resolved.no_cache);
1264    }
1265
1266    #[test]
1267    fn fallow_config_resolve_cache_dir() {
1268        let config = FallowConfig::default();
1269        let resolved = config.resolve(
1270            PathBuf::from("/tmp/project"),
1271            OutputFormat::Human,
1272            4,
1273            true,
1274            true,
1275            None,
1276        );
1277        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1278        assert!(resolved.no_cache);
1279    }
1280
1281    #[test]
1282    fn package_json_entry_points_main() {
1283        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1284        let entries = pkg.entry_points();
1285        assert!(entries.contains(&"dist/index.js".to_string()));
1286    }
1287
1288    #[test]
1289    fn package_json_entry_points_module() {
1290        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1291        let entries = pkg.entry_points();
1292        assert!(entries.contains(&"dist/index.mjs".to_string()));
1293    }
1294
1295    #[test]
1296    fn package_json_entry_points_types() {
1297        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
1298        let entries = pkg.entry_points();
1299        assert!(entries.contains(&"dist/index.d.ts".to_string()));
1300    }
1301
1302    #[test]
1303    fn package_json_entry_points_bin_string() {
1304        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
1305        let entries = pkg.entry_points();
1306        assert!(entries.contains(&"bin/cli.js".to_string()));
1307    }
1308
1309    #[test]
1310    fn package_json_entry_points_bin_object() {
1311        let pkg: PackageJson =
1312            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
1313                .unwrap();
1314        let entries = pkg.entry_points();
1315        assert!(entries.contains(&"bin/cli.js".to_string()));
1316        assert!(entries.contains(&"bin/serve.js".to_string()));
1317    }
1318
1319    #[test]
1320    fn package_json_entry_points_exports_string() {
1321        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
1322        let entries = pkg.entry_points();
1323        assert!(entries.contains(&"./dist/index.js".to_string()));
1324    }
1325
1326    #[test]
1327    fn package_json_entry_points_exports_object() {
1328        let pkg: PackageJson = serde_json::from_str(
1329            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
1330        )
1331        .unwrap();
1332        let entries = pkg.entry_points();
1333        assert!(entries.contains(&"./dist/index.mjs".to_string()));
1334        assert!(entries.contains(&"./dist/index.cjs".to_string()));
1335    }
1336
1337    #[test]
1338    fn package_json_dependency_names() {
1339        let pkg: PackageJson = serde_json::from_str(
1340            r#"{
1341            "dependencies": {"react": "^18", "lodash": "^4"},
1342            "devDependencies": {"typescript": "^5"},
1343            "peerDependencies": {"react-dom": "^18"}
1344        }"#,
1345        )
1346        .unwrap();
1347
1348        let all = pkg.all_dependency_names();
1349        assert!(all.contains(&"react".to_string()));
1350        assert!(all.contains(&"lodash".to_string()));
1351        assert!(all.contains(&"typescript".to_string()));
1352        assert!(all.contains(&"react-dom".to_string()));
1353
1354        let prod = pkg.production_dependency_names();
1355        assert!(prod.contains(&"react".to_string()));
1356        assert!(!prod.contains(&"typescript".to_string()));
1357
1358        let dev = pkg.dev_dependency_names();
1359        assert!(dev.contains(&"typescript".to_string()));
1360        assert!(!dev.contains(&"react".to_string()));
1361    }
1362
1363    #[test]
1364    fn package_json_no_dependencies() {
1365        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1366        assert!(pkg.all_dependency_names().is_empty());
1367        assert!(pkg.production_dependency_names().is_empty());
1368        assert!(pkg.dev_dependency_names().is_empty());
1369        assert!(pkg.entry_points().is_empty());
1370    }
1371
1372    #[test]
1373    fn rules_deserialize_toml_kebab_case() {
1374        let toml_str = r#"
1375[rules]
1376unused-files = "error"
1377unused-exports = "warn"
1378unused-types = "off"
1379"#;
1380        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1381        assert_eq!(config.rules.unused_files, Severity::Error);
1382        assert_eq!(config.rules.unused_exports, Severity::Warn);
1383        assert_eq!(config.rules.unused_types, Severity::Off);
1384        assert_eq!(config.rules.unresolved_imports, Severity::Error);
1385    }
1386
1387    #[test]
1388    fn config_without_rules_defaults_to_error() {
1389        let toml_str = r#"
1390entry = ["src/main.ts"]
1391"#;
1392        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1393        assert_eq!(config.rules.unused_files, Severity::Error);
1394        assert_eq!(config.rules.unused_exports, Severity::Error);
1395    }
1396
1397    #[test]
1398    fn fallow_config_denies_unknown_fields() {
1399        let toml_str = r"
1400unknown_field = true
1401";
1402        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1403        assert!(result.is_err());
1404    }
1405
1406    #[test]
1407    fn fallow_config_deserialize_json() {
1408        let json_str = r#"{"entry": ["src/main.ts"]}"#;
1409        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1410        assert_eq!(config.entry, vec!["src/main.ts"]);
1411    }
1412
1413    #[test]
1414    fn fallow_config_deserialize_jsonc() {
1415        let jsonc_str = r#"{
1416            "entry": ["src/main.ts"],
1417            "rules": {
1418                "unused-files": "warn"
1419            }
1420        }"#;
1421        let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1422        assert_eq!(config.entry, vec!["src/main.ts"]);
1423        assert_eq!(config.rules.unused_files, Severity::Warn);
1424    }
1425
1426    #[test]
1427    fn fallow_config_json_with_schema_field() {
1428        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1429        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1430        assert_eq!(config.entry, vec!["src/main.ts"]);
1431    }
1432
1433    #[test]
1434    fn fallow_config_json_schema_generation() {
1435        let schema = FallowConfig::json_schema();
1436        assert!(schema.is_object());
1437        let obj = schema.as_object().unwrap();
1438        assert!(obj.contains_key("properties"));
1439    }
1440
1441    #[test]
1442    fn config_format_detection() {
1443        assert!(matches!(
1444            ConfigFormat::from_path(Path::new("fallow.toml")),
1445            ConfigFormat::Toml
1446        ));
1447        assert!(matches!(
1448            ConfigFormat::from_path(Path::new(".fallowrc.json")),
1449            ConfigFormat::Json
1450        ));
1451        assert!(matches!(
1452            ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1453            ConfigFormat::Json
1454        ));
1455        assert!(matches!(
1456            ConfigFormat::from_path(Path::new(".fallow.toml")),
1457            ConfigFormat::Toml
1458        ));
1459    }
1460
1461    #[test]
1462    fn config_names_priority_order() {
1463        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1464        assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1465        assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1466        assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1467    }
1468
1469    #[test]
1470    fn load_json_config_file() {
1471        let dir = test_dir("json-config");
1472        let config_path = dir.path().join(".fallowrc.json");
1473        std::fs::write(
1474            &config_path,
1475            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1476        )
1477        .unwrap();
1478
1479        let config = FallowConfig::load(&config_path).unwrap();
1480        assert_eq!(config.entry, vec!["src/index.ts"]);
1481        assert_eq!(config.rules.unused_exports, Severity::Warn);
1482    }
1483
1484    #[test]
1485    fn load_json_config_file_with_health_threshold_override() {
1486        let dir = test_dir("json-health-threshold-override");
1487        let config_path = dir.path().join(".fallowrc.json");
1488        std::fs::write(
1489            &config_path,
1490            r#"{
1491                "health": {
1492                    "thresholdOverrides": [
1493                        {
1494                            "files": ["src/legacy.ts"],
1495                            "functions": ["legacyFlow"],
1496                            "maxCyclomatic": 30,
1497                            "maxCognitive": 25,
1498                            "maxCrap": 80.5,
1499                            "reason": "legacy migration"
1500                        }
1501                    ]
1502                }
1503            }"#,
1504        )
1505        .unwrap();
1506
1507        let config = FallowConfig::load(&config_path).unwrap();
1508        let override_config = &config.health.threshold_overrides[0];
1509        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1510        assert_eq!(override_config.functions, vec!["legacyFlow"]);
1511        assert_eq!(override_config.max_cyclomatic, Some(30));
1512        assert_eq!(override_config.max_cognitive, Some(25));
1513        assert_eq!(override_config.max_crap, Some(80.5));
1514        assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
1515    }
1516
1517    #[test]
1518    fn load_jsonc_config_file() {
1519        let dir = test_dir("jsonc-config");
1520        let config_path = dir.path().join(".fallowrc.json");
1521        std::fs::write(
1522            &config_path,
1523            r#"{
1524                "entry": ["src/index.ts"],
1525                /* Block comment */
1526                "rules": {
1527                    "unused-exports": "warn"
1528                }
1529            }"#,
1530        )
1531        .unwrap();
1532
1533        let config = FallowConfig::load(&config_path).unwrap();
1534        assert_eq!(config.entry, vec!["src/index.ts"]);
1535        assert_eq!(config.rules.unused_exports, Severity::Warn);
1536    }
1537
1538    #[test]
1539    fn load_jsonc_config_file_with_health_threshold_override() {
1540        let dir = test_dir("jsonc-health-threshold-override");
1541        let config_path = dir.path().join(".fallowrc.jsonc");
1542        std::fs::write(
1543            &config_path,
1544            r#"{
1545                "health": {
1546                    // Empty functions means every function in matching files.
1547                    "thresholdOverrides": [
1548                        { "files": ["src/legacy.ts"], "maxCognitive": 25 }
1549                    ]
1550                }
1551            }"#,
1552        )
1553        .unwrap();
1554
1555        let config = FallowConfig::load(&config_path).unwrap();
1556        let override_config = &config.health.threshold_overrides[0];
1557        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1558        assert!(override_config.functions.is_empty());
1559        assert_eq!(override_config.max_cognitive, Some(25));
1560    }
1561
1562    #[test]
1563    fn load_fallowrc_jsonc_extension() {
1564        let dir = test_dir("jsonc-extension");
1565        let config_path = dir.path().join(".fallowrc.jsonc");
1566        std::fs::write(
1567            &config_path,
1568            r#"{
1569                "ignoreDependencies": ["tailwindcss-react-aria-components"],
1570                "entry": ["src/index.ts"]
1571            }"#,
1572        )
1573        .unwrap();
1574
1575        let config = FallowConfig::load(&config_path).unwrap();
1576        assert_eq!(config.entry, vec!["src/index.ts"]);
1577        assert_eq!(
1578            config.ignore_dependencies,
1579            vec!["tailwindcss-react-aria-components"]
1580        );
1581    }
1582
1583    #[test]
1584    fn json_config_ignore_dependencies_camel_case() {
1585        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1586        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1587        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1588    }
1589
1590    #[test]
1591    fn json_config_ignore_unresolved_imports_camel_case() {
1592        let json_str = r#"{"ignoreUnresolvedImports": ["@example/icons", "@example/icons/**"]}"#;
1593        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1594        assert_eq!(
1595            config.ignore_unresolved_imports,
1596            vec!["@example/icons", "@example/icons/**"]
1597        );
1598    }
1599
1600    #[test]
1601    fn json_config_all_fields() {
1602        let json_str = r#"{
1603            "ignoreDependencies": ["lodash"],
1604            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1605            "rules": {
1606                "unused-files": "off",
1607                "unused-exports": "warn",
1608                "unused-dependencies": "error",
1609                "unused-dev-dependencies": "off",
1610                "unused-types": "warn",
1611                "unused-enum-members": "error",
1612                "unused-class-members": "off",
1613                "unresolved-imports": "warn",
1614                "unlisted-dependencies": "error",
1615                "duplicate-exports": "off"
1616            },
1617            "duplicates": {
1618                "minTokens": 100,
1619                "minLines": 10,
1620                "skipLocal": true
1621            }
1622        }"#;
1623        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1624        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1625        assert_eq!(config.rules.unused_files, Severity::Off);
1626        assert_eq!(config.rules.unused_exports, Severity::Warn);
1627        assert_eq!(config.rules.unused_dependencies, Severity::Error);
1628        assert_eq!(config.duplicates.min_tokens, 100);
1629        assert_eq!(config.duplicates.min_lines, 10);
1630        assert!(config.duplicates.skip_local);
1631    }
1632
1633    #[test]
1634    fn extends_single_base() {
1635        let dir = test_dir("extends-single");
1636
1637        std::fs::write(
1638            dir.path().join("base.json"),
1639            r#"{"rules": {"unused-files": "warn"}}"#,
1640        )
1641        .unwrap();
1642        std::fs::write(
1643            dir.path().join(".fallowrc.json"),
1644            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1645        )
1646        .unwrap();
1647
1648        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1649        assert_eq!(config.rules.unused_files, Severity::Warn);
1650        assert_eq!(config.entry, vec!["src/index.ts"]);
1651        assert_eq!(config.rules.unused_exports, Severity::Error);
1652    }
1653
1654    #[test]
1655    fn extends_overlay_overrides_base() {
1656        let dir = test_dir("extends-overlay");
1657
1658        std::fs::write(
1659            dir.path().join("base.json"),
1660            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1661        )
1662        .unwrap();
1663        std::fs::write(
1664            dir.path().join(".fallowrc.json"),
1665            r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1666        )
1667        .unwrap();
1668
1669        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1670        assert_eq!(config.rules.unused_files, Severity::Error);
1671        assert_eq!(config.rules.unused_exports, Severity::Off);
1672    }
1673
1674    #[test]
1675    fn extends_chained() {
1676        let dir = test_dir("extends-chained");
1677
1678        std::fs::write(
1679            dir.path().join("grandparent.json"),
1680            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1681        )
1682        .unwrap();
1683        std::fs::write(
1684            dir.path().join("parent.json"),
1685            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1686        )
1687        .unwrap();
1688        std::fs::write(
1689            dir.path().join(".fallowrc.json"),
1690            r#"{"extends": ["parent.json"]}"#,
1691        )
1692        .unwrap();
1693
1694        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1695        assert_eq!(config.rules.unused_files, Severity::Warn);
1696        assert_eq!(config.rules.unused_exports, Severity::Warn);
1697    }
1698
1699    #[test]
1700    fn extends_circular_detected() {
1701        let dir = test_dir("extends-circular");
1702
1703        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1704        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1705
1706        let result = FallowConfig::load(&dir.path().join("a.json"));
1707        assert!(result.is_err());
1708        let err_msg = format!("{}", result.unwrap_err());
1709        assert!(
1710            err_msg.contains("Circular extends"),
1711            "Expected circular error, got: {err_msg}"
1712        );
1713    }
1714
1715    #[test]
1716    fn extends_missing_file_errors() {
1717        let dir = test_dir("extends-missing");
1718
1719        std::fs::write(
1720            dir.path().join(".fallowrc.json"),
1721            r#"{"extends": ["nonexistent.json"]}"#,
1722        )
1723        .unwrap();
1724
1725        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1726        assert!(result.is_err());
1727        let err_msg = format!("{}", result.unwrap_err());
1728        assert!(
1729            err_msg.contains("not found"),
1730            "Expected not found error, got: {err_msg}"
1731        );
1732    }
1733
1734    #[test]
1735    fn sealed_allows_in_directory_extends() {
1736        let dir = test_dir("sealed-allows-local");
1737        std::fs::write(
1738            dir.path().join("base.json"),
1739            r#"{"ignorePatterns": ["gen/**"]}"#,
1740        )
1741        .unwrap();
1742        std::fs::write(
1743            dir.path().join(".fallowrc.json"),
1744            r#"{"sealed": true, "extends": ["./base.json"]}"#,
1745        )
1746        .unwrap();
1747
1748        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1749        assert!(config.sealed);
1750        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1751    }
1752
1753    #[test]
1754    fn load_rejects_invalid_boundary_coverage_allow_unmatched_glob() {
1755        let dir = test_dir("boundary-coverage-invalid-glob");
1756        std::fs::write(
1757            dir.path().join(".fallowrc.json"),
1758            r#"{"boundaries":{"coverage":{"allowUnmatched":["[invalid"]}}}"#,
1759        )
1760        .unwrap();
1761
1762        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1763        assert!(result.is_err());
1764        let err_msg = format!("{}", result.unwrap_err());
1765        assert!(
1766            err_msg.contains("boundaries.coverage.allowUnmatched"),
1767            "expected coverage field in error, got: {err_msg}"
1768        );
1769    }
1770
1771    #[test]
1772    fn sealed_rejects_extends_escaping_directory() {
1773        let dir = test_dir("sealed-rejects-escape");
1774        let sub = dir.path().join("packages").join("app");
1775        std::fs::create_dir_all(&sub).unwrap();
1776
1777        std::fs::write(
1778            dir.path().join("base.json"),
1779            r#"{"ignorePatterns": ["dist/**"]}"#,
1780        )
1781        .unwrap();
1782        std::fs::write(
1783            sub.join(".fallowrc.json"),
1784            r#"{"sealed": true, "extends": ["../../base.json"]}"#,
1785        )
1786        .unwrap();
1787
1788        let result = FallowConfig::load(&sub.join(".fallowrc.json"));
1789        assert!(
1790            result.is_err(),
1791            "Expected sealed config to reject escaping extends"
1792        );
1793        let err_msg = format!("{}", result.unwrap_err());
1794        assert!(
1795            err_msg.contains("sealed"),
1796            "Error must mention sealed: {err_msg}"
1797        );
1798        assert!(
1799            err_msg.contains("outside the config's directory"),
1800            "Error must explain the constraint: {err_msg}"
1801        );
1802    }
1803
1804    #[test]
1805    fn sealed_rejects_https_extends() {
1806        let dir = test_dir("sealed-rejects-https");
1807        std::fs::write(
1808            dir.path().join(".fallowrc.json"),
1809            r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
1810        )
1811        .unwrap();
1812
1813        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1814        assert!(result.is_err());
1815        let err_msg = format!("{}", result.unwrap_err());
1816        assert!(
1817            err_msg.contains("sealed"),
1818            "Error must mention sealed: {err_msg}"
1819        );
1820        assert!(
1821            err_msg.contains("URL extends"),
1822            "Error must mention URL: {err_msg}"
1823        );
1824    }
1825
1826    #[test]
1827    fn sealed_rejects_npm_extends() {
1828        let dir = test_dir("sealed-rejects-npm");
1829        std::fs::write(
1830            dir.path().join(".fallowrc.json"),
1831            r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
1832        )
1833        .unwrap();
1834
1835        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1836        assert!(result.is_err());
1837        let err_msg = format!("{}", result.unwrap_err());
1838        assert!(
1839            err_msg.contains("sealed"),
1840            "Error must mention sealed: {err_msg}"
1841        );
1842        assert!(
1843            err_msg.contains("npm extends"),
1844            "Error must mention npm: {err_msg}"
1845        );
1846    }
1847
1848    #[test]
1849    fn sealed_default_is_false() {
1850        let dir = test_dir("sealed-default");
1851        std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
1852        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1853        assert!(!config.sealed);
1854    }
1855
1856    #[test]
1857    fn sealed_false_allows_escaping_extends() {
1858        let dir = test_dir("sealed-false-allows");
1859        let sub = dir.path().join("packages").join("app");
1860        std::fs::create_dir_all(&sub).unwrap();
1861
1862        std::fs::write(
1863            dir.path().join("base.json"),
1864            r#"{"ignorePatterns": ["dist/**"]}"#,
1865        )
1866        .unwrap();
1867        std::fs::write(
1868            sub.join(".fallowrc.json"),
1869            r#"{"extends": ["../../base.json"]}"#,
1870        )
1871        .unwrap();
1872
1873        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1874        assert!(!config.sealed);
1875        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1876    }
1877
1878    #[test]
1879    fn extends_string_sugar() {
1880        let dir = test_dir("extends-string");
1881
1882        std::fs::write(
1883            dir.path().join("base.json"),
1884            r#"{"ignorePatterns": ["gen/**"]}"#,
1885        )
1886        .unwrap();
1887        std::fs::write(
1888            dir.path().join(".fallowrc.json"),
1889            r#"{"extends": "base.json"}"#,
1890        )
1891        .unwrap();
1892
1893        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1894        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1895    }
1896
1897    #[test]
1898    fn extends_deep_merge_preserves_arrays() {
1899        let dir = test_dir("extends-array");
1900
1901        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1902        std::fs::write(
1903            dir.path().join(".fallowrc.json"),
1904            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1905        )
1906        .unwrap();
1907
1908        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1909        assert_eq!(config.entry, vec!["src/b.ts"]);
1910    }
1911
1912    fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1913        let pkg_dir = root.join("node_modules").join(name);
1914        std::fs::create_dir_all(&pkg_dir).unwrap();
1915        std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1916    }
1917
1918    fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1919        let pkg_dir = root.join("node_modules").join(name);
1920        std::fs::create_dir_all(&pkg_dir).unwrap();
1921        std::fs::write(
1922            pkg_dir.join("package.json"),
1923            format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1924        )
1925        .unwrap();
1926        std::fs::write(pkg_dir.join(main), config_json).unwrap();
1927    }
1928
1929    #[test]
1930    fn extends_npm_basic_unscoped() {
1931        let dir = test_dir("npm-basic");
1932        create_npm_package(
1933            dir.path(),
1934            "fallow-config-acme",
1935            r#"{"rules": {"unused-files": "warn"}}"#,
1936        );
1937        std::fs::write(
1938            dir.path().join(".fallowrc.json"),
1939            r#"{"extends": "npm:fallow-config-acme"}"#,
1940        )
1941        .unwrap();
1942
1943        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1944        assert_eq!(config.rules.unused_files, Severity::Warn);
1945    }
1946
1947    #[test]
1948    fn extends_npm_scoped_package() {
1949        let dir = test_dir("npm-scoped");
1950        create_npm_package(
1951            dir.path(),
1952            "@company/fallow-config",
1953            r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1954        );
1955        std::fs::write(
1956            dir.path().join(".fallowrc.json"),
1957            r#"{"extends": "npm:@company/fallow-config"}"#,
1958        )
1959        .unwrap();
1960
1961        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1962        assert_eq!(config.rules.unused_exports, Severity::Off);
1963        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1964    }
1965
1966    #[test]
1967    fn extends_npm_with_subpath() {
1968        let dir = test_dir("npm-subpath");
1969        let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1970        std::fs::create_dir_all(&pkg_dir).unwrap();
1971        std::fs::write(
1972            pkg_dir.join("strict.json"),
1973            r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1974        )
1975        .unwrap();
1976
1977        std::fs::write(
1978            dir.path().join(".fallowrc.json"),
1979            r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1980        )
1981        .unwrap();
1982
1983        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1984        assert_eq!(config.rules.unused_files, Severity::Error);
1985        assert_eq!(config.rules.unused_exports, Severity::Error);
1986    }
1987
1988    #[test]
1989    fn extends_npm_package_json_main() {
1990        let dir = test_dir("npm-main");
1991        create_npm_package_with_main(
1992            dir.path(),
1993            "fallow-config-acme",
1994            "config.json",
1995            r#"{"rules": {"unused-types": "off"}}"#,
1996        );
1997        std::fs::write(
1998            dir.path().join(".fallowrc.json"),
1999            r#"{"extends": "npm:fallow-config-acme"}"#,
2000        )
2001        .unwrap();
2002
2003        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2004        assert_eq!(config.rules.unused_types, Severity::Off);
2005    }
2006
2007    #[test]
2008    fn extends_npm_package_json_exports_string() {
2009        let dir = test_dir("npm-exports-str");
2010        let pkg_dir = dir.path().join("node_modules/fallow-config-co");
2011        std::fs::create_dir_all(&pkg_dir).unwrap();
2012        std::fs::write(
2013            pkg_dir.join("package.json"),
2014            r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
2015        )
2016        .unwrap();
2017        std::fs::write(
2018            pkg_dir.join("base.json"),
2019            r#"{"rules": {"circular-dependencies": "warn"}}"#,
2020        )
2021        .unwrap();
2022
2023        std::fs::write(
2024            dir.path().join(".fallowrc.json"),
2025            r#"{"extends": "npm:fallow-config-co"}"#,
2026        )
2027        .unwrap();
2028
2029        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2030        assert_eq!(config.rules.circular_dependencies, Severity::Warn);
2031    }
2032
2033    #[test]
2034    fn extends_npm_package_json_exports_object() {
2035        let dir = test_dir("npm-exports-obj");
2036        let pkg_dir = dir.path().join("node_modules/@co/cfg");
2037        std::fs::create_dir_all(&pkg_dir).unwrap();
2038        std::fs::write(
2039            pkg_dir.join("package.json"),
2040            r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
2041        )
2042        .unwrap();
2043        std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
2044
2045        std::fs::write(
2046            dir.path().join(".fallowrc.json"),
2047            r#"{"extends": "npm:@co/cfg"}"#,
2048        )
2049        .unwrap();
2050
2051        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2052        assert_eq!(config.entry, vec!["src/app.ts"]);
2053    }
2054
2055    #[test]
2056    fn extends_npm_exports_takes_priority_over_main() {
2057        let dir = test_dir("npm-exports-prio");
2058        let pkg_dir = dir.path().join("node_modules/my-config");
2059        std::fs::create_dir_all(&pkg_dir).unwrap();
2060        std::fs::write(
2061            pkg_dir.join("package.json"),
2062            r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
2063        )
2064        .unwrap();
2065        std::fs::write(
2066            pkg_dir.join("old.json"),
2067            r#"{"rules": {"unused-files": "off"}}"#,
2068        )
2069        .unwrap();
2070        std::fs::write(
2071            pkg_dir.join("new.json"),
2072            r#"{"rules": {"unused-files": "warn"}}"#,
2073        )
2074        .unwrap();
2075
2076        std::fs::write(
2077            dir.path().join(".fallowrc.json"),
2078            r#"{"extends": "npm:my-config"}"#,
2079        )
2080        .unwrap();
2081
2082        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2083        assert_eq!(config.rules.unused_files, Severity::Warn);
2084    }
2085
2086    #[test]
2087    fn extends_npm_walk_up_directories() {
2088        let dir = test_dir("npm-walkup");
2089        create_npm_package(
2090            dir.path(),
2091            "shared-config",
2092            r#"{"rules": {"unused-files": "warn"}}"#,
2093        );
2094        let sub = dir.path().join("packages/app");
2095        std::fs::create_dir_all(&sub).unwrap();
2096        std::fs::write(
2097            sub.join(".fallowrc.json"),
2098            r#"{"extends": "npm:shared-config"}"#,
2099        )
2100        .unwrap();
2101
2102        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2103        assert_eq!(config.rules.unused_files, Severity::Warn);
2104    }
2105
2106    #[test]
2107    fn extends_npm_overlay_overrides_base() {
2108        let dir = test_dir("npm-overlay");
2109        create_npm_package(
2110            dir.path(),
2111            "@company/base",
2112            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
2113        );
2114        std::fs::write(
2115            dir.path().join(".fallowrc.json"),
2116            r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
2117        )
2118        .unwrap();
2119
2120        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2121        assert_eq!(config.rules.unused_files, Severity::Error);
2122        assert_eq!(config.rules.unused_exports, Severity::Off);
2123        assert_eq!(config.entry, vec!["src/app.ts"]);
2124    }
2125
2126    #[test]
2127    fn extends_npm_chained_with_relative() {
2128        let dir = test_dir("npm-chained");
2129        let pkg_dir = dir.path().join("node_modules/my-config");
2130        std::fs::create_dir_all(&pkg_dir).unwrap();
2131        std::fs::write(
2132            pkg_dir.join("base.json"),
2133            r#"{"rules": {"unused-files": "warn"}}"#,
2134        )
2135        .unwrap();
2136        std::fs::write(
2137            pkg_dir.join(".fallowrc.json"),
2138            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2139        )
2140        .unwrap();
2141
2142        std::fs::write(
2143            dir.path().join(".fallowrc.json"),
2144            r#"{"extends": "npm:my-config"}"#,
2145        )
2146        .unwrap();
2147
2148        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2149        assert_eq!(config.rules.unused_files, Severity::Warn);
2150        assert_eq!(config.rules.unused_exports, Severity::Off);
2151    }
2152
2153    #[test]
2154    fn extends_npm_mixed_with_relative_paths() {
2155        let dir = test_dir("npm-mixed");
2156        create_npm_package(
2157            dir.path(),
2158            "shared-base",
2159            r#"{"rules": {"unused-files": "off"}}"#,
2160        );
2161        std::fs::write(
2162            dir.path().join("local-overrides.json"),
2163            r#"{"rules": {"unused-files": "warn"}}"#,
2164        )
2165        .unwrap();
2166        std::fs::write(
2167            dir.path().join(".fallowrc.json"),
2168            r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
2169        )
2170        .unwrap();
2171
2172        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2173        assert_eq!(config.rules.unused_files, Severity::Warn);
2174    }
2175
2176    #[test]
2177    fn extends_npm_missing_package_errors() {
2178        let dir = test_dir("npm-missing");
2179        std::fs::write(
2180            dir.path().join(".fallowrc.json"),
2181            r#"{"extends": "npm:nonexistent-package"}"#,
2182        )
2183        .unwrap();
2184
2185        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2186        assert!(result.is_err());
2187        let err_msg = format!("{}", result.unwrap_err());
2188        assert!(
2189            err_msg.contains("not found"),
2190            "Expected 'not found' error, got: {err_msg}"
2191        );
2192        assert!(
2193            err_msg.contains("nonexistent-package"),
2194            "Expected package name in error, got: {err_msg}"
2195        );
2196        assert!(
2197            err_msg.contains("install it"),
2198            "Expected install hint in error, got: {err_msg}"
2199        );
2200    }
2201
2202    #[test]
2203    fn extends_npm_no_config_in_package_errors() {
2204        let dir = test_dir("npm-no-config");
2205        let pkg_dir = dir.path().join("node_modules/empty-pkg");
2206        std::fs::create_dir_all(&pkg_dir).unwrap();
2207        std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
2208
2209        std::fs::write(
2210            dir.path().join(".fallowrc.json"),
2211            r#"{"extends": "npm:empty-pkg"}"#,
2212        )
2213        .unwrap();
2214
2215        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2216        assert!(result.is_err());
2217        let err_msg = format!("{}", result.unwrap_err());
2218        assert!(
2219            err_msg.contains("No fallow config found"),
2220            "Expected 'No fallow config found' error, got: {err_msg}"
2221        );
2222    }
2223
2224    #[test]
2225    fn extends_npm_missing_subpath_errors() {
2226        let dir = test_dir("npm-missing-sub");
2227        let pkg_dir = dir.path().join("node_modules/@co/config");
2228        std::fs::create_dir_all(&pkg_dir).unwrap();
2229
2230        std::fs::write(
2231            dir.path().join(".fallowrc.json"),
2232            r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
2233        )
2234        .unwrap();
2235
2236        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2237        assert!(result.is_err());
2238        let err_msg = format!("{}", result.unwrap_err());
2239        assert!(
2240            err_msg.contains("nonexistent.json"),
2241            "Expected subpath in error, got: {err_msg}"
2242        );
2243    }
2244
2245    #[test]
2246    fn extends_npm_empty_specifier_errors() {
2247        let dir = test_dir("npm-empty");
2248        std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
2249
2250        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2251        assert!(result.is_err());
2252        let err_msg = format!("{}", result.unwrap_err());
2253        assert!(
2254            err_msg.contains("Empty npm specifier"),
2255            "Expected 'Empty npm specifier' error, got: {err_msg}"
2256        );
2257    }
2258
2259    #[test]
2260    fn extends_npm_space_after_colon_trimmed() {
2261        let dir = test_dir("npm-space");
2262        create_npm_package(
2263            dir.path(),
2264            "fallow-config-acme",
2265            r#"{"rules": {"unused-files": "warn"}}"#,
2266        );
2267        std::fs::write(
2268            dir.path().join(".fallowrc.json"),
2269            r#"{"extends": "npm: fallow-config-acme"}"#,
2270        )
2271        .unwrap();
2272
2273        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2274        assert_eq!(config.rules.unused_files, Severity::Warn);
2275    }
2276
2277    #[test]
2278    fn extends_npm_exports_node_condition() {
2279        let dir = test_dir("npm-node-cond");
2280        let pkg_dir = dir.path().join("node_modules/node-config");
2281        std::fs::create_dir_all(&pkg_dir).unwrap();
2282        std::fs::write(
2283            pkg_dir.join("package.json"),
2284            r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
2285        )
2286        .unwrap();
2287        std::fs::write(
2288            pkg_dir.join("node.json"),
2289            r#"{"rules": {"unused-files": "off"}}"#,
2290        )
2291        .unwrap();
2292
2293        std::fs::write(
2294            dir.path().join(".fallowrc.json"),
2295            r#"{"extends": "npm:node-config"}"#,
2296        )
2297        .unwrap();
2298
2299        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2300        assert_eq!(config.rules.unused_files, Severity::Off);
2301    }
2302
2303    #[test]
2304    fn parse_npm_specifier_unscoped() {
2305        assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
2306    }
2307
2308    #[test]
2309    fn parse_npm_specifier_unscoped_with_subpath() {
2310        assert_eq!(
2311            parse_npm_specifier("my-config/strict.json"),
2312            ("my-config", Some("strict.json"))
2313        );
2314    }
2315
2316    #[test]
2317    fn parse_npm_specifier_scoped() {
2318        assert_eq!(
2319            parse_npm_specifier("@company/fallow-config"),
2320            ("@company/fallow-config", None)
2321        );
2322    }
2323
2324    #[test]
2325    fn parse_npm_specifier_scoped_with_subpath() {
2326        assert_eq!(
2327            parse_npm_specifier("@company/fallow-config/strict.json"),
2328            ("@company/fallow-config", Some("strict.json"))
2329        );
2330    }
2331
2332    #[test]
2333    fn parse_npm_specifier_scoped_with_nested_subpath() {
2334        assert_eq!(
2335            parse_npm_specifier("@company/fallow-config/presets/strict.json"),
2336            ("@company/fallow-config", Some("presets/strict.json"))
2337        );
2338    }
2339
2340    #[test]
2341    fn extends_npm_subpath_traversal_rejected() {
2342        let dir = test_dir("npm-traversal-sub");
2343        let pkg_dir = dir.path().join("node_modules/evil-pkg");
2344        std::fs::create_dir_all(&pkg_dir).unwrap();
2345        std::fs::write(
2346            dir.path().join("secret.json"),
2347            r#"{"entry": ["stolen.ts"]}"#,
2348        )
2349        .unwrap();
2350
2351        std::fs::write(
2352            dir.path().join(".fallowrc.json"),
2353            r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
2354        )
2355        .unwrap();
2356
2357        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2358        assert!(result.is_err());
2359        let err_msg = format!("{}", result.unwrap_err());
2360        assert!(
2361            err_msg.contains("traversal") || err_msg.contains("not found"),
2362            "Expected traversal or not-found error, got: {err_msg}"
2363        );
2364    }
2365
2366    #[test]
2367    fn extends_npm_dotdot_package_name_rejected() {
2368        let dir = test_dir("npm-dotdot-name");
2369        std::fs::write(
2370            dir.path().join(".fallowrc.json"),
2371            r#"{"extends": "npm:../relative"}"#,
2372        )
2373        .unwrap();
2374
2375        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2376        assert!(result.is_err());
2377        let err_msg = format!("{}", result.unwrap_err());
2378        assert!(
2379            err_msg.contains("path traversal"),
2380            "Expected 'path traversal' error, got: {err_msg}"
2381        );
2382    }
2383
2384    #[test]
2385    fn extends_npm_scoped_without_name_rejected() {
2386        let dir = test_dir("npm-scope-only");
2387        std::fs::write(
2388            dir.path().join(".fallowrc.json"),
2389            r#"{"extends": "npm:@scope"}"#,
2390        )
2391        .unwrap();
2392
2393        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2394        assert!(result.is_err());
2395        let err_msg = format!("{}", result.unwrap_err());
2396        assert!(
2397            err_msg.contains("@scope/name"),
2398            "Expected scoped name format error, got: {err_msg}"
2399        );
2400    }
2401
2402    #[test]
2403    fn extends_npm_malformed_package_json_errors() {
2404        let dir = test_dir("npm-bad-pkgjson");
2405        let pkg_dir = dir.path().join("node_modules/bad-pkg");
2406        std::fs::create_dir_all(&pkg_dir).unwrap();
2407        std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
2408
2409        std::fs::write(
2410            dir.path().join(".fallowrc.json"),
2411            r#"{"extends": "npm:bad-pkg"}"#,
2412        )
2413        .unwrap();
2414
2415        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2416        assert!(result.is_err());
2417        let err_msg = format!("{}", result.unwrap_err());
2418        assert!(
2419            err_msg.contains("Failed to parse"),
2420            "Expected parse error, got: {err_msg}"
2421        );
2422    }
2423
2424    #[test]
2425    fn extends_npm_exports_traversal_rejected() {
2426        let dir = test_dir("npm-exports-escape");
2427        let pkg_dir = dir.path().join("node_modules/evil-exports");
2428        std::fs::create_dir_all(&pkg_dir).unwrap();
2429        std::fs::write(
2430            pkg_dir.join("package.json"),
2431            r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
2432        )
2433        .unwrap();
2434        std::fs::write(
2435            dir.path().join("secret.json"),
2436            r#"{"entry": ["stolen.ts"]}"#,
2437        )
2438        .unwrap();
2439
2440        std::fs::write(
2441            dir.path().join(".fallowrc.json"),
2442            r#"{"extends": "npm:evil-exports"}"#,
2443        )
2444        .unwrap();
2445
2446        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2447        assert!(result.is_err());
2448        let err_msg = format!("{}", result.unwrap_err());
2449        assert!(
2450            err_msg.contains("traversal"),
2451            "Expected traversal error, got: {err_msg}"
2452        );
2453    }
2454
2455    #[test]
2456    fn deep_merge_scalar_overlay_replaces_base() {
2457        let mut base = serde_json::json!("hello");
2458        deep_merge_json(&mut base, serde_json::json!("world"));
2459        assert_eq!(base, serde_json::json!("world"));
2460    }
2461
2462    #[test]
2463    fn deep_merge_array_overlay_replaces_base() {
2464        let mut base = serde_json::json!(["a", "b"]);
2465        deep_merge_json(&mut base, serde_json::json!(["c"]));
2466        assert_eq!(base, serde_json::json!(["c"]));
2467    }
2468
2469    #[test]
2470    fn deep_merge_nested_object_merge() {
2471        let mut base = serde_json::json!({
2472            "level1": {
2473                "level2": {
2474                    "a": 1,
2475                    "b": 2
2476                }
2477            }
2478        });
2479        let overlay = serde_json::json!({
2480            "level1": {
2481                "level2": {
2482                    "b": 99,
2483                    "c": 3
2484                }
2485            }
2486        });
2487        deep_merge_json(&mut base, overlay);
2488        assert_eq!(base["level1"]["level2"]["a"], 1);
2489        assert_eq!(base["level1"]["level2"]["b"], 99);
2490        assert_eq!(base["level1"]["level2"]["c"], 3);
2491    }
2492
2493    #[test]
2494    fn deep_merge_overlay_adds_new_fields() {
2495        let mut base = serde_json::json!({"existing": true});
2496        let overlay = serde_json::json!({"new_field": "added", "another": 42});
2497        deep_merge_json(&mut base, overlay);
2498        assert_eq!(base["existing"], true);
2499        assert_eq!(base["new_field"], "added");
2500        assert_eq!(base["another"], 42);
2501    }
2502
2503    #[test]
2504    fn deep_merge_null_overlay_replaces_object() {
2505        let mut base = serde_json::json!({"key": "value"});
2506        deep_merge_json(&mut base, serde_json::json!(null));
2507        assert_eq!(base, serde_json::json!(null));
2508    }
2509
2510    #[test]
2511    fn deep_merge_empty_object_overlay_preserves_base() {
2512        let mut base = serde_json::json!({"a": 1, "b": 2});
2513        deep_merge_json(&mut base, serde_json::json!({}));
2514        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2515    }
2516
2517    #[test]
2518    fn rules_severity_error_warn_off_from_json() {
2519        let json_str = r#"{
2520            "rules": {
2521                "unused-files": "error",
2522                "unused-exports": "warn",
2523                "unused-types": "off"
2524            }
2525        }"#;
2526        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2527        assert_eq!(config.rules.unused_files, Severity::Error);
2528        assert_eq!(config.rules.unused_exports, Severity::Warn);
2529        assert_eq!(config.rules.unused_types, Severity::Off);
2530    }
2531
2532    #[test]
2533    fn rules_omitted_default_to_error() {
2534        let json_str = r#"{
2535            "rules": {
2536                "unused-files": "warn"
2537            }
2538        }"#;
2539        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2540        assert_eq!(config.rules.unused_files, Severity::Warn);
2541        assert_eq!(config.rules.unused_exports, Severity::Error);
2542        assert_eq!(config.rules.unused_types, Severity::Error);
2543        assert_eq!(config.rules.unused_dependencies, Severity::Error);
2544        assert_eq!(config.rules.unresolved_imports, Severity::Error);
2545        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
2546        assert_eq!(config.rules.duplicate_exports, Severity::Error);
2547        assert_eq!(config.rules.circular_dependencies, Severity::Error);
2548        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
2549    }
2550
2551    #[test]
2552    fn find_and_load_returns_none_when_no_config() {
2553        let dir = test_dir("find-none");
2554        std::fs::create_dir(dir.path().join(".git")).unwrap();
2555
2556        let result = FallowConfig::find_and_load(dir.path()).unwrap();
2557        assert!(result.is_none());
2558    }
2559
2560    #[test]
2561    fn find_and_load_finds_fallowrc_json() {
2562        let dir = test_dir("find-json");
2563        std::fs::create_dir(dir.path().join(".git")).unwrap();
2564        std::fs::write(
2565            dir.path().join(".fallowrc.json"),
2566            r#"{"entry": ["src/main.ts"]}"#,
2567        )
2568        .unwrap();
2569
2570        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2571        assert_eq!(config.entry, vec!["src/main.ts"]);
2572        assert!(path.ends_with(".fallowrc.json"));
2573    }
2574
2575    #[test]
2576    fn find_and_load_finds_fallowrc_jsonc() {
2577        let dir = test_dir("find-jsonc");
2578        std::fs::create_dir(dir.path().join(".git")).unwrap();
2579        std::fs::write(
2580            dir.path().join(".fallowrc.jsonc"),
2581            r#"{
2582                "entry": ["src/main.ts"]
2583            }"#,
2584        )
2585        .unwrap();
2586
2587        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2588        assert_eq!(config.entry, vec!["src/main.ts"]);
2589        assert!(path.ends_with(".fallowrc.jsonc"));
2590    }
2591
2592    #[test]
2593    fn find_and_load_prefers_fallowrc_json_over_jsonc() {
2594        let dir = test_dir("find-json-vs-jsonc");
2595        std::fs::create_dir(dir.path().join(".git")).unwrap();
2596        std::fs::write(
2597            dir.path().join(".fallowrc.json"),
2598            r#"{"entry": ["from-json.ts"]}"#,
2599        )
2600        .unwrap();
2601        std::fs::write(
2602            dir.path().join(".fallowrc.jsonc"),
2603            r#"{"entry": ["from-jsonc.ts"]}"#,
2604        )
2605        .unwrap();
2606
2607        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2608        assert_eq!(config.entry, vec!["from-json.ts"]);
2609        assert!(path.ends_with(".fallowrc.json"));
2610    }
2611
2612    #[test]
2613    fn find_and_load_prefers_fallowrc_json_over_toml() {
2614        let dir = test_dir("find-priority");
2615        std::fs::create_dir(dir.path().join(".git")).unwrap();
2616        std::fs::write(
2617            dir.path().join(".fallowrc.json"),
2618            r#"{"entry": ["from-json.ts"]}"#,
2619        )
2620        .unwrap();
2621        std::fs::write(
2622            dir.path().join("fallow.toml"),
2623            "entry = [\"from-toml.ts\"]\n",
2624        )
2625        .unwrap();
2626
2627        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2628        assert_eq!(config.entry, vec!["from-json.ts"]);
2629        assert!(path.ends_with(".fallowrc.json"));
2630    }
2631
2632    #[test]
2633    fn shadowed_config_names_empty_when_single_config() {
2634        let dir = test_dir("shadow-single");
2635        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2636        assert!(shadowed_config_names(dir.path(), 0).is_empty());
2637    }
2638
2639    #[test]
2640    fn shadowed_config_names_reports_lower_precedence_toml() {
2641        let dir = test_dir("shadow-json-toml");
2642        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2643        std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
2644        assert_eq!(shadowed_config_names(dir.path(), 0), vec!["fallow.toml"]);
2645    }
2646
2647    #[test]
2648    fn shadowed_config_names_reports_jsonc_sibling() {
2649        let dir = test_dir("shadow-json-jsonc");
2650        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2651        std::fs::write(dir.path().join(".fallowrc.jsonc"), "").unwrap();
2652        assert_eq!(
2653            shadowed_config_names(dir.path(), 0),
2654            vec![".fallowrc.jsonc"]
2655        );
2656    }
2657
2658    #[test]
2659    fn shadowed_config_names_reports_all_lower_when_four_coexist() {
2660        let dir = test_dir("shadow-all-four");
2661        for name in CONFIG_NAMES {
2662            std::fs::write(dir.path().join(name), "").unwrap();
2663        }
2664        assert_eq!(
2665            shadowed_config_names(dir.path(), 0),
2666            vec![".fallowrc.jsonc", "fallow.toml", ".fallow.toml"],
2667        );
2668    }
2669
2670    #[test]
2671    fn shadowed_config_names_scoped_to_indices_after_winner() {
2672        let dir = test_dir("shadow-toml-dottoml");
2673        std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
2674        std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
2675        assert_eq!(shadowed_config_names(dir.path(), 2), vec![".fallow.toml"]);
2676    }
2677
2678    #[test]
2679    fn find_and_load_warns_when_configs_coexist() {
2680        let dir = test_dir("coexist-warn");
2681        std::fs::create_dir(dir.path().join(".git")).unwrap();
2682        std::fs::write(
2683            dir.path().join(".fallowrc.json"),
2684            r#"{"entry": ["from-json.ts"]}"#,
2685        )
2686        .unwrap();
2687        std::fs::write(
2688            dir.path().join("fallow.toml"),
2689            "entry = [\"from-toml.ts\"]\n",
2690        )
2691        .unwrap();
2692
2693        let (result, captured) =
2694            capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
2695
2696        let (config, path) = result.unwrap().unwrap();
2697        assert_eq!(config.entry, vec!["from-json.ts"]);
2698        assert!(path.ends_with(".fallowrc.json"));
2699
2700        assert_eq!(captured.len(), 1);
2701        let (chosen, shadowed) = &captured[0];
2702        assert_eq!(chosen, ".fallowrc.json");
2703        assert_eq!(shadowed, &vec!["fallow.toml".to_owned()]);
2704    }
2705
2706    #[test]
2707    fn find_and_load_does_not_warn_for_single_config() {
2708        let dir = test_dir("coexist-none");
2709        std::fs::create_dir(dir.path().join(".git")).unwrap();
2710        std::fs::write(
2711            dir.path().join(".fallowrc.json"),
2712            r#"{"entry": ["only.ts"]}"#,
2713        )
2714        .unwrap();
2715
2716        let (result, captured) =
2717            capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
2718        assert!(result.unwrap().is_some());
2719        assert!(captured.is_empty());
2720    }
2721
2722    #[test]
2723    fn find_and_load_warns_per_directory_independently() {
2724        let make = |name: &str| {
2725            let dir = test_dir(name);
2726            std::fs::create_dir(dir.path().join(".git")).unwrap();
2727            std::fs::write(dir.path().join(".fallowrc.json"), r#"{"entry": ["a.ts"]}"#).unwrap();
2728            std::fs::write(dir.path().join("fallow.toml"), "entry = [\"a.ts\"]\n").unwrap();
2729            dir
2730        };
2731        let first = make("coexist-dir-a");
2732        let second = make("coexist-dir-b");
2733
2734        let ((), captured) = capture_coexisting_config_warnings(|| {
2735            FallowConfig::find_and_load(first.path()).unwrap();
2736            FallowConfig::find_and_load(second.path()).unwrap();
2737        });
2738
2739        assert_eq!(captured.len(), 2);
2740        assert!(captured.iter().all(|(chosen, shadowed)| {
2741            chosen == ".fallowrc.json" && shadowed == &vec!["fallow.toml".to_owned()]
2742        }));
2743    }
2744
2745    #[test]
2746    fn explicit_load_does_not_warn_about_coexisting_configs() {
2747        let dir = test_dir("coexist-explicit");
2748        std::fs::write(
2749            dir.path().join(".fallowrc.json"),
2750            r#"{"entry": ["chosen.ts"]}"#,
2751        )
2752        .unwrap();
2753        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"other.ts\"]\n").unwrap();
2754
2755        let chosen = dir.path().join("fallow.toml");
2756        let (result, captured) = capture_coexisting_config_warnings(|| FallowConfig::load(&chosen));
2757        assert!(result.is_ok());
2758        assert!(captured.is_empty());
2759    }
2760
2761    #[test]
2762    fn find_and_load_finds_fallow_toml() {
2763        let dir = test_dir("find-toml");
2764        std::fs::create_dir(dir.path().join(".git")).unwrap();
2765        std::fs::write(
2766            dir.path().join("fallow.toml"),
2767            "entry = [\"src/index.ts\"]\n",
2768        )
2769        .unwrap();
2770
2771        let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2772        assert_eq!(config.entry, vec!["src/index.ts"]);
2773    }
2774
2775    #[test]
2776    fn find_and_load_stops_at_git_dir() {
2777        let dir = test_dir("find-git-stop");
2778        let sub = dir.path().join("sub");
2779        std::fs::create_dir(&sub).unwrap();
2780        std::fs::create_dir(dir.path().join(".git")).unwrap();
2781        let result = FallowConfig::find_and_load(&sub).unwrap();
2782        assert!(result.is_none());
2783    }
2784
2785    #[test]
2786    fn find_and_load_walks_past_package_json_in_monorepo() {
2787        let dir = test_dir("find-monorepo");
2788        std::fs::create_dir(dir.path().join(".git")).unwrap();
2789        std::fs::write(
2790            dir.path().join(".fallowrc.json"),
2791            r#"{"entry": ["src/index.ts"]}"#,
2792        )
2793        .unwrap();
2794
2795        let sub = dir.path().join("packages").join("app");
2796        std::fs::create_dir_all(&sub).unwrap();
2797        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2798
2799        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2800        assert_eq!(config.entry, vec!["src/index.ts"]);
2801        assert_eq!(path, dir.path().join(".fallowrc.json"));
2802    }
2803
2804    #[test]
2805    fn find_and_load_sub_package_config_wins_over_root() {
2806        let dir = test_dir("find-monorepo-override");
2807        std::fs::create_dir(dir.path().join(".git")).unwrap();
2808        std::fs::write(
2809            dir.path().join(".fallowrc.json"),
2810            r#"{"entry": ["src/root.ts"]}"#,
2811        )
2812        .unwrap();
2813
2814        let sub = dir.path().join("packages").join("app");
2815        std::fs::create_dir_all(&sub).unwrap();
2816        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2817        std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
2818
2819        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2820        assert_eq!(config.entry, vec!["src/sub.ts"]);
2821        assert_eq!(path, sub.join(".fallowrc.json"));
2822    }
2823
2824    #[test]
2825    fn find_and_load_stops_at_git_file_submodule() {
2826        let dir = test_dir("find-git-file");
2827        std::fs::create_dir(dir.path().join(".git")).unwrap();
2828        std::fs::write(
2829            dir.path().join(".fallowrc.json"),
2830            r#"{"entry": ["src/parent.ts"]}"#,
2831        )
2832        .unwrap();
2833
2834        let submodule = dir.path().join("vendor").join("lib");
2835        std::fs::create_dir_all(&submodule).unwrap();
2836        std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
2837
2838        let result = FallowConfig::find_and_load(&submodule).unwrap();
2839        assert!(
2840            result.is_none(),
2841            "submodule boundary should stop config walk",
2842        );
2843    }
2844
2845    #[test]
2846    fn find_and_load_stops_at_hg_dir() {
2847        let dir = test_dir("find-hg-stop");
2848        let sub = dir.path().join("sub");
2849        std::fs::create_dir(&sub).unwrap();
2850        std::fs::create_dir(dir.path().join(".hg")).unwrap();
2851
2852        let result = FallowConfig::find_and_load(&sub).unwrap();
2853        assert!(result.is_none());
2854    }
2855
2856    #[test]
2857    fn find_and_load_returns_error_for_invalid_config() {
2858        let dir = test_dir("find-invalid");
2859        std::fs::create_dir(dir.path().join(".git")).unwrap();
2860        std::fs::write(
2861            dir.path().join(".fallowrc.json"),
2862            r"{ this is not valid json }",
2863        )
2864        .unwrap();
2865
2866        let result = FallowConfig::find_and_load(dir.path());
2867        assert!(result.is_err());
2868    }
2869
2870    #[test]
2871    fn load_toml_config_file() {
2872        let dir = test_dir("toml-config");
2873        let config_path = dir.path().join("fallow.toml");
2874        std::fs::write(
2875            &config_path,
2876            r#"
2877entry = ["src/index.ts"]
2878ignorePatterns = ["dist/**"]
2879
2880[rules]
2881unused-files = "warn"
2882
2883[duplicates]
2884minTokens = 100
2885"#,
2886        )
2887        .unwrap();
2888
2889        let config = FallowConfig::load(&config_path).unwrap();
2890        assert_eq!(config.entry, vec!["src/index.ts"]);
2891        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2892        assert_eq!(config.rules.unused_files, Severity::Warn);
2893        assert_eq!(config.duplicates.min_tokens, 100);
2894    }
2895
2896    #[test]
2897    fn load_toml_config_file_with_health_threshold_override() {
2898        let dir = test_dir("toml-health-threshold-override");
2899        let config_path = dir.path().join("fallow.toml");
2900        std::fs::write(
2901            &config_path,
2902            r#"
2903[health]
2904thresholdOverrides = [
2905  { files = ["src/legacy.ts"], functions = ["legacyFlow"], maxCyclomatic = 30, maxCognitive = 25, maxCrap = 80.5, reason = "legacy migration" }
2906]
2907"#,
2908        )
2909        .unwrap();
2910
2911        let config = FallowConfig::load(&config_path).unwrap();
2912        let override_config = &config.health.threshold_overrides[0];
2913        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
2914        assert_eq!(override_config.functions, vec!["legacyFlow"]);
2915        assert_eq!(override_config.max_cyclomatic, Some(30));
2916        assert_eq!(override_config.max_cognitive, Some(25));
2917        assert_eq!(override_config.max_crap, Some(80.5));
2918        assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
2919    }
2920
2921    #[test]
2922    fn extends_absolute_path_rejected() {
2923        let dir = test_dir("extends-absolute");
2924
2925        #[cfg(unix)]
2926        let abs_path = "/absolute/path/config.json";
2927        #[cfg(windows)]
2928        let abs_path = "C:\\absolute\\path\\config.json";
2929
2930        let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2931        std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2932
2933        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2934        assert!(result.is_err());
2935        let err_msg = format!("{}", result.unwrap_err());
2936        assert!(
2937            err_msg.contains("must be relative"),
2938            "Expected 'must be relative' error, got: {err_msg}"
2939        );
2940    }
2941
2942    #[test]
2943    fn extends_windows_drive_absolute_path_rejected_on_any_host() {
2944        let dir = test_dir("extends-windows-absolute");
2945
2946        std::fs::write(
2947            dir.path().join(".fallowrc.json"),
2948            r#"{"extends": ["C:\\absolute\\path\\config.json"]}"#,
2949        )
2950        .unwrap();
2951
2952        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2953        assert!(result.is_err());
2954        let err_msg = format!("{}", result.unwrap_err());
2955        assert!(
2956            err_msg.contains("must be relative"),
2957            "Expected 'must be relative' error, got: {err_msg}"
2958        );
2959    }
2960
2961    #[cfg(windows)]
2962    #[test]
2963    fn extends_posix_rooted_absolute_path_rejected_on_windows() {
2964        let dir = test_dir("extends-posix-rooted-absolute");
2965
2966        std::fs::write(
2967            dir.path().join(".fallowrc.json"),
2968            r#"{"extends": ["/absolute/path/config.json"]}"#,
2969        )
2970        .unwrap();
2971
2972        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2973        assert!(result.is_err());
2974        let err_msg = format!("{}", result.unwrap_err());
2975        assert!(
2976            err_msg.contains("must be relative"),
2977            "Expected 'must be relative' error, got: {err_msg}"
2978        );
2979    }
2980
2981    #[test]
2982    fn resolve_production_mode_disables_dev_deps() {
2983        let config = FallowConfig {
2984            production: true.into(),
2985            ..Default::default()
2986        };
2987        let resolved = config.resolve(
2988            PathBuf::from("/tmp/test"),
2989            OutputFormat::Human,
2990            4,
2991            false,
2992            true,
2993            None,
2994        );
2995        assert!(resolved.production);
2996        assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
2997        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
2998        assert_eq!(resolved.rules.unused_files, Severity::Error);
2999        assert_eq!(resolved.rules.unused_exports, Severity::Error);
3000    }
3001
3002    #[test]
3003    fn include_entry_exports_deserializes_from_camelcase_json() {
3004        let json = r#"{ "includeEntryExports": true }"#;
3005        let config: FallowConfig = serde_json::from_str(json).unwrap();
3006        assert!(config.include_entry_exports);
3007    }
3008
3009    #[test]
3010    fn include_entry_exports_deserializes_from_camelcase_toml() {
3011        let toml_str = "includeEntryExports = true\n";
3012        let config: FallowConfig = toml::from_str(toml_str).unwrap();
3013        assert!(config.include_entry_exports);
3014    }
3015
3016    #[test]
3017    fn include_entry_exports_default_is_false() {
3018        let config: FallowConfig = serde_json::from_str("{}").unwrap();
3019        assert!(!config.include_entry_exports);
3020    }
3021
3022    #[test]
3023    fn include_entry_exports_propagates_through_resolve() {
3024        let config = FallowConfig {
3025            include_entry_exports: true,
3026            auto_imports: false,
3027            cache: CacheConfig::default(),
3028            ..Default::default()
3029        };
3030        let resolved = config.resolve(
3031            PathBuf::from("/tmp/test"),
3032            OutputFormat::Human,
3033            1,
3034            true,
3035            true,
3036            None,
3037        );
3038        assert!(resolved.include_entry_exports);
3039    }
3040
3041    #[test]
3042    fn config_format_defaults_to_toml_for_unknown() {
3043        assert!(matches!(
3044            ConfigFormat::from_path(Path::new("config.yaml")),
3045            ConfigFormat::Toml
3046        ));
3047        assert!(matches!(
3048            ConfigFormat::from_path(Path::new("config")),
3049            ConfigFormat::Toml
3050        ));
3051    }
3052
3053    #[test]
3054    fn deep_merge_object_over_scalar_replaces() {
3055        let mut base = serde_json::json!("just a string");
3056        let overlay = serde_json::json!({"key": "value"});
3057        deep_merge_json(&mut base, overlay);
3058        assert_eq!(base, serde_json::json!({"key": "value"}));
3059    }
3060
3061    #[test]
3062    fn deep_merge_scalar_over_object_replaces() {
3063        let mut base = serde_json::json!({"key": "value"});
3064        let overlay = serde_json::json!(42);
3065        deep_merge_json(&mut base, overlay);
3066        assert_eq!(base, serde_json::json!(42));
3067    }
3068
3069    #[test]
3070    fn extends_non_string_non_array_ignored() {
3071        let dir = test_dir("extends-numeric");
3072        std::fs::write(
3073            dir.path().join(".fallowrc.json"),
3074            r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
3075        )
3076        .unwrap();
3077
3078        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3079        assert_eq!(config.entry, vec!["src/index.ts"]);
3080    }
3081
3082    #[test]
3083    fn extends_multiple_bases_later_wins() {
3084        let dir = test_dir("extends-multi-base");
3085
3086        std::fs::write(
3087            dir.path().join("base-a.json"),
3088            r#"{"rules": {"unused-files": "warn"}}"#,
3089        )
3090        .unwrap();
3091        std::fs::write(
3092            dir.path().join("base-b.json"),
3093            r#"{"rules": {"unused-files": "off"}}"#,
3094        )
3095        .unwrap();
3096        std::fs::write(
3097            dir.path().join(".fallowrc.json"),
3098            r#"{"extends": ["base-a.json", "base-b.json"]}"#,
3099        )
3100        .unwrap();
3101
3102        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3103        assert_eq!(config.rules.unused_files, Severity::Off);
3104    }
3105
3106    #[test]
3107    fn load_rejects_empty_security_request_receivers() {
3108        let dir = test_dir("empty-security-request-receivers");
3109        std::fs::write(
3110            dir.path().join(".fallowrc.json"),
3111            r#"{"security": {"requestReceivers": ["req", "  "]}}"#,
3112        )
3113        .unwrap();
3114
3115        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3116        let err = result.expect_err("empty receiver should be rejected");
3117        assert!(
3118            err.to_string().contains("security.requestReceivers"),
3119            "error should name security.requestReceivers: {err}"
3120        );
3121    }
3122
3123    #[test]
3124    fn resolve_normalizes_security_request_receivers() {
3125        let dir = test_dir("normalize-security-request-receivers");
3126        std::fs::write(
3127            dir.path().join(".fallowrc.json"),
3128            r#"{"security": {"requestReceivers": [" HttpReq ", "httpreq", "R"]}}"#,
3129        )
3130        .unwrap();
3131
3132        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3133            .unwrap()
3134            .resolve(
3135                dir.path().to_path_buf(),
3136                OutputFormat::Human,
3137                1,
3138                true,
3139                true,
3140                None,
3141            );
3142        assert_eq!(
3143            config.security.request_receivers,
3144            vec!["httpreq".to_string(), "r".to_string()]
3145        );
3146    }
3147
3148    #[test]
3149    fn fallow_config_deserialize_production() {
3150        let json_str = r#"{"production": true}"#;
3151        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3152        assert!(config.production);
3153    }
3154
3155    #[test]
3156    fn fallow_config_production_defaults_false() {
3157        let config: FallowConfig = serde_json::from_str("{}").unwrap();
3158        assert!(!config.production);
3159    }
3160
3161    #[test]
3162    fn package_json_optional_dependency_names() {
3163        let pkg: PackageJson = serde_json::from_str(
3164            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
3165        )
3166        .unwrap();
3167        let opt = pkg.optional_dependency_names();
3168        assert_eq!(opt.len(), 2);
3169        assert!(opt.contains(&"fsevents".to_string()));
3170        assert!(opt.contains(&"chokidar".to_string()));
3171    }
3172
3173    #[test]
3174    fn package_json_optional_deps_empty_when_missing() {
3175        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
3176        assert!(pkg.optional_dependency_names().is_empty());
3177    }
3178
3179    #[test]
3180    fn find_config_path_returns_fallowrc_json() {
3181        let dir = test_dir("find-path-json");
3182        std::fs::create_dir(dir.path().join(".git")).unwrap();
3183        std::fs::write(
3184            dir.path().join(".fallowrc.json"),
3185            r#"{"entry": ["src/main.ts"]}"#,
3186        )
3187        .unwrap();
3188
3189        let path = FallowConfig::find_config_path(dir.path());
3190        assert!(path.is_some());
3191        assert!(path.unwrap().ends_with(".fallowrc.json"));
3192    }
3193
3194    #[test]
3195    fn find_config_path_returns_fallow_toml() {
3196        let dir = test_dir("find-path-toml");
3197        std::fs::create_dir(dir.path().join(".git")).unwrap();
3198        std::fs::write(
3199            dir.path().join("fallow.toml"),
3200            "entry = [\"src/main.ts\"]\n",
3201        )
3202        .unwrap();
3203
3204        let path = FallowConfig::find_config_path(dir.path());
3205        assert!(path.is_some());
3206        assert!(path.unwrap().ends_with("fallow.toml"));
3207    }
3208
3209    #[test]
3210    fn find_config_path_returns_dot_fallow_toml() {
3211        let dir = test_dir("find-path-dot-toml");
3212        std::fs::create_dir(dir.path().join(".git")).unwrap();
3213        std::fs::write(
3214            dir.path().join(".fallow.toml"),
3215            "entry = [\"src/main.ts\"]\n",
3216        )
3217        .unwrap();
3218
3219        let path = FallowConfig::find_config_path(dir.path());
3220        assert!(path.is_some());
3221        assert!(path.unwrap().ends_with(".fallow.toml"));
3222    }
3223
3224    #[test]
3225    fn find_config_path_prefers_json_over_toml() {
3226        let dir = test_dir("find-path-priority");
3227        std::fs::create_dir(dir.path().join(".git")).unwrap();
3228        std::fs::write(
3229            dir.path().join(".fallowrc.json"),
3230            r#"{"entry": ["json.ts"]}"#,
3231        )
3232        .unwrap();
3233        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
3234
3235        let path = FallowConfig::find_config_path(dir.path());
3236        assert!(path.unwrap().ends_with(".fallowrc.json"));
3237    }
3238
3239    #[test]
3240    fn find_config_path_none_when_no_config() {
3241        let dir = test_dir("find-path-none");
3242        std::fs::create_dir(dir.path().join(".git")).unwrap();
3243
3244        let path = FallowConfig::find_config_path(dir.path());
3245        assert!(path.is_none());
3246    }
3247
3248    #[test]
3249    fn find_config_path_walks_past_package_json_in_monorepo() {
3250        let dir = test_dir("find-path-monorepo");
3251        std::fs::create_dir(dir.path().join(".git")).unwrap();
3252        std::fs::write(
3253            dir.path().join(".fallowrc.json"),
3254            r#"{"entry": ["src/index.ts"]}"#,
3255        )
3256        .unwrap();
3257
3258        let sub = dir.path().join("packages").join("app");
3259        std::fs::create_dir_all(&sub).unwrap();
3260        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3261
3262        let path = FallowConfig::find_config_path(&sub).unwrap();
3263        assert_eq!(path, dir.path().join(".fallowrc.json"));
3264    }
3265
3266    #[test]
3267    fn extends_toml_base() {
3268        let dir = test_dir("extends-toml");
3269
3270        std::fs::write(
3271            dir.path().join("base.json"),
3272            r#"{"rules": {"unused-files": "warn"}}"#,
3273        )
3274        .unwrap();
3275        std::fs::write(
3276            dir.path().join("fallow.toml"),
3277            "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
3278        )
3279        .unwrap();
3280
3281        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3282        assert_eq!(config.rules.unused_files, Severity::Warn);
3283        assert_eq!(config.entry, vec!["src/index.ts"]);
3284    }
3285
3286    #[test]
3287    fn deep_merge_boolean_overlay() {
3288        let mut base = serde_json::json!(true);
3289        deep_merge_json(&mut base, serde_json::json!(false));
3290        assert_eq!(base, serde_json::json!(false));
3291    }
3292
3293    #[test]
3294    fn deep_merge_number_overlay() {
3295        let mut base = serde_json::json!(42);
3296        deep_merge_json(&mut base, serde_json::json!(99));
3297        assert_eq!(base, serde_json::json!(99));
3298    }
3299
3300    #[test]
3301    fn deep_merge_disjoint_objects() {
3302        let mut base = serde_json::json!({"a": 1});
3303        let overlay = serde_json::json!({"b": 2});
3304        deep_merge_json(&mut base, overlay);
3305        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3306    }
3307
3308    #[test]
3309    fn max_extends_depth_is_reasonable() {
3310        assert_eq!(MAX_EXTENDS_DEPTH, 10);
3311    }
3312
3313    #[test]
3314    fn config_names_has_four_entries() {
3315        assert_eq!(CONFIG_NAMES.len(), 4);
3316        for name in CONFIG_NAMES {
3317            assert!(
3318                name.starts_with('.') || name.starts_with("fallow"),
3319                "unexpected config name: {name}"
3320            );
3321        }
3322    }
3323
3324    #[test]
3325    fn package_json_peer_dependency_names() {
3326        let pkg: PackageJson = serde_json::from_str(
3327            r#"{
3328            "dependencies": {"react": "^18"},
3329            "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
3330        }"#,
3331        )
3332        .unwrap();
3333        let all = pkg.all_dependency_names();
3334        assert!(all.contains(&"react".to_string()));
3335        assert!(all.contains(&"react-dom".to_string()));
3336        assert!(all.contains(&"react-native".to_string()));
3337    }
3338
3339    #[test]
3340    fn package_json_scripts_field() {
3341        let pkg: PackageJson = serde_json::from_str(
3342            r#"{
3343            "scripts": {
3344                "build": "tsc",
3345                "test": "vitest",
3346                "lint": "fallow check"
3347            }
3348        }"#,
3349        )
3350        .unwrap();
3351        let scripts = pkg.scripts.unwrap();
3352        assert_eq!(scripts.len(), 3);
3353        assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
3354        assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
3355    }
3356
3357    #[test]
3358    fn extends_toml_chain() {
3359        let dir = test_dir("extends-toml-chain");
3360
3361        std::fs::write(
3362            dir.path().join("base.json"),
3363            r#"{"entry": ["src/base.ts"]}"#,
3364        )
3365        .unwrap();
3366        std::fs::write(
3367            dir.path().join("middle.json"),
3368            r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
3369        )
3370        .unwrap();
3371        std::fs::write(
3372            dir.path().join("fallow.toml"),
3373            "extends = [\"middle.json\"]\n",
3374        )
3375        .unwrap();
3376
3377        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3378        assert_eq!(config.entry, vec!["src/base.ts"]);
3379        assert_eq!(config.rules.unused_files, Severity::Off);
3380    }
3381
3382    #[test]
3383    fn find_and_load_walks_up_directories() {
3384        let dir = test_dir("find-walk-up");
3385        let sub = dir.path().join("src").join("deep");
3386        std::fs::create_dir_all(&sub).unwrap();
3387        std::fs::write(
3388            dir.path().join(".fallowrc.json"),
3389            r#"{"entry": ["src/main.ts"]}"#,
3390        )
3391        .unwrap();
3392        std::fs::create_dir(dir.path().join(".git")).unwrap();
3393
3394        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3395        assert_eq!(config.entry, vec!["src/main.ts"]);
3396        assert!(path.ends_with(".fallowrc.json"));
3397    }
3398
3399    #[test]
3400    fn json_schema_contains_entry_field() {
3401        let schema = FallowConfig::json_schema();
3402        let obj = schema.as_object().unwrap();
3403        let props = obj.get("properties").and_then(|v| v.as_object());
3404        assert!(props.is_some(), "schema should have properties");
3405        assert!(
3406            props.unwrap().contains_key("entry"),
3407            "schema should contain entry property"
3408        );
3409    }
3410
3411    #[test]
3412    fn fallow_config_json_duplicates_all_fields() {
3413        let json = r#"{
3414            "duplicates": {
3415                "enabled": true,
3416                "mode": "semantic",
3417                "minTokens": 200,
3418                "minLines": 20,
3419                "threshold": 10.5,
3420                "ignore": ["**/*.test.ts"],
3421                "skipLocal": true,
3422                "crossLanguage": true,
3423                "normalization": {
3424                    "ignoreIdentifiers": true,
3425                    "ignoreStringValues": false
3426                }
3427            }
3428        }"#;
3429        let config: FallowConfig = serde_json::from_str(json).unwrap();
3430        assert!(config.duplicates.enabled);
3431        assert_eq!(
3432            config.duplicates.mode,
3433            crate::config::DetectionMode::Semantic
3434        );
3435        assert_eq!(config.duplicates.min_tokens, 200);
3436        assert_eq!(config.duplicates.min_lines, 20);
3437        assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
3438        assert!(config.duplicates.skip_local);
3439        assert!(config.duplicates.cross_language);
3440        assert_eq!(
3441            config.duplicates.normalization.ignore_identifiers,
3442            Some(true)
3443        );
3444        assert_eq!(
3445            config.duplicates.normalization.ignore_string_values,
3446            Some(false)
3447        );
3448    }
3449
3450    #[test]
3451    fn normalize_url_basic() {
3452        assert_eq!(
3453            normalize_url_for_dedup("https://example.com/config.json"),
3454            "https://example.com/config.json"
3455        );
3456    }
3457
3458    #[test]
3459    fn normalize_url_trailing_slash() {
3460        assert_eq!(
3461            normalize_url_for_dedup("https://example.com/config/"),
3462            "https://example.com/config"
3463        );
3464    }
3465
3466    #[test]
3467    fn normalize_url_uppercase_scheme_and_host() {
3468        assert_eq!(
3469            normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
3470            "https://example.com/Config.json"
3471        );
3472    }
3473
3474    #[test]
3475    fn normalize_url_root_path() {
3476        assert_eq!(
3477            normalize_url_for_dedup("https://example.com/"),
3478            "https://example.com"
3479        );
3480        assert_eq!(
3481            normalize_url_for_dedup("https://example.com"),
3482            "https://example.com"
3483        );
3484    }
3485
3486    #[test]
3487    fn normalize_url_preserves_path_case() {
3488        assert_eq!(
3489            normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
3490            "https://github.com/Org/Repo/Fallow.json"
3491        );
3492    }
3493
3494    #[test]
3495    fn normalize_url_strips_query_string() {
3496        assert_eq!(
3497            normalize_url_for_dedup("https://example.com/config.json?v=1"),
3498            "https://example.com/config.json"
3499        );
3500    }
3501
3502    #[test]
3503    fn normalize_url_strips_fragment() {
3504        assert_eq!(
3505            normalize_url_for_dedup("https://example.com/config.json#section"),
3506            "https://example.com/config.json"
3507        );
3508    }
3509
3510    #[test]
3511    fn normalize_url_strips_query_and_fragment() {
3512        assert_eq!(
3513            normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
3514            "https://example.com/config.json"
3515        );
3516    }
3517
3518    #[test]
3519    fn normalize_url_default_https_port() {
3520        assert_eq!(
3521            normalize_url_for_dedup("https://example.com:443/config.json"),
3522            "https://example.com/config.json"
3523        );
3524        assert_eq!(
3525            normalize_url_for_dedup("https://example.com:8443/config.json"),
3526            "https://example.com:8443/config.json"
3527        );
3528    }
3529
3530    #[test]
3531    fn extends_http_rejected() {
3532        let dir = test_dir("http-rejected");
3533        std::fs::write(
3534            dir.path().join(".fallowrc.json"),
3535            r#"{"extends": "http://example.com/config.json"}"#,
3536        )
3537        .unwrap();
3538
3539        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3540        assert!(result.is_err());
3541        let err_msg = format!("{}", result.unwrap_err());
3542        assert!(
3543            err_msg.contains("https://"),
3544            "Expected https hint in error, got: {err_msg}"
3545        );
3546        assert!(
3547            err_msg.contains("http://"),
3548            "Expected http:// mention in error, got: {err_msg}"
3549        );
3550    }
3551
3552    #[test]
3553    fn extends_url_circular_detection() {
3554        let mut visited = FxHashSet::default();
3555        let url = "https://example.com/config.json";
3556        let normalized = normalize_url_for_dedup(url);
3557        visited.insert(normalized.clone());
3558
3559        assert!(
3560            !visited.insert(normalized),
3561            "Same URL should be detected as duplicate"
3562        );
3563    }
3564
3565    #[test]
3566    fn extends_url_circular_case_insensitive() {
3567        let mut visited = FxHashSet::default();
3568        visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
3569
3570        let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
3571        assert!(
3572            !visited.insert(normalized),
3573            "Case-different URLs should normalize to the same key"
3574        );
3575    }
3576
3577    #[test]
3578    fn extract_extends_array() {
3579        let mut value = serde_json::json!({
3580            "extends": ["a.json", "b.json"],
3581            "entry": ["src/index.ts"]
3582        });
3583        let extends = extract_extends(&mut value);
3584        assert_eq!(extends, vec!["a.json", "b.json"]);
3585        assert!(value.get("extends").is_none());
3586        assert!(value.get("entry").is_some());
3587    }
3588
3589    #[test]
3590    fn extract_extends_string_sugar() {
3591        let mut value = serde_json::json!({
3592            "extends": "base.json",
3593            "entry": ["src/index.ts"]
3594        });
3595        let extends = extract_extends(&mut value);
3596        assert_eq!(extends, vec!["base.json"]);
3597    }
3598
3599    #[test]
3600    fn extract_extends_none() {
3601        let mut value = serde_json::json!({"entry": ["src/index.ts"]});
3602        let extends = extract_extends(&mut value);
3603        assert!(extends.is_empty());
3604    }
3605
3606    #[test]
3607    fn url_timeout_default() {
3608        let timeout = url_timeout();
3609        assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
3610    }
3611
3612    #[test]
3613    fn extends_url_mixed_with_file_and_npm() {
3614        let dir = test_dir("url-mixed");
3615        std::fs::write(
3616            dir.path().join("local.json"),
3617            r#"{"rules": {"unused-files": "warn"}}"#,
3618        )
3619        .unwrap();
3620        std::fs::write(
3621            dir.path().join(".fallowrc.json"),
3622            r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
3623        )
3624        .unwrap();
3625
3626        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3627        assert!(result.is_err());
3628        let err_msg = format!("{}", result.unwrap_err());
3629        assert!(
3630            err_msg.contains("unreachable.invalid"),
3631            "Expected URL in error message, got: {err_msg}"
3632        );
3633    }
3634
3635    #[test]
3636    fn extends_https_url_unreachable_errors() {
3637        let dir = test_dir("url-unreachable");
3638        std::fs::write(
3639            dir.path().join(".fallowrc.json"),
3640            r#"{"extends": "https://unreachable.invalid/config.json"}"#,
3641        )
3642        .unwrap();
3643
3644        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3645        assert!(result.is_err());
3646        let err_msg = format!("{}", result.unwrap_err());
3647        assert!(
3648            err_msg.contains("unreachable.invalid"),
3649            "Expected URL in error, got: {err_msg}"
3650        );
3651        assert!(
3652            err_msg.contains("local path or npm:"),
3653            "Expected remediation hint, got: {err_msg}"
3654        );
3655    }
3656
3657    #[test]
3658    fn collect_unknown_rule_keys_flags_top_level_typo() {
3659        let merged = serde_json::json!({
3660            "rules": {
3661                "unsued-files": "warn",
3662                "unused-exports": "off"
3663            }
3664        });
3665        let findings = collect_unknown_rule_keys(&merged);
3666        assert_eq!(findings.len(), 1);
3667        assert_eq!(findings[0].context, "rules");
3668        assert_eq!(findings[0].key, "unsued-files");
3669        assert_eq!(findings[0].suggestion, Some("unused-files"));
3670    }
3671
3672    #[test]
3673    fn collect_unknown_rule_keys_flags_overrides_typo() {
3674        let merged = serde_json::json!({
3675            "overrides": [
3676                {
3677                    "files": ["src/**/*.ts"],
3678                    "rules": {
3679                        "unsued-files": "warn"
3680                    }
3681                },
3682                {
3683                    "files": ["tests/**/*.ts"],
3684                    "rules": {
3685                        "circular-dependnecy": "off"
3686                    }
3687                }
3688            ]
3689        });
3690        let findings = collect_unknown_rule_keys(&merged);
3691        assert_eq!(findings.len(), 2);
3692        assert_eq!(findings[0].context, "overrides[0].rules");
3693        assert_eq!(findings[1].context, "overrides[1].rules");
3694        assert_eq!(findings[1].suggestion, Some("circular-dependency"));
3695    }
3696
3697    #[test]
3698    fn collect_unknown_rule_keys_empty_for_valid_config() {
3699        let merged = serde_json::json!({
3700            "rules": {
3701                "unused-files": "warn",
3702                "unused-file": "off",
3703                "circular-dependency": "off",
3704                "boundary-violations": "warn"
3705            },
3706            "overrides": [
3707                {
3708                    "files": ["src/**"],
3709                    "rules": {
3710                        "unused-exports": "warn"
3711                    }
3712                }
3713            ]
3714        });
3715        let findings = collect_unknown_rule_keys(&merged);
3716        assert!(
3717            findings.is_empty(),
3718            "valid rule names and aliases must not be flagged: {findings:?}"
3719        );
3720    }
3721
3722    #[test]
3723    fn collect_unknown_rule_keys_ignores_missing_rules_section() {
3724        let merged = serde_json::json!({
3725            "entry": ["src/main.ts"]
3726        });
3727        let findings = collect_unknown_rule_keys(&merged);
3728        assert!(findings.is_empty());
3729    }
3730
3731    #[test]
3732    fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
3733        let dir = test_dir("wiring");
3734        let path = dir.path().join(".fallowrc.json");
3735        let typo = format!(
3736            "wiring-probe-{}-{}",
3737            std::process::id(),
3738            std::time::SystemTime::now()
3739                .duration_since(std::time::UNIX_EPOCH)
3740                .map_or(0, |d| d.as_nanos())
3741        );
3742        std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
3743
3744        let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
3745
3746        assert!(
3747            config_res.is_ok(),
3748            "load should succeed in phase 1: {:?}",
3749            config_res.err()
3750        );
3751        assert_eq!(
3752            captured.len(),
3753            1,
3754            "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
3755        );
3756        assert_eq!(captured[0].key, typo);
3757        assert_eq!(captured[0].context, "rules");
3758    }
3759
3760    #[test]
3761    fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
3762        let dir = test_dir("misspelled-rule");
3763        std::fs::write(
3764            dir.path().join(".fallowrc.json"),
3765            r#"{"rules": {"unsued-files": "warn"}}"#,
3766        )
3767        .unwrap();
3768
3769        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3770            .expect("load should succeed in phase 1");
3771
3772        assert_eq!(config.rules.unused_files, Severity::Error);
3773    }
3774
3775    #[test]
3776    fn validate_resolved_boundaries_passes_on_valid_config() {
3777        let dir = test_dir("boundaries-valid");
3778        let config = FallowConfig {
3779            boundaries: crate::BoundaryConfig {
3780                coverage: crate::BoundaryCoverageConfig::default(),
3781                calls: crate::BoundaryCallsConfig::default(),
3782                preset: None,
3783                zones: vec![
3784                    crate::BoundaryZone {
3785                        name: "ui".to_string(),
3786                        patterns: vec!["src/components/**".to_string()],
3787                        auto_discover: vec![],
3788                        root: None,
3789                    },
3790                    crate::BoundaryZone {
3791                        name: "db".to_string(),
3792                        patterns: vec!["src/db/**".to_string()],
3793                        auto_discover: vec![],
3794                        root: None,
3795                    },
3796                ],
3797                rules: vec![crate::BoundaryRule {
3798                    from: "ui".to_string(),
3799                    allow: vec!["db".to_string()],
3800                    allow_type_only: vec![],
3801                }],
3802            },
3803            ..FallowConfig::default()
3804        };
3805        config
3806            .validate_resolved_boundaries(dir.path())
3807            .expect("valid config should pass");
3808    }
3809
3810    #[test]
3811    fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
3812        let dir = test_dir("boundaries-unknown-zones");
3813        let config = FallowConfig {
3814            boundaries: crate::BoundaryConfig {
3815                coverage: crate::BoundaryCoverageConfig::default(),
3816                calls: crate::BoundaryCallsConfig::default(),
3817                preset: None,
3818                zones: vec![crate::BoundaryZone {
3819                    name: "ui".to_string(),
3820                    patterns: vec!["src/ui/**".to_string()],
3821                    auto_discover: vec![],
3822                    root: None,
3823                }],
3824                rules: vec![
3825                    crate::BoundaryRule {
3826                        from: "typo-from".to_string(),
3827                        allow: vec!["typo-allow".to_string()],
3828                        allow_type_only: vec!["typo-type-only".to_string()],
3829                    },
3830                    crate::BoundaryRule {
3831                        from: "ui".to_string(),
3832                        allow: vec!["another-typo".to_string()],
3833                        allow_type_only: vec![],
3834                    },
3835                ],
3836            },
3837            ..FallowConfig::default()
3838        };
3839
3840        let errors = config
3841            .validate_resolved_boundaries(dir.path())
3842            .expect_err("invalid zone refs should fail");
3843
3844        assert_eq!(errors.len(), 4, "got: {errors:?}");
3845
3846        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3847        assert!(
3848            rendered
3849                .iter()
3850                .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
3851        );
3852        assert!(
3853            rendered
3854                .iter()
3855                .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
3856        );
3857        assert!(rendered.iter().any(|m| m.contains("typo-type-only")
3858            && m.contains("rules[0]")
3859            && m.contains("allowTypeOnly")));
3860        assert!(
3861            rendered.iter().any(|m| m.contains("another-typo")
3862                && m.contains("rules[1]")
3863                && m.contains("allow"))
3864        );
3865    }
3866
3867    #[test]
3868    fn validate_resolved_boundaries_flags_redundant_root_prefix() {
3869        let dir = test_dir("boundaries-redundant-prefix");
3870        let config = FallowConfig {
3871            boundaries: crate::BoundaryConfig {
3872                coverage: crate::BoundaryCoverageConfig::default(),
3873                calls: crate::BoundaryCallsConfig::default(),
3874                preset: None,
3875                zones: vec![crate::BoundaryZone {
3876                    name: "ui".to_string(),
3877                    patterns: vec!["packages/app/src/**".to_string()],
3878                    auto_discover: vec![],
3879                    root: Some("packages/app/".to_string()),
3880                }],
3881                rules: vec![],
3882            },
3883            ..FallowConfig::default()
3884        };
3885
3886        let errors = config
3887            .validate_resolved_boundaries(dir.path())
3888            .expect_err("redundant root prefix should fail");
3889        assert_eq!(errors.len(), 1, "got: {errors:?}");
3890        let rendered = errors[0].to_string();
3891        assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
3892        assert!(rendered.contains("zone 'ui'"));
3893    }
3894
3895    #[test]
3896    fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
3897        let dir = test_dir("boundaries-mixed-errors");
3898        let config = FallowConfig {
3899            boundaries: crate::BoundaryConfig {
3900                coverage: crate::BoundaryCoverageConfig::default(),
3901                calls: crate::BoundaryCallsConfig::default(),
3902                preset: None,
3903                zones: vec![crate::BoundaryZone {
3904                    name: "ui".to_string(),
3905                    patterns: vec!["packages/app/src/**".to_string()],
3906                    auto_discover: vec![],
3907                    root: Some("packages/app/".to_string()),
3908                }],
3909                rules: vec![crate::BoundaryRule {
3910                    from: "ui".to_string(),
3911                    allow: vec!["typo-zone".to_string()],
3912                    allow_type_only: vec![],
3913                }],
3914            },
3915            ..FallowConfig::default()
3916        };
3917        let errors = config
3918            .validate_resolved_boundaries(dir.path())
3919            .expect_err("mixed errors should fail");
3920        assert_eq!(errors.len(), 2, "got: {errors:?}");
3921        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3922        assert!(
3923            rendered
3924                .iter()
3925                .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
3926        );
3927        assert!(
3928            rendered
3929                .iter()
3930                .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
3931        );
3932    }
3933
3934    #[test]
3935    fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
3936        let dir = test_dir("boundaries-bulletproof");
3937        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
3938        let config = FallowConfig {
3939            boundaries: crate::BoundaryConfig {
3940                coverage: crate::BoundaryCoverageConfig::default(),
3941                calls: crate::BoundaryCallsConfig::default(),
3942                preset: Some(crate::BoundaryPreset::Bulletproof),
3943                zones: vec![],
3944                rules: vec![],
3945            },
3946            ..FallowConfig::default()
3947        };
3948        config
3949            .validate_resolved_boundaries(dir.path())
3950            .expect("Bulletproof with discoverable features should pass");
3951    }
3952}