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/// A benchmark: Vitest's `*.bench.ts` beside the code it measures, or a file
680/// under a `bench`/`benchmarks` directory. It runs the product; it is not the
681/// product. jshttp/cookie keeps four `src/*.bench.ts` files, which were
682/// counted as application source and led the list of code no test ran.
683fn benchmark(file: &str) -> bool {
684    let lower = file.to_ascii_lowercase();
685    let (directories, name) = lower.rsplit_once('/').unwrap_or(("", lower.as_str()));
686    directories
687        .split('/')
688        .any(|segment| matches!(segment, "bench" | "benches" | "benchmark" | "benchmarks"))
689        || name
690            .split('.')
691            .skip(1)
692            .any(|part| matches!(part, "bench" | "benchmark"))
693}
694
695/// Example code at the top of the project or of a package: uuid keeps nine
696/// example apps under `examples/`, which its tsconfig leaves out and which
697/// were counted as application source, putting uuid at 32% of lines. A
698/// directory of that name deeper in the source (`app/examples/page.tsx`) can
699/// be the product and stays in.
700fn example(file: &str, package_root: Option<&str>) -> bool {
701    let local = package_root
702        .and_then(|package| file.strip_prefix(package))
703        .and_then(|rest| rest.strip_prefix('/'))
704        .unwrap_or(file);
705    local.split_once('/').is_some_and(|(first, _)| {
706        matches!(first.to_ascii_lowercase().as_str(), "example" | "examples")
707    })
708}
709
710fn tool_script(file: &str) -> bool {
711    file.to_ascii_lowercase()
712        .split('/')
713        .any(|segment| segment == "scripts")
714}
715
716fn config_file(file: &str) -> bool {
717    let lower = file.to_ascii_lowercase();
718    let name = lower.rsplit('/').next().unwrap_or(&lower);
719    source_file(name)
720        && (CONFIG_TOOLS
721            .iter()
722            .any(|tool| name.starts_with(&format!("{tool}.config.")))
723            || DOT_CONFIG_TOOLS
724                .iter()
725                .any(|tool| name.starts_with(&format!(".{tool}rc.")))
726            || (!lower.contains('/')
727                && (name.contains(".config.")
728                    // wdio.conf.js, karma.conf.js, protractor.conf.js
729                    || name.contains(".conf.")
730                    || name.starts_with("build.")
731                    || name.starts_with("gulpfile.")
732                    || name.starts_with("gruntfile."))))
733}
734
735fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, SourceDiscoveryError> {
736    let mut entries = fs::read_dir(path)
737        .map_err(|error| io_error(path, error))?
738        .collect::<Result<Vec<_>, _>>()
739        .map_err(|error| io_error(path, error))?;
740    entries.sort_by_key(fs::DirEntry::file_name);
741    Ok(entries)
742}
743
744/// Whether the repository around a project ignores the project's own
745/// directory: a copy kept under another checkout's `tmp/` or `.supercov/`.
746/// That repository's `.gitignore` files describe what it does not track, which
747/// is all of this project; applying them left the project with no source.
748fn ignored_by_enclosing_repository(root: &Path) -> bool {
749    if root.join(".git").exists() {
750        return false;
751    }
752    let Some(repository) = root
753        .ancestors()
754        .skip(1)
755        .position(|ancestor| ancestor.join(".git").exists())
756    else {
757        return false;
758    };
759    // Outermost first, so a deeper file's decision wins as it does in git.
760    root.ancestors()
761        .skip(1)
762        .take(repository + 1)
763        .collect::<Vec<_>>()
764        .into_iter()
765        .rev()
766        .fold(false, |ignored, directory| {
767            let (rules, _) = ignore::gitignore::Gitignore::new(directory.join(".gitignore"));
768            match rules.matched_path_or_any_parents(root, true) {
769                ignore::Match::Ignore(_) => true,
770                ignore::Match::Whitelist(_) => false,
771                ignore::Match::None => ignored,
772            }
773        })
774}
775
776/// Source files under a root, in name order.
777///
778/// A file the project's own `.gitignore` excludes is not its source: recording
779/// Actual counted `packages/loot-core/lib-dist/browser/kcab.worker.dev.js`, a
780/// 5 MB bundle its build writes and git ignores, as application code, and
781/// instrumented it. Only the project's `.gitignore` files are read, and only
782/// inside a git repository; a user's global excludes and `.git/info/exclude`
783/// differ between machines, and a run must count the same files on each.
784fn files_under(
785    root: &Path,
786    directory: &Path,
787    output: &mut Vec<PathBuf>,
788) -> Result<(), SourceDiscoveryError> {
789    let metadata = fs::symlink_metadata(directory).map_err(|error| io_error(directory, error))?;
790    if !metadata.file_type().is_dir() {
791        return Err(SourceDiscoveryError::InvalidRoot(directory.to_owned()));
792    }
793    let project = root.to_owned();
794    let walker = ignore::WalkBuilder::new(directory)
795        .standard_filters(false)
796        .git_ignore(true)
797        .parents(!ignored_by_enclosing_repository(directory))
798        .require_git(true)
799        .follow_links(false)
800        .sort_by_file_name(|left, right| left.cmp(right))
801        .filter_entry(move |entry| {
802            entry.depth() == 0
803                || !entry.file_type().is_some_and(|kind| kind.is_dir())
804                || entry.file_name().to_str().is_some_and(|name| {
805                    !generated_directory(name)
806                        && !owned_workspace_store(entry.path())
807                        && !nested_checkout(entry.path())
808                        && !root_tool_directory(&project, entry.path())
809                })
810        })
811        .build();
812    for entry in walker {
813        let entry =
814            entry.map_err(|error| io_error(directory, io::Error::other(error.to_string())))?;
815        let Some(kind) = entry.file_type() else {
816            continue;
817        };
818        if kind.is_symlink() || !kind.is_file() {
819            continue;
820        }
821        let name = entry
822            .file_name()
823            .to_str()
824            .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(entry.path().to_owned()))?;
825        if source_file(name) {
826            output.push(entry.into_path());
827        }
828    }
829    Ok(())
830}
831
832fn read_json(path: &Path) -> Option<Value> {
833    serde_json::from_slice(&fs::read(path).ok()?).ok()
834}
835
836fn package_directories(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
837    fn visit(
838        root: &Path,
839        directory: &Path,
840        depth: usize,
841        found: &mut BTreeSet<PathBuf>,
842    ) -> Result<(), SourceDiscoveryError> {
843        if depth > 5 {
844            return Ok(());
845        }
846        for entry in read_directory(directory)? {
847            let name = entry
848                .file_name()
849                .into_string()
850                .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
851            let path = entry.path();
852            let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
853            if !file_type.is_dir()
854                || file_type.is_symlink()
855                || generated_directory(&name)
856                || owned_workspace_store(&path)
857                || nested_checkout(&path)
858                || root_tool_directory(root, &path)
859            {
860                continue;
861            }
862            let local = local_path(root, &path)?;
863            let under_package_parent = local
864                .split('/')
865                .any(|segment| PACKAGE_PARENTS.contains(&segment));
866            if path.join("package.json").is_file() && (depth == 0 || under_package_parent) {
867                found.insert(path.clone());
868            }
869            visit(root, &path, depth + 1, found)?;
870        }
871        Ok(())
872    }
873
874    let mut found = BTreeSet::from([root.to_owned()]);
875    visit(root, root, 0, &mut found)?;
876    for directory in declared_workspace_packages(root)? {
877        found.insert(directory);
878    }
879    Ok(found.into_iter().collect())
880}
881
882/// Packages the project itself declares: `workspaces` in the root
883/// package.json (array or `{ "packages": [...] }`) and `packages:` in
884/// pnpm-workspace.yaml. The manifest is the most authoritative statement of
885/// where packages live, and it routinely names directories outside the
886/// conventional parents (a real project keeps its Shopify extensions under
887/// `app_extensions/*`; every file there was an ambiguous blocker).
888fn declared_workspace_packages(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
889    let mut patterns = Vec::new();
890    let manifest = read_json(&root.join("package.json")).unwrap_or(Value::Null);
891    let declared = match manifest.get("workspaces") {
892        Some(Value::Array(items)) => Some(items),
893        Some(Value::Object(object)) => object.get("packages").and_then(Value::as_array),
894        _ => None,
895    };
896    if let Some(items) = declared {
897        patterns.extend(items.iter().filter_map(Value::as_str).map(str::to_owned));
898    }
899    if let Ok(pnpm) = fs::read_to_string(root.join("pnpm-workspace.yaml")) {
900        let mut in_packages = false;
901        for line in pnpm.lines() {
902            let trimmed = line.trim();
903            if trimmed.starts_with("packages:") {
904                in_packages = true;
905                continue;
906            }
907            if in_packages {
908                if let Some(item) = trimmed.strip_prefix("- ") {
909                    patterns.push(item.trim_matches(|c| c == '"' || c == '\'').to_owned());
910                } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
911                    in_packages = false;
912                }
913            }
914        }
915    }
916    let mut found = Vec::new();
917    for pattern in patterns {
918        let pattern = pattern.trim_start_matches("./").trim_end_matches('/');
919        if pattern.starts_with('!') || pattern.contains("**") {
920            continue;
921        }
922        let (parent, wildcard) = match pattern.strip_suffix("/*") {
923            Some(parent) => (parent, true),
924            None => (pattern, false),
925        };
926        if parent.is_empty()
927            || parent
928                .split('/')
929                .any(|segment| segment.is_empty() || segment == ".." || segment.contains('*'))
930        {
931            continue;
932        }
933        let base = root.join(parent);
934        let candidates: Vec<PathBuf> = if wildcard {
935            match fs::read_dir(&base) {
936                Ok(entries) => entries
937                    .filter_map(Result::ok)
938                    .map(|entry| entry.path())
939                    .collect(),
940                Err(_) => Vec::new(),
941            }
942        } else {
943            vec![base]
944        };
945        for candidate in candidates {
946            let is_dir = fs::symlink_metadata(&candidate)
947                .is_ok_and(|metadata| metadata.file_type().is_dir());
948            if is_dir
949                && candidate.join("package.json").is_file()
950                && !nested_checkout(&candidate)
951                && !owned_workspace_store(&candidate)
952            {
953                found.push(candidate);
954            }
955        }
956    }
957    Ok(found)
958}
959
960fn string_targets(value: &Value, depth: usize, output: &mut Vec<String>) {
961    if depth > 8 {
962        return;
963    }
964    match value {
965        Value::String(value) => output.push(value.clone()),
966        Value::Array(values) => {
967            for value in values {
968                string_targets(value, depth + 1, output);
969            }
970        }
971        Value::Object(values) => {
972            for value in values.values() {
973                string_targets(value, depth + 1, output);
974            }
975        }
976        _ => {}
977    }
978}
979
980fn entry_targets(directory: &Path, manifest: &Value) -> Vec<PathBuf> {
981    let mut targets = Vec::new();
982    for key in ["main", "module", "browser", "bin", "exports"] {
983        if let Some(value) = manifest.get(key) {
984            string_targets(value, 0, &mut targets);
985        }
986    }
987    targets
988        .into_iter()
989        .filter_map(|target| {
990            if !target.starts_with('.') || target.contains("node_modules") {
991                return None;
992            }
993            let prefix = target.split('*').next()?.trim_end_matches('/');
994            (!prefix.is_empty()).then(|| resolve(directory, prefix))
995        })
996        .collect()
997}
998
999pub(crate) fn strip_jsonc_comments(contents: &str) -> String {
1000    let mut output = String::with_capacity(contents.len());
1001    let mut chars = contents.chars().peekable();
1002    let mut string = false;
1003    let mut escaped = false;
1004    while let Some(character) = chars.next() {
1005        if string {
1006            output.push(character);
1007            if escaped {
1008                escaped = false;
1009            } else if character == '\\' {
1010                escaped = true;
1011            } else if character == '"' {
1012                string = false;
1013            }
1014            continue;
1015        }
1016        if character == '"' {
1017            string = true;
1018            output.push(character);
1019        } else if character == '/' && chars.peek() == Some(&'/') {
1020            chars.next();
1021            for comment in chars.by_ref() {
1022                if comment == '\n' {
1023                    output.push('\n');
1024                    break;
1025                }
1026            }
1027        } else if character == '/' && chars.peek() == Some(&'*') {
1028            chars.next();
1029            let mut previous = '\0';
1030            for comment in chars.by_ref() {
1031                if previous == '*' && comment == '/' {
1032                    break;
1033                }
1034                previous = comment;
1035            }
1036        } else {
1037            output.push(character);
1038        }
1039    }
1040    output
1041}
1042
1043pub(crate) fn strip_trailing_commas(contents: &str) -> String {
1044    let chars = contents.chars().collect::<Vec<_>>();
1045    let mut output = String::with_capacity(contents.len());
1046    let mut string = false;
1047    let mut escaped = false;
1048    for (index, character) in chars.iter().copied().enumerate() {
1049        if string {
1050            output.push(character);
1051            if escaped {
1052                escaped = false;
1053            } else if character == '\\' {
1054                escaped = true;
1055            } else if character == '"' {
1056                string = false;
1057            }
1058            continue;
1059        }
1060        if character == '"' {
1061            string = true;
1062            output.push(character);
1063        } else if character == ','
1064            && chars[index + 1..]
1065                .iter()
1066                .find(|character| !character.is_whitespace())
1067                .is_some_and(|character| matches!(character, '}' | ']'))
1068        {
1069        } else {
1070            output.push(character);
1071        }
1072    }
1073    output
1074}
1075
1076fn tsconfig_roots(directory: &Path) -> Vec<PathBuf> {
1077    let path = directory.join("tsconfig.json");
1078    let Some(contents) = fs::read_to_string(path).ok() else {
1079        return Vec::new();
1080    };
1081    let jsonc = strip_trailing_commas(&strip_jsonc_comments(&contents));
1082    let Some(config) = serde_json::from_str::<Value>(&jsonc).ok() else {
1083        return Vec::new();
1084    };
1085    let mut values = Vec::new();
1086    if let Some(root_dir) = config
1087        .get("compilerOptions")
1088        .and_then(|options| options.get("rootDir"))
1089        .and_then(Value::as_str)
1090    {
1091        values.push(root_dir.to_owned());
1092    }
1093    if let Some(include) = config.get("include").and_then(Value::as_array) {
1094        values.extend(include.iter().filter_map(Value::as_str).map(str::to_owned));
1095    }
1096    if values.is_empty() {
1097        return vec![directory.to_owned()];
1098    }
1099    values
1100        .into_iter()
1101        .filter_map(|value| {
1102            if value.starts_with('!') {
1103                return None;
1104            }
1105            let prefix = value
1106                .find(['?', '*', '{', '['])
1107                .map_or(value.as_str(), |index| &value[..index])
1108                .trim_end_matches('/');
1109            // `next-env.d.ts` is in every Next.js `include`; a declaration
1110            // holds nothing to measure and is not a root.
1111            (!prefix.is_empty() && !declaration_file(prefix)).then(|| resolve(directory, prefix))
1112        })
1113        .collect()
1114}
1115
1116fn within(parent: &Path, child: &Path) -> bool {
1117    child == parent || child.starts_with(parent)
1118}
1119
1120fn nearest_package_root<'a>(path: &Path, packages: &'a [PathBuf]) -> Option<&'a PathBuf> {
1121    packages
1122        .iter()
1123        .filter(|directory| within(directory, path))
1124        .max_by_key(|directory| directory.components().count())
1125}
1126
1127fn scope_limitation(file: &str) -> SourceLimitation {
1128    let digest = Sha256::digest(file.as_bytes());
1129    let id = digest[..10]
1130        .iter()
1131        .map(|byte| format!("{byte:02x}"))
1132        .collect::<String>();
1133    SourceLimitation {
1134        id: format!("scope:{id}"),
1135        kind: "source-scope".into(),
1136        file: file.into(),
1137        line: 1,
1138        column: 1,
1139        source: file.into(),
1140        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(),
1141    }
1142}
1143
1144// A declared Jest setup file is test infrastructure even when it lives under
1145// a source root with an arbitrary name. Instrumenting its mock factories adds
1146// out-of-scope bindings that Babel's jest-hoist correctly rejects.
1147fn declared_test_setup(packages: &[PathBuf]) -> BTreeSet<PathBuf> {
1148    let mut paths = BTreeSet::new();
1149    for package in packages {
1150        let manifest = read_json(&package.join("package.json")).unwrap_or(Value::Null);
1151        let Some(jest) = manifest.get("jest") else {
1152            continue;
1153        };
1154        let root = jest
1155            .get("rootDir")
1156            .and_then(Value::as_str)
1157            .map(|value| {
1158                resolve(
1159                    package,
1160                    value.replace("<rootDir>", &package.to_string_lossy()),
1161                )
1162            })
1163            .unwrap_or_else(|| package.clone());
1164        for field in [
1165            "setupFiles",
1166            "setupFilesAfterEnv",
1167            "globalSetup",
1168            "globalTeardown",
1169        ] {
1170            let values = match jest.get(field) {
1171                Some(Value::Array(values)) => values.iter().collect::<Vec<_>>(),
1172                Some(value) => vec![value],
1173                None => Vec::new(),
1174            };
1175            for value in values.into_iter().filter_map(Value::as_str) {
1176                let path = resolve(&root, value.replace("<rootDir>", &root.to_string_lossy()));
1177                if path.is_file() {
1178                    paths.insert(path);
1179                }
1180            }
1181        }
1182    }
1183    paths
1184}
1185
1186pub fn discover_source_scope(
1187    root: &Path,
1188    configured_roots: Option<&[String]>,
1189) -> Result<DiscoveredSourceScope, SourceDiscoveryError> {
1190    let root = lexical_normalize(root);
1191    let root_metadata = fs::symlink_metadata(&root).map_err(|error| io_error(&root, error))?;
1192    if !root_metadata.file_type().is_dir() {
1193        return Err(SourceDiscoveryError::InvalidRoot(root));
1194    }
1195    let packages = package_directories(&root)?;
1196    let test_setup = declared_test_setup(&packages);
1197    let explicit = configured_roots.is_some_and(|roots| !roots.is_empty());
1198    let include_roots = if explicit {
1199        configured_roots
1200            .unwrap_or_default()
1201            .iter()
1202            .map(|directory| resolve(&root, directory))
1203            .collect::<Vec<_>>()
1204    } else {
1205        packages
1206            .iter()
1207            .flat_map(|directory| {
1208                let manifest = read_json(&directory.join("package.json")).unwrap_or(Value::Null);
1209                let next_files = depends_on_next(&manifest)
1210                    .then_some(NEXT_ROOT_FILES)
1211                    .into_iter()
1212                    .flatten()
1213                    .flat_map(|name| {
1214                        ["ts", "tsx", "js", "jsx", "mjs"]
1215                            .map(|extension| directory.join(format!("{name}.{extension}")))
1216                    });
1217                let candidates = SOURCE_DIRECTORIES
1218                    .iter()
1219                    .map(|name| directory.join(name))
1220                    .chain(next_files)
1221                    .chain(entry_targets(directory, &manifest))
1222                    .chain(tsconfig_roots(directory))
1223                    .collect::<Vec<_>>();
1224                // A declared package that keeps its code somewhere
1225                // unconventional (a Shopify theme extension's `frontend/` and
1226                // `blocks/`, say) is still first-party source. Its own
1227                // directory becomes the root; generated subtrees stay excluded
1228                // by the walker as everywhere else.
1229                if directory != &root
1230                    && !candidates
1231                        .iter()
1232                        .any(|candidate| fs::symlink_metadata(candidate).is_ok())
1233                {
1234                    return vec![directory.clone()];
1235                }
1236                candidates
1237            })
1238            .collect()
1239    };
1240    let existing_roots = existing_roots(include_roots, explicit)?;
1241    let app_directories = packages
1242        .iter()
1243        .filter(|directory| {
1244            depends_on_next(&read_json(&directory.join("package.json")).unwrap_or(Value::Null))
1245        })
1246        .flat_map(|directory| [directory.join("app"), directory.join("src/app")])
1247        .collect::<Vec<_>>();
1248    let mut all_files = Vec::new();
1249    files_under(&root, &root, &mut all_files)?;
1250    all_files.sort();
1251
1252    let mut entries = Vec::new();
1253    let mut included = Vec::new();
1254    let mut limitations = Vec::new();
1255    for path in all_files {
1256        let file = local_path(&root, &path)?;
1257        let package_root = nearest_package_root(&path, &packages)
1258            .map(|package| local_path(&root, package))
1259            .transpose()?
1260            .filter(|package| package != ".");
1261        let entry = |status, reason: &str| SourceScopeEntry {
1262            file: file.clone(),
1263            status,
1264            reason: reason.into(),
1265            package_root: package_root.clone(),
1266        };
1267        let named = explicit
1268            .then(|| below_named_root(&existing_roots, &path))
1269            .flatten();
1270        let guessed = match named {
1271            Some(local) => local.to_string_lossy().replace('\\', "/"),
1272            None => file.clone(),
1273        };
1274        if declaration_file(&file) {
1275            entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
1276        } else if test_setup.contains(&path) {
1277            entries.push(entry(SourceScopeStatus::Excluded, "declared test setup"));
1278        } else if test_or_fixture(&guessed) && !next_app_file(&app_directories, &path) {
1279            entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
1280        } else if tool_script(&guessed) {
1281            entries.push(entry(
1282                SourceScopeStatus::Excluded,
1283                "conventional tool script",
1284            ));
1285        } else if benchmark(&guessed) {
1286            entries.push(entry(SourceScopeStatus::Excluded, "benchmark"));
1287        } else if example(&file, package_root.as_deref()) {
1288            entries.push(entry(SourceScopeStatus::Excluded, "example"));
1289        } else if config_file(&file) {
1290            entries.push(entry(
1291                SourceScopeStatus::Excluded,
1292                "build/test/tool configuration",
1293            ));
1294        } else if built_asset(&file) {
1295            entries.push(entry(SourceScopeStatus::Excluded, BUILT_ASSET_REASON));
1296        } else if root_contains(&existing_roots, &path) {
1297            included.push(path);
1298            entries.push(entry(
1299                SourceScopeStatus::Included,
1300                if explicit {
1301                    "explicit source root"
1302                } else {
1303                    "discovered package source root"
1304                },
1305            ));
1306        } else if explicit {
1307            entries.push(entry(
1308                SourceScopeStatus::Excluded,
1309                "outside explicit source roots",
1310            ));
1311        } else {
1312            entries.push(entry(
1313                SourceScopeStatus::Ambiguous,
1314                "unclassified first-party source",
1315            ));
1316            limitations.push(scope_limitation(&file));
1317        }
1318    }
1319    let source_files = included
1320        .iter()
1321        .map(|path| local_path(&root, path))
1322        .collect::<Result<Vec<_>, _>>()?;
1323    let source_roots = existing_roots
1324        .iter()
1325        .map(|path| local_path(&root, path))
1326        .collect::<Result<Vec<_>, _>>()?;
1327    Ok(DiscoveredSourceScope {
1328        source_files,
1329        source_roots: source_roots.clone(),
1330        scope: SourceScope {
1331            version: 1,
1332            mode: if explicit {
1333                SourceScopeMode::Explicit
1334            } else {
1335                SourceScopeMode::Automatic
1336            },
1337            roots: source_roots,
1338            entries,
1339        },
1340        limitations,
1341    })
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346    use std::{
1347        fs,
1348        time::{SystemTime, UNIX_EPOCH},
1349    };
1350
1351    use super::*;
1352
1353    fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
1354        let nonce = SystemTime::now()
1355            .duration_since(UNIX_EPOCH)
1356            .unwrap()
1357            .as_nanos();
1358        let root = std::env::temp_dir().join(format!(
1359            "supercov-source-{label}-{}-{nonce}",
1360            std::process::id()
1361        ));
1362        fs::create_dir_all(&root).unwrap();
1363        for (file, contents) in files {
1364            let path = root.join(file);
1365            fs::create_dir_all(path.parent().unwrap()).unwrap();
1366            fs::write(path, contents).unwrap();
1367        }
1368        root
1369    }
1370
1371    fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
1372        scope
1373            .scope
1374            .entries
1375            .iter()
1376            .find(|entry| entry.file == file)
1377            .unwrap()
1378    }
1379
1380    #[test]
1381    fn a_file_the_project_tells_git_to_ignore_is_not_its_source() {
1382        // Actual's build writes a 5 MB worker bundle under
1383        // packages/loot-core/lib-dist, which its own nested .gitignore names.
1384        let files = [
1385            ("package.json", r#"{"workspaces":["packages/*"]}"#),
1386            ("packages/core/package.json", r#"{"name":"core"}"#),
1387            ("packages/core/.gitignore", "lib-dist\n"),
1388            ("packages/core/src/index.ts", "export const a = 1;"),
1389            (
1390                "packages/core/lib-dist/browser/worker.dev.js",
1391                "var bundled = 1;",
1392            ),
1393        ];
1394        let repository_root = repository("gitignored", &files);
1395        fs::create_dir_all(repository_root.join(".git")).unwrap();
1396        let discovered = discover_source_scope(&repository_root, None).unwrap();
1397        assert!(
1398            discovered
1399                .source_files
1400                .contains(&"packages/core/src/index.ts".into())
1401        );
1402        assert!(
1403            !discovered
1404                .source_files
1405                .iter()
1406                .any(|file| file.contains("lib-dist")),
1407            "{:?}",
1408            discovered.source_files
1409        );
1410        fs::remove_dir_all(repository_root).unwrap();
1411
1412        // Outside a git repository a .gitignore means nothing.
1413        let plain = repository("not-a-repository", &files);
1414        let discovered = discover_source_scope(&plain, None).unwrap();
1415        assert!(
1416            discovered
1417                .scope
1418                .entries
1419                .iter()
1420                .any(|entry| entry.file.contains("lib-dist"))
1421        );
1422        fs::remove_dir_all(plain).unwrap();
1423    }
1424
1425    #[test]
1426    fn a_project_kept_where_an_enclosing_repository_ignores_it_keeps_its_source() {
1427        let files = [
1428            // What Supercov's own store writes, so nothing inside is tracked.
1429            ("scratch/.gitignore", "*\n"),
1430            ("scratch/app/package.json", r#"{"name":"app"}"#),
1431            ("scratch/app/.gitignore", "dist\n"),
1432            ("scratch/app/src/index.js", "export const a = 1;"),
1433            ("scratch/app/dist/bundle.js", "var bundled = 1;"),
1434        ];
1435        let repository_root = repository("enclosing-ignore", &files);
1436        fs::create_dir_all(repository_root.join(".git")).unwrap();
1437        let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1438        assert_eq!(discovered.source_files, ["src/index.js"]);
1439
1440        // A project the enclosing repository tracks still follows its rules.
1441        fs::remove_file(repository_root.join("scratch/.gitignore")).unwrap();
1442        fs::write(repository_root.join(".gitignore"), "*.gen.js\n").unwrap();
1443        fs::write(repository_root.join("scratch/app/src/api.gen.js"), "var a;").unwrap();
1444        let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1445        assert_eq!(discovered.source_files, ["src/index.js"]);
1446        fs::remove_dir_all(repository_root).unwrap();
1447    }
1448
1449    #[test]
1450    fn excludes_declared_jest_setup_without_hiding_neighboring_application_code() {
1451        let root = repository(
1452            "jest-setup",
1453            &[
1454                (
1455                    "package.json",
1456                    r#"{"jest":{"setupFilesAfterEnv":["<rootDir>/src/bootstrap.js"]}}"#,
1457                ),
1458                (
1459                    "src/bootstrap.js",
1460                    "jest.mock('storage', () => ({read: () => null}));",
1461                ),
1462                ("src/index.js", "export const value = 1;"),
1463            ],
1464        );
1465        let discovered = discover_source_scope(&root, None).unwrap();
1466        assert_eq!(discovered.source_files, ["src/index.js"]);
1467        assert_eq!(
1468            entry(&discovered, "src/bootstrap.js").reason,
1469            "declared test setup"
1470        );
1471        fs::remove_dir_all(root).unwrap();
1472    }
1473
1474    #[test]
1475    fn example_apps_and_conf_files_are_not_source() {
1476        // uuid: a tsconfig with no `include` makes the whole checkout a root.
1477        let root = repository(
1478            "examples",
1479            &[
1480                ("package.json", r#"{"exports":{".":"./dist/index.js"}}"#),
1481                ("tsconfig.json", r#"{"exclude":["dist","examples"]}"#),
1482                ("src/v4.ts", "export const v4 = () => ''"),
1483                ("wdio.conf.js", "exports.config = {}"),
1484                ("karma.conf.cjs", "module.exports = () => {}"),
1485                ("examples/browser-esmodules/package.json", "{}"),
1486                ("examples/browser-esmodules/example.js", "console.log(1)"),
1487                ("examples/utils/testpage.js", "export default 1"),
1488                ("example/demo.js", "console.log(1)"),
1489                ("app/examples/page.tsx", "export default function Page() {}"),
1490            ],
1491        );
1492        let discovered = discover_source_scope(&root, None).unwrap();
1493        assert_eq!(
1494            discovered.source_files,
1495            ["app/examples/page.tsx", "src/v4.ts"]
1496        );
1497        for file in [
1498            "examples/browser-esmodules/example.js",
1499            "examples/utils/testpage.js",
1500            "example/demo.js",
1501        ] {
1502            assert_eq!(entry(&discovered, file).reason, "example", "{file}");
1503        }
1504        for file in ["wdio.conf.js", "karma.conf.cjs"] {
1505            assert_eq!(
1506                entry(&discovered, file).reason,
1507                "build/test/tool configuration",
1508                "{file}"
1509            );
1510        }
1511        fs::remove_dir_all(root).unwrap();
1512    }
1513
1514    #[test]
1515    fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
1516        let root = repository(
1517            "automatic",
1518            &[
1519                ("package.json", r#"{"workspaces":["packages/*"]}"#),
1520                ("src/index.ts", "export const root = true"),
1521                ("lib/helper.js", "export const helper = true"),
1522                ("src/index.test.ts", "test('root', () => {})"),
1523                ("tests/e2e.spec.ts", "test('e2e', () => {})"),
1524                ("scripts/release.mjs", "export const release = true"),
1525                ("vite.config.ts", "export default {}"),
1526                ("build.mjs", "export default async function build() {}"),
1527                (".eslintrc.cjs", "module.exports = {}"),
1528                (".graphqlrc.ts", "export default {}"),
1529                ("orphan.ts", "export const missed = true"),
1530                ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
1531                ("packages/ui/src/index.ts", "export const ui = true"),
1532                ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
1533                ("dist/generated.js", "generated"),
1534                (".cache/tool/generated.js", "cached"),
1535                ("src/parse.bench.ts", "bench('parse', () => {})"),
1536                (
1537                    "benchmarks/value-slice.bench.ts",
1538                    "bench('slice', () => {})",
1539                ),
1540                ("src/benchmarkReport.ts", "export const report = 1"),
1541            ],
1542        );
1543        let discovered = discover_source_scope(&root, None).unwrap();
1544        assert_eq!(
1545            discovered.source_files,
1546            [
1547                "lib/helper.js",
1548                "packages/ui/src/index.ts",
1549                "src/benchmarkReport.ts",
1550                "src/index.ts"
1551            ]
1552        );
1553        for file in ["src/parse.bench.ts", "benchmarks/value-slice.bench.ts"] {
1554            assert_eq!(entry(&discovered, file).reason, "benchmark", "{file}");
1555        }
1556        assert_eq!(
1557            entry(&discovered, "orphan.ts").status,
1558            SourceScopeStatus::Ambiguous
1559        );
1560        assert_eq!(
1561            entry(&discovered, "scripts/release.mjs").reason,
1562            "conventional tool script"
1563        );
1564        assert_eq!(
1565            entry(&discovered, "build.mjs").reason,
1566            "build/test/tool configuration"
1567        );
1568        assert_eq!(
1569            entry(&discovered, "packages/ui/src/index.ts").package_root,
1570            Some("packages/ui".into())
1571        );
1572        assert_eq!(discovered.limitations.len(), 1);
1573        assert_eq!(discovered.limitations[0].file, "orphan.ts");
1574        assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
1575        assert!(
1576            discovered
1577                .scope
1578                .entries
1579                .iter()
1580                .all(|entry| !entry.file.contains(".cache"))
1581        );
1582        fs::remove_dir_all(root).unwrap();
1583    }
1584
1585    #[test]
1586    fn declared_workspaces_outside_conventional_parents_are_package_roots() {
1587        let root = repository(
1588            "declared-workspaces",
1589            &[
1590                ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1591                ("app/main.ts", "product"),
1592                ("app_extensions/discounts/package.json", "{}"),
1593                ("app_extensions/discounts/src/index.ts", "extension"),
1594                // No conventional source directory at all: the package itself
1595                // is the root, so its frontend code is still first-party.
1596                ("app_extensions/upsells/package.json", "{}"),
1597                ("app_extensions/upsells/frontend/embed.ts", "embed"),
1598                ("app_extensions/upsells/dist/embed.js", "built"),
1599            ],
1600        );
1601        let discovered = discover_source_scope(&root, None).unwrap();
1602        assert_eq!(
1603            discovered.source_files,
1604            [
1605                "app/main.ts",
1606                "app_extensions/discounts/src/index.ts",
1607                "app_extensions/upsells/frontend/embed.ts",
1608            ]
1609        );
1610        assert!(
1611            discovered.limitations.is_empty(),
1612            "declared packages must not be blockers: {:?}",
1613            discovered.limitations
1614        );
1615        fs::remove_dir_all(root).unwrap();
1616    }
1617
1618    #[test]
1619    fn hashed_bundles_in_asset_directories_are_built_assets() {
1620        let root = repository(
1621            "built-assets",
1622            &[
1623                ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1624                ("app/main.ts", "product"),
1625                ("app_extensions/upsells/package.json", "{}"),
1626                ("app_extensions/upsells/frontend/embed.ts", "source"),
1627                (
1628                    "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1629                    "bundle",
1630                ),
1631                ("app_extensions/upsells/assets/stylex-DAnmLURx.js", "bundle"),
1632                // A hand-written helper in assets keeps its ordinary treatment.
1633                (
1634                    "app_extensions/upsells/assets/theme-helper.js",
1635                    "hand written",
1636                ),
1637            ],
1638        );
1639        let discovered = discover_source_scope(&root, None).unwrap();
1640        assert!(
1641            !discovered
1642                .source_files
1643                .iter()
1644                .any(|file| file.contains("-Be-aUw9g.js") || file.contains("-DAnmLURx.js"))
1645        );
1646        assert_eq!(
1647            entry(
1648                &discovered,
1649                "app_extensions/upsells/assets/app-embed-Be-aUw9g.js"
1650            )
1651            .reason,
1652            "built asset"
1653        );
1654        assert!(
1655            discovered.limitations.is_empty(),
1656            "bundles are not blockers: {:?}",
1657            discovered.limitations
1658        );
1659        fs::remove_dir_all(root).unwrap();
1660    }
1661
1662    #[test]
1663    fn root_level_hidden_directories_are_tooling_not_source() {
1664        let root = repository(
1665            "root-hidden",
1666            &[
1667                ("package.json", "{}"),
1668                ("app/main.ts", "product"),
1669                (".shopify/bundle/upsells/frontend/embed.js", "cli bundle"),
1670                (".vercel/output/functions/index.js", "deploy output"),
1671                // A nested hidden directory inside a source root keeps its
1672                // ordinary treatment.
1673                ("app/.generated/schema.ts", "generated types"),
1674            ],
1675        );
1676        let discovered = discover_source_scope(&root, None).unwrap();
1677        assert_eq!(
1678            discovered.source_files,
1679            ["app/.generated/schema.ts", "app/main.ts"]
1680        );
1681        assert!(
1682            discovered.limitations.is_empty(),
1683            "{:?}",
1684            discovered.limitations
1685        );
1686        assert!(discovered.scope.entries.iter().all(
1687            |entry| !entry.file.starts_with(".shopify/") && !entry.file.starts_with(".vercel/")
1688        ));
1689        fs::remove_dir_all(root).unwrap();
1690    }
1691
1692    #[test]
1693    fn nested_checkouts_are_neither_source_nor_limitations() {
1694        let root = repository(
1695            "nested-checkout",
1696            &[
1697                ("package.json", "{}"),
1698                ("app/main.ts", "product"),
1699                // An agent worktree: a full copy of the project carrying its own
1700                // `.git` file. Real project, 1,032 of these turned into blockers.
1701                (".claude/worktrees/agent-1/.git", "gitdir: /elsewhere"),
1702                (".claude/worktrees/agent-1/app/main.ts", "copy"),
1703                ("vendor-fork/.git/HEAD", "ref: refs/heads/main"),
1704                ("vendor-fork/src/index.ts", "clone"),
1705            ],
1706        );
1707        let discovered = discover_source_scope(&root, None).unwrap();
1708        assert_eq!(discovered.source_files, ["app/main.ts"]);
1709        assert!(
1710            discovered.limitations.is_empty(),
1711            "nested checkouts must not be blocking limitations: {:?}",
1712            discovered.limitations
1713        );
1714        assert!(
1715            discovered
1716                .scope
1717                .entries
1718                .iter()
1719                .all(|entry| !entry.file.starts_with(".claude/")
1720                    && !entry.file.starts_with("vendor-fork/")),
1721            "nested checkout files must not appear in scope at all"
1722        );
1723        fs::remove_dir_all(root).unwrap();
1724    }
1725
1726    #[test]
1727    fn roots_enter_a_runs_identity_only_when_named() {
1728        // Appending nothing when no roots are named is what keeps every
1729        // fingerprint recorded before this change valid.
1730        let mut unchanged = b"pytest\0-q".to_vec();
1731        fold_roots_into_configuration(&mut unchanged, None);
1732        assert_eq!(unchanged, b"pytest\0-q");
1733        fold_roots_into_configuration(&mut unchanged, Some(&[]));
1734        assert_eq!(unchanged, b"pytest\0-q");
1735
1736        let mut folded = b"pytest\0-q".to_vec();
1737        fold_roots_into_configuration(&mut folded, Some(&["src".to_owned(), "a.py".to_owned()]));
1738        assert_eq!(folded, b"pytest\0-q\0SUPERCOV_SOURCE_ROOTS=src,a.py");
1739    }
1740
1741    #[test]
1742    fn the_variable_is_read_the_same_way_for_every_frontend() {
1743        let read = |value: &str| {
1744            configured_source_roots(&BTreeMap::from([(
1745                SOURCE_ROOTS_VARIABLE.to_owned(),
1746                value.to_owned(),
1747            )]))
1748        };
1749        assert_eq!(
1750            read(" src , app ,"),
1751            Some(vec!["src".to_owned(), "app".to_owned()])
1752        );
1753        assert_eq!(
1754            read(" , "),
1755            None,
1756            "naming nothing leaves discovery automatic"
1757        );
1758        assert_eq!(configured_source_roots(&BTreeMap::new()), None);
1759    }
1760
1761    #[test]
1762    fn a_next_application_is_measured_where_next_keeps_its_code() {
1763        // A stock Next.js layout reported 89 files it could not classify:
1764        // root `components/`, `proxy.ts` and `instrumentation.ts`. And five
1765        // "test connection" endpoints were left out as tests, because under
1766        // `app/` a directory named `test` is a URL segment.
1767        let root = repository(
1768            "next-layout",
1769            &[
1770                ("package.json", r#"{"dependencies":{"next":"16.1.1"}}"#),
1771                (
1772                    "tsconfig.json",
1773                    r#"{"include":["next-env.d.ts","**/*.ts",".next/types/**/*.ts"]}"#,
1774                ),
1775                ("next-env.d.ts", "declare const next: unknown;"),
1776                ("app/page.tsx", "page"),
1777                ("app/api/wordpress/test/route.ts", "route"),
1778                ("app/api/wordpress/test/route.test.ts", "test"),
1779                ("app/api/wordpress/test/helper.ts", "helper"),
1780                ("app/__tests__/page.tsx", "test"),
1781                ("components/button.tsx", "component"),
1782                ("pages/api/legacy.ts", "page"),
1783                ("proxy.ts", "proxy"),
1784                ("instrumentation.ts", "instrumentation"),
1785                ("simulators/echo.ts", "simulator"),
1786            ],
1787        );
1788        let discovered = discover_source_scope(&root, None).unwrap();
1789        assert_eq!(
1790            discovered.source_files,
1791            [
1792                "app/api/wordpress/test/route.ts",
1793                "app/page.tsx",
1794                "components/button.tsx",
1795                "instrumentation.ts",
1796                "pages/api/legacy.ts",
1797                "proxy.ts",
1798            ]
1799        );
1800        assert_eq!(
1801            discovered.source_roots,
1802            [
1803                "app",
1804                "components",
1805                "instrumentation.ts",
1806                "pages",
1807                "proxy.ts"
1808            ],
1809            "a declaration file is not a root"
1810        );
1811        for file in [
1812            "app/api/wordpress/test/route.test.ts",
1813            "app/api/wordpress/test/helper.ts",
1814            "app/__tests__/page.tsx",
1815        ] {
1816            assert_eq!(entry(&discovered, file).reason, "test or fixture source");
1817        }
1818        // Nothing says what `simulators/` is, so it is still asked about.
1819        assert_eq!(
1820            entry(&discovered, "simulators/echo.ts").status,
1821            SourceScopeStatus::Ambiguous
1822        );
1823        // Named roots keep the route too.
1824        let roots = vec!["app".into()];
1825        let named = discover_source_scope(&root, Some(&roots)).unwrap();
1826        assert_eq!(
1827            named.source_files,
1828            ["app/api/wordpress/test/route.ts", "app/page.tsx"]
1829        );
1830        fs::remove_dir_all(root).unwrap();
1831    }
1832
1833    #[test]
1834    fn a_route_file_name_means_nothing_outside_a_next_application() {
1835        let root = repository(
1836            "not-next",
1837            &[
1838                ("package.json", "{}"),
1839                ("app/main.ts", "main"),
1840                ("app/test/route.ts", "a test helper"),
1841                ("proxy.ts", "unknown"),
1842            ],
1843        );
1844        let discovered = discover_source_scope(&root, None).unwrap();
1845        assert_eq!(discovered.source_files, ["app/main.ts"]);
1846        assert_eq!(
1847            entry(&discovered, "app/test/route.ts").reason,
1848            "test or fixture source"
1849        );
1850        assert_eq!(
1851            entry(&discovered, "proxy.ts").status,
1852            SourceScopeStatus::Ambiguous
1853        );
1854        fs::remove_dir_all(root).unwrap();
1855    }
1856
1857    #[test]
1858    fn a_named_root_settles_what_it_names_and_guesses_only_below_it() {
1859        // `scripts`, or a directory called `test-utils`, read as tooling and
1860        // tests to discovery. Naming one as a source root says otherwise, and
1861        // there was no way to say it: the named root stayed excluded.
1862        let root = repository(
1863            "named-roots",
1864            &[
1865                ("package.json", "{}"),
1866                ("src/main.ts", "main"),
1867                ("src/test-utils/build.ts", "helper"),
1868                ("src/test-utils/__tests__/build.test.ts", "test"),
1869                ("scripts/release.ts", "script"),
1870                ("scripts/fixtures/sample.ts", "fixture"),
1871                ("tools/one.spec.ts", "named file"),
1872            ],
1873        );
1874        let roots = vec!["src".into()];
1875        let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1876        assert_eq!(discovered.source_files, ["src/main.ts"]);
1877
1878        let roots = vec![
1879            "src/test-utils".into(),
1880            "scripts".into(),
1881            "tools/one.spec.ts".into(),
1882        ];
1883        let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1884        assert_eq!(
1885            discovered.source_files,
1886            [
1887                "scripts/release.ts",
1888                "src/test-utils/build.ts",
1889                "tools/one.spec.ts",
1890            ]
1891        );
1892        assert_eq!(
1893            entry(&discovered, "src/test-utils/__tests__/build.test.ts").reason,
1894            "test or fixture source"
1895        );
1896        assert_eq!(
1897            entry(&discovered, "scripts/fixtures/sample.ts").reason,
1898            "test or fixture source"
1899        );
1900        fs::remove_dir_all(root).unwrap();
1901    }
1902
1903    #[test]
1904    fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
1905        let root = repository(
1906            "explicit",
1907            &[
1908                ("package.json", "{}"),
1909                ("product/main.ts", "product"),
1910                ("orphan.ts", "outside"),
1911            ],
1912        );
1913        let roots = vec!["product".into()];
1914        let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1915        assert_eq!(discovered.source_files, ["product/main.ts"]);
1916        assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
1917        assert_eq!(
1918            entry(&discovered, "orphan.ts").reason,
1919            "outside explicit source roots"
1920        );
1921        assert!(discovered.limitations.is_empty());
1922        fs::remove_dir_all(root).unwrap();
1923    }
1924
1925    #[test]
1926    fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
1927        let root = repository(
1928            "jsonc",
1929            &[
1930                ("package.json", r#"{"main":"./dist/index.js"}"#),
1931                (
1932                    "tsconfig.json",
1933                    "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
1934                ),
1935                ("events.ts", "event"),
1936                ("žalias.ts", "unicode"),
1937                ("library.test.ts", "test"),
1938            ],
1939        );
1940        let discovered = discover_source_scope(&root, None).unwrap();
1941        assert!(discovered.source_roots.contains(&".".into()));
1942        assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
1943        assert!(discovered.limitations.is_empty());
1944        fs::remove_dir_all(root).unwrap();
1945    }
1946
1947    #[cfg(unix)]
1948    #[test]
1949    fn never_follows_source_directory_or_explicit_root_symlinks() {
1950        use std::os::unix::fs::symlink;
1951
1952        let root = repository(
1953            "symlink",
1954            &[("package.json", "{}"), ("src/real.ts", "real")],
1955        );
1956        let outside = repository("outside", &[("secret.ts", "secret")]);
1957        symlink(&outside, root.join("linked")).unwrap();
1958        symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
1959        let discovered = discover_source_scope(&root, None).unwrap();
1960        assert_eq!(discovered.source_files, ["src/real.ts"]);
1961        let explicit = vec!["linked".into()];
1962        assert!(matches!(
1963            discover_source_scope(&root, Some(&explicit)),
1964            Err(SourceDiscoveryError::InvalidRoot(_))
1965        ));
1966        fs::remove_dir_all(root).unwrap();
1967        fs::remove_dir_all(outside).unwrap();
1968    }
1969
1970    #[test]
1971    fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
1972        let root = repository(
1973            "workspace-store",
1974            &[
1975                ("package.json", "{}"),
1976                ("src/main.ts", "main"),
1977                ("supercov/user.ts", "user code"),
1978            ],
1979        );
1980        let before = discover_source_scope(&root, None).unwrap();
1981        assert_eq!(
1982            entry(&before, "supercov/user.ts").status,
1983            SourceScopeStatus::Ambiguous
1984        );
1985        fs::write(
1986            root.join("supercov/.supercov-workspace-store"),
1987            b"Supercov instrumented workspace. Safe to delete.\n",
1988        )
1989        .unwrap();
1990        let after = discover_source_scope(&root, None).unwrap();
1991        assert!(
1992            after
1993                .scope
1994                .entries
1995                .iter()
1996                .all(|entry| !entry.file.starts_with("supercov/"))
1997        );
1998        fs::remove_dir_all(root).unwrap();
1999    }
2000}