1use std::{
7 collections::{BTreeMap, BTreeSet},
8 fs, io,
9 path::{Component, Path, PathBuf},
10};
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use sha2::{Digest, Sha256};
15
16const GENERATED_DIRECTORIES: &[&str] = &[
17 ".cache",
18 "generated",
19 ".git",
20 ".mcdc-pool",
21 ".next",
22 ".nuxt",
23 ".output",
24 ".supercov",
25 "build",
26 "coverage",
27 "dist",
28 "node_modules",
29 "out",
30 "playwright-report",
31 "results",
32 "test-results",
33 "vendor",
34];
35pub fn target_is_build_output(directory: &Path) -> bool {
45 const BUILDS: &[&str] = &[
46 "Cargo.toml",
47 "pom.xml",
48 "build.gradle",
49 "build.gradle.kts",
50 "settings.gradle",
51 "settings.gradle.kts",
52 "build.sbt",
53 "project.clj",
54 ];
55 const MARKERS: &[&str] = &["CACHEDIR.TAG", ".rustc_info.json"];
56 directory
57 .parent()
58 .is_some_and(|parent| BUILDS.iter().any(|name| parent.join(name).is_file()))
59 || MARKERS.iter().any(|name| directory.join(name).is_file())
60}
61
62const SOURCE_DIRECTORIES: &[&str] = &["app", "src", "lib", "server", "client", "functions", "api"];
63const PACKAGE_PARENTS: &[&str] = &["apps", "packages", "services", "workspaces"];
64const TEST_DIRECTORIES: &[&str] = &[
65 "__tests__",
66 "test",
67 "tests",
68 "spec",
69 "specs",
70 "e2e",
71 "fixture",
72 "fixtures",
73 "mock",
74 "mocks",
75 "__mocks__",
76];
77const CONFIG_TOOLS: &[&str] = &[
78 "babel",
79 "eslint",
80 "graphql",
81 "jest",
82 "next",
83 "nuxt",
84 "playwright",
85 "postcss",
86 "prettier",
87 "remix",
88 "rollup",
89 "stylelint",
90 "tailwind",
91 "tsup",
92 "vite",
93 "vitest",
94 "webpack",
95];
96const DOT_CONFIG_TOOLS: &[&str] = &["babel", "eslint", "graphql", "prettier", "stylelint"];
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "lowercase")]
100pub enum SourceScopeStatus {
101 Included,
102 Excluded,
103 Ambiguous,
104}
105
106pub const BUILT_ASSET_REASON: &str = "built asset";
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase", deny_unknown_fields)]
111pub struct SourceScopeEntry {
112 pub file: String,
113 pub status: SourceScopeStatus,
114 pub reason: String,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub package_root: Option<String>,
117}
118
119impl SourceScopeEntry {
120 pub fn is_generated_output(&self) -> bool {
124 self.status == SourceScopeStatus::Excluded && self.reason == BUILT_ASSET_REASON
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "lowercase")]
130pub enum SourceScopeMode {
131 Automatic,
132 Explicit,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137pub struct SourceScope {
138 pub version: u32,
139 pub mode: SourceScopeMode,
140 pub roots: Vec<String>,
141 pub entries: Vec<SourceScopeEntry>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase", deny_unknown_fields)]
146pub struct SourceLimitation {
147 pub id: String,
148 pub kind: String,
149 pub file: String,
150 pub line: usize,
151 pub column: usize,
152 pub source: String,
153 pub reason: String,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase", deny_unknown_fields)]
158pub struct DiscoveredSourceScope {
159 pub source_files: Vec<String>,
160 pub source_roots: Vec<String>,
161 pub scope: SourceScope,
162 pub limitations: Vec<SourceLimitation>,
163}
164
165#[derive(Debug)]
166pub enum SourceDiscoveryError {
167 Io { path: PathBuf, source: io::Error },
168 NonUtf8Path(PathBuf),
169 InvalidRoot(PathBuf),
170}
171
172impl std::fmt::Display for SourceDiscoveryError {
173 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 match self {
175 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
176 Self::NonUtf8Path(path) => {
177 write!(
178 formatter,
179 "source path is not valid UTF-8: {}",
180 path.display()
181 )
182 }
183 Self::InvalidRoot(path) => write!(
184 formatter,
185 "source root is not a regular file or directory: {}",
186 path.display()
187 ),
188 }
189 }
190}
191
192impl std::error::Error for SourceDiscoveryError {}
193
194fn io_error(path: &Path, source: io::Error) -> SourceDiscoveryError {
195 SourceDiscoveryError::Io {
196 path: path.to_owned(),
197 source,
198 }
199}
200
201pub const SOURCE_ROOTS_VARIABLE: &str = "SUPERCOV_SOURCE_ROOTS";
203
204pub const OUTSIDE_EXPLICIT_ROOTS: &str = "outside explicit source roots";
206
207pub fn configured_source_roots(environment: &BTreeMap<String, String>) -> Option<Vec<String>> {
217 let roots = environment
218 .get(SOURCE_ROOTS_VARIABLE)?
219 .split(',')
220 .map(str::trim)
221 .filter(|root| !root.is_empty())
222 .map(str::to_owned)
223 .collect::<Vec<_>>();
224 (!roots.is_empty()).then_some(roots)
225}
226
227pub fn fold_roots_into_configuration(configuration: &mut Vec<u8>, roots: Option<&[String]>) {
233 let Some(roots) = roots.filter(|roots| !roots.is_empty()) else {
234 return;
235 };
236 configuration.push(0);
237 configuration
238 .extend_from_slice(format!("{SOURCE_ROOTS_VARIABLE}={}", roots.join(",")).as_bytes());
239}
240
241#[derive(Debug, Clone)]
247pub struct ExplicitSourceRoots {
248 root: PathBuf,
249 roots: Vec<PathBuf>,
250 configured: Vec<String>,
251}
252
253impl ExplicitSourceRoots {
254 pub fn resolve(root: &Path, configured: &[String]) -> Result<Self, SourceDiscoveryError> {
255 let root = lexical_normalize(root);
256 let roots = existing_roots(configured.iter().map(|value| resolve(&root, value)), true)?;
257 Ok(Self {
258 root,
259 roots,
260 configured: configured.to_vec(),
261 })
262 }
263
264 pub fn from_environment(
266 root: &Path,
267 environment: &BTreeMap<String, String>,
268 ) -> Result<Option<Self>, SourceDiscoveryError> {
269 configured_source_roots(environment)
270 .map(|configured| Self::resolve(root, &configured))
271 .transpose()
272 }
273
274 pub fn from_environment_in(
281 project: &Path,
282 workspace: &Path,
283 environment: &BTreeMap<String, String>,
284 ) -> Result<Option<Self>, SourceDiscoveryError> {
285 let Some(configured) = configured_source_roots(environment) else {
286 return Ok(None);
287 };
288 let project = lexical_normalize(project);
289 let rebased = configured
290 .iter()
291 .map(|value| {
292 let path = Path::new(value);
293 if !path.is_absolute() {
294 return value.clone();
295 }
296 lexical_normalize(path)
297 .strip_prefix(&project)
298 .map(|rest| {
299 let rest = rest.to_string_lossy().into_owned();
300 if rest.is_empty() { ".".into() } else { rest }
301 })
302 .unwrap_or_else(|_| value.clone())
303 })
304 .collect::<Vec<_>>();
305 let mut roots = Self::resolve(workspace, &rebased)?;
306 roots.configured = configured;
307 Ok(Some(roots))
308 }
309
310 pub fn contains(&self, relative: &str) -> bool {
313 root_contains(&self.roots, &lexical_normalize(&self.root.join(relative)))
314 }
315
316 pub fn configured(&self) -> &[String] {
320 &self.configured
321 }
322
323 pub fn refuse_if_empty(&self, remaining: usize, language: &str) -> Result<(), String> {
327 if remaining > 0 {
328 return Ok(());
329 }
330 let missing = self
331 .configured
332 .iter()
333 .filter(|value| fs::symlink_metadata(resolve(&self.root, value)).is_err())
334 .cloned()
335 .collect::<Vec<_>>();
336 let mut message = format!(
337 "{SOURCE_ROOTS_VARIABLE}={} left no {language} source to measure",
338 self.configured.join(",")
339 );
340 if !missing.is_empty() {
341 message.push_str(&format!("; {} does not exist", missing.join(", ")));
342 }
343 message.push_str("; name the directories or files that hold your own code");
344 Err(message)
345 }
346
347 pub fn scope(
353 &self,
354 included: &[String],
355 excluded: &[(String, &'static str)],
356 ) -> Result<SourceScope, SourceDiscoveryError> {
357 let mut entries = included
358 .iter()
359 .map(|file| SourceScopeEntry {
360 file: file.clone(),
361 status: SourceScopeStatus::Included,
362 reason: "explicit source root".into(),
363 package_root: None,
364 })
365 .chain(
366 excluded
367 .iter()
368 .filter(|(_, reason)| *reason == OUTSIDE_EXPLICIT_ROOTS)
369 .map(|(file, reason)| SourceScopeEntry {
370 file: file.clone(),
371 status: SourceScopeStatus::Excluded,
372 reason: (*reason).into(),
373 package_root: None,
374 }),
375 )
376 .collect::<Vec<_>>();
377 entries.sort_by(|left, right| left.file.cmp(&right.file));
378 Ok(SourceScope {
379 version: 1,
380 mode: SourceScopeMode::Explicit,
381 roots: self.local_roots()?,
382 entries,
383 })
384 }
385
386 pub fn local_roots(&self) -> Result<Vec<String>, SourceDiscoveryError> {
388 self.roots
389 .iter()
390 .map(|path| local_path(&self.root, path))
391 .collect()
392 }
393
394 pub fn narrow<T>(
399 &self,
400 sources: &mut Vec<T>,
401 excluded: &mut Vec<(String, &'static str)>,
402 file: impl Fn(&T) -> &str,
403 ) {
404 let (kept, outside): (Vec<T>, Vec<T>) = sources
405 .drain(..)
406 .partition(|source| self.contains(file(source)));
407 excluded.extend(
408 outside
409 .iter()
410 .map(|source| (file(source).to_owned(), OUTSIDE_EXPLICIT_ROOTS)),
411 );
412 *sources = kept;
413 }
414}
415
416fn existing_roots(
423 candidates: impl IntoIterator<Item = PathBuf>,
424 named: bool,
425) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
426 let mut existing = BTreeSet::new();
427 for path in candidates {
428 match fs::symlink_metadata(&path) {
429 Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_dir() => {
430 existing.insert(path);
431 }
432 Ok(_) if !named => {}
433 Ok(_) => return Err(SourceDiscoveryError::InvalidRoot(path)),
434 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
435 Err(error) => return Err(io_error(&path, error)),
436 }
437 }
438 Ok(existing.into_iter().collect())
439}
440
441fn root_contains(roots: &[PathBuf], path: &Path) -> bool {
444 roots.iter().any(|directory| {
445 fs::symlink_metadata(directory)
446 .map(|metadata| {
447 if metadata.file_type().is_dir() {
448 within(directory, path)
449 } else {
450 directory == path
451 }
452 })
453 .unwrap_or(false)
454 })
455}
456
457fn lexical_normalize(path: &Path) -> PathBuf {
458 let mut output = PathBuf::new();
459 for component in path.components() {
460 match component {
461 Component::CurDir => {}
462 Component::ParentDir => {
463 if !output.pop() {
464 output.push(component.as_os_str());
465 }
466 }
467 _ => output.push(component.as_os_str()),
468 }
469 }
470 output
471}
472
473fn resolve(root: &Path, value: impl AsRef<Path>) -> PathBuf {
474 let value = value.as_ref();
475 let joined;
476 let path = if value.is_absolute() {
477 value
478 } else {
479 joined = root.join(value);
480 &joined
481 };
482 lexical_normalize(path)
483}
484
485fn local_path(root: &Path, path: &Path) -> Result<String, SourceDiscoveryError> {
486 let local = path
487 .strip_prefix(root)
488 .map_err(|_| SourceDiscoveryError::InvalidRoot(path.to_owned()))?;
489 if local.as_os_str().is_empty() {
490 return Ok(".".into());
491 }
492 local
493 .components()
494 .map(|component| {
495 component
496 .as_os_str()
497 .to_str()
498 .map(str::to_owned)
499 .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(path.to_owned()))
500 })
501 .collect::<Result<Vec<_>, _>>()
502 .map(|parts| parts.join("/"))
503}
504
505fn generated_directory(name: &str) -> bool {
506 GENERATED_DIRECTORIES.contains(&name)
507}
508
509fn owned_workspace_store(path: &Path) -> bool {
510 crate::workspace::owned_workspace_path(path)
511}
512
513fn nested_checkout(path: &Path) -> bool {
518 fs::symlink_metadata(path.join(".git")).is_ok()
519}
520
521fn built_asset(file: &str) -> bool {
529 let mut segments = file.rsplit('/');
530 let Some(name) = segments.next() else {
531 return false;
532 };
533 let in_asset_directory =
534 segments.any(|segment| matches!(segment, "assets" | "static" | "public"));
535 let Some(stem) = name
536 .strip_suffix(".js")
537 .or_else(|| name.strip_suffix(".mjs"))
538 .or_else(|| name.strip_suffix(".cjs"))
539 else {
540 return false;
541 };
542 if stem.len() < 10 || stem.as_bytes()[stem.len() - 9] != b'-' {
545 return false;
546 }
547 let hash = &stem[stem.len() - 8..];
548 let looks_hashed = hash
549 .bytes()
550 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
551 && (hash.bytes().any(|byte| byte.is_ascii_digit())
552 || (hash.bytes().any(|byte| byte.is_ascii_uppercase())
553 && hash.bytes().any(|byte| byte.is_ascii_lowercase())));
554 in_asset_directory && looks_hashed
555}
556
557fn root_tool_directory(root: &Path, path: &Path) -> bool {
558 path.parent() == Some(root)
559 && path
560 .file_name()
561 .and_then(|name| name.to_str())
562 .is_some_and(|name| name.starts_with('.'))
563}
564
565fn source_file(name: &str) -> bool {
566 let lower = name.to_ascii_lowercase();
567 [
568 ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
569 ".mtsx",
570 ]
571 .iter()
572 .any(|extension| lower.ends_with(extension))
573}
574
575fn declaration_file(file: &str) -> bool {
576 let lower = file.to_ascii_lowercase();
577 lower.ends_with(".d.ts") || lower.ends_with(".d.cts") || lower.ends_with(".d.mts")
578}
579
580fn test_or_fixture(file: &str) -> bool {
581 let lower = file.to_ascii_lowercase();
582 if lower
583 .split('/')
584 .any(|segment| TEST_DIRECTORIES.contains(&segment))
585 {
586 return true;
587 }
588 lower
589 .split(['/', '_', '.', '-'])
590 .any(|part| matches!(part, "test" | "spec"))
591}
592
593fn benchmark(file: &str) -> bool {
598 let lower = file.to_ascii_lowercase();
599 let (directories, name) = lower.rsplit_once('/').unwrap_or(("", lower.as_str()));
600 directories
601 .split('/')
602 .any(|segment| matches!(segment, "bench" | "benches" | "benchmark" | "benchmarks"))
603 || name
604 .split('.')
605 .skip(1)
606 .any(|part| matches!(part, "bench" | "benchmark"))
607}
608
609fn example(file: &str, package_root: Option<&str>) -> bool {
615 let local = package_root
616 .and_then(|package| file.strip_prefix(package))
617 .and_then(|rest| rest.strip_prefix('/'))
618 .unwrap_or(file);
619 local.split_once('/').is_some_and(|(first, _)| {
620 matches!(first.to_ascii_lowercase().as_str(), "example" | "examples")
621 })
622}
623
624fn tool_script(file: &str) -> bool {
625 file.to_ascii_lowercase()
626 .split('/')
627 .any(|segment| segment == "scripts")
628}
629
630fn config_file(file: &str) -> bool {
631 let lower = file.to_ascii_lowercase();
632 let name = lower.rsplit('/').next().unwrap_or(&lower);
633 source_file(name)
634 && (CONFIG_TOOLS
635 .iter()
636 .any(|tool| name.starts_with(&format!("{tool}.config.")))
637 || DOT_CONFIG_TOOLS
638 .iter()
639 .any(|tool| name.starts_with(&format!(".{tool}rc.")))
640 || (!lower.contains('/')
641 && (name.contains(".config.")
642 || name.contains(".conf.")
644 || name.starts_with("build.")
645 || name.starts_with("gulpfile.")
646 || name.starts_with("gruntfile."))))
647}
648
649fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, SourceDiscoveryError> {
650 let mut entries = fs::read_dir(path)
651 .map_err(|error| io_error(path, error))?
652 .collect::<Result<Vec<_>, _>>()
653 .map_err(|error| io_error(path, error))?;
654 entries.sort_by_key(fs::DirEntry::file_name);
655 Ok(entries)
656}
657
658fn ignored_by_enclosing_repository(root: &Path) -> bool {
663 if root.join(".git").exists() {
664 return false;
665 }
666 let Some(repository) = root
667 .ancestors()
668 .skip(1)
669 .position(|ancestor| ancestor.join(".git").exists())
670 else {
671 return false;
672 };
673 root.ancestors()
675 .skip(1)
676 .take(repository + 1)
677 .collect::<Vec<_>>()
678 .into_iter()
679 .rev()
680 .fold(false, |ignored, directory| {
681 let (rules, _) = ignore::gitignore::Gitignore::new(directory.join(".gitignore"));
682 match rules.matched_path_or_any_parents(root, true) {
683 ignore::Match::Ignore(_) => true,
684 ignore::Match::Whitelist(_) => false,
685 ignore::Match::None => ignored,
686 }
687 })
688}
689
690fn files_under(
699 root: &Path,
700 directory: &Path,
701 output: &mut Vec<PathBuf>,
702) -> Result<(), SourceDiscoveryError> {
703 let metadata = fs::symlink_metadata(directory).map_err(|error| io_error(directory, error))?;
704 if !metadata.file_type().is_dir() {
705 return Err(SourceDiscoveryError::InvalidRoot(directory.to_owned()));
706 }
707 let project = root.to_owned();
708 let walker = ignore::WalkBuilder::new(directory)
709 .standard_filters(false)
710 .git_ignore(true)
711 .parents(!ignored_by_enclosing_repository(directory))
712 .require_git(true)
713 .follow_links(false)
714 .sort_by_file_name(|left, right| left.cmp(right))
715 .filter_entry(move |entry| {
716 entry.depth() == 0
717 || !entry.file_type().is_some_and(|kind| kind.is_dir())
718 || entry.file_name().to_str().is_some_and(|name| {
719 !generated_directory(name)
720 && !owned_workspace_store(entry.path())
721 && !nested_checkout(entry.path())
722 && !root_tool_directory(&project, entry.path())
723 })
724 })
725 .build();
726 for entry in walker {
727 let entry =
728 entry.map_err(|error| io_error(directory, io::Error::other(error.to_string())))?;
729 let Some(kind) = entry.file_type() else {
730 continue;
731 };
732 if kind.is_symlink() || !kind.is_file() {
733 continue;
734 }
735 let name = entry
736 .file_name()
737 .to_str()
738 .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(entry.path().to_owned()))?;
739 if source_file(name) {
740 output.push(entry.into_path());
741 }
742 }
743 Ok(())
744}
745
746fn read_json(path: &Path) -> Option<Value> {
747 serde_json::from_slice(&fs::read(path).ok()?).ok()
748}
749
750fn package_directories(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
751 fn visit(
752 root: &Path,
753 directory: &Path,
754 depth: usize,
755 found: &mut BTreeSet<PathBuf>,
756 ) -> Result<(), SourceDiscoveryError> {
757 if depth > 5 {
758 return Ok(());
759 }
760 for entry in read_directory(directory)? {
761 let name = entry
762 .file_name()
763 .into_string()
764 .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
765 let path = entry.path();
766 let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
767 if !file_type.is_dir()
768 || file_type.is_symlink()
769 || generated_directory(&name)
770 || owned_workspace_store(&path)
771 || nested_checkout(&path)
772 || root_tool_directory(root, &path)
773 {
774 continue;
775 }
776 let local = local_path(root, &path)?;
777 let under_package_parent = local
778 .split('/')
779 .any(|segment| PACKAGE_PARENTS.contains(&segment));
780 if path.join("package.json").is_file() && (depth == 0 || under_package_parent) {
781 found.insert(path.clone());
782 }
783 visit(root, &path, depth + 1, found)?;
784 }
785 Ok(())
786 }
787
788 let mut found = BTreeSet::from([root.to_owned()]);
789 visit(root, root, 0, &mut found)?;
790 for directory in declared_workspace_packages(root)? {
791 found.insert(directory);
792 }
793 Ok(found.into_iter().collect())
794}
795
796fn declared_workspace_packages(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
803 let mut patterns = Vec::new();
804 let manifest = read_json(&root.join("package.json")).unwrap_or(Value::Null);
805 let declared = match manifest.get("workspaces") {
806 Some(Value::Array(items)) => Some(items),
807 Some(Value::Object(object)) => object.get("packages").and_then(Value::as_array),
808 _ => None,
809 };
810 if let Some(items) = declared {
811 patterns.extend(items.iter().filter_map(Value::as_str).map(str::to_owned));
812 }
813 if let Ok(pnpm) = fs::read_to_string(root.join("pnpm-workspace.yaml")) {
814 let mut in_packages = false;
815 for line in pnpm.lines() {
816 let trimmed = line.trim();
817 if trimmed.starts_with("packages:") {
818 in_packages = true;
819 continue;
820 }
821 if in_packages {
822 if let Some(item) = trimmed.strip_prefix("- ") {
823 patterns.push(item.trim_matches(|c| c == '"' || c == '\'').to_owned());
824 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
825 in_packages = false;
826 }
827 }
828 }
829 }
830 let mut found = Vec::new();
831 for pattern in patterns {
832 let pattern = pattern.trim_start_matches("./").trim_end_matches('/');
833 if pattern.starts_with('!') || pattern.contains("**") {
834 continue;
835 }
836 let (parent, wildcard) = match pattern.strip_suffix("/*") {
837 Some(parent) => (parent, true),
838 None => (pattern, false),
839 };
840 if parent.is_empty()
841 || parent
842 .split('/')
843 .any(|segment| segment.is_empty() || segment == ".." || segment.contains('*'))
844 {
845 continue;
846 }
847 let base = root.join(parent);
848 let candidates: Vec<PathBuf> = if wildcard {
849 match fs::read_dir(&base) {
850 Ok(entries) => entries
851 .filter_map(Result::ok)
852 .map(|entry| entry.path())
853 .collect(),
854 Err(_) => Vec::new(),
855 }
856 } else {
857 vec![base]
858 };
859 for candidate in candidates {
860 let is_dir = fs::symlink_metadata(&candidate)
861 .is_ok_and(|metadata| metadata.file_type().is_dir());
862 if is_dir
863 && candidate.join("package.json").is_file()
864 && !nested_checkout(&candidate)
865 && !owned_workspace_store(&candidate)
866 {
867 found.push(candidate);
868 }
869 }
870 }
871 Ok(found)
872}
873
874fn string_targets(value: &Value, depth: usize, output: &mut Vec<String>) {
875 if depth > 8 {
876 return;
877 }
878 match value {
879 Value::String(value) => output.push(value.clone()),
880 Value::Array(values) => {
881 for value in values {
882 string_targets(value, depth + 1, output);
883 }
884 }
885 Value::Object(values) => {
886 for value in values.values() {
887 string_targets(value, depth + 1, output);
888 }
889 }
890 _ => {}
891 }
892}
893
894fn entry_targets(directory: &Path, manifest: &Value) -> Vec<PathBuf> {
895 let mut targets = Vec::new();
896 for key in ["main", "module", "browser", "bin", "exports"] {
897 if let Some(value) = manifest.get(key) {
898 string_targets(value, 0, &mut targets);
899 }
900 }
901 targets
902 .into_iter()
903 .filter_map(|target| {
904 if !target.starts_with('.') || target.contains("node_modules") {
905 return None;
906 }
907 let prefix = target.split('*').next()?.trim_end_matches('/');
908 (!prefix.is_empty()).then(|| resolve(directory, prefix))
909 })
910 .collect()
911}
912
913pub(crate) fn strip_jsonc_comments(contents: &str) -> String {
914 let mut output = String::with_capacity(contents.len());
915 let mut chars = contents.chars().peekable();
916 let mut string = false;
917 let mut escaped = false;
918 while let Some(character) = chars.next() {
919 if string {
920 output.push(character);
921 if escaped {
922 escaped = false;
923 } else if character == '\\' {
924 escaped = true;
925 } else if character == '"' {
926 string = false;
927 }
928 continue;
929 }
930 if character == '"' {
931 string = true;
932 output.push(character);
933 } else if character == '/' && chars.peek() == Some(&'/') {
934 chars.next();
935 for comment in chars.by_ref() {
936 if comment == '\n' {
937 output.push('\n');
938 break;
939 }
940 }
941 } else if character == '/' && chars.peek() == Some(&'*') {
942 chars.next();
943 let mut previous = '\0';
944 for comment in chars.by_ref() {
945 if previous == '*' && comment == '/' {
946 break;
947 }
948 previous = comment;
949 }
950 } else {
951 output.push(character);
952 }
953 }
954 output
955}
956
957pub(crate) fn strip_trailing_commas(contents: &str) -> String {
958 let chars = contents.chars().collect::<Vec<_>>();
959 let mut output = String::with_capacity(contents.len());
960 let mut string = false;
961 let mut escaped = false;
962 for (index, character) in chars.iter().copied().enumerate() {
963 if string {
964 output.push(character);
965 if escaped {
966 escaped = false;
967 } else if character == '\\' {
968 escaped = true;
969 } else if character == '"' {
970 string = false;
971 }
972 continue;
973 }
974 if character == '"' {
975 string = true;
976 output.push(character);
977 } else if character == ','
978 && chars[index + 1..]
979 .iter()
980 .find(|character| !character.is_whitespace())
981 .is_some_and(|character| matches!(character, '}' | ']'))
982 {
983 } else {
984 output.push(character);
985 }
986 }
987 output
988}
989
990fn tsconfig_roots(directory: &Path) -> Vec<PathBuf> {
991 let path = directory.join("tsconfig.json");
992 let Some(contents) = fs::read_to_string(path).ok() else {
993 return Vec::new();
994 };
995 let jsonc = strip_trailing_commas(&strip_jsonc_comments(&contents));
996 let Some(config) = serde_json::from_str::<Value>(&jsonc).ok() else {
997 return Vec::new();
998 };
999 let mut values = Vec::new();
1000 if let Some(root_dir) = config
1001 .get("compilerOptions")
1002 .and_then(|options| options.get("rootDir"))
1003 .and_then(Value::as_str)
1004 {
1005 values.push(root_dir.to_owned());
1006 }
1007 if let Some(include) = config.get("include").and_then(Value::as_array) {
1008 values.extend(include.iter().filter_map(Value::as_str).map(str::to_owned));
1009 }
1010 if values.is_empty() {
1011 return vec![directory.to_owned()];
1012 }
1013 values
1014 .into_iter()
1015 .filter_map(|value| {
1016 if value.starts_with('!') {
1017 return None;
1018 }
1019 let prefix = value
1020 .find(['?', '*', '{', '['])
1021 .map_or(value.as_str(), |index| &value[..index])
1022 .trim_end_matches('/');
1023 (!prefix.is_empty()).then(|| resolve(directory, prefix))
1024 })
1025 .collect()
1026}
1027
1028fn within(parent: &Path, child: &Path) -> bool {
1029 child == parent || child.starts_with(parent)
1030}
1031
1032fn nearest_package_root<'a>(path: &Path, packages: &'a [PathBuf]) -> Option<&'a PathBuf> {
1033 packages
1034 .iter()
1035 .filter(|directory| within(directory, path))
1036 .max_by_key(|directory| directory.components().count())
1037}
1038
1039fn scope_limitation(file: &str) -> SourceLimitation {
1040 let digest = Sha256::digest(file.as_bytes());
1041 let id = digest[..10]
1042 .iter()
1043 .map(|byte| format!("{byte:02x}"))
1044 .collect::<String>();
1045 SourceLimitation {
1046 id: format!("scope:{id}"),
1047 kind: "source-scope".into(),
1048 file: file.into(),
1049 line: 1,
1050 column: 1,
1051 source: file.into(),
1052 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(),
1053 }
1054}
1055
1056fn declared_test_setup(packages: &[PathBuf]) -> BTreeSet<PathBuf> {
1060 let mut paths = BTreeSet::new();
1061 for package in packages {
1062 let manifest = read_json(&package.join("package.json")).unwrap_or(Value::Null);
1063 let Some(jest) = manifest.get("jest") else {
1064 continue;
1065 };
1066 let root = jest
1067 .get("rootDir")
1068 .and_then(Value::as_str)
1069 .map(|value| {
1070 resolve(
1071 package,
1072 value.replace("<rootDir>", &package.to_string_lossy()),
1073 )
1074 })
1075 .unwrap_or_else(|| package.clone());
1076 for field in [
1077 "setupFiles",
1078 "setupFilesAfterEnv",
1079 "globalSetup",
1080 "globalTeardown",
1081 ] {
1082 let values = match jest.get(field) {
1083 Some(Value::Array(values)) => values.iter().collect::<Vec<_>>(),
1084 Some(value) => vec![value],
1085 None => Vec::new(),
1086 };
1087 for value in values.into_iter().filter_map(Value::as_str) {
1088 let path = resolve(&root, value.replace("<rootDir>", &root.to_string_lossy()));
1089 if path.is_file() {
1090 paths.insert(path);
1091 }
1092 }
1093 }
1094 }
1095 paths
1096}
1097
1098pub fn discover_source_scope(
1099 root: &Path,
1100 configured_roots: Option<&[String]>,
1101) -> Result<DiscoveredSourceScope, SourceDiscoveryError> {
1102 let root = lexical_normalize(root);
1103 let root_metadata = fs::symlink_metadata(&root).map_err(|error| io_error(&root, error))?;
1104 if !root_metadata.file_type().is_dir() {
1105 return Err(SourceDiscoveryError::InvalidRoot(root));
1106 }
1107 let packages = package_directories(&root)?;
1108 let test_setup = declared_test_setup(&packages);
1109 let explicit = configured_roots.is_some_and(|roots| !roots.is_empty());
1110 let include_roots = if explicit {
1111 configured_roots
1112 .unwrap_or_default()
1113 .iter()
1114 .map(|directory| resolve(&root, directory))
1115 .collect::<Vec<_>>()
1116 } else {
1117 packages
1118 .iter()
1119 .flat_map(|directory| {
1120 let manifest = read_json(&directory.join("package.json")).unwrap_or(Value::Null);
1121 let candidates = SOURCE_DIRECTORIES
1122 .iter()
1123 .map(|name| directory.join(name))
1124 .chain(entry_targets(directory, &manifest))
1125 .chain(tsconfig_roots(directory))
1126 .collect::<Vec<_>>();
1127 if directory != &root
1133 && !candidates
1134 .iter()
1135 .any(|candidate| fs::symlink_metadata(candidate).is_ok())
1136 {
1137 return vec![directory.clone()];
1138 }
1139 candidates
1140 })
1141 .collect()
1142 };
1143 let existing_roots = existing_roots(include_roots, explicit)?;
1144 let mut all_files = Vec::new();
1145 files_under(&root, &root, &mut all_files)?;
1146 all_files.sort();
1147
1148 let mut entries = Vec::new();
1149 let mut included = Vec::new();
1150 let mut limitations = Vec::new();
1151 for path in all_files {
1152 let file = local_path(&root, &path)?;
1153 let package_root = nearest_package_root(&path, &packages)
1154 .map(|package| local_path(&root, package))
1155 .transpose()?
1156 .filter(|package| package != ".");
1157 let entry = |status, reason: &str| SourceScopeEntry {
1158 file: file.clone(),
1159 status,
1160 reason: reason.into(),
1161 package_root: package_root.clone(),
1162 };
1163 if declaration_file(&file) {
1164 entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
1165 } else if test_setup.contains(&path) {
1166 entries.push(entry(SourceScopeStatus::Excluded, "declared test setup"));
1167 } else if test_or_fixture(&file) {
1168 entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
1169 } else if tool_script(&file) {
1170 entries.push(entry(
1171 SourceScopeStatus::Excluded,
1172 "conventional tool script",
1173 ));
1174 } else if benchmark(&file) {
1175 entries.push(entry(SourceScopeStatus::Excluded, "benchmark"));
1176 } else if example(&file, package_root.as_deref()) {
1177 entries.push(entry(SourceScopeStatus::Excluded, "example"));
1178 } else if config_file(&file) {
1179 entries.push(entry(
1180 SourceScopeStatus::Excluded,
1181 "build/test/tool configuration",
1182 ));
1183 } else if built_asset(&file) {
1184 entries.push(entry(SourceScopeStatus::Excluded, BUILT_ASSET_REASON));
1185 } else if root_contains(&existing_roots, &path) {
1186 included.push(path);
1187 entries.push(entry(
1188 SourceScopeStatus::Included,
1189 if explicit {
1190 "explicit source root"
1191 } else {
1192 "discovered package source root"
1193 },
1194 ));
1195 } else if explicit {
1196 entries.push(entry(
1197 SourceScopeStatus::Excluded,
1198 "outside explicit source roots",
1199 ));
1200 } else {
1201 entries.push(entry(
1202 SourceScopeStatus::Ambiguous,
1203 "unclassified first-party source",
1204 ));
1205 limitations.push(scope_limitation(&file));
1206 }
1207 }
1208 let source_files = included
1209 .iter()
1210 .map(|path| local_path(&root, path))
1211 .collect::<Result<Vec<_>, _>>()?;
1212 let source_roots = existing_roots
1213 .iter()
1214 .map(|path| local_path(&root, path))
1215 .collect::<Result<Vec<_>, _>>()?;
1216 Ok(DiscoveredSourceScope {
1217 source_files,
1218 source_roots: source_roots.clone(),
1219 scope: SourceScope {
1220 version: 1,
1221 mode: if explicit {
1222 SourceScopeMode::Explicit
1223 } else {
1224 SourceScopeMode::Automatic
1225 },
1226 roots: source_roots,
1227 entries,
1228 },
1229 limitations,
1230 })
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235 use std::{
1236 fs,
1237 time::{SystemTime, UNIX_EPOCH},
1238 };
1239
1240 use super::*;
1241
1242 fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
1243 let nonce = SystemTime::now()
1244 .duration_since(UNIX_EPOCH)
1245 .unwrap()
1246 .as_nanos();
1247 let root = std::env::temp_dir().join(format!(
1248 "supercov-source-{label}-{}-{nonce}",
1249 std::process::id()
1250 ));
1251 fs::create_dir_all(&root).unwrap();
1252 for (file, contents) in files {
1253 let path = root.join(file);
1254 fs::create_dir_all(path.parent().unwrap()).unwrap();
1255 fs::write(path, contents).unwrap();
1256 }
1257 root
1258 }
1259
1260 fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
1261 scope
1262 .scope
1263 .entries
1264 .iter()
1265 .find(|entry| entry.file == file)
1266 .unwrap()
1267 }
1268
1269 #[test]
1270 fn a_file_the_project_tells_git_to_ignore_is_not_its_source() {
1271 let files = [
1274 ("package.json", r#"{"workspaces":["packages/*"]}"#),
1275 ("packages/core/package.json", r#"{"name":"core"}"#),
1276 ("packages/core/.gitignore", "lib-dist\n"),
1277 ("packages/core/src/index.ts", "export const a = 1;"),
1278 (
1279 "packages/core/lib-dist/browser/worker.dev.js",
1280 "var bundled = 1;",
1281 ),
1282 ];
1283 let repository_root = repository("gitignored", &files);
1284 fs::create_dir_all(repository_root.join(".git")).unwrap();
1285 let discovered = discover_source_scope(&repository_root, None).unwrap();
1286 assert!(
1287 discovered
1288 .source_files
1289 .contains(&"packages/core/src/index.ts".into())
1290 );
1291 assert!(
1292 !discovered
1293 .source_files
1294 .iter()
1295 .any(|file| file.contains("lib-dist")),
1296 "{:?}",
1297 discovered.source_files
1298 );
1299 fs::remove_dir_all(repository_root).unwrap();
1300
1301 let plain = repository("not-a-repository", &files);
1303 let discovered = discover_source_scope(&plain, None).unwrap();
1304 assert!(
1305 discovered
1306 .scope
1307 .entries
1308 .iter()
1309 .any(|entry| entry.file.contains("lib-dist"))
1310 );
1311 fs::remove_dir_all(plain).unwrap();
1312 }
1313
1314 #[test]
1315 fn a_project_kept_where_an_enclosing_repository_ignores_it_keeps_its_source() {
1316 let files = [
1317 ("scratch/.gitignore", "*\n"),
1319 ("scratch/app/package.json", r#"{"name":"app"}"#),
1320 ("scratch/app/.gitignore", "dist\n"),
1321 ("scratch/app/src/index.js", "export const a = 1;"),
1322 ("scratch/app/dist/bundle.js", "var bundled = 1;"),
1323 ];
1324 let repository_root = repository("enclosing-ignore", &files);
1325 fs::create_dir_all(repository_root.join(".git")).unwrap();
1326 let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1327 assert_eq!(discovered.source_files, ["src/index.js"]);
1328
1329 fs::remove_file(repository_root.join("scratch/.gitignore")).unwrap();
1331 fs::write(repository_root.join(".gitignore"), "*.gen.js\n").unwrap();
1332 fs::write(repository_root.join("scratch/app/src/api.gen.js"), "var a;").unwrap();
1333 let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1334 assert_eq!(discovered.source_files, ["src/index.js"]);
1335 fs::remove_dir_all(repository_root).unwrap();
1336 }
1337
1338 #[test]
1339 fn excludes_declared_jest_setup_without_hiding_neighboring_application_code() {
1340 let root = repository(
1341 "jest-setup",
1342 &[
1343 (
1344 "package.json",
1345 r#"{"jest":{"setupFilesAfterEnv":["<rootDir>/src/bootstrap.js"]}}"#,
1346 ),
1347 (
1348 "src/bootstrap.js",
1349 "jest.mock('storage', () => ({read: () => null}));",
1350 ),
1351 ("src/index.js", "export const value = 1;"),
1352 ],
1353 );
1354 let discovered = discover_source_scope(&root, None).unwrap();
1355 assert_eq!(discovered.source_files, ["src/index.js"]);
1356 assert_eq!(
1357 entry(&discovered, "src/bootstrap.js").reason,
1358 "declared test setup"
1359 );
1360 fs::remove_dir_all(root).unwrap();
1361 }
1362
1363 #[test]
1364 fn example_apps_and_conf_files_are_not_source() {
1365 let root = repository(
1367 "examples",
1368 &[
1369 ("package.json", r#"{"exports":{".":"./dist/index.js"}}"#),
1370 ("tsconfig.json", r#"{"exclude":["dist","examples"]}"#),
1371 ("src/v4.ts", "export const v4 = () => ''"),
1372 ("wdio.conf.js", "exports.config = {}"),
1373 ("karma.conf.cjs", "module.exports = () => {}"),
1374 ("examples/browser-esmodules/package.json", "{}"),
1375 ("examples/browser-esmodules/example.js", "console.log(1)"),
1376 ("examples/utils/testpage.js", "export default 1"),
1377 ("example/demo.js", "console.log(1)"),
1378 ("app/examples/page.tsx", "export default function Page() {}"),
1379 ],
1380 );
1381 let discovered = discover_source_scope(&root, None).unwrap();
1382 assert_eq!(
1383 discovered.source_files,
1384 ["app/examples/page.tsx", "src/v4.ts"]
1385 );
1386 for file in [
1387 "examples/browser-esmodules/example.js",
1388 "examples/utils/testpage.js",
1389 "example/demo.js",
1390 ] {
1391 assert_eq!(entry(&discovered, file).reason, "example", "{file}");
1392 }
1393 for file in ["wdio.conf.js", "karma.conf.cjs"] {
1394 assert_eq!(
1395 entry(&discovered, file).reason,
1396 "build/test/tool configuration",
1397 "{file}"
1398 );
1399 }
1400 fs::remove_dir_all(root).unwrap();
1401 }
1402
1403 #[test]
1404 fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
1405 let root = repository(
1406 "automatic",
1407 &[
1408 ("package.json", r#"{"workspaces":["packages/*"]}"#),
1409 ("src/index.ts", "export const root = true"),
1410 ("lib/helper.js", "export const helper = true"),
1411 ("src/index.test.ts", "test('root', () => {})"),
1412 ("tests/e2e.spec.ts", "test('e2e', () => {})"),
1413 ("scripts/release.mjs", "export const release = true"),
1414 ("vite.config.ts", "export default {}"),
1415 ("build.mjs", "export default async function build() {}"),
1416 (".eslintrc.cjs", "module.exports = {}"),
1417 (".graphqlrc.ts", "export default {}"),
1418 ("orphan.ts", "export const missed = true"),
1419 ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
1420 ("packages/ui/src/index.ts", "export const ui = true"),
1421 ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
1422 ("dist/generated.js", "generated"),
1423 (".cache/tool/generated.js", "cached"),
1424 ("src/parse.bench.ts", "bench('parse', () => {})"),
1425 (
1426 "benchmarks/value-slice.bench.ts",
1427 "bench('slice', () => {})",
1428 ),
1429 ("src/benchmarkReport.ts", "export const report = 1"),
1430 ],
1431 );
1432 let discovered = discover_source_scope(&root, None).unwrap();
1433 assert_eq!(
1434 discovered.source_files,
1435 [
1436 "lib/helper.js",
1437 "packages/ui/src/index.ts",
1438 "src/benchmarkReport.ts",
1439 "src/index.ts"
1440 ]
1441 );
1442 for file in ["src/parse.bench.ts", "benchmarks/value-slice.bench.ts"] {
1443 assert_eq!(entry(&discovered, file).reason, "benchmark", "{file}");
1444 }
1445 assert_eq!(
1446 entry(&discovered, "orphan.ts").status,
1447 SourceScopeStatus::Ambiguous
1448 );
1449 assert_eq!(
1450 entry(&discovered, "scripts/release.mjs").reason,
1451 "conventional tool script"
1452 );
1453 assert_eq!(
1454 entry(&discovered, "build.mjs").reason,
1455 "build/test/tool configuration"
1456 );
1457 assert_eq!(
1458 entry(&discovered, "packages/ui/src/index.ts").package_root,
1459 Some("packages/ui".into())
1460 );
1461 assert_eq!(discovered.limitations.len(), 1);
1462 assert_eq!(discovered.limitations[0].file, "orphan.ts");
1463 assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
1464 assert!(
1465 discovered
1466 .scope
1467 .entries
1468 .iter()
1469 .all(|entry| !entry.file.contains(".cache"))
1470 );
1471 fs::remove_dir_all(root).unwrap();
1472 }
1473
1474 #[test]
1475 fn declared_workspaces_outside_conventional_parents_are_package_roots() {
1476 let root = repository(
1477 "declared-workspaces",
1478 &[
1479 ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1480 ("app/main.ts", "product"),
1481 ("app_extensions/discounts/package.json", "{}"),
1482 ("app_extensions/discounts/src/index.ts", "extension"),
1483 ("app_extensions/upsells/package.json", "{}"),
1486 ("app_extensions/upsells/frontend/embed.ts", "embed"),
1487 ("app_extensions/upsells/dist/embed.js", "built"),
1488 ],
1489 );
1490 let discovered = discover_source_scope(&root, None).unwrap();
1491 assert_eq!(
1492 discovered.source_files,
1493 [
1494 "app/main.ts",
1495 "app_extensions/discounts/src/index.ts",
1496 "app_extensions/upsells/frontend/embed.ts",
1497 ]
1498 );
1499 assert!(
1500 discovered.limitations.is_empty(),
1501 "declared packages must not be blockers: {:?}",
1502 discovered.limitations
1503 );
1504 fs::remove_dir_all(root).unwrap();
1505 }
1506
1507 #[test]
1508 fn hashed_bundles_in_asset_directories_are_built_assets() {
1509 let root = repository(
1510 "built-assets",
1511 &[
1512 ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1513 ("app/main.ts", "product"),
1514 ("app_extensions/upsells/package.json", "{}"),
1515 ("app_extensions/upsells/frontend/embed.ts", "source"),
1516 (
1517 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1518 "bundle",
1519 ),
1520 ("app_extensions/upsells/assets/stylex-DAnmLURx.js", "bundle"),
1521 (
1523 "app_extensions/upsells/assets/theme-helper.js",
1524 "hand written",
1525 ),
1526 ],
1527 );
1528 let discovered = discover_source_scope(&root, None).unwrap();
1529 assert!(
1530 !discovered
1531 .source_files
1532 .iter()
1533 .any(|file| file.contains("-Be-aUw9g.js") || file.contains("-DAnmLURx.js"))
1534 );
1535 assert_eq!(
1536 entry(
1537 &discovered,
1538 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js"
1539 )
1540 .reason,
1541 "built asset"
1542 );
1543 assert!(
1544 discovered.limitations.is_empty(),
1545 "bundles are not blockers: {:?}",
1546 discovered.limitations
1547 );
1548 fs::remove_dir_all(root).unwrap();
1549 }
1550
1551 #[test]
1552 fn root_level_hidden_directories_are_tooling_not_source() {
1553 let root = repository(
1554 "root-hidden",
1555 &[
1556 ("package.json", "{}"),
1557 ("app/main.ts", "product"),
1558 (".shopify/bundle/upsells/frontend/embed.js", "cli bundle"),
1559 (".vercel/output/functions/index.js", "deploy output"),
1560 ("app/.generated/schema.ts", "generated types"),
1563 ],
1564 );
1565 let discovered = discover_source_scope(&root, None).unwrap();
1566 assert_eq!(
1567 discovered.source_files,
1568 ["app/.generated/schema.ts", "app/main.ts"]
1569 );
1570 assert!(
1571 discovered.limitations.is_empty(),
1572 "{:?}",
1573 discovered.limitations
1574 );
1575 assert!(discovered.scope.entries.iter().all(
1576 |entry| !entry.file.starts_with(".shopify/") && !entry.file.starts_with(".vercel/")
1577 ));
1578 fs::remove_dir_all(root).unwrap();
1579 }
1580
1581 #[test]
1582 fn nested_checkouts_are_neither_source_nor_limitations() {
1583 let root = repository(
1584 "nested-checkout",
1585 &[
1586 ("package.json", "{}"),
1587 ("app/main.ts", "product"),
1588 (".claude/worktrees/agent-1/.git", "gitdir: /elsewhere"),
1591 (".claude/worktrees/agent-1/app/main.ts", "copy"),
1592 ("vendor-fork/.git/HEAD", "ref: refs/heads/main"),
1593 ("vendor-fork/src/index.ts", "clone"),
1594 ],
1595 );
1596 let discovered = discover_source_scope(&root, None).unwrap();
1597 assert_eq!(discovered.source_files, ["app/main.ts"]);
1598 assert!(
1599 discovered.limitations.is_empty(),
1600 "nested checkouts must not be blocking limitations: {:?}",
1601 discovered.limitations
1602 );
1603 assert!(
1604 discovered
1605 .scope
1606 .entries
1607 .iter()
1608 .all(|entry| !entry.file.starts_with(".claude/")
1609 && !entry.file.starts_with("vendor-fork/")),
1610 "nested checkout files must not appear in scope at all"
1611 );
1612 fs::remove_dir_all(root).unwrap();
1613 }
1614
1615 #[test]
1616 fn roots_enter_a_runs_identity_only_when_named() {
1617 let mut unchanged = b"pytest\0-q".to_vec();
1620 fold_roots_into_configuration(&mut unchanged, None);
1621 assert_eq!(unchanged, b"pytest\0-q");
1622 fold_roots_into_configuration(&mut unchanged, Some(&[]));
1623 assert_eq!(unchanged, b"pytest\0-q");
1624
1625 let mut folded = b"pytest\0-q".to_vec();
1626 fold_roots_into_configuration(&mut folded, Some(&["src".to_owned(), "a.py".to_owned()]));
1627 assert_eq!(folded, b"pytest\0-q\0SUPERCOV_SOURCE_ROOTS=src,a.py");
1628 }
1629
1630 #[test]
1631 fn the_variable_is_read_the_same_way_for_every_frontend() {
1632 let read = |value: &str| {
1633 configured_source_roots(&BTreeMap::from([(
1634 SOURCE_ROOTS_VARIABLE.to_owned(),
1635 value.to_owned(),
1636 )]))
1637 };
1638 assert_eq!(
1639 read(" src , app ,"),
1640 Some(vec!["src".to_owned(), "app".to_owned()])
1641 );
1642 assert_eq!(
1643 read(" , "),
1644 None,
1645 "naming nothing leaves discovery automatic"
1646 );
1647 assert_eq!(configured_source_roots(&BTreeMap::new()), None);
1648 }
1649
1650 #[test]
1651 fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
1652 let root = repository(
1653 "explicit",
1654 &[
1655 ("package.json", "{}"),
1656 ("product/main.ts", "product"),
1657 ("orphan.ts", "outside"),
1658 ],
1659 );
1660 let roots = vec!["product".into()];
1661 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1662 assert_eq!(discovered.source_files, ["product/main.ts"]);
1663 assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
1664 assert_eq!(
1665 entry(&discovered, "orphan.ts").reason,
1666 "outside explicit source roots"
1667 );
1668 assert!(discovered.limitations.is_empty());
1669 fs::remove_dir_all(root).unwrap();
1670 }
1671
1672 #[test]
1673 fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
1674 let root = repository(
1675 "jsonc",
1676 &[
1677 ("package.json", r#"{"main":"./dist/index.js"}"#),
1678 (
1679 "tsconfig.json",
1680 "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
1681 ),
1682 ("events.ts", "event"),
1683 ("žalias.ts", "unicode"),
1684 ("library.test.ts", "test"),
1685 ],
1686 );
1687 let discovered = discover_source_scope(&root, None).unwrap();
1688 assert!(discovered.source_roots.contains(&".".into()));
1689 assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
1690 assert!(discovered.limitations.is_empty());
1691 fs::remove_dir_all(root).unwrap();
1692 }
1693
1694 #[cfg(unix)]
1695 #[test]
1696 fn never_follows_source_directory_or_explicit_root_symlinks() {
1697 use std::os::unix::fs::symlink;
1698
1699 let root = repository(
1700 "symlink",
1701 &[("package.json", "{}"), ("src/real.ts", "real")],
1702 );
1703 let outside = repository("outside", &[("secret.ts", "secret")]);
1704 symlink(&outside, root.join("linked")).unwrap();
1705 symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
1706 let discovered = discover_source_scope(&root, None).unwrap();
1707 assert_eq!(discovered.source_files, ["src/real.ts"]);
1708 let explicit = vec!["linked".into()];
1709 assert!(matches!(
1710 discover_source_scope(&root, Some(&explicit)),
1711 Err(SourceDiscoveryError::InvalidRoot(_))
1712 ));
1713 fs::remove_dir_all(root).unwrap();
1714 fs::remove_dir_all(outside).unwrap();
1715 }
1716
1717 #[test]
1718 fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
1719 let root = repository(
1720 "workspace-store",
1721 &[
1722 ("package.json", "{}"),
1723 ("src/main.ts", "main"),
1724 ("supercov/user.ts", "user code"),
1725 ],
1726 );
1727 let before = discover_source_scope(&root, None).unwrap();
1728 assert_eq!(
1729 entry(&before, "supercov/user.ts").status,
1730 SourceScopeStatus::Ambiguous
1731 );
1732 fs::write(
1733 root.join("supercov/.supercov-workspace-store"),
1734 b"Supercov instrumented workspace. Safe to delete.\n",
1735 )
1736 .unwrap();
1737 let after = discover_source_scope(&root, None).unwrap();
1738 assert!(
1739 after
1740 .scope
1741 .entries
1742 .iter()
1743 .all(|entry| !entry.file.starts_with("supercov/"))
1744 );
1745 fs::remove_dir_all(root).unwrap();
1746 }
1747}