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