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    ".git",
19    ".mcdc-pool",
20    ".next",
21    ".nuxt",
22    ".output",
23    ".supercov",
24    "build",
25    "coverage",
26    "dist",
27    "node_modules",
28    "out",
29    "playwright-report",
30    "results",
31    "test-results",
32    "vendor",
33];
34const SOURCE_DIRECTORIES: &[&str] = &["app", "src", "lib", "server", "client", "functions", "api"];
35const PACKAGE_PARENTS: &[&str] = &["apps", "packages", "services", "workspaces"];
36const TEST_DIRECTORIES: &[&str] = &[
37    "__tests__",
38    "test",
39    "tests",
40    "spec",
41    "specs",
42    "e2e",
43    "fixture",
44    "fixtures",
45    "mock",
46    "mocks",
47    "__mocks__",
48];
49const CONFIG_TOOLS: &[&str] = &[
50    "babel",
51    "eslint",
52    "graphql",
53    "jest",
54    "next",
55    "nuxt",
56    "playwright",
57    "postcss",
58    "prettier",
59    "remix",
60    "rollup",
61    "stylelint",
62    "tailwind",
63    "tsup",
64    "vite",
65    "vitest",
66    "webpack",
67];
68const DOT_CONFIG_TOOLS: &[&str] = &["babel", "eslint", "graphql", "prettier", "stylelint"];
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum SourceScopeStatus {
73    Included,
74    Excluded,
75    Ambiguous,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase", deny_unknown_fields)]
80pub struct SourceScopeEntry {
81    pub file: String,
82    pub status: SourceScopeStatus,
83    pub reason: String,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub package_root: Option<String>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum SourceScopeMode {
91    Automatic,
92    Explicit,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase", deny_unknown_fields)]
97pub struct SourceScope {
98    pub version: u32,
99    pub mode: SourceScopeMode,
100    pub roots: Vec<String>,
101    pub entries: Vec<SourceScopeEntry>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase", deny_unknown_fields)]
106pub struct SourceLimitation {
107    pub id: String,
108    pub kind: String,
109    pub file: String,
110    pub line: usize,
111    pub column: usize,
112    pub source: String,
113    pub reason: String,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase", deny_unknown_fields)]
118pub struct DiscoveredSourceScope {
119    pub source_files: Vec<String>,
120    pub source_roots: Vec<String>,
121    pub scope: SourceScope,
122    pub limitations: Vec<SourceLimitation>,
123}
124
125#[derive(Debug)]
126pub enum SourceDiscoveryError {
127    Io { path: PathBuf, source: io::Error },
128    NonUtf8Path(PathBuf),
129    InvalidRoot(PathBuf),
130}
131
132impl std::fmt::Display for SourceDiscoveryError {
133    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
136            Self::NonUtf8Path(path) => {
137                write!(
138                    formatter,
139                    "source path is not valid UTF-8: {}",
140                    path.display()
141                )
142            }
143            Self::InvalidRoot(path) => write!(
144                formatter,
145                "source root is not a regular file or directory: {}",
146                path.display()
147            ),
148        }
149    }
150}
151
152impl std::error::Error for SourceDiscoveryError {}
153
154fn io_error(path: &Path, source: io::Error) -> SourceDiscoveryError {
155    SourceDiscoveryError::Io {
156        path: path.to_owned(),
157        source,
158    }
159}
160
161fn lexical_normalize(path: &Path) -> PathBuf {
162    let mut output = PathBuf::new();
163    for component in path.components() {
164        match component {
165            Component::CurDir => {}
166            Component::ParentDir => {
167                if !output.pop() {
168                    output.push(component.as_os_str());
169                }
170            }
171            _ => output.push(component.as_os_str()),
172        }
173    }
174    output
175}
176
177fn resolve(root: &Path, value: impl AsRef<Path>) -> PathBuf {
178    let value = value.as_ref();
179    let joined;
180    let path = if value.is_absolute() {
181        value
182    } else {
183        joined = root.join(value);
184        &joined
185    };
186    lexical_normalize(path)
187}
188
189fn local_path(root: &Path, path: &Path) -> Result<String, SourceDiscoveryError> {
190    let local = path
191        .strip_prefix(root)
192        .map_err(|_| SourceDiscoveryError::InvalidRoot(path.to_owned()))?;
193    if local.as_os_str().is_empty() {
194        return Ok(".".into());
195    }
196    local
197        .components()
198        .map(|component| {
199            component
200                .as_os_str()
201                .to_str()
202                .map(str::to_owned)
203                .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(path.to_owned()))
204        })
205        .collect::<Result<Vec<_>, _>>()
206        .map(|parts| parts.join("/"))
207}
208
209fn generated_directory(name: &str) -> bool {
210    GENERATED_DIRECTORIES.contains(&name)
211}
212
213fn owned_workspace_store(path: &Path) -> bool {
214    path.file_name().is_some_and(|name| name == "supercov")
215        && path.join(".supercov-workspace-store").is_file()
216}
217
218fn source_file(name: &str) -> bool {
219    let lower = name.to_ascii_lowercase();
220    [
221        ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
222        ".mtsx",
223    ]
224    .iter()
225    .any(|extension| lower.ends_with(extension))
226}
227
228fn declaration_file(file: &str) -> bool {
229    let lower = file.to_ascii_lowercase();
230    lower.ends_with(".d.ts") || lower.ends_with(".d.cts") || lower.ends_with(".d.mts")
231}
232
233fn test_or_fixture(file: &str) -> bool {
234    let lower = file.to_ascii_lowercase();
235    if lower
236        .split('/')
237        .any(|segment| TEST_DIRECTORIES.contains(&segment))
238    {
239        return true;
240    }
241    lower
242        .split(['/', '_', '.', '-'])
243        .any(|part| matches!(part, "test" | "spec"))
244}
245
246fn tool_script(file: &str) -> bool {
247    file.to_ascii_lowercase()
248        .split('/')
249        .any(|segment| segment == "scripts")
250}
251
252fn config_file(file: &str) -> bool {
253    let lower = file.to_ascii_lowercase();
254    let name = lower.rsplit('/').next().unwrap_or(&lower);
255    source_file(name)
256        && (CONFIG_TOOLS
257            .iter()
258            .any(|tool| name.starts_with(&format!("{tool}.config.")))
259            || DOT_CONFIG_TOOLS
260                .iter()
261                .any(|tool| name.starts_with(&format!(".{tool}rc.")))
262            || (!lower.contains('/')
263                && (name.contains(".config.")
264                    || name.starts_with("build.")
265                    || name.starts_with("gulpfile.")
266                    || name.starts_with("gruntfile."))))
267}
268
269fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, SourceDiscoveryError> {
270    let mut entries = fs::read_dir(path)
271        .map_err(|error| io_error(path, error))?
272        .collect::<Result<Vec<_>, _>>()
273        .map_err(|error| io_error(path, error))?;
274    entries.sort_by_key(fs::DirEntry::file_name);
275    Ok(entries)
276}
277
278fn files_under(directory: &Path, output: &mut Vec<PathBuf>) -> Result<(), SourceDiscoveryError> {
279    let metadata = fs::symlink_metadata(directory).map_err(|error| io_error(directory, error))?;
280    if !metadata.file_type().is_dir() {
281        return Err(SourceDiscoveryError::InvalidRoot(directory.to_owned()));
282    }
283    for entry in read_directory(directory)? {
284        let name = entry
285            .file_name()
286            .into_string()
287            .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
288        let path = entry.path();
289        let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
290        if file_type.is_symlink() {
291            continue;
292        }
293        if file_type.is_dir() {
294            if !generated_directory(&name) && !owned_workspace_store(&path) {
295                files_under(&path, output)?;
296            }
297        } else if file_type.is_file() && source_file(&name) {
298            output.push(path);
299        }
300    }
301    Ok(())
302}
303
304fn read_json(path: &Path) -> Option<Value> {
305    serde_json::from_slice(&fs::read(path).ok()?).ok()
306}
307
308fn package_directories(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
309    fn visit(
310        root: &Path,
311        directory: &Path,
312        depth: usize,
313        found: &mut BTreeSet<PathBuf>,
314    ) -> Result<(), SourceDiscoveryError> {
315        if depth > 5 {
316            return Ok(());
317        }
318        for entry in read_directory(directory)? {
319            let name = entry
320                .file_name()
321                .into_string()
322                .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
323            let path = entry.path();
324            let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
325            if !file_type.is_dir()
326                || file_type.is_symlink()
327                || generated_directory(&name)
328                || owned_workspace_store(&path)
329            {
330                continue;
331            }
332            let local = local_path(root, &path)?;
333            let under_package_parent = local
334                .split('/')
335                .any(|segment| PACKAGE_PARENTS.contains(&segment));
336            if path.join("package.json").is_file() && (depth == 0 || under_package_parent) {
337                found.insert(path.clone());
338            }
339            visit(root, &path, depth + 1, found)?;
340        }
341        Ok(())
342    }
343
344    let mut found = BTreeSet::from([root.to_owned()]);
345    visit(root, root, 0, &mut found)?;
346    Ok(found.into_iter().collect())
347}
348
349fn string_targets(value: &Value, depth: usize, output: &mut Vec<String>) {
350    if depth > 8 {
351        return;
352    }
353    match value {
354        Value::String(value) => output.push(value.clone()),
355        Value::Array(values) => {
356            for value in values {
357                string_targets(value, depth + 1, output);
358            }
359        }
360        Value::Object(values) => {
361            for value in values.values() {
362                string_targets(value, depth + 1, output);
363            }
364        }
365        _ => {}
366    }
367}
368
369fn entry_targets(directory: &Path, manifest: &Value) -> Vec<PathBuf> {
370    let mut targets = Vec::new();
371    for key in ["main", "module", "browser", "bin", "exports"] {
372        if let Some(value) = manifest.get(key) {
373            string_targets(value, 0, &mut targets);
374        }
375    }
376    targets
377        .into_iter()
378        .filter_map(|target| {
379            if !target.starts_with('.') || target.contains("node_modules") {
380                return None;
381            }
382            let prefix = target.split('*').next()?.trim_end_matches('/');
383            (!prefix.is_empty()).then(|| resolve(directory, prefix))
384        })
385        .collect()
386}
387
388fn strip_jsonc_comments(contents: &str) -> String {
389    let mut output = String::with_capacity(contents.len());
390    let mut chars = contents.chars().peekable();
391    let mut string = false;
392    let mut escaped = false;
393    while let Some(character) = chars.next() {
394        if string {
395            output.push(character);
396            if escaped {
397                escaped = false;
398            } else if character == '\\' {
399                escaped = true;
400            } else if character == '"' {
401                string = false;
402            }
403            continue;
404        }
405        if character == '"' {
406            string = true;
407            output.push(character);
408        } else if character == '/' && chars.peek() == Some(&'/') {
409            chars.next();
410            for comment in chars.by_ref() {
411                if comment == '\n' {
412                    output.push('\n');
413                    break;
414                }
415            }
416        } else if character == '/' && chars.peek() == Some(&'*') {
417            chars.next();
418            let mut previous = '\0';
419            for comment in chars.by_ref() {
420                if previous == '*' && comment == '/' {
421                    break;
422                }
423                previous = comment;
424            }
425        } else {
426            output.push(character);
427        }
428    }
429    output
430}
431
432fn strip_trailing_commas(contents: &str) -> String {
433    let chars = contents.chars().collect::<Vec<_>>();
434    let mut output = String::with_capacity(contents.len());
435    let mut string = false;
436    let mut escaped = false;
437    for (index, character) in chars.iter().copied().enumerate() {
438        if string {
439            output.push(character);
440            if escaped {
441                escaped = false;
442            } else if character == '\\' {
443                escaped = true;
444            } else if character == '"' {
445                string = false;
446            }
447            continue;
448        }
449        if character == '"' {
450            string = true;
451            output.push(character);
452        } else if character == ','
453            && chars[index + 1..]
454                .iter()
455                .find(|character| !character.is_whitespace())
456                .is_some_and(|character| matches!(character, '}' | ']'))
457        {
458        } else {
459            output.push(character);
460        }
461    }
462    output
463}
464
465fn tsconfig_roots(directory: &Path) -> Vec<PathBuf> {
466    let path = directory.join("tsconfig.json");
467    let Some(contents) = fs::read_to_string(path).ok() else {
468        return Vec::new();
469    };
470    let jsonc = strip_trailing_commas(&strip_jsonc_comments(&contents));
471    let Some(config) = serde_json::from_str::<Value>(&jsonc).ok() else {
472        return Vec::new();
473    };
474    let mut values = Vec::new();
475    if let Some(root_dir) = config
476        .get("compilerOptions")
477        .and_then(|options| options.get("rootDir"))
478        .and_then(Value::as_str)
479    {
480        values.push(root_dir.to_owned());
481    }
482    if let Some(include) = config.get("include").and_then(Value::as_array) {
483        values.extend(include.iter().filter_map(Value::as_str).map(str::to_owned));
484    }
485    if values.is_empty() {
486        return vec![directory.to_owned()];
487    }
488    values
489        .into_iter()
490        .filter_map(|value| {
491            if value.starts_with('!') {
492                return None;
493            }
494            let prefix = value
495                .find(['?', '*', '{', '['])
496                .map_or(value.as_str(), |index| &value[..index])
497                .trim_end_matches('/');
498            (!prefix.is_empty()).then(|| resolve(directory, prefix))
499        })
500        .collect()
501}
502
503fn within(parent: &Path, child: &Path) -> bool {
504    child == parent || child.starts_with(parent)
505}
506
507fn nearest_package_root<'a>(path: &Path, packages: &'a [PathBuf]) -> Option<&'a PathBuf> {
508    packages
509        .iter()
510        .filter(|directory| within(directory, path))
511        .max_by_key(|directory| directory.components().count())
512}
513
514fn scope_limitation(file: &str) -> SourceLimitation {
515    let digest = Sha256::digest(file.as_bytes());
516    let id = digest[..10]
517        .iter()
518        .map(|byte| format!("{byte:02x}"))
519        .collect::<String>();
520    SourceLimitation {
521        id: format!("scope:{id}"),
522        kind: "source-scope".into(),
523        file: file.into(),
524        line: 1,
525        column: 1,
526        source: file.into(),
527        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(),
528    }
529}
530
531pub fn discover_source_scope(
532    root: &Path,
533    configured_roots: Option<&[String]>,
534) -> Result<DiscoveredSourceScope, SourceDiscoveryError> {
535    let root = lexical_normalize(root);
536    let root_metadata = fs::symlink_metadata(&root).map_err(|error| io_error(&root, error))?;
537    if !root_metadata.file_type().is_dir() {
538        return Err(SourceDiscoveryError::InvalidRoot(root));
539    }
540    let packages = package_directories(&root)?;
541    let explicit = configured_roots.is_some_and(|roots| !roots.is_empty());
542    let include_roots = if explicit {
543        configured_roots
544            .unwrap_or_default()
545            .iter()
546            .map(|directory| resolve(&root, directory))
547            .collect::<Vec<_>>()
548    } else {
549        packages
550            .iter()
551            .flat_map(|directory| {
552                let manifest = read_json(&directory.join("package.json")).unwrap_or(Value::Null);
553                SOURCE_DIRECTORIES
554                    .iter()
555                    .map(|name| directory.join(name))
556                    .chain(entry_targets(directory, &manifest))
557                    .chain(tsconfig_roots(directory))
558                    .collect::<Vec<_>>()
559            })
560            .collect()
561    };
562    let mut existing_roots = BTreeSet::new();
563    for path in include_roots {
564        match fs::symlink_metadata(&path) {
565            Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_dir() => {
566                existing_roots.insert(path);
567            }
568            Ok(_) => return Err(SourceDiscoveryError::InvalidRoot(path)),
569            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
570            Err(error) => return Err(io_error(&path, error)),
571        }
572    }
573    let existing_roots = existing_roots.into_iter().collect::<Vec<_>>();
574    let mut all_files = Vec::new();
575    files_under(&root, &mut all_files)?;
576    all_files.sort();
577
578    let mut entries = Vec::new();
579    let mut included = Vec::new();
580    let mut limitations = Vec::new();
581    for path in all_files {
582        let file = local_path(&root, &path)?;
583        let package_root = nearest_package_root(&path, &packages)
584            .map(|package| local_path(&root, package))
585            .transpose()?
586            .filter(|package| package != ".");
587        let entry = |status, reason: &str| SourceScopeEntry {
588            file: file.clone(),
589            status,
590            reason: reason.into(),
591            package_root: package_root.clone(),
592        };
593        if declaration_file(&file) {
594            entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
595        } else if test_or_fixture(&file) {
596            entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
597        } else if tool_script(&file) {
598            entries.push(entry(
599                SourceScopeStatus::Excluded,
600                "conventional tool script",
601            ));
602        } else if config_file(&file) {
603            entries.push(entry(
604                SourceScopeStatus::Excluded,
605                "build/test/tool configuration",
606            ));
607        } else if existing_roots.iter().any(|directory| {
608            fs::symlink_metadata(directory)
609                .map(|metadata| {
610                    if metadata.file_type().is_dir() {
611                        within(directory, &path)
612                    } else {
613                        directory == &path
614                    }
615                })
616                .unwrap_or(false)
617        }) {
618            included.push(path);
619            entries.push(entry(
620                SourceScopeStatus::Included,
621                if explicit {
622                    "explicit source root"
623                } else {
624                    "discovered package source root"
625                },
626            ));
627        } else if explicit {
628            entries.push(entry(
629                SourceScopeStatus::Excluded,
630                "outside explicit source roots",
631            ));
632        } else {
633            entries.push(entry(
634                SourceScopeStatus::Ambiguous,
635                "unclassified first-party source",
636            ));
637            limitations.push(scope_limitation(&file));
638        }
639    }
640    let source_files = included
641        .iter()
642        .map(|path| local_path(&root, path))
643        .collect::<Result<Vec<_>, _>>()?;
644    let source_roots = existing_roots
645        .iter()
646        .map(|path| local_path(&root, path))
647        .collect::<Result<Vec<_>, _>>()?;
648    Ok(DiscoveredSourceScope {
649        source_files,
650        source_roots: source_roots.clone(),
651        scope: SourceScope {
652            version: 1,
653            mode: if explicit {
654                SourceScopeMode::Explicit
655            } else {
656                SourceScopeMode::Automatic
657            },
658            roots: source_roots,
659            entries,
660        },
661        limitations,
662    })
663}
664
665#[cfg(test)]
666mod tests {
667    use std::{
668        fs,
669        time::{SystemTime, UNIX_EPOCH},
670    };
671
672    use super::*;
673
674    fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
675        let nonce = SystemTime::now()
676            .duration_since(UNIX_EPOCH)
677            .unwrap()
678            .as_nanos();
679        let root = std::env::temp_dir().join(format!(
680            "supercov-source-{label}-{}-{nonce}",
681            std::process::id()
682        ));
683        fs::create_dir_all(&root).unwrap();
684        for (file, contents) in files {
685            let path = root.join(file);
686            fs::create_dir_all(path.parent().unwrap()).unwrap();
687            fs::write(path, contents).unwrap();
688        }
689        root
690    }
691
692    fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
693        scope
694            .scope
695            .entries
696            .iter()
697            .find(|entry| entry.file == file)
698            .unwrap()
699    }
700
701    #[test]
702    fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
703        let root = repository(
704            "automatic",
705            &[
706                ("package.json", r#"{"workspaces":["packages/*"]}"#),
707                ("src/index.ts", "export const root = true"),
708                ("lib/helper.js", "export const helper = true"),
709                ("src/index.test.ts", "test('root', () => {})"),
710                ("tests/e2e.spec.ts", "test('e2e', () => {})"),
711                ("scripts/release.mjs", "export const release = true"),
712                ("vite.config.ts", "export default {}"),
713                ("build.mjs", "export default async function build() {}"),
714                (".eslintrc.cjs", "module.exports = {}"),
715                (".graphqlrc.ts", "export default {}"),
716                ("orphan.ts", "export const missed = true"),
717                ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
718                ("packages/ui/src/index.ts", "export const ui = true"),
719                ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
720                ("dist/generated.js", "generated"),
721                (".cache/tool/generated.js", "cached"),
722            ],
723        );
724        let discovered = discover_source_scope(&root, None).unwrap();
725        assert_eq!(
726            discovered.source_files,
727            ["lib/helper.js", "packages/ui/src/index.ts", "src/index.ts"]
728        );
729        assert_eq!(
730            entry(&discovered, "orphan.ts").status,
731            SourceScopeStatus::Ambiguous
732        );
733        assert_eq!(
734            entry(&discovered, "scripts/release.mjs").reason,
735            "conventional tool script"
736        );
737        assert_eq!(
738            entry(&discovered, "build.mjs").reason,
739            "build/test/tool configuration"
740        );
741        assert_eq!(
742            entry(&discovered, "packages/ui/src/index.ts").package_root,
743            Some("packages/ui".into())
744        );
745        assert_eq!(discovered.limitations.len(), 1);
746        assert_eq!(discovered.limitations[0].file, "orphan.ts");
747        assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
748        assert!(
749            discovered
750                .scope
751                .entries
752                .iter()
753                .all(|entry| !entry.file.contains(".cache"))
754        );
755        fs::remove_dir_all(root).unwrap();
756    }
757
758    #[test]
759    fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
760        let root = repository(
761            "explicit",
762            &[
763                ("package.json", "{}"),
764                ("product/main.ts", "product"),
765                ("orphan.ts", "outside"),
766            ],
767        );
768        let roots = vec!["product".into()];
769        let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
770        assert_eq!(discovered.source_files, ["product/main.ts"]);
771        assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
772        assert_eq!(
773            entry(&discovered, "orphan.ts").reason,
774            "outside explicit source roots"
775        );
776        assert!(discovered.limitations.is_empty());
777        fs::remove_dir_all(root).unwrap();
778    }
779
780    #[test]
781    fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
782        let root = repository(
783            "jsonc",
784            &[
785                ("package.json", r#"{"main":"./dist/index.js"}"#),
786                (
787                    "tsconfig.json",
788                    "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
789                ),
790                ("events.ts", "event"),
791                ("žalias.ts", "unicode"),
792                ("library.test.ts", "test"),
793            ],
794        );
795        let discovered = discover_source_scope(&root, None).unwrap();
796        assert!(discovered.source_roots.contains(&".".into()));
797        assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
798        assert!(discovered.limitations.is_empty());
799        fs::remove_dir_all(root).unwrap();
800    }
801
802    #[cfg(unix)]
803    #[test]
804    fn never_follows_source_directory_or_explicit_root_symlinks() {
805        use std::os::unix::fs::symlink;
806
807        let root = repository(
808            "symlink",
809            &[("package.json", "{}"), ("src/real.ts", "real")],
810        );
811        let outside = repository("outside", &[("secret.ts", "secret")]);
812        symlink(&outside, root.join("linked")).unwrap();
813        symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
814        let discovered = discover_source_scope(&root, None).unwrap();
815        assert_eq!(discovered.source_files, ["src/real.ts"]);
816        let explicit = vec!["linked".into()];
817        assert!(matches!(
818            discover_source_scope(&root, Some(&explicit)),
819            Err(SourceDiscoveryError::InvalidRoot(_))
820        ));
821        fs::remove_dir_all(root).unwrap();
822        fs::remove_dir_all(outside).unwrap();
823    }
824
825    #[test]
826    fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
827        let root = repository(
828            "workspace-store",
829            &[
830                ("package.json", "{}"),
831                ("src/main.ts", "main"),
832                ("supercov/user.ts", "user code"),
833            ],
834        );
835        let before = discover_source_scope(&root, None).unwrap();
836        assert_eq!(
837            entry(&before, "supercov/user.ts").status,
838            SourceScopeStatus::Ambiguous
839        );
840        fs::write(root.join("supercov/.supercov-workspace-store"), "owned").unwrap();
841        let after = discover_source_scope(&root, None).unwrap();
842        assert!(
843            after
844                .scope
845                .entries
846                .iter()
847                .all(|entry| !entry.file.starts_with("supercov/"))
848        );
849        fs::remove_dir_all(root).unwrap();
850    }
851}