Skip to main content

supercov_engine/
source_discovery.rs

1//! Deterministic first-party JavaScript/TypeScript source discovery.
2//!
3//! Discovery defines the coverage denominator. The walker never follows
4//! links and turns unclassified first-party files into explicit blockers.
5
6use std::{
7    collections::{BTreeMap, BTreeSet},
8    fs, io,
9    path::{Component, Path, PathBuf},
10};
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use sha2::{Digest, Sha256};
15
16const GENERATED_DIRECTORIES: &[&str] = &[
17    ".cache",
18    "generated",
19    ".git",
20    ".mcdc-pool",
21    ".next",
22    ".nuxt",
23    ".output",
24    ".supercov",
25    "build",
26    "coverage",
27    "dist",
28    "node_modules",
29    "out",
30    "playwright-report",
31    "results",
32    "test-results",
33    "vendor",
34];
35/// Whether a directory called `target` is a build tool's output rather than a
36/// project's own code.
37///
38/// Cargo, Maven, sbt and Leiningen write there, but the name is not theirs: a
39/// Flask application kept its whole package in `target/`, and dropping the
40/// directory by name left the repository with no source at all. It is output
41/// when a build that writes there is declared beside it, or when Cargo's own
42/// markers are inside it, which also covers a `CARGO_TARGET_DIR` placed
43/// elsewhere.
44pub fn target_is_build_output(directory: &Path) -> bool {
45    const BUILDS: &[&str] = &[
46        "Cargo.toml",
47        "pom.xml",
48        "build.gradle",
49        "build.gradle.kts",
50        "settings.gradle",
51        "settings.gradle.kts",
52        "build.sbt",
53        "project.clj",
54    ];
55    const MARKERS: &[&str] = &["CACHEDIR.TAG", ".rustc_info.json"];
56    directory
57        .parent()
58        .is_some_and(|parent| BUILDS.iter().any(|name| parent.join(name).is_file()))
59        || MARKERS.iter().any(|name| directory.join(name).is_file())
60}
61
62const SOURCE_DIRECTORIES: &[&str] = &[
63    "app",
64    "src",
65    "lib",
66    "server",
67    "client",
68    "functions",
69    "api",
70    "components",
71    "pages",
72];
73/// The files Next.js reads from a project's root by name. `proxy` is what
74/// Next 16 calls `middleware`.
75const NEXT_ROOT_FILES: &[&str] = &[
76    "proxy",
77    "middleware",
78    "instrumentation",
79    "instrumentation-client",
80];
81/// The file names the App Router gives a meaning to under `app/`.
82const NEXT_APP_FILES: &[&str] = &[
83    "page",
84    "layout",
85    "route",
86    "loading",
87    "error",
88    "global-error",
89    "not-found",
90    "template",
91    "default",
92    "forbidden",
93    "unauthorized",
94    "icon",
95    "apple-icon",
96    "opengraph-image",
97    "twitter-image",
98    "sitemap",
99    "robots",
100    "manifest",
101];
102const PACKAGE_PARENTS: &[&str] = &["apps", "packages", "services", "workspaces"];
103const TEST_DIRECTORIES: &[&str] = &[
104    "__tests__",
105    "test",
106    "tests",
107    "spec",
108    "specs",
109    "e2e",
110    "fixture",
111    "fixtures",
112    "mock",
113    "mocks",
114    "__mocks__",
115];
116const CONFIG_TOOLS: &[&str] = &[
117    "babel",
118    "eslint",
119    "graphql",
120    "jest",
121    "next",
122    "nuxt",
123    "playwright",
124    "postcss",
125    "prettier",
126    "remix",
127    "rollup",
128    "stylelint",
129    "tailwind",
130    "tsup",
131    "vite",
132    "vitest",
133    "webpack",
134];
135const DOT_CONFIG_TOOLS: &[&str] = &["babel", "eslint", "graphql", "prettier", "stylelint"];
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "lowercase")]
139pub enum SourceScopeStatus {
140    Included,
141    Excluded,
142    Ambiguous,
143}
144
145/// Scope reason for a bundler's output found in the tree (see `built_asset`).
146pub const BUILT_ASSET_REASON: &str = "built asset";
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase", deny_unknown_fields)]
150pub struct SourceScopeEntry {
151    pub file: String,
152    pub status: SourceScopeStatus,
153    pub reason: String,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub package_root: Option<String>,
156}
157
158impl SourceScopeEntry {
159    /// A file the wrapped command generates rather than one anyone edits: it
160    /// is never instrumented or cached, and a bundler renames it on every
161    /// build, so it must not feed the source fingerprint.
162    pub fn is_generated_output(&self) -> bool {
163        self.status == SourceScopeStatus::Excluded && self.reason == BUILT_ASSET_REASON
164    }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "lowercase")]
169pub enum SourceScopeMode {
170    Automatic,
171    Explicit,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct SourceScope {
177    pub version: u32,
178    pub mode: SourceScopeMode,
179    pub roots: Vec<String>,
180    pub entries: Vec<SourceScopeEntry>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase", deny_unknown_fields)]
185pub struct SourceLimitation {
186    pub id: String,
187    pub kind: String,
188    pub file: String,
189    pub line: usize,
190    pub column: usize,
191    pub source: String,
192    pub reason: String,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase", deny_unknown_fields)]
197pub struct DiscoveredSourceScope {
198    pub source_files: Vec<String>,
199    pub source_roots: Vec<String>,
200    pub scope: SourceScope,
201    pub limitations: Vec<SourceLimitation>,
202}
203
204#[derive(Debug)]
205pub enum SourceDiscoveryError {
206    Io { path: PathBuf, source: io::Error },
207    NonUtf8Path(PathBuf),
208    InvalidRoot(PathBuf),
209}
210
211impl std::fmt::Display for SourceDiscoveryError {
212    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        match self {
214            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
215            Self::NonUtf8Path(path) => {
216                write!(
217                    formatter,
218                    "source path is not valid UTF-8: {}",
219                    path.display()
220                )
221            }
222            Self::InvalidRoot(path) => write!(
223                formatter,
224                "source root is not a regular file or directory: {}",
225                path.display()
226            ),
227        }
228    }
229}
230
231impl std::error::Error for SourceDiscoveryError {}
232
233fn io_error(path: &Path, source: io::Error) -> SourceDiscoveryError {
234    SourceDiscoveryError::Io {
235        path: path.to_owned(),
236        source,
237    }
238}
239
240/// The variable that names first-party source roots, for every frontend.
241pub const SOURCE_ROOTS_VARIABLE: &str = "SUPERCOV_SOURCE_ROOTS";
242
243/// Why a file a frontend would otherwise measure was left out.
244pub const OUTSIDE_EXPLICIT_ROOTS: &str = "outside explicit source roots";
245
246/// The first-party source roots named in `SUPERCOV_SOURCE_ROOTS`:
247/// comma-separated, trimmed, empties dropped. `None` when the variable is unset
248/// or names nothing, which leaves discovery automatic.
249///
250/// Every frontend reads the roots through here. It used to be read on the
251/// JavaScript path alone while the documentation described it without a
252/// language, so a Python project that set it had every file measured anyway --
253/// a vendored tree beside flat-layout sources came out at 660 files instead of
254/// fifteen, with nothing to say the variable had been ignored.
255pub fn configured_source_roots(environment: &BTreeMap<String, String>) -> Option<Vec<String>> {
256    let roots = environment
257        .get(SOURCE_ROOTS_VARIABLE)?
258        .split(',')
259        .map(str::trim)
260        .filter(|root| !root.is_empty())
261        .map(str::to_owned)
262        .collect::<Vec<_>>();
263    (!roots.is_empty()).then_some(roots)
264}
265
266/// Fold the roots a run was measured under into its execution configuration,
267/// the part of a run's identity that records how it was asked to run. Two runs
268/// over different roots measure different denominators and are not the same
269/// run; nothing is appended when no roots were named, so every existing
270/// fingerprint stays as it was.
271pub fn fold_roots_into_configuration(configuration: &mut Vec<u8>, roots: Option<&[String]>) {
272    let Some(roots) = roots.filter(|roots| !roots.is_empty()) else {
273        return;
274    };
275    configuration.push(0);
276    configuration
277        .extend_from_slice(format!("{SOURCE_ROOTS_VARIABLE}={}", roots.join(",")).as_bytes());
278}
279
280/// Explicit source roots resolved against a project root. Each is an existing
281/// directory or a single file, so a flat layout can name the modules it keeps
282/// at the root one by one. A root that does not exist names nothing to measure
283/// and is skipped; one that is neither a file nor a directory is refused, since
284/// a symlink root would reach outside what the project owns.
285#[derive(Debug, Clone)]
286pub struct ExplicitSourceRoots {
287    root: PathBuf,
288    roots: Vec<PathBuf>,
289    configured: Vec<String>,
290}
291
292impl ExplicitSourceRoots {
293    pub fn resolve(root: &Path, configured: &[String]) -> Result<Self, SourceDiscoveryError> {
294        let root = lexical_normalize(root);
295        let roots = existing_roots(configured.iter().map(|value| resolve(&root, value)), true)?;
296        Ok(Self {
297            root,
298            roots,
299            configured: configured.to_vec(),
300        })
301    }
302
303    /// Read the roots from the environment, if any are named.
304    pub fn from_environment(
305        root: &Path,
306        environment: &BTreeMap<String, String>,
307    ) -> Result<Option<Self>, SourceDiscoveryError> {
308        configured_source_roots(environment)
309            .map(|configured| Self::resolve(root, &configured))
310            .transpose()
311    }
312
313    /// Read the roots named against `project`, resolved inside `workspace` --
314    /// the isolated copy Go, the JVM and Rust measure. The copy mirrors the
315    /// project, so a relative root names the same file in both. An absolute one
316    /// names the original tree, which nothing in the copy lies under, so it is
317    /// rebased onto the copy; without that it would match nothing and read as a
318    /// typo. The roots are still quoted as they were written.
319    pub fn from_environment_in(
320        project: &Path,
321        workspace: &Path,
322        environment: &BTreeMap<String, String>,
323    ) -> Result<Option<Self>, SourceDiscoveryError> {
324        let Some(configured) = configured_source_roots(environment) else {
325            return Ok(None);
326        };
327        let project = lexical_normalize(project);
328        let rebased = configured
329            .iter()
330            .map(|value| {
331                let path = Path::new(value);
332                if !path.is_absolute() {
333                    return value.clone();
334                }
335                lexical_normalize(path)
336                    .strip_prefix(&project)
337                    .map(|rest| {
338                        let rest = rest.to_string_lossy().into_owned();
339                        if rest.is_empty() { ".".into() } else { rest }
340                    })
341                    .unwrap_or_else(|_| value.clone())
342            })
343            .collect::<Vec<_>>();
344        let mut roots = Self::resolve(workspace, &rebased)?;
345        roots.configured = configured;
346        Ok(Some(roots))
347    }
348
349    /// Whether a project-relative file lies under these roots: inside a
350    /// directory root, or the very file a file root names.
351    pub fn contains(&self, relative: &str) -> bool {
352        root_contains(&self.roots, &lexical_normalize(&self.root.join(relative)))
353    }
354
355    /// The roots exactly as they were written, including any that name nothing.
356    /// An error about roots quotes these, because the one that does not exist
357    /// is usually the typo the person needs to see.
358    pub fn configured(&self) -> &[String] {
359        &self.configured
360    }
361
362    /// Refuse roots that left a frontend nothing to measure. A typo in the
363    /// variable must not become a green run over zero files -- the failure mode
364    /// most likely to pass unnoticed in an agent loop.
365    pub fn refuse_if_empty(&self, remaining: usize, language: &str) -> Result<(), String> {
366        if remaining > 0 {
367            return Ok(());
368        }
369        let missing = self
370            .configured
371            .iter()
372            .filter(|value| fs::symlink_metadata(resolve(&self.root, value)).is_err())
373            .cloned()
374            .collect::<Vec<_>>();
375        let mut message = format!(
376            "{SOURCE_ROOTS_VARIABLE}={} left no {language} source to measure",
377            self.configured.join(",")
378        );
379        if !missing.is_empty() {
380            message.push_str(&format!("; {} does not exist", missing.join(", ")));
381        }
382        message.push_str("; name the directories or files that hold your own code");
383        Err(message)
384    }
385
386    /// The scope a frontend that has no scope of its own records when explicit
387    /// roots shaped what it measures: the files kept, and the files the roots
388    /// left out. Without it, honouring the variable would be as invisible as
389    /// ignoring it -- the complaint that started this -- since `runs <id>
390    /// scope` would have nothing to show.
391    pub fn scope(
392        &self,
393        included: &[String],
394        excluded: &[(String, &'static str)],
395    ) -> Result<SourceScope, SourceDiscoveryError> {
396        let mut entries = included
397            .iter()
398            .map(|file| SourceScopeEntry {
399                file: file.clone(),
400                status: SourceScopeStatus::Included,
401                reason: "explicit source root".into(),
402                package_root: None,
403            })
404            .chain(
405                excluded
406                    .iter()
407                    .filter(|(_, reason)| *reason == OUTSIDE_EXPLICIT_ROOTS)
408                    .map(|(file, reason)| SourceScopeEntry {
409                        file: file.clone(),
410                        status: SourceScopeStatus::Excluded,
411                        reason: (*reason).into(),
412                        package_root: None,
413                    }),
414            )
415            .collect::<Vec<_>>();
416        entries.sort_by(|left, right| left.file.cmp(&right.file));
417        Ok(SourceScope {
418            version: 1,
419            mode: SourceScopeMode::Explicit,
420            roots: self.local_roots()?,
421            entries,
422        })
423    }
424
425    /// The roots as project-relative paths, as the recorded scope names them.
426    pub fn local_roots(&self) -> Result<Vec<String>, SourceDiscoveryError> {
427        self.roots
428            .iter()
429            .map(|path| local_path(&self.root, path))
430            .collect()
431    }
432
433    /// Split `sources` into the files these roots keep and the ones they leave
434    /// out, recording why each was left out. A frontend's own exclusions --
435    /// tests, environments, build output -- have already been applied, so a
436    /// root narrows what is measured and never pulls a test file back in.
437    pub fn narrow<T>(
438        &self,
439        sources: &mut Vec<T>,
440        excluded: &mut Vec<(String, &'static str)>,
441        file: impl Fn(&T) -> &str,
442    ) {
443        let (kept, outside): (Vec<T>, Vec<T>) = sources
444            .drain(..)
445            .partition(|source| self.contains(file(source)));
446        excluded.extend(
447            outside
448                .iter()
449                .map(|source| (file(source).to_owned(), OUTSIDE_EXPLICIT_ROOTS)),
450        );
451        *sources = kept;
452    }
453}
454
455/// The configured or discovered roots that exist, each a regular file or
456/// directory. Shared by explicit and automatic discovery so the two cannot
457/// disagree about what counts as a root. A root someone named that is
458/// anything else is refused; a conventional one discovery merely guessed
459/// (`lib` linked to `src`, a `main` entry linked into `dist/`) is passed
460/// over, as the copy the run measures passes over a link out of the project.
461fn existing_roots(
462    candidates: impl IntoIterator<Item = PathBuf>,
463    named: bool,
464) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
465    let mut existing = BTreeSet::new();
466    for path in candidates {
467        match fs::symlink_metadata(&path) {
468            Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_dir() => {
469                existing.insert(path);
470            }
471            Ok(_) if !named => {}
472            Ok(_) => return Err(SourceDiscoveryError::InvalidRoot(path)),
473            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
474            Err(error) => return Err(io_error(&path, error)),
475        }
476    }
477    Ok(existing.into_iter().collect())
478}
479
480/// Whether `path` lies under one of `roots`: inside a directory root, or equal
481/// to a file root.
482fn root_contains(roots: &[PathBuf], path: &Path) -> bool {
483    roots.iter().any(|directory| {
484        fs::symlink_metadata(directory)
485            .map(|metadata| {
486                if metadata.file_type().is_dir() {
487                    within(directory, path)
488                } else {
489                    directory == path
490                }
491            })
492            .unwrap_or(false)
493    })
494}
495
496fn lexical_normalize(path: &Path) -> PathBuf {
497    let mut output = PathBuf::new();
498    for component in path.components() {
499        match component {
500            Component::CurDir => {}
501            Component::ParentDir => {
502                if !output.pop() {
503                    output.push(component.as_os_str());
504                }
505            }
506            _ => output.push(component.as_os_str()),
507        }
508    }
509    output
510}
511
512fn resolve(root: &Path, value: impl AsRef<Path>) -> PathBuf {
513    let value = value.as_ref();
514    let joined;
515    let path = if value.is_absolute() {
516        value
517    } else {
518        joined = root.join(value);
519        &joined
520    };
521    lexical_normalize(path)
522}
523
524fn local_path(root: &Path, path: &Path) -> Result<String, SourceDiscoveryError> {
525    let local = path
526        .strip_prefix(root)
527        .map_err(|_| SourceDiscoveryError::InvalidRoot(path.to_owned()))?;
528    if local.as_os_str().is_empty() {
529        return Ok(".".into());
530    }
531    local
532        .components()
533        .map(|component| {
534            component
535                .as_os_str()
536                .to_str()
537                .map(str::to_owned)
538                .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(path.to_owned()))
539        })
540        .collect::<Result<Vec<_>, _>>()
541        .map(|parts| parts.join("/"))
542}
543
544fn generated_directory(name: &str) -> bool {
545    GENERATED_DIRECTORIES.contains(&name)
546}
547
548fn owned_workspace_store(path: &Path) -> bool {
549    crate::workspace::owned_workspace_path(path)
550}
551
552/// A directory carrying its own `.git` entry is another checkout — a nested
553/// clone, a submodule, or an agent worktree such as `.claude/worktrees/*` —
554/// not this project's source. Treating its files as ambiguous first-party
555/// code turned one real project's report into 1,032 blocking limitations.
556fn nested_checkout(path: &Path) -> bool {
557    fs::symlink_metadata(path.join(".git")).is_ok()
558}
559
560/// A hidden directory at the project root is tool state (.shopify, .vercel,
561/// .idea, .claude, ...) by convention, never application source. Nested
562/// hidden directories keep their normal treatment so a source tree that
563/// happens to contain one is not silently truncated.
564/// A hashed bundle (`app-embed-Be-aUw9g.js`) inside an assets/static/public
565/// directory is a bundler's output, not source: a theme extension's `assets/`
566/// receives its Vite build, and every such bundle was an ambiguous blocker.
567fn built_asset(file: &str) -> bool {
568    let mut segments = file.rsplit('/');
569    let Some(name) = segments.next() else {
570        return false;
571    };
572    let in_asset_directory =
573        segments.any(|segment| matches!(segment, "assets" | "static" | "public"));
574    let Some(stem) = name
575        .strip_suffix(".js")
576        .or_else(|| name.strip_suffix(".mjs"))
577        .or_else(|| name.strip_suffix(".cjs"))
578    else {
579        return false;
580    };
581    // Bundler hashes are eight base64url characters, which may themselves
582    // contain '-', so take the suffix by length rather than splitting on it.
583    if stem.len() < 10 || stem.as_bytes()[stem.len() - 9] != b'-' {
584        return false;
585    }
586    let hash = &stem[stem.len() - 8..];
587    let looks_hashed = hash
588        .bytes()
589        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
590        && (hash.bytes().any(|byte| byte.is_ascii_digit())
591            || (hash.bytes().any(|byte| byte.is_ascii_uppercase())
592                && hash.bytes().any(|byte| byte.is_ascii_lowercase())));
593    in_asset_directory && looks_hashed
594}
595
596fn root_tool_directory(root: &Path, path: &Path) -> bool {
597    path.parent() == Some(root)
598        && path
599            .file_name()
600            .and_then(|name| name.to_str())
601            .is_some_and(|name| name.starts_with('.'))
602}
603
604fn source_file(name: &str) -> bool {
605    let lower = name.to_ascii_lowercase();
606    [
607        ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
608        ".mtsx",
609    ]
610    .iter()
611    .any(|extension| lower.ends_with(extension))
612}
613
614fn declaration_file(file: &str) -> bool {
615    let lower = file.to_ascii_lowercase();
616    lower.ends_with(".d.ts") || lower.ends_with(".d.cts") || lower.ends_with(".d.mts")
617}
618
619fn test_or_fixture(file: &str) -> bool {
620    let lower = file.to_ascii_lowercase();
621    if lower
622        .split('/')
623        .any(|segment| TEST_DIRECTORIES.contains(&segment))
624    {
625        return true;
626    }
627    lower
628        .split(['/', '_', '.', '-'])
629        .any(|part| matches!(part, "test" | "spec"))
630}
631
632fn depends_on_next(manifest: &Value) -> bool {
633    ["dependencies", "devDependencies", "peerDependencies"]
634        .iter()
635        .any(|field| {
636            manifest
637                .get(field)
638                .is_some_and(|all| all.get("next").is_some())
639        })
640}
641
642/// A file the App Router serves. Under `app/` a directory is a URL segment,
643/// so `app/api/wordpress/test/route.ts` is the handler of `/api/wordpress/test`
644/// and not a test: five such "test connection" endpoints of one application
645/// were left out of what it measured. A folder that starts with `_` is private
646/// to the project and is not a segment, which keeps `__tests__` a test folder.
647fn next_app_file(app_directories: &[PathBuf], path: &Path) -> bool {
648    let Some(local) = app_directories
649        .iter()
650        .find_map(|directory| path.strip_prefix(directory).ok())
651    else {
652        return false;
653    };
654    let Some(name) = local.file_name().and_then(|name| name.to_str()) else {
655        return false;
656    };
657    let conventional = name.split_once('.').is_some_and(|(stem, extension)| {
658        NEXT_APP_FILES.contains(&stem) && !extension.contains('.')
659    });
660    conventional
661        && local.parent().is_none_or(|folders| {
662            folders
663                .components()
664                .all(|folder| !folder.as_os_str().to_string_lossy().starts_with('_'))
665        })
666}
667
668/// The path a named root leaves to judge: the part below it. Whoever names
669/// `scripts` or `src/test-utils` as a source root has said what it holds, so
670/// only what lies deeper is still guessed at. A root that is a file names that
671/// file, and nothing about it is guessed.
672fn below_named_root<'a>(roots: &[PathBuf], path: &'a Path) -> Option<&'a Path> {
673    roots
674        .iter()
675        .filter_map(|root| path.strip_prefix(root).ok())
676        .min_by_key(|local| local.components().count())
677}
678
679/// Code kept beside a project's packages without being part of one, by the
680/// directory at the top of the project it lies in: the documentation's
681/// build script, example programs, benchmarks, fuzz harnesses. A directory of such a name
682/// deeper down can be a package of the product and is left to be measured.
683///
684/// The frontends that walk a tree for every source file (Python, Ruby)
685/// counted these. On h11 `docs/`, `examples/`, `bench/` made six of eighteen
686/// measured files, none of which a test runs, and the package's own 99% of
687/// lines read 69%; click measured thirteen such files of thirty. Each of
688/// eight projects looked at had at least one of the three.
689pub fn beside_the_product(file: &str) -> Option<&'static str> {
690    let (first, _) = file.split_once('/')?;
691    match first.to_ascii_lowercase().as_str() {
692        "docs" | "doc" | "documentation" => Some("documentation"),
693        "examples" | "example" => Some("example"),
694        "bench" | "benches" | "benchmark" | "benchmarks" => Some("benchmark"),
695        "fuzz" | "fuzzing" => Some("fuzz harness"),
696        _ => None,
697    }
698}
699
700/// Take what lies beside the product out of `sources`, saying why. For a
701/// project that names no roots: a root someone named is what they say it is.
702pub fn leave_out_what_lies_beside(
703    sources: &mut Vec<String>,
704    excluded: &mut Vec<(String, &'static str)>,
705) {
706    sources.retain(|file| match beside_the_product(file) {
707        Some(reason) => {
708            excluded.push((file.clone(), reason));
709            false
710        }
711        None => true,
712    });
713}
714
715/// The scope a frontend records when nothing named its roots: each file it
716/// measures, and each file it left out with why. `runs <id> scope` lists it,
717/// and for a Go, Rust or JVM run had nothing to list: the summary offered the
718/// command and the command answered that the run held no inventory.
719pub fn automatic_scope(
720    measured: impl IntoIterator<Item = (String, String)>,
721    left_out: impl IntoIterator<Item = (String, String)>,
722) -> SourceScope {
723    let entry = |status: SourceScopeStatus| {
724        move |(file, reason)| SourceScopeEntry {
725            file,
726            status: status.clone(),
727            reason,
728            package_root: None,
729        }
730    };
731    let mut entries = measured
732        .into_iter()
733        .map(entry(SourceScopeStatus::Included))
734        .chain(left_out.into_iter().map(entry(SourceScopeStatus::Excluded)))
735        .collect::<Vec<_>>();
736    entries.sort_by(|left, right| left.file.cmp(&right.file));
737    SourceScope {
738        version: 1,
739        mode: SourceScopeMode::Automatic,
740        roots: vec![".".into()],
741        entries,
742    }
743}
744
745/// A benchmark: Vitest's `*.bench.ts` beside the code it measures, or a file
746/// under a `bench`/`benchmarks` directory. It runs the product; it is not the
747/// product. jshttp/cookie keeps four `src/*.bench.ts` files, which were
748/// counted as application source and led the list of code no test ran.
749fn benchmark(file: &str) -> bool {
750    let lower = file.to_ascii_lowercase();
751    let (directories, name) = lower.rsplit_once('/').unwrap_or(("", lower.as_str()));
752    directories
753        .split('/')
754        .any(|segment| matches!(segment, "bench" | "benches" | "benchmark" | "benchmarks"))
755        || name
756            .split('.')
757            .skip(1)
758            .any(|part| matches!(part, "bench" | "benchmark"))
759}
760
761/// Example code at the top of the project or of a package: uuid keeps nine
762/// example apps under `examples/`, which its tsconfig leaves out and which
763/// were counted as application source, putting uuid at 32% of lines. A
764/// directory of that name deeper in the source (`app/examples/page.tsx`) can
765/// be the product and stays in.
766fn example(file: &str, package_root: Option<&str>) -> bool {
767    let local = package_root
768        .and_then(|package| file.strip_prefix(package))
769        .and_then(|rest| rest.strip_prefix('/'))
770        .unwrap_or(file);
771    local.split_once('/').is_some_and(|(first, _)| {
772        matches!(first.to_ascii_lowercase().as_str(), "example" | "examples")
773    })
774}
775
776fn tool_script(file: &str) -> bool {
777    file.to_ascii_lowercase()
778        .split('/')
779        .any(|segment| segment == "scripts")
780}
781
782fn config_file(file: &str) -> bool {
783    let lower = file.to_ascii_lowercase();
784    let name = lower.rsplit('/').next().unwrap_or(&lower);
785    source_file(name)
786        && (CONFIG_TOOLS
787            .iter()
788            .any(|tool| name.starts_with(&format!("{tool}.config.")))
789            || DOT_CONFIG_TOOLS
790                .iter()
791                .any(|tool| name.starts_with(&format!(".{tool}rc.")))
792            || (!lower.contains('/')
793                && (name.contains(".config.")
794                    // wdio.conf.js, karma.conf.js, protractor.conf.js
795                    || name.contains(".conf.")
796                    || name.starts_with("build.")
797                    || name.starts_with("gulpfile.")
798                    || name.starts_with("gruntfile."))))
799}
800
801fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, SourceDiscoveryError> {
802    let mut entries = fs::read_dir(path)
803        .map_err(|error| io_error(path, error))?
804        .collect::<Result<Vec<_>, _>>()
805        .map_err(|error| io_error(path, error))?;
806    entries.sort_by_key(fs::DirEntry::file_name);
807    Ok(entries)
808}
809
810/// Whether the repository around a project ignores the project's own
811/// directory: a copy kept under another checkout's `tmp/` or `.supercov/`.
812/// That repository's `.gitignore` files describe what it does not track, which
813/// is all of this project; applying them left the project with no source.
814fn ignored_by_enclosing_repository(root: &Path) -> bool {
815    if root.join(".git").exists() {
816        return false;
817    }
818    let Some(repository) = root
819        .ancestors()
820        .skip(1)
821        .position(|ancestor| ancestor.join(".git").exists())
822    else {
823        return false;
824    };
825    // Outermost first, so a deeper file's decision wins as it does in git.
826    root.ancestors()
827        .skip(1)
828        .take(repository + 1)
829        .collect::<Vec<_>>()
830        .into_iter()
831        .rev()
832        .fold(false, |ignored, directory| {
833            let (rules, _) = ignore::gitignore::Gitignore::new(directory.join(".gitignore"));
834            match rules.matched_path_or_any_parents(root, true) {
835                ignore::Match::Ignore(_) => true,
836                ignore::Match::Whitelist(_) => false,
837                ignore::Match::None => ignored,
838            }
839        })
840}
841
842/// Source files under a root, in name order.
843///
844/// A file the project's own `.gitignore` excludes is not its source: recording
845/// Actual counted `packages/loot-core/lib-dist/browser/kcab.worker.dev.js`, a
846/// 5 MB bundle its build writes and git ignores, as application code, and
847/// instrumented it. Only the project's `.gitignore` files are read, and only
848/// inside a git repository; a user's global excludes and `.git/info/exclude`
849/// differ between machines, and a run must count the same files on each.
850fn files_under(
851    root: &Path,
852    directory: &Path,
853    output: &mut Vec<PathBuf>,
854) -> Result<(), SourceDiscoveryError> {
855    let metadata = fs::symlink_metadata(directory).map_err(|error| io_error(directory, error))?;
856    if !metadata.file_type().is_dir() {
857        return Err(SourceDiscoveryError::InvalidRoot(directory.to_owned()));
858    }
859    let project = root.to_owned();
860    let walker = ignore::WalkBuilder::new(directory)
861        .standard_filters(false)
862        .git_ignore(true)
863        .parents(!ignored_by_enclosing_repository(directory))
864        .require_git(true)
865        .follow_links(false)
866        .sort_by_file_name(|left, right| left.cmp(right))
867        .filter_entry(move |entry| {
868            entry.depth() == 0
869                || !entry.file_type().is_some_and(|kind| kind.is_dir())
870                || entry.file_name().to_str().is_some_and(|name| {
871                    !generated_directory(name)
872                        && !owned_workspace_store(entry.path())
873                        && !nested_checkout(entry.path())
874                        && !root_tool_directory(&project, entry.path())
875                })
876        })
877        .build();
878    for entry in walker {
879        let entry =
880            entry.map_err(|error| io_error(directory, io::Error::other(error.to_string())))?;
881        let Some(kind) = entry.file_type() else {
882            continue;
883        };
884        if kind.is_symlink() || !kind.is_file() {
885            continue;
886        }
887        let name = entry
888            .file_name()
889            .to_str()
890            .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(entry.path().to_owned()))?;
891        if source_file(name) {
892            output.push(entry.into_path());
893        }
894    }
895    Ok(())
896}
897
898fn read_json(path: &Path) -> Option<Value> {
899    serde_json::from_slice(&fs::read(path).ok()?).ok()
900}
901
902fn package_directories(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
903    fn visit(
904        root: &Path,
905        directory: &Path,
906        depth: usize,
907        found: &mut BTreeSet<PathBuf>,
908    ) -> Result<(), SourceDiscoveryError> {
909        if depth > 5 {
910            return Ok(());
911        }
912        for entry in read_directory(directory)? {
913            let name = entry
914                .file_name()
915                .into_string()
916                .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
917            let path = entry.path();
918            let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
919            if !file_type.is_dir()
920                || file_type.is_symlink()
921                || generated_directory(&name)
922                || owned_workspace_store(&path)
923                || nested_checkout(&path)
924                || root_tool_directory(root, &path)
925            {
926                continue;
927            }
928            let local = local_path(root, &path)?;
929            let under_package_parent = local
930                .split('/')
931                .any(|segment| PACKAGE_PARENTS.contains(&segment));
932            if path.join("package.json").is_file() && (depth == 0 || under_package_parent) {
933                found.insert(path.clone());
934            }
935            visit(root, &path, depth + 1, found)?;
936        }
937        Ok(())
938    }
939
940    let mut found = BTreeSet::from([root.to_owned()]);
941    visit(root, root, 0, &mut found)?;
942    for directory in declared_workspace_packages(root)? {
943        found.insert(directory);
944    }
945    Ok(found.into_iter().collect())
946}
947
948/// Packages the project itself declares: `workspaces` in the root
949/// package.json (array or `{ "packages": [...] }`) and `packages:` in
950/// pnpm-workspace.yaml. The manifest is the most authoritative statement of
951/// where packages live, and it routinely names directories outside the
952/// conventional parents (a real project keeps its Shopify extensions under
953/// `app_extensions/*`; every file there was an ambiguous blocker).
954fn declared_workspace_packages(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
955    let mut patterns = Vec::new();
956    let manifest = read_json(&root.join("package.json")).unwrap_or(Value::Null);
957    let declared = match manifest.get("workspaces") {
958        Some(Value::Array(items)) => Some(items),
959        Some(Value::Object(object)) => object.get("packages").and_then(Value::as_array),
960        _ => None,
961    };
962    if let Some(items) = declared {
963        patterns.extend(items.iter().filter_map(Value::as_str).map(str::to_owned));
964    }
965    if let Ok(pnpm) = fs::read_to_string(root.join("pnpm-workspace.yaml")) {
966        let mut in_packages = false;
967        for line in pnpm.lines() {
968            let trimmed = line.trim();
969            if trimmed.starts_with("packages:") {
970                in_packages = true;
971                continue;
972            }
973            if in_packages {
974                if let Some(item) = trimmed.strip_prefix("- ") {
975                    patterns.push(item.trim_matches(|c| c == '"' || c == '\'').to_owned());
976                } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
977                    in_packages = false;
978                }
979            }
980        }
981    }
982    let mut found = Vec::new();
983    for pattern in patterns {
984        let pattern = pattern.trim_start_matches("./").trim_end_matches('/');
985        if pattern.starts_with('!') || pattern.contains("**") {
986            continue;
987        }
988        let (parent, wildcard) = match pattern.strip_suffix("/*") {
989            Some(parent) => (parent, true),
990            None => (pattern, false),
991        };
992        if parent.is_empty()
993            || parent
994                .split('/')
995                .any(|segment| segment.is_empty() || segment == ".." || segment.contains('*'))
996        {
997            continue;
998        }
999        let base = root.join(parent);
1000        let candidates: Vec<PathBuf> = if wildcard {
1001            match fs::read_dir(&base) {
1002                Ok(entries) => entries
1003                    .filter_map(Result::ok)
1004                    .map(|entry| entry.path())
1005                    .collect(),
1006                Err(_) => Vec::new(),
1007            }
1008        } else {
1009            vec![base]
1010        };
1011        for candidate in candidates {
1012            let is_dir = fs::symlink_metadata(&candidate)
1013                .is_ok_and(|metadata| metadata.file_type().is_dir());
1014            if is_dir
1015                && candidate.join("package.json").is_file()
1016                && !nested_checkout(&candidate)
1017                && !owned_workspace_store(&candidate)
1018            {
1019                found.push(candidate);
1020            }
1021        }
1022    }
1023    Ok(found)
1024}
1025
1026fn string_targets(value: &Value, depth: usize, output: &mut Vec<String>) {
1027    if depth > 8 {
1028        return;
1029    }
1030    match value {
1031        Value::String(value) => output.push(value.clone()),
1032        Value::Array(values) => {
1033            for value in values {
1034                string_targets(value, depth + 1, output);
1035            }
1036        }
1037        Value::Object(values) => {
1038            for value in values.values() {
1039                string_targets(value, depth + 1, output);
1040            }
1041        }
1042        _ => {}
1043    }
1044}
1045
1046fn entry_targets(directory: &Path, manifest: &Value) -> Vec<PathBuf> {
1047    let mut targets = Vec::new();
1048    for key in ["main", "module", "browser", "bin", "exports"] {
1049        if let Some(value) = manifest.get(key) {
1050            string_targets(value, 0, &mut targets);
1051        }
1052    }
1053    targets
1054        .into_iter()
1055        .filter_map(|target| {
1056            if !target.starts_with('.') || target.contains("node_modules") {
1057                return None;
1058            }
1059            let prefix = target.split('*').next()?.trim_end_matches('/');
1060            (!prefix.is_empty()).then(|| resolve(directory, prefix))
1061        })
1062        .collect()
1063}
1064
1065pub(crate) fn strip_jsonc_comments(contents: &str) -> String {
1066    let mut output = String::with_capacity(contents.len());
1067    let mut chars = contents.chars().peekable();
1068    let mut string = false;
1069    let mut escaped = false;
1070    while let Some(character) = chars.next() {
1071        if string {
1072            output.push(character);
1073            if escaped {
1074                escaped = false;
1075            } else if character == '\\' {
1076                escaped = true;
1077            } else if character == '"' {
1078                string = false;
1079            }
1080            continue;
1081        }
1082        if character == '"' {
1083            string = true;
1084            output.push(character);
1085        } else if character == '/' && chars.peek() == Some(&'/') {
1086            chars.next();
1087            for comment in chars.by_ref() {
1088                if comment == '\n' {
1089                    output.push('\n');
1090                    break;
1091                }
1092            }
1093        } else if character == '/' && chars.peek() == Some(&'*') {
1094            chars.next();
1095            let mut previous = '\0';
1096            for comment in chars.by_ref() {
1097                if previous == '*' && comment == '/' {
1098                    break;
1099                }
1100                previous = comment;
1101            }
1102        } else {
1103            output.push(character);
1104        }
1105    }
1106    output
1107}
1108
1109pub(crate) fn strip_trailing_commas(contents: &str) -> String {
1110    let chars = contents.chars().collect::<Vec<_>>();
1111    let mut output = String::with_capacity(contents.len());
1112    let mut string = false;
1113    let mut escaped = false;
1114    for (index, character) in chars.iter().copied().enumerate() {
1115        if string {
1116            output.push(character);
1117            if escaped {
1118                escaped = false;
1119            } else if character == '\\' {
1120                escaped = true;
1121            } else if character == '"' {
1122                string = false;
1123            }
1124            continue;
1125        }
1126        if character == '"' {
1127            string = true;
1128            output.push(character);
1129        } else if character == ','
1130            && chars[index + 1..]
1131                .iter()
1132                .find(|character| !character.is_whitespace())
1133                .is_some_and(|character| matches!(character, '}' | ']'))
1134        {
1135        } else {
1136            output.push(character);
1137        }
1138    }
1139    output
1140}
1141
1142fn tsconfig_roots(directory: &Path) -> Vec<PathBuf> {
1143    let path = directory.join("tsconfig.json");
1144    let Some(contents) = fs::read_to_string(path).ok() else {
1145        return Vec::new();
1146    };
1147    let jsonc = strip_trailing_commas(&strip_jsonc_comments(&contents));
1148    let Some(config) = serde_json::from_str::<Value>(&jsonc).ok() else {
1149        return Vec::new();
1150    };
1151    let mut values = Vec::new();
1152    if let Some(root_dir) = config
1153        .get("compilerOptions")
1154        .and_then(|options| options.get("rootDir"))
1155        .and_then(Value::as_str)
1156    {
1157        values.push(root_dir.to_owned());
1158    }
1159    if let Some(include) = config.get("include").and_then(Value::as_array) {
1160        values.extend(include.iter().filter_map(Value::as_str).map(str::to_owned));
1161    }
1162    if values.is_empty() {
1163        return vec![directory.to_owned()];
1164    }
1165    values
1166        .into_iter()
1167        .filter_map(|value| {
1168            if value.starts_with('!') {
1169                return None;
1170            }
1171            let prefix = value
1172                .find(['?', '*', '{', '['])
1173                .map_or(value.as_str(), |index| &value[..index])
1174                .trim_end_matches('/');
1175            // `next-env.d.ts` is in every Next.js `include`; a declaration
1176            // holds nothing to measure and is not a root.
1177            (!prefix.is_empty() && !declaration_file(prefix)).then(|| resolve(directory, prefix))
1178        })
1179        .collect()
1180}
1181
1182fn within(parent: &Path, child: &Path) -> bool {
1183    child == parent || child.starts_with(parent)
1184}
1185
1186fn nearest_package_root<'a>(path: &Path, packages: &'a [PathBuf]) -> Option<&'a PathBuf> {
1187    packages
1188        .iter()
1189        .filter(|directory| within(directory, path))
1190        .max_by_key(|directory| directory.components().count())
1191}
1192
1193fn scope_limitation(file: &str) -> SourceLimitation {
1194    let digest = Sha256::digest(file.as_bytes());
1195    let id = digest[..10]
1196        .iter()
1197        .map(|byte| format!("{byte:02x}"))
1198        .collect::<String>();
1199    SourceLimitation {
1200        id: format!("scope:{id}"),
1201        kind: "source-scope".into(),
1202        file: file.into(),
1203        line: 1,
1204        column: 1,
1205        source: file.into(),
1206        reason: "First-party JavaScript/TypeScript source could not be classified automatically. Configure SUPERCOV_SOURCE_ROOTS or move it under a discovered package source root.".into(),
1207    }
1208}
1209
1210// A declared Jest setup file is test infrastructure even when it lives under
1211// a source root with an arbitrary name. Instrumenting its mock factories adds
1212// out-of-scope bindings that Babel's jest-hoist correctly rejects.
1213fn declared_test_setup(packages: &[PathBuf]) -> BTreeSet<PathBuf> {
1214    let mut paths = BTreeSet::new();
1215    for package in packages {
1216        let manifest = read_json(&package.join("package.json")).unwrap_or(Value::Null);
1217        let Some(jest) = manifest.get("jest") else {
1218            continue;
1219        };
1220        let root = jest
1221            .get("rootDir")
1222            .and_then(Value::as_str)
1223            .map(|value| {
1224                resolve(
1225                    package,
1226                    value.replace("<rootDir>", &package.to_string_lossy()),
1227                )
1228            })
1229            .unwrap_or_else(|| package.clone());
1230        for field in [
1231            "setupFiles",
1232            "setupFilesAfterEnv",
1233            "globalSetup",
1234            "globalTeardown",
1235        ] {
1236            let values = match jest.get(field) {
1237                Some(Value::Array(values)) => values.iter().collect::<Vec<_>>(),
1238                Some(value) => vec![value],
1239                None => Vec::new(),
1240            };
1241            for value in values.into_iter().filter_map(Value::as_str) {
1242                let path = resolve(&root, value.replace("<rootDir>", &root.to_string_lossy()));
1243                if path.is_file() {
1244                    paths.insert(path);
1245                }
1246            }
1247        }
1248    }
1249    paths
1250}
1251
1252pub fn discover_source_scope(
1253    root: &Path,
1254    configured_roots: Option<&[String]>,
1255) -> Result<DiscoveredSourceScope, SourceDiscoveryError> {
1256    let root = lexical_normalize(root);
1257    let root_metadata = fs::symlink_metadata(&root).map_err(|error| io_error(&root, error))?;
1258    if !root_metadata.file_type().is_dir() {
1259        return Err(SourceDiscoveryError::InvalidRoot(root));
1260    }
1261    let packages = package_directories(&root)?;
1262    let test_setup = declared_test_setup(&packages);
1263    let explicit = configured_roots.is_some_and(|roots| !roots.is_empty());
1264    let include_roots = if explicit {
1265        configured_roots
1266            .unwrap_or_default()
1267            .iter()
1268            .map(|directory| resolve(&root, directory))
1269            .collect::<Vec<_>>()
1270    } else {
1271        packages
1272            .iter()
1273            .flat_map(|directory| {
1274                let manifest = read_json(&directory.join("package.json")).unwrap_or(Value::Null);
1275                let next_files = depends_on_next(&manifest)
1276                    .then_some(NEXT_ROOT_FILES)
1277                    .into_iter()
1278                    .flatten()
1279                    .flat_map(|name| {
1280                        ["ts", "tsx", "js", "jsx", "mjs"]
1281                            .map(|extension| directory.join(format!("{name}.{extension}")))
1282                    });
1283                let candidates = SOURCE_DIRECTORIES
1284                    .iter()
1285                    .map(|name| directory.join(name))
1286                    .chain(next_files)
1287                    .chain(entry_targets(directory, &manifest))
1288                    .chain(tsconfig_roots(directory))
1289                    // A tsconfig that includes a sibling package
1290                    // (`../packages/ui/src`) names code outside this
1291                    // project: not its source, and not in its copy. Only
1292                    // a root the user names is held to being inside.
1293                    .filter(|candidate| within(&root, candidate))
1294                    .collect::<Vec<_>>();
1295                // A declared package that keeps its code somewhere
1296                // unconventional (a Shopify theme extension's `frontend/` and
1297                // `blocks/`, say) is still first-party source. Its own
1298                // directory becomes the root; generated subtrees stay excluded
1299                // by the walker as everywhere else.
1300                if directory != &root
1301                    && !candidates
1302                        .iter()
1303                        .any(|candidate| fs::symlink_metadata(candidate).is_ok())
1304                {
1305                    return vec![directory.clone()];
1306                }
1307                candidates
1308            })
1309            .collect()
1310    };
1311    let existing_roots = existing_roots(include_roots, explicit)?;
1312    let app_directories = packages
1313        .iter()
1314        .filter(|directory| {
1315            depends_on_next(&read_json(&directory.join("package.json")).unwrap_or(Value::Null))
1316        })
1317        .flat_map(|directory| [directory.join("app"), directory.join("src/app")])
1318        .collect::<Vec<_>>();
1319    let mut all_files = Vec::new();
1320    files_under(&root, &root, &mut all_files)?;
1321    all_files.sort();
1322
1323    let mut entries = Vec::new();
1324    let mut included = Vec::new();
1325    let mut limitations = Vec::new();
1326    for path in all_files {
1327        let file = local_path(&root, &path)?;
1328        let package_root = nearest_package_root(&path, &packages)
1329            .map(|package| local_path(&root, package))
1330            .transpose()?
1331            .filter(|package| package != ".");
1332        let entry = |status, reason: &str| SourceScopeEntry {
1333            file: file.clone(),
1334            status,
1335            reason: reason.into(),
1336            package_root: package_root.clone(),
1337        };
1338        let named = explicit
1339            .then(|| below_named_root(&existing_roots, &path))
1340            .flatten();
1341        let guessed = match named {
1342            Some(local) => local.to_string_lossy().replace('\\', "/"),
1343            None => file.clone(),
1344        };
1345        if declaration_file(&file) {
1346            entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
1347        } else if test_setup.contains(&path) {
1348            entries.push(entry(SourceScopeStatus::Excluded, "declared test setup"));
1349        } else if test_or_fixture(&guessed) && !next_app_file(&app_directories, &path) {
1350            entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
1351        } else if tool_script(&guessed) {
1352            entries.push(entry(
1353                SourceScopeStatus::Excluded,
1354                "conventional tool script",
1355            ));
1356        } else if benchmark(&guessed) {
1357            entries.push(entry(SourceScopeStatus::Excluded, "benchmark"));
1358        } else if example(&file, package_root.as_deref()) {
1359            entries.push(entry(SourceScopeStatus::Excluded, "example"));
1360        } else if config_file(&file) {
1361            entries.push(entry(
1362                SourceScopeStatus::Excluded,
1363                "build/test/tool configuration",
1364            ));
1365        } else if built_asset(&file) {
1366            entries.push(entry(SourceScopeStatus::Excluded, BUILT_ASSET_REASON));
1367        } else if root_contains(&existing_roots, &path) {
1368            included.push(path);
1369            entries.push(entry(
1370                SourceScopeStatus::Included,
1371                if explicit {
1372                    "explicit source root"
1373                } else {
1374                    "discovered package source root"
1375                },
1376            ));
1377        } else if explicit {
1378            entries.push(entry(
1379                SourceScopeStatus::Excluded,
1380                "outside explicit source roots",
1381            ));
1382        } else {
1383            entries.push(entry(
1384                SourceScopeStatus::Ambiguous,
1385                "unclassified first-party source",
1386            ));
1387            limitations.push(scope_limitation(&file));
1388        }
1389    }
1390    let source_files = included
1391        .iter()
1392        .map(|path| local_path(&root, path))
1393        .collect::<Result<Vec<_>, _>>()?;
1394    let source_roots = existing_roots
1395        .iter()
1396        .map(|path| local_path(&root, path))
1397        .collect::<Result<Vec<_>, _>>()?;
1398    Ok(DiscoveredSourceScope {
1399        source_files,
1400        source_roots: source_roots.clone(),
1401        scope: SourceScope {
1402            version: 1,
1403            mode: if explicit {
1404                SourceScopeMode::Explicit
1405            } else {
1406                SourceScopeMode::Automatic
1407            },
1408            roots: source_roots,
1409            entries,
1410        },
1411        limitations,
1412    })
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417    use std::{
1418        fs,
1419        time::{SystemTime, UNIX_EPOCH},
1420    };
1421
1422    use super::*;
1423
1424    fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
1425        let nonce = SystemTime::now()
1426            .duration_since(UNIX_EPOCH)
1427            .unwrap()
1428            .as_nanos();
1429        let root = std::env::temp_dir().join(format!(
1430            "supercov-source-{label}-{}-{nonce}",
1431            std::process::id()
1432        ));
1433        fs::create_dir_all(&root).unwrap();
1434        for (file, contents) in files {
1435            let path = root.join(file);
1436            fs::create_dir_all(path.parent().unwrap()).unwrap();
1437            fs::write(path, contents).unwrap();
1438        }
1439        root
1440    }
1441
1442    fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
1443        scope
1444            .scope
1445            .entries
1446            .iter()
1447            .find(|entry| entry.file == file)
1448            .unwrap()
1449    }
1450
1451    #[test]
1452    fn a_file_the_project_tells_git_to_ignore_is_not_its_source() {
1453        // Actual's build writes a 5 MB worker bundle under
1454        // packages/loot-core/lib-dist, which its own nested .gitignore names.
1455        let files = [
1456            ("package.json", r#"{"workspaces":["packages/*"]}"#),
1457            ("packages/core/package.json", r#"{"name":"core"}"#),
1458            ("packages/core/.gitignore", "lib-dist\n"),
1459            ("packages/core/src/index.ts", "export const a = 1;"),
1460            (
1461                "packages/core/lib-dist/browser/worker.dev.js",
1462                "var bundled = 1;",
1463            ),
1464        ];
1465        let repository_root = repository("gitignored", &files);
1466        fs::create_dir_all(repository_root.join(".git")).unwrap();
1467        let discovered = discover_source_scope(&repository_root, None).unwrap();
1468        assert!(
1469            discovered
1470                .source_files
1471                .contains(&"packages/core/src/index.ts".into())
1472        );
1473        assert!(
1474            !discovered
1475                .source_files
1476                .iter()
1477                .any(|file| file.contains("lib-dist")),
1478            "{:?}",
1479            discovered.source_files
1480        );
1481        fs::remove_dir_all(repository_root).unwrap();
1482
1483        // Outside a git repository a .gitignore means nothing.
1484        let plain = repository("not-a-repository", &files);
1485        let discovered = discover_source_scope(&plain, None).unwrap();
1486        assert!(
1487            discovered
1488                .scope
1489                .entries
1490                .iter()
1491                .any(|entry| entry.file.contains("lib-dist"))
1492        );
1493        fs::remove_dir_all(plain).unwrap();
1494    }
1495
1496    #[test]
1497    fn a_project_kept_where_an_enclosing_repository_ignores_it_keeps_its_source() {
1498        let files = [
1499            // What Supercov's own store writes, so nothing inside is tracked.
1500            ("scratch/.gitignore", "*\n"),
1501            ("scratch/app/package.json", r#"{"name":"app"}"#),
1502            ("scratch/app/.gitignore", "dist\n"),
1503            ("scratch/app/src/index.js", "export const a = 1;"),
1504            ("scratch/app/dist/bundle.js", "var bundled = 1;"),
1505        ];
1506        let repository_root = repository("enclosing-ignore", &files);
1507        fs::create_dir_all(repository_root.join(".git")).unwrap();
1508        let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1509        assert_eq!(discovered.source_files, ["src/index.js"]);
1510
1511        // A project the enclosing repository tracks still follows its rules.
1512        fs::remove_file(repository_root.join("scratch/.gitignore")).unwrap();
1513        fs::write(repository_root.join(".gitignore"), "*.gen.js\n").unwrap();
1514        fs::write(repository_root.join("scratch/app/src/api.gen.js"), "var a;").unwrap();
1515        let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1516        assert_eq!(discovered.source_files, ["src/index.js"]);
1517        fs::remove_dir_all(repository_root).unwrap();
1518    }
1519
1520    #[test]
1521    fn excludes_declared_jest_setup_without_hiding_neighboring_application_code() {
1522        let root = repository(
1523            "jest-setup",
1524            &[
1525                (
1526                    "package.json",
1527                    r#"{"jest":{"setupFilesAfterEnv":["<rootDir>/src/bootstrap.js"]}}"#,
1528                ),
1529                (
1530                    "src/bootstrap.js",
1531                    "jest.mock('storage', () => ({read: () => null}));",
1532                ),
1533                ("src/index.js", "export const value = 1;"),
1534            ],
1535        );
1536        let discovered = discover_source_scope(&root, None).unwrap();
1537        assert_eq!(discovered.source_files, ["src/index.js"]);
1538        assert_eq!(
1539            entry(&discovered, "src/bootstrap.js").reason,
1540            "declared test setup"
1541        );
1542        fs::remove_dir_all(root).unwrap();
1543    }
1544
1545    #[test]
1546    fn example_apps_and_conf_files_are_not_source() {
1547        // uuid: a tsconfig with no `include` makes the whole checkout a root.
1548        let root = repository(
1549            "examples",
1550            &[
1551                ("package.json", r#"{"exports":{".":"./dist/index.js"}}"#),
1552                ("tsconfig.json", r#"{"exclude":["dist","examples"]}"#),
1553                ("src/v4.ts", "export const v4 = () => ''"),
1554                ("wdio.conf.js", "exports.config = {}"),
1555                ("karma.conf.cjs", "module.exports = () => {}"),
1556                ("examples/browser-esmodules/package.json", "{}"),
1557                ("examples/browser-esmodules/example.js", "console.log(1)"),
1558                ("examples/utils/testpage.js", "export default 1"),
1559                ("example/demo.js", "console.log(1)"),
1560                ("app/examples/page.tsx", "export default function Page() {}"),
1561            ],
1562        );
1563        let discovered = discover_source_scope(&root, None).unwrap();
1564        assert_eq!(
1565            discovered.source_files,
1566            ["app/examples/page.tsx", "src/v4.ts"]
1567        );
1568        for file in [
1569            "examples/browser-esmodules/example.js",
1570            "examples/utils/testpage.js",
1571            "example/demo.js",
1572        ] {
1573            assert_eq!(entry(&discovered, file).reason, "example", "{file}");
1574        }
1575        for file in ["wdio.conf.js", "karma.conf.cjs"] {
1576            assert_eq!(
1577                entry(&discovered, file).reason,
1578                "build/test/tool configuration",
1579                "{file}"
1580            );
1581        }
1582        fs::remove_dir_all(root).unwrap();
1583    }
1584
1585    #[test]
1586    fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
1587        let root = repository(
1588            "automatic",
1589            &[
1590                ("package.json", r#"{"workspaces":["packages/*"]}"#),
1591                ("src/index.ts", "export const root = true"),
1592                ("lib/helper.js", "export const helper = true"),
1593                ("src/index.test.ts", "test('root', () => {})"),
1594                ("tests/e2e.spec.ts", "test('e2e', () => {})"),
1595                ("scripts/release.mjs", "export const release = true"),
1596                ("vite.config.ts", "export default {}"),
1597                ("build.mjs", "export default async function build() {}"),
1598                (".eslintrc.cjs", "module.exports = {}"),
1599                (".graphqlrc.ts", "export default {}"),
1600                ("orphan.ts", "export const missed = true"),
1601                ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
1602                ("packages/ui/src/index.ts", "export const ui = true"),
1603                ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
1604                ("dist/generated.js", "generated"),
1605                (".cache/tool/generated.js", "cached"),
1606                ("src/parse.bench.ts", "bench('parse', () => {})"),
1607                (
1608                    "benchmarks/value-slice.bench.ts",
1609                    "bench('slice', () => {})",
1610                ),
1611                ("src/benchmarkReport.ts", "export const report = 1"),
1612            ],
1613        );
1614        let discovered = discover_source_scope(&root, None).unwrap();
1615        assert_eq!(
1616            discovered.source_files,
1617            [
1618                "lib/helper.js",
1619                "packages/ui/src/index.ts",
1620                "src/benchmarkReport.ts",
1621                "src/index.ts"
1622            ]
1623        );
1624        for file in ["src/parse.bench.ts", "benchmarks/value-slice.bench.ts"] {
1625            assert_eq!(entry(&discovered, file).reason, "benchmark", "{file}");
1626        }
1627        assert_eq!(
1628            entry(&discovered, "orphan.ts").status,
1629            SourceScopeStatus::Ambiguous
1630        );
1631        assert_eq!(
1632            entry(&discovered, "scripts/release.mjs").reason,
1633            "conventional tool script"
1634        );
1635        assert_eq!(
1636            entry(&discovered, "build.mjs").reason,
1637            "build/test/tool configuration"
1638        );
1639        assert_eq!(
1640            entry(&discovered, "packages/ui/src/index.ts").package_root,
1641            Some("packages/ui".into())
1642        );
1643        assert_eq!(discovered.limitations.len(), 1);
1644        assert_eq!(discovered.limitations[0].file, "orphan.ts");
1645        assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
1646        assert!(
1647            discovered
1648                .scope
1649                .entries
1650                .iter()
1651                .all(|entry| !entry.file.contains(".cache"))
1652        );
1653        fs::remove_dir_all(root).unwrap();
1654    }
1655
1656    #[test]
1657    fn declared_workspaces_outside_conventional_parents_are_package_roots() {
1658        let root = repository(
1659            "declared-workspaces",
1660            &[
1661                ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1662                ("app/main.ts", "product"),
1663                ("app_extensions/discounts/package.json", "{}"),
1664                ("app_extensions/discounts/src/index.ts", "extension"),
1665                // No conventional source directory at all: the package itself
1666                // is the root, so its frontend code is still first-party.
1667                ("app_extensions/upsells/package.json", "{}"),
1668                ("app_extensions/upsells/frontend/embed.ts", "embed"),
1669                ("app_extensions/upsells/dist/embed.js", "built"),
1670            ],
1671        );
1672        let discovered = discover_source_scope(&root, None).unwrap();
1673        assert_eq!(
1674            discovered.source_files,
1675            [
1676                "app/main.ts",
1677                "app_extensions/discounts/src/index.ts",
1678                "app_extensions/upsells/frontend/embed.ts",
1679            ]
1680        );
1681        assert!(
1682            discovered.limitations.is_empty(),
1683            "declared packages must not be blockers: {:?}",
1684            discovered.limitations
1685        );
1686        fs::remove_dir_all(root).unwrap();
1687    }
1688
1689    #[test]
1690    fn hashed_bundles_in_asset_directories_are_built_assets() {
1691        let root = repository(
1692            "built-assets",
1693            &[
1694                ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1695                ("app/main.ts", "product"),
1696                ("app_extensions/upsells/package.json", "{}"),
1697                ("app_extensions/upsells/frontend/embed.ts", "source"),
1698                (
1699                    "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1700                    "bundle",
1701                ),
1702                ("app_extensions/upsells/assets/stylex-DAnmLURx.js", "bundle"),
1703                // A hand-written helper in assets keeps its ordinary treatment.
1704                (
1705                    "app_extensions/upsells/assets/theme-helper.js",
1706                    "hand written",
1707                ),
1708            ],
1709        );
1710        let discovered = discover_source_scope(&root, None).unwrap();
1711        assert!(
1712            !discovered
1713                .source_files
1714                .iter()
1715                .any(|file| file.contains("-Be-aUw9g.js") || file.contains("-DAnmLURx.js"))
1716        );
1717        assert_eq!(
1718            entry(
1719                &discovered,
1720                "app_extensions/upsells/assets/app-embed-Be-aUw9g.js"
1721            )
1722            .reason,
1723            "built asset"
1724        );
1725        assert!(
1726            discovered.limitations.is_empty(),
1727            "bundles are not blockers: {:?}",
1728            discovered.limitations
1729        );
1730        fs::remove_dir_all(root).unwrap();
1731    }
1732
1733    #[test]
1734    fn root_level_hidden_directories_are_tooling_not_source() {
1735        let root = repository(
1736            "root-hidden",
1737            &[
1738                ("package.json", "{}"),
1739                ("app/main.ts", "product"),
1740                (".shopify/bundle/upsells/frontend/embed.js", "cli bundle"),
1741                (".vercel/output/functions/index.js", "deploy output"),
1742                // A nested hidden directory inside a source root keeps its
1743                // ordinary treatment.
1744                ("app/.generated/schema.ts", "generated types"),
1745            ],
1746        );
1747        let discovered = discover_source_scope(&root, None).unwrap();
1748        assert_eq!(
1749            discovered.source_files,
1750            ["app/.generated/schema.ts", "app/main.ts"]
1751        );
1752        assert!(
1753            discovered.limitations.is_empty(),
1754            "{:?}",
1755            discovered.limitations
1756        );
1757        assert!(discovered.scope.entries.iter().all(
1758            |entry| !entry.file.starts_with(".shopify/") && !entry.file.starts_with(".vercel/")
1759        ));
1760        fs::remove_dir_all(root).unwrap();
1761    }
1762
1763    #[test]
1764    fn nested_checkouts_are_neither_source_nor_limitations() {
1765        let root = repository(
1766            "nested-checkout",
1767            &[
1768                ("package.json", "{}"),
1769                ("app/main.ts", "product"),
1770                // An agent worktree: a full copy of the project carrying its own
1771                // `.git` file. Real project, 1,032 of these turned into blockers.
1772                (".claude/worktrees/agent-1/.git", "gitdir: /elsewhere"),
1773                (".claude/worktrees/agent-1/app/main.ts", "copy"),
1774                ("vendor-fork/.git/HEAD", "ref: refs/heads/main"),
1775                ("vendor-fork/src/index.ts", "clone"),
1776            ],
1777        );
1778        let discovered = discover_source_scope(&root, None).unwrap();
1779        assert_eq!(discovered.source_files, ["app/main.ts"]);
1780        assert!(
1781            discovered.limitations.is_empty(),
1782            "nested checkouts must not be blocking limitations: {:?}",
1783            discovered.limitations
1784        );
1785        assert!(
1786            discovered
1787                .scope
1788                .entries
1789                .iter()
1790                .all(|entry| !entry.file.starts_with(".claude/")
1791                    && !entry.file.starts_with("vendor-fork/")),
1792            "nested checkout files must not appear in scope at all"
1793        );
1794        fs::remove_dir_all(root).unwrap();
1795    }
1796
1797    #[test]
1798    fn roots_enter_a_runs_identity_only_when_named() {
1799        // Appending nothing when no roots are named is what keeps every
1800        // fingerprint recorded before this change valid.
1801        let mut unchanged = b"pytest\0-q".to_vec();
1802        fold_roots_into_configuration(&mut unchanged, None);
1803        assert_eq!(unchanged, b"pytest\0-q");
1804        fold_roots_into_configuration(&mut unchanged, Some(&[]));
1805        assert_eq!(unchanged, b"pytest\0-q");
1806
1807        let mut folded = b"pytest\0-q".to_vec();
1808        fold_roots_into_configuration(&mut folded, Some(&["src".to_owned(), "a.py".to_owned()]));
1809        assert_eq!(folded, b"pytest\0-q\0SUPERCOV_SOURCE_ROOTS=src,a.py");
1810    }
1811
1812    #[test]
1813    fn the_variable_is_read_the_same_way_for_every_frontend() {
1814        let read = |value: &str| {
1815            configured_source_roots(&BTreeMap::from([(
1816                SOURCE_ROOTS_VARIABLE.to_owned(),
1817                value.to_owned(),
1818            )]))
1819        };
1820        assert_eq!(
1821            read(" src , app ,"),
1822            Some(vec!["src".to_owned(), "app".to_owned()])
1823        );
1824        assert_eq!(
1825            read(" , "),
1826            None,
1827            "naming nothing leaves discovery automatic"
1828        );
1829        assert_eq!(configured_source_roots(&BTreeMap::new()), None);
1830    }
1831
1832    #[test]
1833    fn a_next_application_is_measured_where_next_keeps_its_code() {
1834        // A stock Next.js layout reported 89 files it could not classify:
1835        // root `components/`, `proxy.ts` and `instrumentation.ts`. And five
1836        // "test connection" endpoints were left out as tests, because under
1837        // `app/` a directory named `test` is a URL segment.
1838        let root = repository(
1839            "next-layout",
1840            &[
1841                ("package.json", r#"{"dependencies":{"next":"16.1.1"}}"#),
1842                (
1843                    "tsconfig.json",
1844                    r#"{"include":["next-env.d.ts","**/*.ts",".next/types/**/*.ts"]}"#,
1845                ),
1846                ("next-env.d.ts", "declare const next: unknown;"),
1847                ("app/page.tsx", "page"),
1848                ("app/api/wordpress/test/route.ts", "route"),
1849                ("app/api/wordpress/test/route.test.ts", "test"),
1850                ("app/api/wordpress/test/helper.ts", "helper"),
1851                ("app/__tests__/page.tsx", "test"),
1852                ("components/button.tsx", "component"),
1853                ("pages/api/legacy.ts", "page"),
1854                ("proxy.ts", "proxy"),
1855                ("instrumentation.ts", "instrumentation"),
1856                ("simulators/echo.ts", "simulator"),
1857            ],
1858        );
1859        let discovered = discover_source_scope(&root, None).unwrap();
1860        assert_eq!(
1861            discovered.source_files,
1862            [
1863                "app/api/wordpress/test/route.ts",
1864                "app/page.tsx",
1865                "components/button.tsx",
1866                "instrumentation.ts",
1867                "pages/api/legacy.ts",
1868                "proxy.ts",
1869            ]
1870        );
1871        assert_eq!(
1872            discovered.source_roots,
1873            [
1874                "app",
1875                "components",
1876                "instrumentation.ts",
1877                "pages",
1878                "proxy.ts"
1879            ],
1880            "a declaration file is not a root"
1881        );
1882        for file in [
1883            "app/api/wordpress/test/route.test.ts",
1884            "app/api/wordpress/test/helper.ts",
1885            "app/__tests__/page.tsx",
1886        ] {
1887            assert_eq!(entry(&discovered, file).reason, "test or fixture source");
1888        }
1889        // Nothing says what `simulators/` is, so it is still asked about.
1890        assert_eq!(
1891            entry(&discovered, "simulators/echo.ts").status,
1892            SourceScopeStatus::Ambiguous
1893        );
1894        // Named roots keep the route too.
1895        let roots = vec!["app".into()];
1896        let named = discover_source_scope(&root, Some(&roots)).unwrap();
1897        assert_eq!(
1898            named.source_files,
1899            ["app/api/wordpress/test/route.ts", "app/page.tsx"]
1900        );
1901        fs::remove_dir_all(root).unwrap();
1902    }
1903
1904    #[test]
1905    fn a_route_file_name_means_nothing_outside_a_next_application() {
1906        let root = repository(
1907            "not-next",
1908            &[
1909                ("package.json", "{}"),
1910                ("app/main.ts", "main"),
1911                ("app/test/route.ts", "a test helper"),
1912                ("proxy.ts", "unknown"),
1913            ],
1914        );
1915        let discovered = discover_source_scope(&root, None).unwrap();
1916        assert_eq!(discovered.source_files, ["app/main.ts"]);
1917        assert_eq!(
1918            entry(&discovered, "app/test/route.ts").reason,
1919            "test or fixture source"
1920        );
1921        assert_eq!(
1922            entry(&discovered, "proxy.ts").status,
1923            SourceScopeStatus::Ambiguous
1924        );
1925        fs::remove_dir_all(root).unwrap();
1926    }
1927
1928    #[test]
1929    fn a_named_root_settles_what_it_names_and_guesses_only_below_it() {
1930        // `scripts`, or a directory called `test-utils`, read as tooling and
1931        // tests to discovery. Naming one as a source root says otherwise, and
1932        // there was no way to say it: the named root stayed excluded.
1933        let root = repository(
1934            "named-roots",
1935            &[
1936                ("package.json", "{}"),
1937                ("src/main.ts", "main"),
1938                ("src/test-utils/build.ts", "helper"),
1939                ("src/test-utils/__tests__/build.test.ts", "test"),
1940                ("scripts/release.ts", "script"),
1941                ("scripts/fixtures/sample.ts", "fixture"),
1942                ("tools/one.spec.ts", "named file"),
1943            ],
1944        );
1945        let roots = vec!["src".into()];
1946        let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1947        assert_eq!(discovered.source_files, ["src/main.ts"]);
1948
1949        let roots = vec![
1950            "src/test-utils".into(),
1951            "scripts".into(),
1952            "tools/one.spec.ts".into(),
1953        ];
1954        let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1955        assert_eq!(
1956            discovered.source_files,
1957            [
1958                "scripts/release.ts",
1959                "src/test-utils/build.ts",
1960                "tools/one.spec.ts",
1961            ]
1962        );
1963        assert_eq!(
1964            entry(&discovered, "src/test-utils/__tests__/build.test.ts").reason,
1965            "test or fixture source"
1966        );
1967        assert_eq!(
1968            entry(&discovered, "scripts/fixtures/sample.ts").reason,
1969            "test or fixture source"
1970        );
1971        fs::remove_dir_all(root).unwrap();
1972    }
1973
1974    #[test]
1975    fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
1976        let root = repository(
1977            "explicit",
1978            &[
1979                ("package.json", "{}"),
1980                ("product/main.ts", "product"),
1981                ("orphan.ts", "outside"),
1982            ],
1983        );
1984        let roots = vec!["product".into()];
1985        let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1986        assert_eq!(discovered.source_files, ["product/main.ts"]);
1987        assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
1988        assert_eq!(
1989            entry(&discovered, "orphan.ts").reason,
1990            "outside explicit source roots"
1991        );
1992        assert!(discovered.limitations.is_empty());
1993        fs::remove_dir_all(root).unwrap();
1994    }
1995
1996    #[test]
1997    fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
1998        let root = repository(
1999            "jsonc",
2000            &[
2001                ("package.json", r#"{"main":"./dist/index.js"}"#),
2002                (
2003                    "tsconfig.json",
2004                    "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
2005                ),
2006                ("events.ts", "event"),
2007                ("žalias.ts", "unicode"),
2008                ("library.test.ts", "test"),
2009            ],
2010        );
2011        let discovered = discover_source_scope(&root, None).unwrap();
2012        assert!(discovered.source_roots.contains(&".".into()));
2013        assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
2014        assert!(discovered.limitations.is_empty());
2015        fs::remove_dir_all(root).unwrap();
2016    }
2017
2018    #[cfg(unix)]
2019    #[test]
2020    fn never_follows_source_directory_or_explicit_root_symlinks() {
2021        use std::os::unix::fs::symlink;
2022
2023        let root = repository(
2024            "symlink",
2025            &[("package.json", "{}"), ("src/real.ts", "real")],
2026        );
2027        let outside = repository("outside", &[("secret.ts", "secret")]);
2028        symlink(&outside, root.join("linked")).unwrap();
2029        symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
2030        let discovered = discover_source_scope(&root, None).unwrap();
2031        assert_eq!(discovered.source_files, ["src/real.ts"]);
2032        let explicit = vec!["linked".into()];
2033        assert!(matches!(
2034            discover_source_scope(&root, Some(&explicit)),
2035            Err(SourceDiscoveryError::InvalidRoot(_))
2036        ));
2037        fs::remove_dir_all(root).unwrap();
2038        fs::remove_dir_all(outside).unwrap();
2039    }
2040
2041    #[test]
2042    fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
2043        let root = repository(
2044            "workspace-store",
2045            &[
2046                ("package.json", "{}"),
2047                ("src/main.ts", "main"),
2048                ("supercov/user.ts", "user code"),
2049            ],
2050        );
2051        let before = discover_source_scope(&root, None).unwrap();
2052        assert_eq!(
2053            entry(&before, "supercov/user.ts").status,
2054            SourceScopeStatus::Ambiguous
2055        );
2056        fs::write(
2057            root.join("supercov/.supercov-workspace-store"),
2058            b"Supercov instrumented workspace. Safe to delete.\n",
2059        )
2060        .unwrap();
2061        let after = discover_source_scope(&root, None).unwrap();
2062        assert!(
2063            after
2064                .scope
2065                .entries
2066                .iter()
2067                .all(|entry| !entry.file.starts_with("supercov/"))
2068        );
2069        fs::remove_dir_all(root).unwrap();
2070    }
2071}