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::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
174fn lexical_normalize(path: &Path) -> PathBuf {
175    let mut output = PathBuf::new();
176    for component in path.components() {
177        match component {
178            Component::CurDir => {}
179            Component::ParentDir => {
180                if !output.pop() {
181                    output.push(component.as_os_str());
182                }
183            }
184            _ => output.push(component.as_os_str()),
185        }
186    }
187    output
188}
189
190fn resolve(root: &Path, value: impl AsRef<Path>) -> PathBuf {
191    let value = value.as_ref();
192    let joined;
193    let path = if value.is_absolute() {
194        value
195    } else {
196        joined = root.join(value);
197        &joined
198    };
199    lexical_normalize(path)
200}
201
202fn local_path(root: &Path, path: &Path) -> Result<String, SourceDiscoveryError> {
203    let local = path
204        .strip_prefix(root)
205        .map_err(|_| SourceDiscoveryError::InvalidRoot(path.to_owned()))?;
206    if local.as_os_str().is_empty() {
207        return Ok(".".into());
208    }
209    local
210        .components()
211        .map(|component| {
212            component
213                .as_os_str()
214                .to_str()
215                .map(str::to_owned)
216                .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(path.to_owned()))
217        })
218        .collect::<Result<Vec<_>, _>>()
219        .map(|parts| parts.join("/"))
220}
221
222fn generated_directory(name: &str) -> bool {
223    GENERATED_DIRECTORIES.contains(&name)
224}
225
226fn owned_workspace_store(path: &Path) -> bool {
227    crate::workspace::owned_workspace_path(path)
228}
229
230/// A directory carrying its own `.git` entry is another checkout — a nested
231/// clone, a submodule, or an agent worktree such as `.claude/worktrees/*` —
232/// not this project's source. Treating its files as ambiguous first-party
233/// code turned one real project's report into 1,032 blocking limitations.
234fn nested_checkout(path: &Path) -> bool {
235    fs::symlink_metadata(path.join(".git")).is_ok()
236}
237
238/// A hidden directory at the project root is tool state (.shopify, .vercel,
239/// .idea, .claude, ...) by convention, never application source. Nested
240/// hidden directories keep their normal treatment so a source tree that
241/// happens to contain one is not silently truncated.
242/// A hashed bundle (`app-embed-Be-aUw9g.js`) inside an assets/static/public
243/// directory is a bundler's output, not source: a theme extension's `assets/`
244/// receives its Vite build, and every such bundle was an ambiguous blocker.
245fn built_asset(file: &str) -> bool {
246    let mut segments = file.rsplit('/');
247    let Some(name) = segments.next() else {
248        return false;
249    };
250    let in_asset_directory =
251        segments.any(|segment| matches!(segment, "assets" | "static" | "public"));
252    let Some(stem) = name
253        .strip_suffix(".js")
254        .or_else(|| name.strip_suffix(".mjs"))
255        .or_else(|| name.strip_suffix(".cjs"))
256    else {
257        return false;
258    };
259    // Bundler hashes are eight base64url characters, which may themselves
260    // contain '-', so take the suffix by length rather than splitting on it.
261    if stem.len() < 10 || stem.as_bytes()[stem.len() - 9] != b'-' {
262        return false;
263    }
264    let hash = &stem[stem.len() - 8..];
265    let looks_hashed = hash
266        .bytes()
267        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
268        && (hash.bytes().any(|byte| byte.is_ascii_digit())
269            || (hash.bytes().any(|byte| byte.is_ascii_uppercase())
270                && hash.bytes().any(|byte| byte.is_ascii_lowercase())));
271    in_asset_directory && looks_hashed
272}
273
274fn root_tool_directory(root: &Path, path: &Path) -> bool {
275    path.parent() == Some(root)
276        && path
277            .file_name()
278            .and_then(|name| name.to_str())
279            .is_some_and(|name| name.starts_with('.'))
280}
281
282fn source_file(name: &str) -> bool {
283    let lower = name.to_ascii_lowercase();
284    [
285        ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
286        ".mtsx",
287    ]
288    .iter()
289    .any(|extension| lower.ends_with(extension))
290}
291
292fn declaration_file(file: &str) -> bool {
293    let lower = file.to_ascii_lowercase();
294    lower.ends_with(".d.ts") || lower.ends_with(".d.cts") || lower.ends_with(".d.mts")
295}
296
297fn test_or_fixture(file: &str) -> bool {
298    let lower = file.to_ascii_lowercase();
299    if lower
300        .split('/')
301        .any(|segment| TEST_DIRECTORIES.contains(&segment))
302    {
303        return true;
304    }
305    lower
306        .split(['/', '_', '.', '-'])
307        .any(|part| matches!(part, "test" | "spec"))
308}
309
310fn tool_script(file: &str) -> bool {
311    file.to_ascii_lowercase()
312        .split('/')
313        .any(|segment| segment == "scripts")
314}
315
316fn config_file(file: &str) -> bool {
317    let lower = file.to_ascii_lowercase();
318    let name = lower.rsplit('/').next().unwrap_or(&lower);
319    source_file(name)
320        && (CONFIG_TOOLS
321            .iter()
322            .any(|tool| name.starts_with(&format!("{tool}.config.")))
323            || DOT_CONFIG_TOOLS
324                .iter()
325                .any(|tool| name.starts_with(&format!(".{tool}rc.")))
326            || (!lower.contains('/')
327                && (name.contains(".config.")
328                    || name.starts_with("build.")
329                    || name.starts_with("gulpfile.")
330                    || name.starts_with("gruntfile."))))
331}
332
333fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, SourceDiscoveryError> {
334    let mut entries = fs::read_dir(path)
335        .map_err(|error| io_error(path, error))?
336        .collect::<Result<Vec<_>, _>>()
337        .map_err(|error| io_error(path, error))?;
338    entries.sort_by_key(fs::DirEntry::file_name);
339    Ok(entries)
340}
341
342fn files_under(
343    root: &Path,
344    directory: &Path,
345    output: &mut Vec<PathBuf>,
346) -> Result<(), SourceDiscoveryError> {
347    let metadata = fs::symlink_metadata(directory).map_err(|error| io_error(directory, error))?;
348    if !metadata.file_type().is_dir() {
349        return Err(SourceDiscoveryError::InvalidRoot(directory.to_owned()));
350    }
351    for entry in read_directory(directory)? {
352        let name = entry
353            .file_name()
354            .into_string()
355            .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
356        let path = entry.path();
357        let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
358        if file_type.is_symlink() {
359            continue;
360        }
361        if file_type.is_dir() {
362            if !generated_directory(&name)
363                && !owned_workspace_store(&path)
364                && !nested_checkout(&path)
365                && !root_tool_directory(root, &path)
366            {
367                files_under(root, &path, output)?;
368            }
369        } else if file_type.is_file() && source_file(&name) {
370            output.push(path);
371        }
372    }
373    Ok(())
374}
375
376fn read_json(path: &Path) -> Option<Value> {
377    serde_json::from_slice(&fs::read(path).ok()?).ok()
378}
379
380fn package_directories(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
381    fn visit(
382        root: &Path,
383        directory: &Path,
384        depth: usize,
385        found: &mut BTreeSet<PathBuf>,
386    ) -> Result<(), SourceDiscoveryError> {
387        if depth > 5 {
388            return Ok(());
389        }
390        for entry in read_directory(directory)? {
391            let name = entry
392                .file_name()
393                .into_string()
394                .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
395            let path = entry.path();
396            let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
397            if !file_type.is_dir()
398                || file_type.is_symlink()
399                || generated_directory(&name)
400                || owned_workspace_store(&path)
401                || nested_checkout(&path)
402                || root_tool_directory(root, &path)
403            {
404                continue;
405            }
406            let local = local_path(root, &path)?;
407            let under_package_parent = local
408                .split('/')
409                .any(|segment| PACKAGE_PARENTS.contains(&segment));
410            if path.join("package.json").is_file() && (depth == 0 || under_package_parent) {
411                found.insert(path.clone());
412            }
413            visit(root, &path, depth + 1, found)?;
414        }
415        Ok(())
416    }
417
418    let mut found = BTreeSet::from([root.to_owned()]);
419    visit(root, root, 0, &mut found)?;
420    for directory in declared_workspace_packages(root)? {
421        found.insert(directory);
422    }
423    Ok(found.into_iter().collect())
424}
425
426/// Packages the project itself declares: `workspaces` in the root
427/// package.json (array or `{ "packages": [...] }`) and `packages:` in
428/// pnpm-workspace.yaml. The manifest is the most authoritative statement of
429/// where packages live, and it routinely names directories outside the
430/// conventional parents (a real project keeps its Shopify extensions under
431/// `app_extensions/*`; every file there was an ambiguous blocker).
432fn declared_workspace_packages(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
433    let mut patterns = Vec::new();
434    let manifest = read_json(&root.join("package.json")).unwrap_or(Value::Null);
435    let declared = match manifest.get("workspaces") {
436        Some(Value::Array(items)) => Some(items),
437        Some(Value::Object(object)) => object.get("packages").and_then(Value::as_array),
438        _ => None,
439    };
440    if let Some(items) = declared {
441        patterns.extend(items.iter().filter_map(Value::as_str).map(str::to_owned));
442    }
443    if let Ok(pnpm) = fs::read_to_string(root.join("pnpm-workspace.yaml")) {
444        let mut in_packages = false;
445        for line in pnpm.lines() {
446            let trimmed = line.trim();
447            if trimmed.starts_with("packages:") {
448                in_packages = true;
449                continue;
450            }
451            if in_packages {
452                if let Some(item) = trimmed.strip_prefix("- ") {
453                    patterns.push(item.trim_matches(|c| c == '"' || c == '\'').to_owned());
454                } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
455                    in_packages = false;
456                }
457            }
458        }
459    }
460    let mut found = Vec::new();
461    for pattern in patterns {
462        let pattern = pattern.trim_start_matches("./").trim_end_matches('/');
463        if pattern.starts_with('!') || pattern.contains("**") {
464            continue;
465        }
466        let (parent, wildcard) = match pattern.strip_suffix("/*") {
467            Some(parent) => (parent, true),
468            None => (pattern, false),
469        };
470        if parent.is_empty()
471            || parent
472                .split('/')
473                .any(|segment| segment.is_empty() || segment == ".." || segment.contains('*'))
474        {
475            continue;
476        }
477        let base = root.join(parent);
478        let candidates: Vec<PathBuf> = if wildcard {
479            match fs::read_dir(&base) {
480                Ok(entries) => entries
481                    .filter_map(Result::ok)
482                    .map(|entry| entry.path())
483                    .collect(),
484                Err(_) => Vec::new(),
485            }
486        } else {
487            vec![base]
488        };
489        for candidate in candidates {
490            let is_dir = fs::symlink_metadata(&candidate)
491                .is_ok_and(|metadata| metadata.file_type().is_dir());
492            if is_dir
493                && candidate.join("package.json").is_file()
494                && !nested_checkout(&candidate)
495                && !owned_workspace_store(&candidate)
496            {
497                found.push(candidate);
498            }
499        }
500    }
501    Ok(found)
502}
503
504fn string_targets(value: &Value, depth: usize, output: &mut Vec<String>) {
505    if depth > 8 {
506        return;
507    }
508    match value {
509        Value::String(value) => output.push(value.clone()),
510        Value::Array(values) => {
511            for value in values {
512                string_targets(value, depth + 1, output);
513            }
514        }
515        Value::Object(values) => {
516            for value in values.values() {
517                string_targets(value, depth + 1, output);
518            }
519        }
520        _ => {}
521    }
522}
523
524fn entry_targets(directory: &Path, manifest: &Value) -> Vec<PathBuf> {
525    let mut targets = Vec::new();
526    for key in ["main", "module", "browser", "bin", "exports"] {
527        if let Some(value) = manifest.get(key) {
528            string_targets(value, 0, &mut targets);
529        }
530    }
531    targets
532        .into_iter()
533        .filter_map(|target| {
534            if !target.starts_with('.') || target.contains("node_modules") {
535                return None;
536            }
537            let prefix = target.split('*').next()?.trim_end_matches('/');
538            (!prefix.is_empty()).then(|| resolve(directory, prefix))
539        })
540        .collect()
541}
542
543pub(crate) fn strip_jsonc_comments(contents: &str) -> String {
544    let mut output = String::with_capacity(contents.len());
545    let mut chars = contents.chars().peekable();
546    let mut string = false;
547    let mut escaped = false;
548    while let Some(character) = chars.next() {
549        if string {
550            output.push(character);
551            if escaped {
552                escaped = false;
553            } else if character == '\\' {
554                escaped = true;
555            } else if character == '"' {
556                string = false;
557            }
558            continue;
559        }
560        if character == '"' {
561            string = true;
562            output.push(character);
563        } else if character == '/' && chars.peek() == Some(&'/') {
564            chars.next();
565            for comment in chars.by_ref() {
566                if comment == '\n' {
567                    output.push('\n');
568                    break;
569                }
570            }
571        } else if character == '/' && chars.peek() == Some(&'*') {
572            chars.next();
573            let mut previous = '\0';
574            for comment in chars.by_ref() {
575                if previous == '*' && comment == '/' {
576                    break;
577                }
578                previous = comment;
579            }
580        } else {
581            output.push(character);
582        }
583    }
584    output
585}
586
587pub(crate) fn strip_trailing_commas(contents: &str) -> String {
588    let chars = contents.chars().collect::<Vec<_>>();
589    let mut output = String::with_capacity(contents.len());
590    let mut string = false;
591    let mut escaped = false;
592    for (index, character) in chars.iter().copied().enumerate() {
593        if string {
594            output.push(character);
595            if escaped {
596                escaped = false;
597            } else if character == '\\' {
598                escaped = true;
599            } else if character == '"' {
600                string = false;
601            }
602            continue;
603        }
604        if character == '"' {
605            string = true;
606            output.push(character);
607        } else if character == ','
608            && chars[index + 1..]
609                .iter()
610                .find(|character| !character.is_whitespace())
611                .is_some_and(|character| matches!(character, '}' | ']'))
612        {
613        } else {
614            output.push(character);
615        }
616    }
617    output
618}
619
620fn tsconfig_roots(directory: &Path) -> Vec<PathBuf> {
621    let path = directory.join("tsconfig.json");
622    let Some(contents) = fs::read_to_string(path).ok() else {
623        return Vec::new();
624    };
625    let jsonc = strip_trailing_commas(&strip_jsonc_comments(&contents));
626    let Some(config) = serde_json::from_str::<Value>(&jsonc).ok() else {
627        return Vec::new();
628    };
629    let mut values = Vec::new();
630    if let Some(root_dir) = config
631        .get("compilerOptions")
632        .and_then(|options| options.get("rootDir"))
633        .and_then(Value::as_str)
634    {
635        values.push(root_dir.to_owned());
636    }
637    if let Some(include) = config.get("include").and_then(Value::as_array) {
638        values.extend(include.iter().filter_map(Value::as_str).map(str::to_owned));
639    }
640    if values.is_empty() {
641        return vec![directory.to_owned()];
642    }
643    values
644        .into_iter()
645        .filter_map(|value| {
646            if value.starts_with('!') {
647                return None;
648            }
649            let prefix = value
650                .find(['?', '*', '{', '['])
651                .map_or(value.as_str(), |index| &value[..index])
652                .trim_end_matches('/');
653            (!prefix.is_empty()).then(|| resolve(directory, prefix))
654        })
655        .collect()
656}
657
658fn within(parent: &Path, child: &Path) -> bool {
659    child == parent || child.starts_with(parent)
660}
661
662fn nearest_package_root<'a>(path: &Path, packages: &'a [PathBuf]) -> Option<&'a PathBuf> {
663    packages
664        .iter()
665        .filter(|directory| within(directory, path))
666        .max_by_key(|directory| directory.components().count())
667}
668
669fn scope_limitation(file: &str) -> SourceLimitation {
670    let digest = Sha256::digest(file.as_bytes());
671    let id = digest[..10]
672        .iter()
673        .map(|byte| format!("{byte:02x}"))
674        .collect::<String>();
675    SourceLimitation {
676        id: format!("scope:{id}"),
677        kind: "source-scope".into(),
678        file: file.into(),
679        line: 1,
680        column: 1,
681        source: file.into(),
682        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(),
683    }
684}
685
686pub fn discover_source_scope(
687    root: &Path,
688    configured_roots: Option<&[String]>,
689) -> Result<DiscoveredSourceScope, SourceDiscoveryError> {
690    let root = lexical_normalize(root);
691    let root_metadata = fs::symlink_metadata(&root).map_err(|error| io_error(&root, error))?;
692    if !root_metadata.file_type().is_dir() {
693        return Err(SourceDiscoveryError::InvalidRoot(root));
694    }
695    let packages = package_directories(&root)?;
696    let explicit = configured_roots.is_some_and(|roots| !roots.is_empty());
697    let include_roots = if explicit {
698        configured_roots
699            .unwrap_or_default()
700            .iter()
701            .map(|directory| resolve(&root, directory))
702            .collect::<Vec<_>>()
703    } else {
704        packages
705            .iter()
706            .flat_map(|directory| {
707                let manifest = read_json(&directory.join("package.json")).unwrap_or(Value::Null);
708                let candidates = SOURCE_DIRECTORIES
709                    .iter()
710                    .map(|name| directory.join(name))
711                    .chain(entry_targets(directory, &manifest))
712                    .chain(tsconfig_roots(directory))
713                    .collect::<Vec<_>>();
714                // A declared package that keeps its code somewhere
715                // unconventional (a Shopify theme extension's `frontend/` and
716                // `blocks/`, say) is still first-party source. Its own
717                // directory becomes the root; generated subtrees stay excluded
718                // by the walker as everywhere else.
719                if directory != &root
720                    && !candidates
721                        .iter()
722                        .any(|candidate| fs::symlink_metadata(candidate).is_ok())
723                {
724                    return vec![directory.clone()];
725                }
726                candidates
727            })
728            .collect()
729    };
730    let mut existing_roots = BTreeSet::new();
731    for path in include_roots {
732        match fs::symlink_metadata(&path) {
733            Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_dir() => {
734                existing_roots.insert(path);
735            }
736            Ok(_) => return Err(SourceDiscoveryError::InvalidRoot(path)),
737            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
738            Err(error) => return Err(io_error(&path, error)),
739        }
740    }
741    let existing_roots = existing_roots.into_iter().collect::<Vec<_>>();
742    let mut all_files = Vec::new();
743    files_under(&root, &root, &mut all_files)?;
744    all_files.sort();
745
746    let mut entries = Vec::new();
747    let mut included = Vec::new();
748    let mut limitations = Vec::new();
749    for path in all_files {
750        let file = local_path(&root, &path)?;
751        let package_root = nearest_package_root(&path, &packages)
752            .map(|package| local_path(&root, package))
753            .transpose()?
754            .filter(|package| package != ".");
755        let entry = |status, reason: &str| SourceScopeEntry {
756            file: file.clone(),
757            status,
758            reason: reason.into(),
759            package_root: package_root.clone(),
760        };
761        if declaration_file(&file) {
762            entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
763        } else if test_or_fixture(&file) {
764            entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
765        } else if tool_script(&file) {
766            entries.push(entry(
767                SourceScopeStatus::Excluded,
768                "conventional tool script",
769            ));
770        } else if config_file(&file) {
771            entries.push(entry(
772                SourceScopeStatus::Excluded,
773                "build/test/tool configuration",
774            ));
775        } else if built_asset(&file) {
776            entries.push(entry(SourceScopeStatus::Excluded, BUILT_ASSET_REASON));
777        } else if existing_roots.iter().any(|directory| {
778            fs::symlink_metadata(directory)
779                .map(|metadata| {
780                    if metadata.file_type().is_dir() {
781                        within(directory, &path)
782                    } else {
783                        directory == &path
784                    }
785                })
786                .unwrap_or(false)
787        }) {
788            included.push(path);
789            entries.push(entry(
790                SourceScopeStatus::Included,
791                if explicit {
792                    "explicit source root"
793                } else {
794                    "discovered package source root"
795                },
796            ));
797        } else if explicit {
798            entries.push(entry(
799                SourceScopeStatus::Excluded,
800                "outside explicit source roots",
801            ));
802        } else {
803            entries.push(entry(
804                SourceScopeStatus::Ambiguous,
805                "unclassified first-party source",
806            ));
807            limitations.push(scope_limitation(&file));
808        }
809    }
810    let source_files = included
811        .iter()
812        .map(|path| local_path(&root, path))
813        .collect::<Result<Vec<_>, _>>()?;
814    let source_roots = existing_roots
815        .iter()
816        .map(|path| local_path(&root, path))
817        .collect::<Result<Vec<_>, _>>()?;
818    Ok(DiscoveredSourceScope {
819        source_files,
820        source_roots: source_roots.clone(),
821        scope: SourceScope {
822            version: 1,
823            mode: if explicit {
824                SourceScopeMode::Explicit
825            } else {
826                SourceScopeMode::Automatic
827            },
828            roots: source_roots,
829            entries,
830        },
831        limitations,
832    })
833}
834
835#[cfg(test)]
836mod tests {
837    use std::{
838        fs,
839        time::{SystemTime, UNIX_EPOCH},
840    };
841
842    use super::*;
843
844    fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
845        let nonce = SystemTime::now()
846            .duration_since(UNIX_EPOCH)
847            .unwrap()
848            .as_nanos();
849        let root = std::env::temp_dir().join(format!(
850            "supercov-source-{label}-{}-{nonce}",
851            std::process::id()
852        ));
853        fs::create_dir_all(&root).unwrap();
854        for (file, contents) in files {
855            let path = root.join(file);
856            fs::create_dir_all(path.parent().unwrap()).unwrap();
857            fs::write(path, contents).unwrap();
858        }
859        root
860    }
861
862    fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
863        scope
864            .scope
865            .entries
866            .iter()
867            .find(|entry| entry.file == file)
868            .unwrap()
869    }
870
871    #[test]
872    fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
873        let root = repository(
874            "automatic",
875            &[
876                ("package.json", r#"{"workspaces":["packages/*"]}"#),
877                ("src/index.ts", "export const root = true"),
878                ("lib/helper.js", "export const helper = true"),
879                ("src/index.test.ts", "test('root', () => {})"),
880                ("tests/e2e.spec.ts", "test('e2e', () => {})"),
881                ("scripts/release.mjs", "export const release = true"),
882                ("vite.config.ts", "export default {}"),
883                ("build.mjs", "export default async function build() {}"),
884                (".eslintrc.cjs", "module.exports = {}"),
885                (".graphqlrc.ts", "export default {}"),
886                ("orphan.ts", "export const missed = true"),
887                ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
888                ("packages/ui/src/index.ts", "export const ui = true"),
889                ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
890                ("dist/generated.js", "generated"),
891                (".cache/tool/generated.js", "cached"),
892            ],
893        );
894        let discovered = discover_source_scope(&root, None).unwrap();
895        assert_eq!(
896            discovered.source_files,
897            ["lib/helper.js", "packages/ui/src/index.ts", "src/index.ts"]
898        );
899        assert_eq!(
900            entry(&discovered, "orphan.ts").status,
901            SourceScopeStatus::Ambiguous
902        );
903        assert_eq!(
904            entry(&discovered, "scripts/release.mjs").reason,
905            "conventional tool script"
906        );
907        assert_eq!(
908            entry(&discovered, "build.mjs").reason,
909            "build/test/tool configuration"
910        );
911        assert_eq!(
912            entry(&discovered, "packages/ui/src/index.ts").package_root,
913            Some("packages/ui".into())
914        );
915        assert_eq!(discovered.limitations.len(), 1);
916        assert_eq!(discovered.limitations[0].file, "orphan.ts");
917        assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
918        assert!(
919            discovered
920                .scope
921                .entries
922                .iter()
923                .all(|entry| !entry.file.contains(".cache"))
924        );
925        fs::remove_dir_all(root).unwrap();
926    }
927
928    #[test]
929    fn declared_workspaces_outside_conventional_parents_are_package_roots() {
930        let root = repository(
931            "declared-workspaces",
932            &[
933                ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
934                ("app/main.ts", "product"),
935                ("app_extensions/discounts/package.json", "{}"),
936                ("app_extensions/discounts/src/index.ts", "extension"),
937                // No conventional source directory at all: the package itself
938                // is the root, so its frontend code is still first-party.
939                ("app_extensions/upsells/package.json", "{}"),
940                ("app_extensions/upsells/frontend/embed.ts", "embed"),
941                ("app_extensions/upsells/dist/embed.js", "built"),
942            ],
943        );
944        let discovered = discover_source_scope(&root, None).unwrap();
945        assert_eq!(
946            discovered.source_files,
947            [
948                "app/main.ts",
949                "app_extensions/discounts/src/index.ts",
950                "app_extensions/upsells/frontend/embed.ts",
951            ]
952        );
953        assert!(
954            discovered.limitations.is_empty(),
955            "declared packages must not be blockers: {:?}",
956            discovered.limitations
957        );
958        fs::remove_dir_all(root).unwrap();
959    }
960
961    #[test]
962    fn hashed_bundles_in_asset_directories_are_built_assets() {
963        let root = repository(
964            "built-assets",
965            &[
966                ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
967                ("app/main.ts", "product"),
968                ("app_extensions/upsells/package.json", "{}"),
969                ("app_extensions/upsells/frontend/embed.ts", "source"),
970                (
971                    "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
972                    "bundle",
973                ),
974                ("app_extensions/upsells/assets/stylex-DAnmLURx.js", "bundle"),
975                // A hand-written helper in assets keeps its ordinary treatment.
976                (
977                    "app_extensions/upsells/assets/theme-helper.js",
978                    "hand written",
979                ),
980            ],
981        );
982        let discovered = discover_source_scope(&root, None).unwrap();
983        assert!(
984            !discovered
985                .source_files
986                .iter()
987                .any(|file| file.contains("-Be-aUw9g.js") || file.contains("-DAnmLURx.js"))
988        );
989        assert_eq!(
990            entry(
991                &discovered,
992                "app_extensions/upsells/assets/app-embed-Be-aUw9g.js"
993            )
994            .reason,
995            "built asset"
996        );
997        assert!(
998            discovered.limitations.is_empty(),
999            "bundles are not blockers: {:?}",
1000            discovered.limitations
1001        );
1002        fs::remove_dir_all(root).unwrap();
1003    }
1004
1005    #[test]
1006    fn root_level_hidden_directories_are_tooling_not_source() {
1007        let root = repository(
1008            "root-hidden",
1009            &[
1010                ("package.json", "{}"),
1011                ("app/main.ts", "product"),
1012                (".shopify/bundle/upsells/frontend/embed.js", "cli bundle"),
1013                (".vercel/output/functions/index.js", "deploy output"),
1014                // A nested hidden directory inside a source root keeps its
1015                // ordinary treatment.
1016                ("app/.generated/schema.ts", "generated types"),
1017            ],
1018        );
1019        let discovered = discover_source_scope(&root, None).unwrap();
1020        assert_eq!(
1021            discovered.source_files,
1022            ["app/.generated/schema.ts", "app/main.ts"]
1023        );
1024        assert!(
1025            discovered.limitations.is_empty(),
1026            "{:?}",
1027            discovered.limitations
1028        );
1029        assert!(discovered.scope.entries.iter().all(
1030            |entry| !entry.file.starts_with(".shopify/") && !entry.file.starts_with(".vercel/")
1031        ));
1032        fs::remove_dir_all(root).unwrap();
1033    }
1034
1035    #[test]
1036    fn nested_checkouts_are_neither_source_nor_limitations() {
1037        let root = repository(
1038            "nested-checkout",
1039            &[
1040                ("package.json", "{}"),
1041                ("app/main.ts", "product"),
1042                // An agent worktree: a full copy of the project carrying its own
1043                // `.git` file. Real project, 1,032 of these turned into blockers.
1044                (".claude/worktrees/agent-1/.git", "gitdir: /elsewhere"),
1045                (".claude/worktrees/agent-1/app/main.ts", "copy"),
1046                ("vendor-fork/.git/HEAD", "ref: refs/heads/main"),
1047                ("vendor-fork/src/index.ts", "clone"),
1048            ],
1049        );
1050        let discovered = discover_source_scope(&root, None).unwrap();
1051        assert_eq!(discovered.source_files, ["app/main.ts"]);
1052        assert!(
1053            discovered.limitations.is_empty(),
1054            "nested checkouts must not be blocking limitations: {:?}",
1055            discovered.limitations
1056        );
1057        assert!(
1058            discovered
1059                .scope
1060                .entries
1061                .iter()
1062                .all(|entry| !entry.file.starts_with(".claude/")
1063                    && !entry.file.starts_with("vendor-fork/")),
1064            "nested checkout files must not appear in scope at all"
1065        );
1066        fs::remove_dir_all(root).unwrap();
1067    }
1068
1069    #[test]
1070    fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
1071        let root = repository(
1072            "explicit",
1073            &[
1074                ("package.json", "{}"),
1075                ("product/main.ts", "product"),
1076                ("orphan.ts", "outside"),
1077            ],
1078        );
1079        let roots = vec!["product".into()];
1080        let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1081        assert_eq!(discovered.source_files, ["product/main.ts"]);
1082        assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
1083        assert_eq!(
1084            entry(&discovered, "orphan.ts").reason,
1085            "outside explicit source roots"
1086        );
1087        assert!(discovered.limitations.is_empty());
1088        fs::remove_dir_all(root).unwrap();
1089    }
1090
1091    #[test]
1092    fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
1093        let root = repository(
1094            "jsonc",
1095            &[
1096                ("package.json", r#"{"main":"./dist/index.js"}"#),
1097                (
1098                    "tsconfig.json",
1099                    "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
1100                ),
1101                ("events.ts", "event"),
1102                ("žalias.ts", "unicode"),
1103                ("library.test.ts", "test"),
1104            ],
1105        );
1106        let discovered = discover_source_scope(&root, None).unwrap();
1107        assert!(discovered.source_roots.contains(&".".into()));
1108        assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
1109        assert!(discovered.limitations.is_empty());
1110        fs::remove_dir_all(root).unwrap();
1111    }
1112
1113    #[cfg(unix)]
1114    #[test]
1115    fn never_follows_source_directory_or_explicit_root_symlinks() {
1116        use std::os::unix::fs::symlink;
1117
1118        let root = repository(
1119            "symlink",
1120            &[("package.json", "{}"), ("src/real.ts", "real")],
1121        );
1122        let outside = repository("outside", &[("secret.ts", "secret")]);
1123        symlink(&outside, root.join("linked")).unwrap();
1124        symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
1125        let discovered = discover_source_scope(&root, None).unwrap();
1126        assert_eq!(discovered.source_files, ["src/real.ts"]);
1127        let explicit = vec!["linked".into()];
1128        assert!(matches!(
1129            discover_source_scope(&root, Some(&explicit)),
1130            Err(SourceDiscoveryError::InvalidRoot(_))
1131        ));
1132        fs::remove_dir_all(root).unwrap();
1133        fs::remove_dir_all(outside).unwrap();
1134    }
1135
1136    #[test]
1137    fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
1138        let root = repository(
1139            "workspace-store",
1140            &[
1141                ("package.json", "{}"),
1142                ("src/main.ts", "main"),
1143                ("supercov/user.ts", "user code"),
1144            ],
1145        );
1146        let before = discover_source_scope(&root, None).unwrap();
1147        assert_eq!(
1148            entry(&before, "supercov/user.ts").status,
1149            SourceScopeStatus::Ambiguous
1150        );
1151        fs::write(
1152            root.join("supercov/.supercov-workspace-store"),
1153            b"Supercov instrumented workspace. Safe to delete.\n",
1154        )
1155        .unwrap();
1156        let after = discover_source_scope(&root, None).unwrap();
1157        assert!(
1158            after
1159                .scope
1160                .entries
1161                .iter()
1162                .all(|entry| !entry.file.starts_with("supercov/"))
1163        );
1164        fs::remove_dir_all(root).unwrap();
1165    }
1166}