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