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
686// A declared Jest setup file is test infrastructure even when it lives under
687// a source root with an arbitrary name. Instrumenting its mock factories adds
688// out-of-scope bindings that Babel's jest-hoist correctly rejects.
689fn declared_test_setup(packages: &[PathBuf]) -> BTreeSet<PathBuf> {
690    let mut paths = BTreeSet::new();
691    for package in packages {
692        let manifest = read_json(&package.join("package.json")).unwrap_or(Value::Null);
693        let Some(jest) = manifest.get("jest") else {
694            continue;
695        };
696        let root = jest
697            .get("rootDir")
698            .and_then(Value::as_str)
699            .map(|value| {
700                resolve(
701                    package,
702                    value.replace("<rootDir>", &package.to_string_lossy()),
703                )
704            })
705            .unwrap_or_else(|| package.clone());
706        for field in [
707            "setupFiles",
708            "setupFilesAfterEnv",
709            "globalSetup",
710            "globalTeardown",
711        ] {
712            let values = match jest.get(field) {
713                Some(Value::Array(values)) => values.iter().collect::<Vec<_>>(),
714                Some(value) => vec![value],
715                None => Vec::new(),
716            };
717            for value in values.into_iter().filter_map(Value::as_str) {
718                let path = resolve(&root, value.replace("<rootDir>", &root.to_string_lossy()));
719                if path.is_file() {
720                    paths.insert(path);
721                }
722            }
723        }
724    }
725    paths
726}
727
728pub fn discover_source_scope(
729    root: &Path,
730    configured_roots: Option<&[String]>,
731) -> Result<DiscoveredSourceScope, SourceDiscoveryError> {
732    let root = lexical_normalize(root);
733    let root_metadata = fs::symlink_metadata(&root).map_err(|error| io_error(&root, error))?;
734    if !root_metadata.file_type().is_dir() {
735        return Err(SourceDiscoveryError::InvalidRoot(root));
736    }
737    let packages = package_directories(&root)?;
738    let test_setup = declared_test_setup(&packages);
739    let explicit = configured_roots.is_some_and(|roots| !roots.is_empty());
740    let include_roots = if explicit {
741        configured_roots
742            .unwrap_or_default()
743            .iter()
744            .map(|directory| resolve(&root, directory))
745            .collect::<Vec<_>>()
746    } else {
747        packages
748            .iter()
749            .flat_map(|directory| {
750                let manifest = read_json(&directory.join("package.json")).unwrap_or(Value::Null);
751                let candidates = SOURCE_DIRECTORIES
752                    .iter()
753                    .map(|name| directory.join(name))
754                    .chain(entry_targets(directory, &manifest))
755                    .chain(tsconfig_roots(directory))
756                    .collect::<Vec<_>>();
757                // A declared package that keeps its code somewhere
758                // unconventional (a Shopify theme extension's `frontend/` and
759                // `blocks/`, say) is still first-party source. Its own
760                // directory becomes the root; generated subtrees stay excluded
761                // by the walker as everywhere else.
762                if directory != &root
763                    && !candidates
764                        .iter()
765                        .any(|candidate| fs::symlink_metadata(candidate).is_ok())
766                {
767                    return vec![directory.clone()];
768                }
769                candidates
770            })
771            .collect()
772    };
773    let mut existing_roots = BTreeSet::new();
774    for path in include_roots {
775        match fs::symlink_metadata(&path) {
776            Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_dir() => {
777                existing_roots.insert(path);
778            }
779            Ok(_) => return Err(SourceDiscoveryError::InvalidRoot(path)),
780            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
781            Err(error) => return Err(io_error(&path, error)),
782        }
783    }
784    let existing_roots = existing_roots.into_iter().collect::<Vec<_>>();
785    let mut all_files = Vec::new();
786    files_under(&root, &root, &mut all_files)?;
787    all_files.sort();
788
789    let mut entries = Vec::new();
790    let mut included = Vec::new();
791    let mut limitations = Vec::new();
792    for path in all_files {
793        let file = local_path(&root, &path)?;
794        let package_root = nearest_package_root(&path, &packages)
795            .map(|package| local_path(&root, package))
796            .transpose()?
797            .filter(|package| package != ".");
798        let entry = |status, reason: &str| SourceScopeEntry {
799            file: file.clone(),
800            status,
801            reason: reason.into(),
802            package_root: package_root.clone(),
803        };
804        if declaration_file(&file) {
805            entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
806        } else if test_setup.contains(&path) {
807            entries.push(entry(SourceScopeStatus::Excluded, "declared test setup"));
808        } else if test_or_fixture(&file) {
809            entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
810        } else if tool_script(&file) {
811            entries.push(entry(
812                SourceScopeStatus::Excluded,
813                "conventional tool script",
814            ));
815        } else if config_file(&file) {
816            entries.push(entry(
817                SourceScopeStatus::Excluded,
818                "build/test/tool configuration",
819            ));
820        } else if built_asset(&file) {
821            entries.push(entry(SourceScopeStatus::Excluded, BUILT_ASSET_REASON));
822        } else if existing_roots.iter().any(|directory| {
823            fs::symlink_metadata(directory)
824                .map(|metadata| {
825                    if metadata.file_type().is_dir() {
826                        within(directory, &path)
827                    } else {
828                        directory == &path
829                    }
830                })
831                .unwrap_or(false)
832        }) {
833            included.push(path);
834            entries.push(entry(
835                SourceScopeStatus::Included,
836                if explicit {
837                    "explicit source root"
838                } else {
839                    "discovered package source root"
840                },
841            ));
842        } else if explicit {
843            entries.push(entry(
844                SourceScopeStatus::Excluded,
845                "outside explicit source roots",
846            ));
847        } else {
848            entries.push(entry(
849                SourceScopeStatus::Ambiguous,
850                "unclassified first-party source",
851            ));
852            limitations.push(scope_limitation(&file));
853        }
854    }
855    let source_files = included
856        .iter()
857        .map(|path| local_path(&root, path))
858        .collect::<Result<Vec<_>, _>>()?;
859    let source_roots = existing_roots
860        .iter()
861        .map(|path| local_path(&root, path))
862        .collect::<Result<Vec<_>, _>>()?;
863    Ok(DiscoveredSourceScope {
864        source_files,
865        source_roots: source_roots.clone(),
866        scope: SourceScope {
867            version: 1,
868            mode: if explicit {
869                SourceScopeMode::Explicit
870            } else {
871                SourceScopeMode::Automatic
872            },
873            roots: source_roots,
874            entries,
875        },
876        limitations,
877    })
878}
879
880#[cfg(test)]
881mod tests {
882    use std::{
883        fs,
884        time::{SystemTime, UNIX_EPOCH},
885    };
886
887    use super::*;
888
889    fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
890        let nonce = SystemTime::now()
891            .duration_since(UNIX_EPOCH)
892            .unwrap()
893            .as_nanos();
894        let root = std::env::temp_dir().join(format!(
895            "supercov-source-{label}-{}-{nonce}",
896            std::process::id()
897        ));
898        fs::create_dir_all(&root).unwrap();
899        for (file, contents) in files {
900            let path = root.join(file);
901            fs::create_dir_all(path.parent().unwrap()).unwrap();
902            fs::write(path, contents).unwrap();
903        }
904        root
905    }
906
907    fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
908        scope
909            .scope
910            .entries
911            .iter()
912            .find(|entry| entry.file == file)
913            .unwrap()
914    }
915
916    #[test]
917    fn excludes_declared_jest_setup_without_hiding_neighboring_application_code() {
918        let root = repository(
919            "jest-setup",
920            &[
921                (
922                    "package.json",
923                    r#"{"jest":{"setupFilesAfterEnv":["<rootDir>/src/bootstrap.js"]}}"#,
924                ),
925                (
926                    "src/bootstrap.js",
927                    "jest.mock('storage', () => ({read: () => null}));",
928                ),
929                ("src/index.js", "export const value = 1;"),
930            ],
931        );
932        let discovered = discover_source_scope(&root, None).unwrap();
933        assert_eq!(discovered.source_files, ["src/index.js"]);
934        assert_eq!(
935            entry(&discovered, "src/bootstrap.js").reason,
936            "declared test setup"
937        );
938        fs::remove_dir_all(root).unwrap();
939    }
940
941    #[test]
942    fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
943        let root = repository(
944            "automatic",
945            &[
946                ("package.json", r#"{"workspaces":["packages/*"]}"#),
947                ("src/index.ts", "export const root = true"),
948                ("lib/helper.js", "export const helper = true"),
949                ("src/index.test.ts", "test('root', () => {})"),
950                ("tests/e2e.spec.ts", "test('e2e', () => {})"),
951                ("scripts/release.mjs", "export const release = true"),
952                ("vite.config.ts", "export default {}"),
953                ("build.mjs", "export default async function build() {}"),
954                (".eslintrc.cjs", "module.exports = {}"),
955                (".graphqlrc.ts", "export default {}"),
956                ("orphan.ts", "export const missed = true"),
957                ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
958                ("packages/ui/src/index.ts", "export const ui = true"),
959                ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
960                ("dist/generated.js", "generated"),
961                (".cache/tool/generated.js", "cached"),
962            ],
963        );
964        let discovered = discover_source_scope(&root, None).unwrap();
965        assert_eq!(
966            discovered.source_files,
967            ["lib/helper.js", "packages/ui/src/index.ts", "src/index.ts"]
968        );
969        assert_eq!(
970            entry(&discovered, "orphan.ts").status,
971            SourceScopeStatus::Ambiguous
972        );
973        assert_eq!(
974            entry(&discovered, "scripts/release.mjs").reason,
975            "conventional tool script"
976        );
977        assert_eq!(
978            entry(&discovered, "build.mjs").reason,
979            "build/test/tool configuration"
980        );
981        assert_eq!(
982            entry(&discovered, "packages/ui/src/index.ts").package_root,
983            Some("packages/ui".into())
984        );
985        assert_eq!(discovered.limitations.len(), 1);
986        assert_eq!(discovered.limitations[0].file, "orphan.ts");
987        assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
988        assert!(
989            discovered
990                .scope
991                .entries
992                .iter()
993                .all(|entry| !entry.file.contains(".cache"))
994        );
995        fs::remove_dir_all(root).unwrap();
996    }
997
998    #[test]
999    fn declared_workspaces_outside_conventional_parents_are_package_roots() {
1000        let root = repository(
1001            "declared-workspaces",
1002            &[
1003                ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1004                ("app/main.ts", "product"),
1005                ("app_extensions/discounts/package.json", "{}"),
1006                ("app_extensions/discounts/src/index.ts", "extension"),
1007                // No conventional source directory at all: the package itself
1008                // is the root, so its frontend code is still first-party.
1009                ("app_extensions/upsells/package.json", "{}"),
1010                ("app_extensions/upsells/frontend/embed.ts", "embed"),
1011                ("app_extensions/upsells/dist/embed.js", "built"),
1012            ],
1013        );
1014        let discovered = discover_source_scope(&root, None).unwrap();
1015        assert_eq!(
1016            discovered.source_files,
1017            [
1018                "app/main.ts",
1019                "app_extensions/discounts/src/index.ts",
1020                "app_extensions/upsells/frontend/embed.ts",
1021            ]
1022        );
1023        assert!(
1024            discovered.limitations.is_empty(),
1025            "declared packages must not be blockers: {:?}",
1026            discovered.limitations
1027        );
1028        fs::remove_dir_all(root).unwrap();
1029    }
1030
1031    #[test]
1032    fn hashed_bundles_in_asset_directories_are_built_assets() {
1033        let root = repository(
1034            "built-assets",
1035            &[
1036                ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1037                ("app/main.ts", "product"),
1038                ("app_extensions/upsells/package.json", "{}"),
1039                ("app_extensions/upsells/frontend/embed.ts", "source"),
1040                (
1041                    "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1042                    "bundle",
1043                ),
1044                ("app_extensions/upsells/assets/stylex-DAnmLURx.js", "bundle"),
1045                // A hand-written helper in assets keeps its ordinary treatment.
1046                (
1047                    "app_extensions/upsells/assets/theme-helper.js",
1048                    "hand written",
1049                ),
1050            ],
1051        );
1052        let discovered = discover_source_scope(&root, None).unwrap();
1053        assert!(
1054            !discovered
1055                .source_files
1056                .iter()
1057                .any(|file| file.contains("-Be-aUw9g.js") || file.contains("-DAnmLURx.js"))
1058        );
1059        assert_eq!(
1060            entry(
1061                &discovered,
1062                "app_extensions/upsells/assets/app-embed-Be-aUw9g.js"
1063            )
1064            .reason,
1065            "built asset"
1066        );
1067        assert!(
1068            discovered.limitations.is_empty(),
1069            "bundles are not blockers: {:?}",
1070            discovered.limitations
1071        );
1072        fs::remove_dir_all(root).unwrap();
1073    }
1074
1075    #[test]
1076    fn root_level_hidden_directories_are_tooling_not_source() {
1077        let root = repository(
1078            "root-hidden",
1079            &[
1080                ("package.json", "{}"),
1081                ("app/main.ts", "product"),
1082                (".shopify/bundle/upsells/frontend/embed.js", "cli bundle"),
1083                (".vercel/output/functions/index.js", "deploy output"),
1084                // A nested hidden directory inside a source root keeps its
1085                // ordinary treatment.
1086                ("app/.generated/schema.ts", "generated types"),
1087            ],
1088        );
1089        let discovered = discover_source_scope(&root, None).unwrap();
1090        assert_eq!(
1091            discovered.source_files,
1092            ["app/.generated/schema.ts", "app/main.ts"]
1093        );
1094        assert!(
1095            discovered.limitations.is_empty(),
1096            "{:?}",
1097            discovered.limitations
1098        );
1099        assert!(discovered.scope.entries.iter().all(
1100            |entry| !entry.file.starts_with(".shopify/") && !entry.file.starts_with(".vercel/")
1101        ));
1102        fs::remove_dir_all(root).unwrap();
1103    }
1104
1105    #[test]
1106    fn nested_checkouts_are_neither_source_nor_limitations() {
1107        let root = repository(
1108            "nested-checkout",
1109            &[
1110                ("package.json", "{}"),
1111                ("app/main.ts", "product"),
1112                // An agent worktree: a full copy of the project carrying its own
1113                // `.git` file. Real project, 1,032 of these turned into blockers.
1114                (".claude/worktrees/agent-1/.git", "gitdir: /elsewhere"),
1115                (".claude/worktrees/agent-1/app/main.ts", "copy"),
1116                ("vendor-fork/.git/HEAD", "ref: refs/heads/main"),
1117                ("vendor-fork/src/index.ts", "clone"),
1118            ],
1119        );
1120        let discovered = discover_source_scope(&root, None).unwrap();
1121        assert_eq!(discovered.source_files, ["app/main.ts"]);
1122        assert!(
1123            discovered.limitations.is_empty(),
1124            "nested checkouts must not be blocking limitations: {:?}",
1125            discovered.limitations
1126        );
1127        assert!(
1128            discovered
1129                .scope
1130                .entries
1131                .iter()
1132                .all(|entry| !entry.file.starts_with(".claude/")
1133                    && !entry.file.starts_with("vendor-fork/")),
1134            "nested checkout files must not appear in scope at all"
1135        );
1136        fs::remove_dir_all(root).unwrap();
1137    }
1138
1139    #[test]
1140    fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
1141        let root = repository(
1142            "explicit",
1143            &[
1144                ("package.json", "{}"),
1145                ("product/main.ts", "product"),
1146                ("orphan.ts", "outside"),
1147            ],
1148        );
1149        let roots = vec!["product".into()];
1150        let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1151        assert_eq!(discovered.source_files, ["product/main.ts"]);
1152        assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
1153        assert_eq!(
1154            entry(&discovered, "orphan.ts").reason,
1155            "outside explicit source roots"
1156        );
1157        assert!(discovered.limitations.is_empty());
1158        fs::remove_dir_all(root).unwrap();
1159    }
1160
1161    #[test]
1162    fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
1163        let root = repository(
1164            "jsonc",
1165            &[
1166                ("package.json", r#"{"main":"./dist/index.js"}"#),
1167                (
1168                    "tsconfig.json",
1169                    "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
1170                ),
1171                ("events.ts", "event"),
1172                ("žalias.ts", "unicode"),
1173                ("library.test.ts", "test"),
1174            ],
1175        );
1176        let discovered = discover_source_scope(&root, None).unwrap();
1177        assert!(discovered.source_roots.contains(&".".into()));
1178        assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
1179        assert!(discovered.limitations.is_empty());
1180        fs::remove_dir_all(root).unwrap();
1181    }
1182
1183    #[cfg(unix)]
1184    #[test]
1185    fn never_follows_source_directory_or_explicit_root_symlinks() {
1186        use std::os::unix::fs::symlink;
1187
1188        let root = repository(
1189            "symlink",
1190            &[("package.json", "{}"), ("src/real.ts", "real")],
1191        );
1192        let outside = repository("outside", &[("secret.ts", "secret")]);
1193        symlink(&outside, root.join("linked")).unwrap();
1194        symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
1195        let discovered = discover_source_scope(&root, None).unwrap();
1196        assert_eq!(discovered.source_files, ["src/real.ts"]);
1197        let explicit = vec!["linked".into()];
1198        assert!(matches!(
1199            discover_source_scope(&root, Some(&explicit)),
1200            Err(SourceDiscoveryError::InvalidRoot(_))
1201        ));
1202        fs::remove_dir_all(root).unwrap();
1203        fs::remove_dir_all(outside).unwrap();
1204    }
1205
1206    #[test]
1207    fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
1208        let root = repository(
1209            "workspace-store",
1210            &[
1211                ("package.json", "{}"),
1212                ("src/main.ts", "main"),
1213                ("supercov/user.ts", "user code"),
1214            ],
1215        );
1216        let before = discover_source_scope(&root, None).unwrap();
1217        assert_eq!(
1218            entry(&before, "supercov/user.ts").status,
1219            SourceScopeStatus::Ambiguous
1220        );
1221        fs::write(
1222            root.join("supercov/.supercov-workspace-store"),
1223            b"Supercov instrumented workspace. Safe to delete.\n",
1224        )
1225        .unwrap();
1226        let after = discover_source_scope(&root, None).unwrap();
1227        assert!(
1228            after
1229                .scope
1230                .entries
1231                .iter()
1232                .all(|entry| !entry.file.starts_with("supercov/"))
1233        );
1234        fs::remove_dir_all(root).unwrap();
1235    }
1236}