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                    .collect::<Vec<_>>();
1290                // A declared package that keeps its code somewhere
1291                // unconventional (a Shopify theme extension's `frontend/` and
1292                // `blocks/`, say) is still first-party source. Its own
1293                // directory becomes the root; generated subtrees stay excluded
1294                // by the walker as everywhere else.
1295                if directory != &root
1296                    && !candidates
1297                        .iter()
1298                        .any(|candidate| fs::symlink_metadata(candidate).is_ok())
1299                {
1300                    return vec![directory.clone()];
1301                }
1302                candidates
1303            })
1304            .collect()
1305    };
1306    let existing_roots = existing_roots(include_roots, explicit)?;
1307    let app_directories = packages
1308        .iter()
1309        .filter(|directory| {
1310            depends_on_next(&read_json(&directory.join("package.json")).unwrap_or(Value::Null))
1311        })
1312        .flat_map(|directory| [directory.join("app"), directory.join("src/app")])
1313        .collect::<Vec<_>>();
1314    let mut all_files = Vec::new();
1315    files_under(&root, &root, &mut all_files)?;
1316    all_files.sort();
1317
1318    let mut entries = Vec::new();
1319    let mut included = Vec::new();
1320    let mut limitations = Vec::new();
1321    for path in all_files {
1322        let file = local_path(&root, &path)?;
1323        let package_root = nearest_package_root(&path, &packages)
1324            .map(|package| local_path(&root, package))
1325            .transpose()?
1326            .filter(|package| package != ".");
1327        let entry = |status, reason: &str| SourceScopeEntry {
1328            file: file.clone(),
1329            status,
1330            reason: reason.into(),
1331            package_root: package_root.clone(),
1332        };
1333        let named = explicit
1334            .then(|| below_named_root(&existing_roots, &path))
1335            .flatten();
1336        let guessed = match named {
1337            Some(local) => local.to_string_lossy().replace('\\', "/"),
1338            None => file.clone(),
1339        };
1340        if declaration_file(&file) {
1341            entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
1342        } else if test_setup.contains(&path) {
1343            entries.push(entry(SourceScopeStatus::Excluded, "declared test setup"));
1344        } else if test_or_fixture(&guessed) && !next_app_file(&app_directories, &path) {
1345            entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
1346        } else if tool_script(&guessed) {
1347            entries.push(entry(
1348                SourceScopeStatus::Excluded,
1349                "conventional tool script",
1350            ));
1351        } else if benchmark(&guessed) {
1352            entries.push(entry(SourceScopeStatus::Excluded, "benchmark"));
1353        } else if example(&file, package_root.as_deref()) {
1354            entries.push(entry(SourceScopeStatus::Excluded, "example"));
1355        } else if config_file(&file) {
1356            entries.push(entry(
1357                SourceScopeStatus::Excluded,
1358                "build/test/tool configuration",
1359            ));
1360        } else if built_asset(&file) {
1361            entries.push(entry(SourceScopeStatus::Excluded, BUILT_ASSET_REASON));
1362        } else if root_contains(&existing_roots, &path) {
1363            included.push(path);
1364            entries.push(entry(
1365                SourceScopeStatus::Included,
1366                if explicit {
1367                    "explicit source root"
1368                } else {
1369                    "discovered package source root"
1370                },
1371            ));
1372        } else if explicit {
1373            entries.push(entry(
1374                SourceScopeStatus::Excluded,
1375                "outside explicit source roots",
1376            ));
1377        } else {
1378            entries.push(entry(
1379                SourceScopeStatus::Ambiguous,
1380                "unclassified first-party source",
1381            ));
1382            limitations.push(scope_limitation(&file));
1383        }
1384    }
1385    let source_files = included
1386        .iter()
1387        .map(|path| local_path(&root, path))
1388        .collect::<Result<Vec<_>, _>>()?;
1389    let source_roots = existing_roots
1390        .iter()
1391        .map(|path| local_path(&root, path))
1392        .collect::<Result<Vec<_>, _>>()?;
1393    Ok(DiscoveredSourceScope {
1394        source_files,
1395        source_roots: source_roots.clone(),
1396        scope: SourceScope {
1397            version: 1,
1398            mode: if explicit {
1399                SourceScopeMode::Explicit
1400            } else {
1401                SourceScopeMode::Automatic
1402            },
1403            roots: source_roots,
1404            entries,
1405        },
1406        limitations,
1407    })
1408}
1409
1410#[cfg(test)]
1411mod tests {
1412    use std::{
1413        fs,
1414        time::{SystemTime, UNIX_EPOCH},
1415    };
1416
1417    use super::*;
1418
1419    fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
1420        let nonce = SystemTime::now()
1421            .duration_since(UNIX_EPOCH)
1422            .unwrap()
1423            .as_nanos();
1424        let root = std::env::temp_dir().join(format!(
1425            "supercov-source-{label}-{}-{nonce}",
1426            std::process::id()
1427        ));
1428        fs::create_dir_all(&root).unwrap();
1429        for (file, contents) in files {
1430            let path = root.join(file);
1431            fs::create_dir_all(path.parent().unwrap()).unwrap();
1432            fs::write(path, contents).unwrap();
1433        }
1434        root
1435    }
1436
1437    fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
1438        scope
1439            .scope
1440            .entries
1441            .iter()
1442            .find(|entry| entry.file == file)
1443            .unwrap()
1444    }
1445
1446    #[test]
1447    fn a_file_the_project_tells_git_to_ignore_is_not_its_source() {
1448        // Actual's build writes a 5 MB worker bundle under
1449        // packages/loot-core/lib-dist, which its own nested .gitignore names.
1450        let files = [
1451            ("package.json", r#"{"workspaces":["packages/*"]}"#),
1452            ("packages/core/package.json", r#"{"name":"core"}"#),
1453            ("packages/core/.gitignore", "lib-dist\n"),
1454            ("packages/core/src/index.ts", "export const a = 1;"),
1455            (
1456                "packages/core/lib-dist/browser/worker.dev.js",
1457                "var bundled = 1;",
1458            ),
1459        ];
1460        let repository_root = repository("gitignored", &files);
1461        fs::create_dir_all(repository_root.join(".git")).unwrap();
1462        let discovered = discover_source_scope(&repository_root, None).unwrap();
1463        assert!(
1464            discovered
1465                .source_files
1466                .contains(&"packages/core/src/index.ts".into())
1467        );
1468        assert!(
1469            !discovered
1470                .source_files
1471                .iter()
1472                .any(|file| file.contains("lib-dist")),
1473            "{:?}",
1474            discovered.source_files
1475        );
1476        fs::remove_dir_all(repository_root).unwrap();
1477
1478        // Outside a git repository a .gitignore means nothing.
1479        let plain = repository("not-a-repository", &files);
1480        let discovered = discover_source_scope(&plain, None).unwrap();
1481        assert!(
1482            discovered
1483                .scope
1484                .entries
1485                .iter()
1486                .any(|entry| entry.file.contains("lib-dist"))
1487        );
1488        fs::remove_dir_all(plain).unwrap();
1489    }
1490
1491    #[test]
1492    fn a_project_kept_where_an_enclosing_repository_ignores_it_keeps_its_source() {
1493        let files = [
1494            // What Supercov's own store writes, so nothing inside is tracked.
1495            ("scratch/.gitignore", "*\n"),
1496            ("scratch/app/package.json", r#"{"name":"app"}"#),
1497            ("scratch/app/.gitignore", "dist\n"),
1498            ("scratch/app/src/index.js", "export const a = 1;"),
1499            ("scratch/app/dist/bundle.js", "var bundled = 1;"),
1500        ];
1501        let repository_root = repository("enclosing-ignore", &files);
1502        fs::create_dir_all(repository_root.join(".git")).unwrap();
1503        let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1504        assert_eq!(discovered.source_files, ["src/index.js"]);
1505
1506        // A project the enclosing repository tracks still follows its rules.
1507        fs::remove_file(repository_root.join("scratch/.gitignore")).unwrap();
1508        fs::write(repository_root.join(".gitignore"), "*.gen.js\n").unwrap();
1509        fs::write(repository_root.join("scratch/app/src/api.gen.js"), "var a;").unwrap();
1510        let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1511        assert_eq!(discovered.source_files, ["src/index.js"]);
1512        fs::remove_dir_all(repository_root).unwrap();
1513    }
1514
1515    #[test]
1516    fn excludes_declared_jest_setup_without_hiding_neighboring_application_code() {
1517        let root = repository(
1518            "jest-setup",
1519            &[
1520                (
1521                    "package.json",
1522                    r#"{"jest":{"setupFilesAfterEnv":["<rootDir>/src/bootstrap.js"]}}"#,
1523                ),
1524                (
1525                    "src/bootstrap.js",
1526                    "jest.mock('storage', () => ({read: () => null}));",
1527                ),
1528                ("src/index.js", "export const value = 1;"),
1529            ],
1530        );
1531        let discovered = discover_source_scope(&root, None).unwrap();
1532        assert_eq!(discovered.source_files, ["src/index.js"]);
1533        assert_eq!(
1534            entry(&discovered, "src/bootstrap.js").reason,
1535            "declared test setup"
1536        );
1537        fs::remove_dir_all(root).unwrap();
1538    }
1539
1540    #[test]
1541    fn example_apps_and_conf_files_are_not_source() {
1542        // uuid: a tsconfig with no `include` makes the whole checkout a root.
1543        let root = repository(
1544            "examples",
1545            &[
1546                ("package.json", r#"{"exports":{".":"./dist/index.js"}}"#),
1547                ("tsconfig.json", r#"{"exclude":["dist","examples"]}"#),
1548                ("src/v4.ts", "export const v4 = () => ''"),
1549                ("wdio.conf.js", "exports.config = {}"),
1550                ("karma.conf.cjs", "module.exports = () => {}"),
1551                ("examples/browser-esmodules/package.json", "{}"),
1552                ("examples/browser-esmodules/example.js", "console.log(1)"),
1553                ("examples/utils/testpage.js", "export default 1"),
1554                ("example/demo.js", "console.log(1)"),
1555                ("app/examples/page.tsx", "export default function Page() {}"),
1556            ],
1557        );
1558        let discovered = discover_source_scope(&root, None).unwrap();
1559        assert_eq!(
1560            discovered.source_files,
1561            ["app/examples/page.tsx", "src/v4.ts"]
1562        );
1563        for file in [
1564            "examples/browser-esmodules/example.js",
1565            "examples/utils/testpage.js",
1566            "example/demo.js",
1567        ] {
1568            assert_eq!(entry(&discovered, file).reason, "example", "{file}");
1569        }
1570        for file in ["wdio.conf.js", "karma.conf.cjs"] {
1571            assert_eq!(
1572                entry(&discovered, file).reason,
1573                "build/test/tool configuration",
1574                "{file}"
1575            );
1576        }
1577        fs::remove_dir_all(root).unwrap();
1578    }
1579
1580    #[test]
1581    fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
1582        let root = repository(
1583            "automatic",
1584            &[
1585                ("package.json", r#"{"workspaces":["packages/*"]}"#),
1586                ("src/index.ts", "export const root = true"),
1587                ("lib/helper.js", "export const helper = true"),
1588                ("src/index.test.ts", "test('root', () => {})"),
1589                ("tests/e2e.spec.ts", "test('e2e', () => {})"),
1590                ("scripts/release.mjs", "export const release = true"),
1591                ("vite.config.ts", "export default {}"),
1592                ("build.mjs", "export default async function build() {}"),
1593                (".eslintrc.cjs", "module.exports = {}"),
1594                (".graphqlrc.ts", "export default {}"),
1595                ("orphan.ts", "export const missed = true"),
1596                ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
1597                ("packages/ui/src/index.ts", "export const ui = true"),
1598                ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
1599                ("dist/generated.js", "generated"),
1600                (".cache/tool/generated.js", "cached"),
1601                ("src/parse.bench.ts", "bench('parse', () => {})"),
1602                (
1603                    "benchmarks/value-slice.bench.ts",
1604                    "bench('slice', () => {})",
1605                ),
1606                ("src/benchmarkReport.ts", "export const report = 1"),
1607            ],
1608        );
1609        let discovered = discover_source_scope(&root, None).unwrap();
1610        assert_eq!(
1611            discovered.source_files,
1612            [
1613                "lib/helper.js",
1614                "packages/ui/src/index.ts",
1615                "src/benchmarkReport.ts",
1616                "src/index.ts"
1617            ]
1618        );
1619        for file in ["src/parse.bench.ts", "benchmarks/value-slice.bench.ts"] {
1620            assert_eq!(entry(&discovered, file).reason, "benchmark", "{file}");
1621        }
1622        assert_eq!(
1623            entry(&discovered, "orphan.ts").status,
1624            SourceScopeStatus::Ambiguous
1625        );
1626        assert_eq!(
1627            entry(&discovered, "scripts/release.mjs").reason,
1628            "conventional tool script"
1629        );
1630        assert_eq!(
1631            entry(&discovered, "build.mjs").reason,
1632            "build/test/tool configuration"
1633        );
1634        assert_eq!(
1635            entry(&discovered, "packages/ui/src/index.ts").package_root,
1636            Some("packages/ui".into())
1637        );
1638        assert_eq!(discovered.limitations.len(), 1);
1639        assert_eq!(discovered.limitations[0].file, "orphan.ts");
1640        assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
1641        assert!(
1642            discovered
1643                .scope
1644                .entries
1645                .iter()
1646                .all(|entry| !entry.file.contains(".cache"))
1647        );
1648        fs::remove_dir_all(root).unwrap();
1649    }
1650
1651    #[test]
1652    fn declared_workspaces_outside_conventional_parents_are_package_roots() {
1653        let root = repository(
1654            "declared-workspaces",
1655            &[
1656                ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1657                ("app/main.ts", "product"),
1658                ("app_extensions/discounts/package.json", "{}"),
1659                ("app_extensions/discounts/src/index.ts", "extension"),
1660                // No conventional source directory at all: the package itself
1661                // is the root, so its frontend code is still first-party.
1662                ("app_extensions/upsells/package.json", "{}"),
1663                ("app_extensions/upsells/frontend/embed.ts", "embed"),
1664                ("app_extensions/upsells/dist/embed.js", "built"),
1665            ],
1666        );
1667        let discovered = discover_source_scope(&root, None).unwrap();
1668        assert_eq!(
1669            discovered.source_files,
1670            [
1671                "app/main.ts",
1672                "app_extensions/discounts/src/index.ts",
1673                "app_extensions/upsells/frontend/embed.ts",
1674            ]
1675        );
1676        assert!(
1677            discovered.limitations.is_empty(),
1678            "declared packages must not be blockers: {:?}",
1679            discovered.limitations
1680        );
1681        fs::remove_dir_all(root).unwrap();
1682    }
1683
1684    #[test]
1685    fn hashed_bundles_in_asset_directories_are_built_assets() {
1686        let root = repository(
1687            "built-assets",
1688            &[
1689                ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1690                ("app/main.ts", "product"),
1691                ("app_extensions/upsells/package.json", "{}"),
1692                ("app_extensions/upsells/frontend/embed.ts", "source"),
1693                (
1694                    "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1695                    "bundle",
1696                ),
1697                ("app_extensions/upsells/assets/stylex-DAnmLURx.js", "bundle"),
1698                // A hand-written helper in assets keeps its ordinary treatment.
1699                (
1700                    "app_extensions/upsells/assets/theme-helper.js",
1701                    "hand written",
1702                ),
1703            ],
1704        );
1705        let discovered = discover_source_scope(&root, None).unwrap();
1706        assert!(
1707            !discovered
1708                .source_files
1709                .iter()
1710                .any(|file| file.contains("-Be-aUw9g.js") || file.contains("-DAnmLURx.js"))
1711        );
1712        assert_eq!(
1713            entry(
1714                &discovered,
1715                "app_extensions/upsells/assets/app-embed-Be-aUw9g.js"
1716            )
1717            .reason,
1718            "built asset"
1719        );
1720        assert!(
1721            discovered.limitations.is_empty(),
1722            "bundles are not blockers: {:?}",
1723            discovered.limitations
1724        );
1725        fs::remove_dir_all(root).unwrap();
1726    }
1727
1728    #[test]
1729    fn root_level_hidden_directories_are_tooling_not_source() {
1730        let root = repository(
1731            "root-hidden",
1732            &[
1733                ("package.json", "{}"),
1734                ("app/main.ts", "product"),
1735                (".shopify/bundle/upsells/frontend/embed.js", "cli bundle"),
1736                (".vercel/output/functions/index.js", "deploy output"),
1737                // A nested hidden directory inside a source root keeps its
1738                // ordinary treatment.
1739                ("app/.generated/schema.ts", "generated types"),
1740            ],
1741        );
1742        let discovered = discover_source_scope(&root, None).unwrap();
1743        assert_eq!(
1744            discovered.source_files,
1745            ["app/.generated/schema.ts", "app/main.ts"]
1746        );
1747        assert!(
1748            discovered.limitations.is_empty(),
1749            "{:?}",
1750            discovered.limitations
1751        );
1752        assert!(discovered.scope.entries.iter().all(
1753            |entry| !entry.file.starts_with(".shopify/") && !entry.file.starts_with(".vercel/")
1754        ));
1755        fs::remove_dir_all(root).unwrap();
1756    }
1757
1758    #[test]
1759    fn nested_checkouts_are_neither_source_nor_limitations() {
1760        let root = repository(
1761            "nested-checkout",
1762            &[
1763                ("package.json", "{}"),
1764                ("app/main.ts", "product"),
1765                // An agent worktree: a full copy of the project carrying its own
1766                // `.git` file. Real project, 1,032 of these turned into blockers.
1767                (".claude/worktrees/agent-1/.git", "gitdir: /elsewhere"),
1768                (".claude/worktrees/agent-1/app/main.ts", "copy"),
1769                ("vendor-fork/.git/HEAD", "ref: refs/heads/main"),
1770                ("vendor-fork/src/index.ts", "clone"),
1771            ],
1772        );
1773        let discovered = discover_source_scope(&root, None).unwrap();
1774        assert_eq!(discovered.source_files, ["app/main.ts"]);
1775        assert!(
1776            discovered.limitations.is_empty(),
1777            "nested checkouts must not be blocking limitations: {:?}",
1778            discovered.limitations
1779        );
1780        assert!(
1781            discovered
1782                .scope
1783                .entries
1784                .iter()
1785                .all(|entry| !entry.file.starts_with(".claude/")
1786                    && !entry.file.starts_with("vendor-fork/")),
1787            "nested checkout files must not appear in scope at all"
1788        );
1789        fs::remove_dir_all(root).unwrap();
1790    }
1791
1792    #[test]
1793    fn roots_enter_a_runs_identity_only_when_named() {
1794        // Appending nothing when no roots are named is what keeps every
1795        // fingerprint recorded before this change valid.
1796        let mut unchanged = b"pytest\0-q".to_vec();
1797        fold_roots_into_configuration(&mut unchanged, None);
1798        assert_eq!(unchanged, b"pytest\0-q");
1799        fold_roots_into_configuration(&mut unchanged, Some(&[]));
1800        assert_eq!(unchanged, b"pytest\0-q");
1801
1802        let mut folded = b"pytest\0-q".to_vec();
1803        fold_roots_into_configuration(&mut folded, Some(&["src".to_owned(), "a.py".to_owned()]));
1804        assert_eq!(folded, b"pytest\0-q\0SUPERCOV_SOURCE_ROOTS=src,a.py");
1805    }
1806
1807    #[test]
1808    fn the_variable_is_read_the_same_way_for_every_frontend() {
1809        let read = |value: &str| {
1810            configured_source_roots(&BTreeMap::from([(
1811                SOURCE_ROOTS_VARIABLE.to_owned(),
1812                value.to_owned(),
1813            )]))
1814        };
1815        assert_eq!(
1816            read(" src , app ,"),
1817            Some(vec!["src".to_owned(), "app".to_owned()])
1818        );
1819        assert_eq!(
1820            read(" , "),
1821            None,
1822            "naming nothing leaves discovery automatic"
1823        );
1824        assert_eq!(configured_source_roots(&BTreeMap::new()), None);
1825    }
1826
1827    #[test]
1828    fn a_next_application_is_measured_where_next_keeps_its_code() {
1829        // A stock Next.js layout reported 89 files it could not classify:
1830        // root `components/`, `proxy.ts` and `instrumentation.ts`. And five
1831        // "test connection" endpoints were left out as tests, because under
1832        // `app/` a directory named `test` is a URL segment.
1833        let root = repository(
1834            "next-layout",
1835            &[
1836                ("package.json", r#"{"dependencies":{"next":"16.1.1"}}"#),
1837                (
1838                    "tsconfig.json",
1839                    r#"{"include":["next-env.d.ts","**/*.ts",".next/types/**/*.ts"]}"#,
1840                ),
1841                ("next-env.d.ts", "declare const next: unknown;"),
1842                ("app/page.tsx", "page"),
1843                ("app/api/wordpress/test/route.ts", "route"),
1844                ("app/api/wordpress/test/route.test.ts", "test"),
1845                ("app/api/wordpress/test/helper.ts", "helper"),
1846                ("app/__tests__/page.tsx", "test"),
1847                ("components/button.tsx", "component"),
1848                ("pages/api/legacy.ts", "page"),
1849                ("proxy.ts", "proxy"),
1850                ("instrumentation.ts", "instrumentation"),
1851                ("simulators/echo.ts", "simulator"),
1852            ],
1853        );
1854        let discovered = discover_source_scope(&root, None).unwrap();
1855        assert_eq!(
1856            discovered.source_files,
1857            [
1858                "app/api/wordpress/test/route.ts",
1859                "app/page.tsx",
1860                "components/button.tsx",
1861                "instrumentation.ts",
1862                "pages/api/legacy.ts",
1863                "proxy.ts",
1864            ]
1865        );
1866        assert_eq!(
1867            discovered.source_roots,
1868            [
1869                "app",
1870                "components",
1871                "instrumentation.ts",
1872                "pages",
1873                "proxy.ts"
1874            ],
1875            "a declaration file is not a root"
1876        );
1877        for file in [
1878            "app/api/wordpress/test/route.test.ts",
1879            "app/api/wordpress/test/helper.ts",
1880            "app/__tests__/page.tsx",
1881        ] {
1882            assert_eq!(entry(&discovered, file).reason, "test or fixture source");
1883        }
1884        // Nothing says what `simulators/` is, so it is still asked about.
1885        assert_eq!(
1886            entry(&discovered, "simulators/echo.ts").status,
1887            SourceScopeStatus::Ambiguous
1888        );
1889        // Named roots keep the route too.
1890        let roots = vec!["app".into()];
1891        let named = discover_source_scope(&root, Some(&roots)).unwrap();
1892        assert_eq!(
1893            named.source_files,
1894            ["app/api/wordpress/test/route.ts", "app/page.tsx"]
1895        );
1896        fs::remove_dir_all(root).unwrap();
1897    }
1898
1899    #[test]
1900    fn a_route_file_name_means_nothing_outside_a_next_application() {
1901        let root = repository(
1902            "not-next",
1903            &[
1904                ("package.json", "{}"),
1905                ("app/main.ts", "main"),
1906                ("app/test/route.ts", "a test helper"),
1907                ("proxy.ts", "unknown"),
1908            ],
1909        );
1910        let discovered = discover_source_scope(&root, None).unwrap();
1911        assert_eq!(discovered.source_files, ["app/main.ts"]);
1912        assert_eq!(
1913            entry(&discovered, "app/test/route.ts").reason,
1914            "test or fixture source"
1915        );
1916        assert_eq!(
1917            entry(&discovered, "proxy.ts").status,
1918            SourceScopeStatus::Ambiguous
1919        );
1920        fs::remove_dir_all(root).unwrap();
1921    }
1922
1923    #[test]
1924    fn a_named_root_settles_what_it_names_and_guesses_only_below_it() {
1925        // `scripts`, or a directory called `test-utils`, read as tooling and
1926        // tests to discovery. Naming one as a source root says otherwise, and
1927        // there was no way to say it: the named root stayed excluded.
1928        let root = repository(
1929            "named-roots",
1930            &[
1931                ("package.json", "{}"),
1932                ("src/main.ts", "main"),
1933                ("src/test-utils/build.ts", "helper"),
1934                ("src/test-utils/__tests__/build.test.ts", "test"),
1935                ("scripts/release.ts", "script"),
1936                ("scripts/fixtures/sample.ts", "fixture"),
1937                ("tools/one.spec.ts", "named file"),
1938            ],
1939        );
1940        let roots = vec!["src".into()];
1941        let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1942        assert_eq!(discovered.source_files, ["src/main.ts"]);
1943
1944        let roots = vec![
1945            "src/test-utils".into(),
1946            "scripts".into(),
1947            "tools/one.spec.ts".into(),
1948        ];
1949        let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1950        assert_eq!(
1951            discovered.source_files,
1952            [
1953                "scripts/release.ts",
1954                "src/test-utils/build.ts",
1955                "tools/one.spec.ts",
1956            ]
1957        );
1958        assert_eq!(
1959            entry(&discovered, "src/test-utils/__tests__/build.test.ts").reason,
1960            "test or fixture source"
1961        );
1962        assert_eq!(
1963            entry(&discovered, "scripts/fixtures/sample.ts").reason,
1964            "test or fixture source"
1965        );
1966        fs::remove_dir_all(root).unwrap();
1967    }
1968
1969    #[test]
1970    fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
1971        let root = repository(
1972            "explicit",
1973            &[
1974                ("package.json", "{}"),
1975                ("product/main.ts", "product"),
1976                ("orphan.ts", "outside"),
1977            ],
1978        );
1979        let roots = vec!["product".into()];
1980        let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1981        assert_eq!(discovered.source_files, ["product/main.ts"]);
1982        assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
1983        assert_eq!(
1984            entry(&discovered, "orphan.ts").reason,
1985            "outside explicit source roots"
1986        );
1987        assert!(discovered.limitations.is_empty());
1988        fs::remove_dir_all(root).unwrap();
1989    }
1990
1991    #[test]
1992    fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
1993        let root = repository(
1994            "jsonc",
1995            &[
1996                ("package.json", r#"{"main":"./dist/index.js"}"#),
1997                (
1998                    "tsconfig.json",
1999                    "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
2000                ),
2001                ("events.ts", "event"),
2002                ("žalias.ts", "unicode"),
2003                ("library.test.ts", "test"),
2004            ],
2005        );
2006        let discovered = discover_source_scope(&root, None).unwrap();
2007        assert!(discovered.source_roots.contains(&".".into()));
2008        assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
2009        assert!(discovered.limitations.is_empty());
2010        fs::remove_dir_all(root).unwrap();
2011    }
2012
2013    #[cfg(unix)]
2014    #[test]
2015    fn never_follows_source_directory_or_explicit_root_symlinks() {
2016        use std::os::unix::fs::symlink;
2017
2018        let root = repository(
2019            "symlink",
2020            &[("package.json", "{}"), ("src/real.ts", "real")],
2021        );
2022        let outside = repository("outside", &[("secret.ts", "secret")]);
2023        symlink(&outside, root.join("linked")).unwrap();
2024        symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
2025        let discovered = discover_source_scope(&root, None).unwrap();
2026        assert_eq!(discovered.source_files, ["src/real.ts"]);
2027        let explicit = vec!["linked".into()];
2028        assert!(matches!(
2029            discover_source_scope(&root, Some(&explicit)),
2030            Err(SourceDiscoveryError::InvalidRoot(_))
2031        ));
2032        fs::remove_dir_all(root).unwrap();
2033        fs::remove_dir_all(outside).unwrap();
2034    }
2035
2036    #[test]
2037    fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
2038        let root = repository(
2039            "workspace-store",
2040            &[
2041                ("package.json", "{}"),
2042                ("src/main.ts", "main"),
2043                ("supercov/user.ts", "user code"),
2044            ],
2045        );
2046        let before = discover_source_scope(&root, None).unwrap();
2047        assert_eq!(
2048            entry(&before, "supercov/user.ts").status,
2049            SourceScopeStatus::Ambiguous
2050        );
2051        fs::write(
2052            root.join("supercov/.supercov-workspace-store"),
2053            b"Supercov instrumented workspace. Safe to delete.\n",
2054        )
2055        .unwrap();
2056        let after = discover_source_scope(&root, None).unwrap();
2057        assert!(
2058            after
2059                .scope
2060                .entries
2061                .iter()
2062                .all(|entry| !entry.file.starts_with("supercov/"))
2063        );
2064        fs::remove_dir_all(root).unwrap();
2065    }
2066}