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] = &[
63 "app",
64 "src",
65 "lib",
66 "server",
67 "client",
68 "functions",
69 "api",
70 "components",
71 "pages",
72];
73const NEXT_ROOT_FILES: &[&str] = &[
76 "proxy",
77 "middleware",
78 "instrumentation",
79 "instrumentation-client",
80];
81const NEXT_APP_FILES: &[&str] = &[
83 "page",
84 "layout",
85 "route",
86 "loading",
87 "error",
88 "global-error",
89 "not-found",
90 "template",
91 "default",
92 "forbidden",
93 "unauthorized",
94 "icon",
95 "apple-icon",
96 "opengraph-image",
97 "twitter-image",
98 "sitemap",
99 "robots",
100 "manifest",
101];
102const PACKAGE_PARENTS: &[&str] = &["apps", "packages", "services", "workspaces"];
103const TEST_DIRECTORIES: &[&str] = &[
104 "__tests__",
105 "test",
106 "tests",
107 "spec",
108 "specs",
109 "e2e",
110 "fixture",
111 "fixtures",
112 "mock",
113 "mocks",
114 "__mocks__",
115];
116const CONFIG_TOOLS: &[&str] = &[
117 "babel",
118 "eslint",
119 "graphql",
120 "jest",
121 "next",
122 "nuxt",
123 "playwright",
124 "postcss",
125 "prettier",
126 "remix",
127 "rollup",
128 "stylelint",
129 "tailwind",
130 "tsup",
131 "vite",
132 "vitest",
133 "webpack",
134];
135const DOT_CONFIG_TOOLS: &[&str] = &["babel", "eslint", "graphql", "prettier", "stylelint"];
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "lowercase")]
139pub enum SourceScopeStatus {
140 Included,
141 Excluded,
142 Ambiguous,
143}
144
145pub const BUILT_ASSET_REASON: &str = "built asset";
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase", deny_unknown_fields)]
150pub struct SourceScopeEntry {
151 pub file: String,
152 pub status: SourceScopeStatus,
153 pub reason: String,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub package_root: Option<String>,
156}
157
158impl SourceScopeEntry {
159 pub fn is_generated_output(&self) -> bool {
163 self.status == SourceScopeStatus::Excluded && self.reason == BUILT_ASSET_REASON
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "lowercase")]
169pub enum SourceScopeMode {
170 Automatic,
171 Explicit,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct SourceScope {
177 pub version: u32,
178 pub mode: SourceScopeMode,
179 pub roots: Vec<String>,
180 pub entries: Vec<SourceScopeEntry>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase", deny_unknown_fields)]
185pub struct SourceLimitation {
186 pub id: String,
187 pub kind: String,
188 pub file: String,
189 pub line: usize,
190 pub column: usize,
191 pub source: String,
192 pub reason: String,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase", deny_unknown_fields)]
197pub struct DiscoveredSourceScope {
198 pub source_files: Vec<String>,
199 pub source_roots: Vec<String>,
200 pub scope: SourceScope,
201 pub limitations: Vec<SourceLimitation>,
202}
203
204#[derive(Debug)]
205pub enum SourceDiscoveryError {
206 Io { path: PathBuf, source: io::Error },
207 NonUtf8Path(PathBuf),
208 InvalidRoot(PathBuf),
209}
210
211impl std::fmt::Display for SourceDiscoveryError {
212 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213 match self {
214 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
215 Self::NonUtf8Path(path) => {
216 write!(
217 formatter,
218 "source path is not valid UTF-8: {}",
219 path.display()
220 )
221 }
222 Self::InvalidRoot(path) => write!(
223 formatter,
224 "source root is not a regular file or directory: {}",
225 path.display()
226 ),
227 }
228 }
229}
230
231impl std::error::Error for SourceDiscoveryError {}
232
233fn io_error(path: &Path, source: io::Error) -> SourceDiscoveryError {
234 SourceDiscoveryError::Io {
235 path: path.to_owned(),
236 source,
237 }
238}
239
240pub const SOURCE_ROOTS_VARIABLE: &str = "SUPERCOV_SOURCE_ROOTS";
242
243pub const OUTSIDE_EXPLICIT_ROOTS: &str = "outside explicit source roots";
245
246pub fn configured_source_roots(environment: &BTreeMap<String, String>) -> Option<Vec<String>> {
256 let roots = environment
257 .get(SOURCE_ROOTS_VARIABLE)?
258 .split(',')
259 .map(str::trim)
260 .filter(|root| !root.is_empty())
261 .map(str::to_owned)
262 .collect::<Vec<_>>();
263 (!roots.is_empty()).then_some(roots)
264}
265
266pub fn fold_roots_into_configuration(configuration: &mut Vec<u8>, roots: Option<&[String]>) {
272 let Some(roots) = roots.filter(|roots| !roots.is_empty()) else {
273 return;
274 };
275 configuration.push(0);
276 configuration
277 .extend_from_slice(format!("{SOURCE_ROOTS_VARIABLE}={}", roots.join(",")).as_bytes());
278}
279
280#[derive(Debug, Clone)]
286pub struct ExplicitSourceRoots {
287 root: PathBuf,
288 roots: Vec<PathBuf>,
289 configured: Vec<String>,
290}
291
292impl ExplicitSourceRoots {
293 pub fn resolve(root: &Path, configured: &[String]) -> Result<Self, SourceDiscoveryError> {
294 let root = lexical_normalize(root);
295 let roots = existing_roots(configured.iter().map(|value| resolve(&root, value)), true)?;
296 Ok(Self {
297 root,
298 roots,
299 configured: configured.to_vec(),
300 })
301 }
302
303 pub fn from_environment(
305 root: &Path,
306 environment: &BTreeMap<String, String>,
307 ) -> Result<Option<Self>, SourceDiscoveryError> {
308 configured_source_roots(environment)
309 .map(|configured| Self::resolve(root, &configured))
310 .transpose()
311 }
312
313 pub fn from_environment_in(
320 project: &Path,
321 workspace: &Path,
322 environment: &BTreeMap<String, String>,
323 ) -> Result<Option<Self>, SourceDiscoveryError> {
324 let Some(configured) = configured_source_roots(environment) else {
325 return Ok(None);
326 };
327 let project = lexical_normalize(project);
328 let rebased = configured
329 .iter()
330 .map(|value| {
331 let path = Path::new(value);
332 if !path.is_absolute() {
333 return value.clone();
334 }
335 lexical_normalize(path)
336 .strip_prefix(&project)
337 .map(|rest| {
338 let rest = rest.to_string_lossy().into_owned();
339 if rest.is_empty() { ".".into() } else { rest }
340 })
341 .unwrap_or_else(|_| value.clone())
342 })
343 .collect::<Vec<_>>();
344 let mut roots = Self::resolve(workspace, &rebased)?;
345 roots.configured = configured;
346 Ok(Some(roots))
347 }
348
349 pub fn contains(&self, relative: &str) -> bool {
352 root_contains(&self.roots, &lexical_normalize(&self.root.join(relative)))
353 }
354
355 pub fn configured(&self) -> &[String] {
359 &self.configured
360 }
361
362 pub fn refuse_if_empty(&self, remaining: usize, language: &str) -> Result<(), String> {
366 if remaining > 0 {
367 return Ok(());
368 }
369 let missing = self
370 .configured
371 .iter()
372 .filter(|value| fs::symlink_metadata(resolve(&self.root, value)).is_err())
373 .cloned()
374 .collect::<Vec<_>>();
375 let mut message = format!(
376 "{SOURCE_ROOTS_VARIABLE}={} left no {language} source to measure",
377 self.configured.join(",")
378 );
379 if !missing.is_empty() {
380 message.push_str(&format!("; {} does not exist", missing.join(", ")));
381 }
382 message.push_str("; name the directories or files that hold your own code");
383 Err(message)
384 }
385
386 pub fn scope(
392 &self,
393 included: &[String],
394 excluded: &[(String, &'static str)],
395 ) -> Result<SourceScope, SourceDiscoveryError> {
396 let mut entries = included
397 .iter()
398 .map(|file| SourceScopeEntry {
399 file: file.clone(),
400 status: SourceScopeStatus::Included,
401 reason: "explicit source root".into(),
402 package_root: None,
403 })
404 .chain(
405 excluded
406 .iter()
407 .filter(|(_, reason)| *reason == OUTSIDE_EXPLICIT_ROOTS)
408 .map(|(file, reason)| SourceScopeEntry {
409 file: file.clone(),
410 status: SourceScopeStatus::Excluded,
411 reason: (*reason).into(),
412 package_root: None,
413 }),
414 )
415 .collect::<Vec<_>>();
416 entries.sort_by(|left, right| left.file.cmp(&right.file));
417 Ok(SourceScope {
418 version: 1,
419 mode: SourceScopeMode::Explicit,
420 roots: self.local_roots()?,
421 entries,
422 })
423 }
424
425 pub fn local_roots(&self) -> Result<Vec<String>, SourceDiscoveryError> {
427 self.roots
428 .iter()
429 .map(|path| local_path(&self.root, path))
430 .collect()
431 }
432
433 pub fn narrow<T>(
438 &self,
439 sources: &mut Vec<T>,
440 excluded: &mut Vec<(String, &'static str)>,
441 file: impl Fn(&T) -> &str,
442 ) {
443 let (kept, outside): (Vec<T>, Vec<T>) = sources
444 .drain(..)
445 .partition(|source| self.contains(file(source)));
446 excluded.extend(
447 outside
448 .iter()
449 .map(|source| (file(source).to_owned(), OUTSIDE_EXPLICIT_ROOTS)),
450 );
451 *sources = kept;
452 }
453}
454
455fn existing_roots(
462 candidates: impl IntoIterator<Item = PathBuf>,
463 named: bool,
464) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
465 let mut existing = BTreeSet::new();
466 for path in candidates {
467 match fs::symlink_metadata(&path) {
468 Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_dir() => {
469 existing.insert(path);
470 }
471 Ok(_) if !named => {}
472 Ok(_) => return Err(SourceDiscoveryError::InvalidRoot(path)),
473 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
474 Err(error) => return Err(io_error(&path, error)),
475 }
476 }
477 Ok(existing.into_iter().collect())
478}
479
480fn root_contains(roots: &[PathBuf], path: &Path) -> bool {
483 roots.iter().any(|directory| {
484 fs::symlink_metadata(directory)
485 .map(|metadata| {
486 if metadata.file_type().is_dir() {
487 within(directory, path)
488 } else {
489 directory == path
490 }
491 })
492 .unwrap_or(false)
493 })
494}
495
496fn lexical_normalize(path: &Path) -> PathBuf {
497 let mut output = PathBuf::new();
498 for component in path.components() {
499 match component {
500 Component::CurDir => {}
501 Component::ParentDir => {
502 if !output.pop() {
503 output.push(component.as_os_str());
504 }
505 }
506 _ => output.push(component.as_os_str()),
507 }
508 }
509 output
510}
511
512fn resolve(root: &Path, value: impl AsRef<Path>) -> PathBuf {
513 let value = value.as_ref();
514 let joined;
515 let path = if value.is_absolute() {
516 value
517 } else {
518 joined = root.join(value);
519 &joined
520 };
521 lexical_normalize(path)
522}
523
524fn local_path(root: &Path, path: &Path) -> Result<String, SourceDiscoveryError> {
525 let local = path
526 .strip_prefix(root)
527 .map_err(|_| SourceDiscoveryError::InvalidRoot(path.to_owned()))?;
528 if local.as_os_str().is_empty() {
529 return Ok(".".into());
530 }
531 local
532 .components()
533 .map(|component| {
534 component
535 .as_os_str()
536 .to_str()
537 .map(str::to_owned)
538 .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(path.to_owned()))
539 })
540 .collect::<Result<Vec<_>, _>>()
541 .map(|parts| parts.join("/"))
542}
543
544fn generated_directory(name: &str) -> bool {
545 GENERATED_DIRECTORIES.contains(&name)
546}
547
548fn owned_workspace_store(path: &Path) -> bool {
549 crate::workspace::owned_workspace_path(path)
550}
551
552fn nested_checkout(path: &Path) -> bool {
557 fs::symlink_metadata(path.join(".git")).is_ok()
558}
559
560fn built_asset(file: &str) -> bool {
568 let mut segments = file.rsplit('/');
569 let Some(name) = segments.next() else {
570 return false;
571 };
572 let in_asset_directory =
573 segments.any(|segment| matches!(segment, "assets" | "static" | "public"));
574 let Some(stem) = name
575 .strip_suffix(".js")
576 .or_else(|| name.strip_suffix(".mjs"))
577 .or_else(|| name.strip_suffix(".cjs"))
578 else {
579 return false;
580 };
581 if stem.len() < 10 || stem.as_bytes()[stem.len() - 9] != b'-' {
584 return false;
585 }
586 let hash = &stem[stem.len() - 8..];
587 let looks_hashed = hash
588 .bytes()
589 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
590 && (hash.bytes().any(|byte| byte.is_ascii_digit())
591 || (hash.bytes().any(|byte| byte.is_ascii_uppercase())
592 && hash.bytes().any(|byte| byte.is_ascii_lowercase())));
593 in_asset_directory && looks_hashed
594}
595
596fn root_tool_directory(root: &Path, path: &Path) -> bool {
597 path.parent() == Some(root)
598 && path
599 .file_name()
600 .and_then(|name| name.to_str())
601 .is_some_and(|name| name.starts_with('.'))
602}
603
604fn source_file(name: &str) -> bool {
605 let lower = name.to_ascii_lowercase();
606 [
607 ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
608 ".mtsx",
609 ]
610 .iter()
611 .any(|extension| lower.ends_with(extension))
612}
613
614fn declaration_file(file: &str) -> bool {
615 let lower = file.to_ascii_lowercase();
616 lower.ends_with(".d.ts") || lower.ends_with(".d.cts") || lower.ends_with(".d.mts")
617}
618
619fn test_or_fixture(file: &str) -> bool {
620 let lower = file.to_ascii_lowercase();
621 if lower
622 .split('/')
623 .any(|segment| TEST_DIRECTORIES.contains(&segment))
624 {
625 return true;
626 }
627 lower
628 .split(['/', '_', '.', '-'])
629 .any(|part| matches!(part, "test" | "spec"))
630}
631
632fn depends_on_next(manifest: &Value) -> bool {
633 ["dependencies", "devDependencies", "peerDependencies"]
634 .iter()
635 .any(|field| {
636 manifest
637 .get(field)
638 .is_some_and(|all| all.get("next").is_some())
639 })
640}
641
642fn next_app_file(app_directories: &[PathBuf], path: &Path) -> bool {
648 let Some(local) = app_directories
649 .iter()
650 .find_map(|directory| path.strip_prefix(directory).ok())
651 else {
652 return false;
653 };
654 let Some(name) = local.file_name().and_then(|name| name.to_str()) else {
655 return false;
656 };
657 let conventional = name.split_once('.').is_some_and(|(stem, extension)| {
658 NEXT_APP_FILES.contains(&stem) && !extension.contains('.')
659 });
660 conventional
661 && local.parent().is_none_or(|folders| {
662 folders
663 .components()
664 .all(|folder| !folder.as_os_str().to_string_lossy().starts_with('_'))
665 })
666}
667
668fn below_named_root<'a>(roots: &[PathBuf], path: &'a Path) -> Option<&'a Path> {
673 roots
674 .iter()
675 .filter_map(|root| path.strip_prefix(root).ok())
676 .min_by_key(|local| local.components().count())
677}
678
679pub fn beside_the_product(file: &str) -> Option<&'static str> {
690 let (first, _) = file.split_once('/')?;
691 match first.to_ascii_lowercase().as_str() {
692 "docs" | "doc" | "documentation" => Some("documentation"),
693 "examples" | "example" => Some("example"),
694 "bench" | "benches" | "benchmark" | "benchmarks" => Some("benchmark"),
695 "fuzz" | "fuzzing" => Some("fuzz harness"),
696 _ => None,
697 }
698}
699
700pub fn leave_out_what_lies_beside(
703 sources: &mut Vec<String>,
704 excluded: &mut Vec<(String, &'static str)>,
705) {
706 sources.retain(|file| match beside_the_product(file) {
707 Some(reason) => {
708 excluded.push((file.clone(), reason));
709 false
710 }
711 None => true,
712 });
713}
714
715pub fn automatic_scope(
720 measured: impl IntoIterator<Item = (String, String)>,
721 left_out: impl IntoIterator<Item = (String, String)>,
722) -> SourceScope {
723 let entry = |status: SourceScopeStatus| {
724 move |(file, reason)| SourceScopeEntry {
725 file,
726 status: status.clone(),
727 reason,
728 package_root: None,
729 }
730 };
731 let mut entries = measured
732 .into_iter()
733 .map(entry(SourceScopeStatus::Included))
734 .chain(left_out.into_iter().map(entry(SourceScopeStatus::Excluded)))
735 .collect::<Vec<_>>();
736 entries.sort_by(|left, right| left.file.cmp(&right.file));
737 SourceScope {
738 version: 1,
739 mode: SourceScopeMode::Automatic,
740 roots: vec![".".into()],
741 entries,
742 }
743}
744
745fn benchmark(file: &str) -> bool {
750 let lower = file.to_ascii_lowercase();
751 let (directories, name) = lower.rsplit_once('/').unwrap_or(("", lower.as_str()));
752 directories
753 .split('/')
754 .any(|segment| matches!(segment, "bench" | "benches" | "benchmark" | "benchmarks"))
755 || name
756 .split('.')
757 .skip(1)
758 .any(|part| matches!(part, "bench" | "benchmark"))
759}
760
761fn example(file: &str, package_root: Option<&str>) -> bool {
767 let local = package_root
768 .and_then(|package| file.strip_prefix(package))
769 .and_then(|rest| rest.strip_prefix('/'))
770 .unwrap_or(file);
771 local.split_once('/').is_some_and(|(first, _)| {
772 matches!(first.to_ascii_lowercase().as_str(), "example" | "examples")
773 })
774}
775
776fn tool_script(file: &str) -> bool {
777 file.to_ascii_lowercase()
778 .split('/')
779 .any(|segment| segment == "scripts")
780}
781
782fn config_file(file: &str) -> bool {
783 let lower = file.to_ascii_lowercase();
784 let name = lower.rsplit('/').next().unwrap_or(&lower);
785 source_file(name)
786 && (CONFIG_TOOLS
787 .iter()
788 .any(|tool| name.starts_with(&format!("{tool}.config.")))
789 || DOT_CONFIG_TOOLS
790 .iter()
791 .any(|tool| name.starts_with(&format!(".{tool}rc.")))
792 || (!lower.contains('/')
793 && (name.contains(".config.")
794 || name.contains(".conf.")
796 || name.starts_with("build.")
797 || name.starts_with("gulpfile.")
798 || name.starts_with("gruntfile."))))
799}
800
801fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, SourceDiscoveryError> {
802 let mut entries = fs::read_dir(path)
803 .map_err(|error| io_error(path, error))?
804 .collect::<Result<Vec<_>, _>>()
805 .map_err(|error| io_error(path, error))?;
806 entries.sort_by_key(fs::DirEntry::file_name);
807 Ok(entries)
808}
809
810fn ignored_by_enclosing_repository(root: &Path) -> bool {
815 if root.join(".git").exists() {
816 return false;
817 }
818 let Some(repository) = root
819 .ancestors()
820 .skip(1)
821 .position(|ancestor| ancestor.join(".git").exists())
822 else {
823 return false;
824 };
825 root.ancestors()
827 .skip(1)
828 .take(repository + 1)
829 .collect::<Vec<_>>()
830 .into_iter()
831 .rev()
832 .fold(false, |ignored, directory| {
833 let (rules, _) = ignore::gitignore::Gitignore::new(directory.join(".gitignore"));
834 match rules.matched_path_or_any_parents(root, true) {
835 ignore::Match::Ignore(_) => true,
836 ignore::Match::Whitelist(_) => false,
837 ignore::Match::None => ignored,
838 }
839 })
840}
841
842fn files_under(
851 root: &Path,
852 directory: &Path,
853 output: &mut Vec<PathBuf>,
854) -> Result<(), SourceDiscoveryError> {
855 let metadata = fs::symlink_metadata(directory).map_err(|error| io_error(directory, error))?;
856 if !metadata.file_type().is_dir() {
857 return Err(SourceDiscoveryError::InvalidRoot(directory.to_owned()));
858 }
859 let project = root.to_owned();
860 let walker = ignore::WalkBuilder::new(directory)
861 .standard_filters(false)
862 .git_ignore(true)
863 .parents(!ignored_by_enclosing_repository(directory))
864 .require_git(true)
865 .follow_links(false)
866 .sort_by_file_name(|left, right| left.cmp(right))
867 .filter_entry(move |entry| {
868 entry.depth() == 0
869 || !entry.file_type().is_some_and(|kind| kind.is_dir())
870 || entry.file_name().to_str().is_some_and(|name| {
871 !generated_directory(name)
872 && !owned_workspace_store(entry.path())
873 && !nested_checkout(entry.path())
874 && !root_tool_directory(&project, entry.path())
875 })
876 })
877 .build();
878 for entry in walker {
879 let entry =
880 entry.map_err(|error| io_error(directory, io::Error::other(error.to_string())))?;
881 let Some(kind) = entry.file_type() else {
882 continue;
883 };
884 if kind.is_symlink() || !kind.is_file() {
885 continue;
886 }
887 let name = entry
888 .file_name()
889 .to_str()
890 .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(entry.path().to_owned()))?;
891 if source_file(name) {
892 output.push(entry.into_path());
893 }
894 }
895 Ok(())
896}
897
898fn read_json(path: &Path) -> Option<Value> {
899 serde_json::from_slice(&fs::read(path).ok()?).ok()
900}
901
902fn package_directories(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
903 fn visit(
904 root: &Path,
905 directory: &Path,
906 depth: usize,
907 found: &mut BTreeSet<PathBuf>,
908 ) -> Result<(), SourceDiscoveryError> {
909 if depth > 5 {
910 return Ok(());
911 }
912 for entry in read_directory(directory)? {
913 let name = entry
914 .file_name()
915 .into_string()
916 .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
917 let path = entry.path();
918 let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
919 if !file_type.is_dir()
920 || file_type.is_symlink()
921 || generated_directory(&name)
922 || owned_workspace_store(&path)
923 || nested_checkout(&path)
924 || root_tool_directory(root, &path)
925 {
926 continue;
927 }
928 let local = local_path(root, &path)?;
929 let under_package_parent = local
930 .split('/')
931 .any(|segment| PACKAGE_PARENTS.contains(&segment));
932 if path.join("package.json").is_file() && (depth == 0 || under_package_parent) {
933 found.insert(path.clone());
934 }
935 visit(root, &path, depth + 1, found)?;
936 }
937 Ok(())
938 }
939
940 let mut found = BTreeSet::from([root.to_owned()]);
941 visit(root, root, 0, &mut found)?;
942 for directory in declared_workspace_packages(root)? {
943 found.insert(directory);
944 }
945 Ok(found.into_iter().collect())
946}
947
948fn declared_workspace_packages(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
955 let mut patterns = Vec::new();
956 let manifest = read_json(&root.join("package.json")).unwrap_or(Value::Null);
957 let declared = match manifest.get("workspaces") {
958 Some(Value::Array(items)) => Some(items),
959 Some(Value::Object(object)) => object.get("packages").and_then(Value::as_array),
960 _ => None,
961 };
962 if let Some(items) = declared {
963 patterns.extend(items.iter().filter_map(Value::as_str).map(str::to_owned));
964 }
965 if let Ok(pnpm) = fs::read_to_string(root.join("pnpm-workspace.yaml")) {
966 let mut in_packages = false;
967 for line in pnpm.lines() {
968 let trimmed = line.trim();
969 if trimmed.starts_with("packages:") {
970 in_packages = true;
971 continue;
972 }
973 if in_packages {
974 if let Some(item) = trimmed.strip_prefix("- ") {
975 patterns.push(item.trim_matches(|c| c == '"' || c == '\'').to_owned());
976 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
977 in_packages = false;
978 }
979 }
980 }
981 }
982 let mut found = Vec::new();
983 for pattern in patterns {
984 let pattern = pattern.trim_start_matches("./").trim_end_matches('/');
985 if pattern.starts_with('!') || pattern.contains("**") {
986 continue;
987 }
988 let (parent, wildcard) = match pattern.strip_suffix("/*") {
989 Some(parent) => (parent, true),
990 None => (pattern, false),
991 };
992 if parent.is_empty()
993 || parent
994 .split('/')
995 .any(|segment| segment.is_empty() || segment == ".." || segment.contains('*'))
996 {
997 continue;
998 }
999 let base = root.join(parent);
1000 let candidates: Vec<PathBuf> = if wildcard {
1001 match fs::read_dir(&base) {
1002 Ok(entries) => entries
1003 .filter_map(Result::ok)
1004 .map(|entry| entry.path())
1005 .collect(),
1006 Err(_) => Vec::new(),
1007 }
1008 } else {
1009 vec![base]
1010 };
1011 for candidate in candidates {
1012 let is_dir = fs::symlink_metadata(&candidate)
1013 .is_ok_and(|metadata| metadata.file_type().is_dir());
1014 if is_dir
1015 && candidate.join("package.json").is_file()
1016 && !nested_checkout(&candidate)
1017 && !owned_workspace_store(&candidate)
1018 {
1019 found.push(candidate);
1020 }
1021 }
1022 }
1023 Ok(found)
1024}
1025
1026fn string_targets(value: &Value, depth: usize, output: &mut Vec<String>) {
1027 if depth > 8 {
1028 return;
1029 }
1030 match value {
1031 Value::String(value) => output.push(value.clone()),
1032 Value::Array(values) => {
1033 for value in values {
1034 string_targets(value, depth + 1, output);
1035 }
1036 }
1037 Value::Object(values) => {
1038 for value in values.values() {
1039 string_targets(value, depth + 1, output);
1040 }
1041 }
1042 _ => {}
1043 }
1044}
1045
1046fn entry_targets(directory: &Path, manifest: &Value) -> Vec<PathBuf> {
1047 let mut targets = Vec::new();
1048 for key in ["main", "module", "browser", "bin", "exports"] {
1049 if let Some(value) = manifest.get(key) {
1050 string_targets(value, 0, &mut targets);
1051 }
1052 }
1053 targets
1054 .into_iter()
1055 .filter_map(|target| {
1056 if !target.starts_with('.') || target.contains("node_modules") {
1057 return None;
1058 }
1059 let prefix = target.split('*').next()?.trim_end_matches('/');
1060 (!prefix.is_empty()).then(|| resolve(directory, prefix))
1061 })
1062 .collect()
1063}
1064
1065pub(crate) fn strip_jsonc_comments(contents: &str) -> String {
1066 let mut output = String::with_capacity(contents.len());
1067 let mut chars = contents.chars().peekable();
1068 let mut string = false;
1069 let mut escaped = false;
1070 while let Some(character) = chars.next() {
1071 if string {
1072 output.push(character);
1073 if escaped {
1074 escaped = false;
1075 } else if character == '\\' {
1076 escaped = true;
1077 } else if character == '"' {
1078 string = false;
1079 }
1080 continue;
1081 }
1082 if character == '"' {
1083 string = true;
1084 output.push(character);
1085 } else if character == '/' && chars.peek() == Some(&'/') {
1086 chars.next();
1087 for comment in chars.by_ref() {
1088 if comment == '\n' {
1089 output.push('\n');
1090 break;
1091 }
1092 }
1093 } else if character == '/' && chars.peek() == Some(&'*') {
1094 chars.next();
1095 let mut previous = '\0';
1096 for comment in chars.by_ref() {
1097 if previous == '*' && comment == '/' {
1098 break;
1099 }
1100 previous = comment;
1101 }
1102 } else {
1103 output.push(character);
1104 }
1105 }
1106 output
1107}
1108
1109pub(crate) fn strip_trailing_commas(contents: &str) -> String {
1110 let chars = contents.chars().collect::<Vec<_>>();
1111 let mut output = String::with_capacity(contents.len());
1112 let mut string = false;
1113 let mut escaped = false;
1114 for (index, character) in chars.iter().copied().enumerate() {
1115 if string {
1116 output.push(character);
1117 if escaped {
1118 escaped = false;
1119 } else if character == '\\' {
1120 escaped = true;
1121 } else if character == '"' {
1122 string = false;
1123 }
1124 continue;
1125 }
1126 if character == '"' {
1127 string = true;
1128 output.push(character);
1129 } else if character == ','
1130 && chars[index + 1..]
1131 .iter()
1132 .find(|character| !character.is_whitespace())
1133 .is_some_and(|character| matches!(character, '}' | ']'))
1134 {
1135 } else {
1136 output.push(character);
1137 }
1138 }
1139 output
1140}
1141
1142fn tsconfig_roots(directory: &Path) -> Vec<PathBuf> {
1143 let path = directory.join("tsconfig.json");
1144 let Some(contents) = fs::read_to_string(path).ok() else {
1145 return Vec::new();
1146 };
1147 let jsonc = strip_trailing_commas(&strip_jsonc_comments(&contents));
1148 let Some(config) = serde_json::from_str::<Value>(&jsonc).ok() else {
1149 return Vec::new();
1150 };
1151 let mut values = Vec::new();
1152 if let Some(root_dir) = config
1153 .get("compilerOptions")
1154 .and_then(|options| options.get("rootDir"))
1155 .and_then(Value::as_str)
1156 {
1157 values.push(root_dir.to_owned());
1158 }
1159 if let Some(include) = config.get("include").and_then(Value::as_array) {
1160 values.extend(include.iter().filter_map(Value::as_str).map(str::to_owned));
1161 }
1162 if values.is_empty() {
1163 return vec![directory.to_owned()];
1164 }
1165 values
1166 .into_iter()
1167 .filter_map(|value| {
1168 if value.starts_with('!') {
1169 return None;
1170 }
1171 let prefix = value
1172 .find(['?', '*', '{', '['])
1173 .map_or(value.as_str(), |index| &value[..index])
1174 .trim_end_matches('/');
1175 (!prefix.is_empty() && !declaration_file(prefix)).then(|| resolve(directory, prefix))
1178 })
1179 .collect()
1180}
1181
1182fn within(parent: &Path, child: &Path) -> bool {
1183 child == parent || child.starts_with(parent)
1184}
1185
1186fn nearest_package_root<'a>(path: &Path, packages: &'a [PathBuf]) -> Option<&'a PathBuf> {
1187 packages
1188 .iter()
1189 .filter(|directory| within(directory, path))
1190 .max_by_key(|directory| directory.components().count())
1191}
1192
1193fn scope_limitation(file: &str) -> SourceLimitation {
1194 let digest = Sha256::digest(file.as_bytes());
1195 let id = digest[..10]
1196 .iter()
1197 .map(|byte| format!("{byte:02x}"))
1198 .collect::<String>();
1199 SourceLimitation {
1200 id: format!("scope:{id}"),
1201 kind: "source-scope".into(),
1202 file: file.into(),
1203 line: 1,
1204 column: 1,
1205 source: file.into(),
1206 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(),
1207 }
1208}
1209
1210fn declared_test_setup(packages: &[PathBuf]) -> BTreeSet<PathBuf> {
1214 let mut paths = BTreeSet::new();
1215 for package in packages {
1216 let manifest = read_json(&package.join("package.json")).unwrap_or(Value::Null);
1217 let Some(jest) = manifest.get("jest") else {
1218 continue;
1219 };
1220 let root = jest
1221 .get("rootDir")
1222 .and_then(Value::as_str)
1223 .map(|value| {
1224 resolve(
1225 package,
1226 value.replace("<rootDir>", &package.to_string_lossy()),
1227 )
1228 })
1229 .unwrap_or_else(|| package.clone());
1230 for field in [
1231 "setupFiles",
1232 "setupFilesAfterEnv",
1233 "globalSetup",
1234 "globalTeardown",
1235 ] {
1236 let values = match jest.get(field) {
1237 Some(Value::Array(values)) => values.iter().collect::<Vec<_>>(),
1238 Some(value) => vec![value],
1239 None => Vec::new(),
1240 };
1241 for value in values.into_iter().filter_map(Value::as_str) {
1242 let path = resolve(&root, value.replace("<rootDir>", &root.to_string_lossy()));
1243 if path.is_file() {
1244 paths.insert(path);
1245 }
1246 }
1247 }
1248 }
1249 paths
1250}
1251
1252pub fn discover_source_scope(
1253 root: &Path,
1254 configured_roots: Option<&[String]>,
1255) -> Result<DiscoveredSourceScope, SourceDiscoveryError> {
1256 let root = lexical_normalize(root);
1257 let root_metadata = fs::symlink_metadata(&root).map_err(|error| io_error(&root, error))?;
1258 if !root_metadata.file_type().is_dir() {
1259 return Err(SourceDiscoveryError::InvalidRoot(root));
1260 }
1261 let packages = package_directories(&root)?;
1262 let test_setup = declared_test_setup(&packages);
1263 let explicit = configured_roots.is_some_and(|roots| !roots.is_empty());
1264 let include_roots = if explicit {
1265 configured_roots
1266 .unwrap_or_default()
1267 .iter()
1268 .map(|directory| resolve(&root, directory))
1269 .collect::<Vec<_>>()
1270 } else {
1271 packages
1272 .iter()
1273 .flat_map(|directory| {
1274 let manifest = read_json(&directory.join("package.json")).unwrap_or(Value::Null);
1275 let next_files = depends_on_next(&manifest)
1276 .then_some(NEXT_ROOT_FILES)
1277 .into_iter()
1278 .flatten()
1279 .flat_map(|name| {
1280 ["ts", "tsx", "js", "jsx", "mjs"]
1281 .map(|extension| directory.join(format!("{name}.{extension}")))
1282 });
1283 let candidates = SOURCE_DIRECTORIES
1284 .iter()
1285 .map(|name| directory.join(name))
1286 .chain(next_files)
1287 .chain(entry_targets(directory, &manifest))
1288 .chain(tsconfig_roots(directory))
1289 .filter(|candidate| within(&root, candidate))
1294 .collect::<Vec<_>>();
1295 if directory != &root
1301 && !candidates
1302 .iter()
1303 .any(|candidate| fs::symlink_metadata(candidate).is_ok())
1304 {
1305 return vec![directory.clone()];
1306 }
1307 candidates
1308 })
1309 .collect()
1310 };
1311 let existing_roots = existing_roots(include_roots, explicit)?;
1312 let app_directories = packages
1313 .iter()
1314 .filter(|directory| {
1315 depends_on_next(&read_json(&directory.join("package.json")).unwrap_or(Value::Null))
1316 })
1317 .flat_map(|directory| [directory.join("app"), directory.join("src/app")])
1318 .collect::<Vec<_>>();
1319 let mut all_files = Vec::new();
1320 files_under(&root, &root, &mut all_files)?;
1321 all_files.sort();
1322
1323 let mut entries = Vec::new();
1324 let mut included = Vec::new();
1325 let mut limitations = Vec::new();
1326 for path in all_files {
1327 let file = local_path(&root, &path)?;
1328 let package_root = nearest_package_root(&path, &packages)
1329 .map(|package| local_path(&root, package))
1330 .transpose()?
1331 .filter(|package| package != ".");
1332 let entry = |status, reason: &str| SourceScopeEntry {
1333 file: file.clone(),
1334 status,
1335 reason: reason.into(),
1336 package_root: package_root.clone(),
1337 };
1338 let named = explicit
1339 .then(|| below_named_root(&existing_roots, &path))
1340 .flatten();
1341 let guessed = match named {
1342 Some(local) => local.to_string_lossy().replace('\\', "/"),
1343 None => file.clone(),
1344 };
1345 if declaration_file(&file) {
1346 entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
1347 } else if test_setup.contains(&path) {
1348 entries.push(entry(SourceScopeStatus::Excluded, "declared test setup"));
1349 } else if test_or_fixture(&guessed) && !next_app_file(&app_directories, &path) {
1350 entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
1351 } else if tool_script(&guessed) {
1352 entries.push(entry(
1353 SourceScopeStatus::Excluded,
1354 "conventional tool script",
1355 ));
1356 } else if benchmark(&guessed) {
1357 entries.push(entry(SourceScopeStatus::Excluded, "benchmark"));
1358 } else if example(&file, package_root.as_deref()) {
1359 entries.push(entry(SourceScopeStatus::Excluded, "example"));
1360 } else if config_file(&file) {
1361 entries.push(entry(
1362 SourceScopeStatus::Excluded,
1363 "build/test/tool configuration",
1364 ));
1365 } else if built_asset(&file) {
1366 entries.push(entry(SourceScopeStatus::Excluded, BUILT_ASSET_REASON));
1367 } else if root_contains(&existing_roots, &path) {
1368 included.push(path);
1369 entries.push(entry(
1370 SourceScopeStatus::Included,
1371 if explicit {
1372 "explicit source root"
1373 } else {
1374 "discovered package source root"
1375 },
1376 ));
1377 } else if explicit {
1378 entries.push(entry(
1379 SourceScopeStatus::Excluded,
1380 "outside explicit source roots",
1381 ));
1382 } else {
1383 entries.push(entry(
1384 SourceScopeStatus::Ambiguous,
1385 "unclassified first-party source",
1386 ));
1387 limitations.push(scope_limitation(&file));
1388 }
1389 }
1390 let source_files = included
1391 .iter()
1392 .map(|path| local_path(&root, path))
1393 .collect::<Result<Vec<_>, _>>()?;
1394 let source_roots = existing_roots
1395 .iter()
1396 .map(|path| local_path(&root, path))
1397 .collect::<Result<Vec<_>, _>>()?;
1398 Ok(DiscoveredSourceScope {
1399 source_files,
1400 source_roots: source_roots.clone(),
1401 scope: SourceScope {
1402 version: 1,
1403 mode: if explicit {
1404 SourceScopeMode::Explicit
1405 } else {
1406 SourceScopeMode::Automatic
1407 },
1408 roots: source_roots,
1409 entries,
1410 },
1411 limitations,
1412 })
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417 use std::{
1418 fs,
1419 time::{SystemTime, UNIX_EPOCH},
1420 };
1421
1422 use super::*;
1423
1424 fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
1425 let nonce = SystemTime::now()
1426 .duration_since(UNIX_EPOCH)
1427 .unwrap()
1428 .as_nanos();
1429 let root = std::env::temp_dir().join(format!(
1430 "supercov-source-{label}-{}-{nonce}",
1431 std::process::id()
1432 ));
1433 fs::create_dir_all(&root).unwrap();
1434 for (file, contents) in files {
1435 let path = root.join(file);
1436 fs::create_dir_all(path.parent().unwrap()).unwrap();
1437 fs::write(path, contents).unwrap();
1438 }
1439 root
1440 }
1441
1442 fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
1443 scope
1444 .scope
1445 .entries
1446 .iter()
1447 .find(|entry| entry.file == file)
1448 .unwrap()
1449 }
1450
1451 #[test]
1452 fn a_file_the_project_tells_git_to_ignore_is_not_its_source() {
1453 let files = [
1456 ("package.json", r#"{"workspaces":["packages/*"]}"#),
1457 ("packages/core/package.json", r#"{"name":"core"}"#),
1458 ("packages/core/.gitignore", "lib-dist\n"),
1459 ("packages/core/src/index.ts", "export const a = 1;"),
1460 (
1461 "packages/core/lib-dist/browser/worker.dev.js",
1462 "var bundled = 1;",
1463 ),
1464 ];
1465 let repository_root = repository("gitignored", &files);
1466 fs::create_dir_all(repository_root.join(".git")).unwrap();
1467 let discovered = discover_source_scope(&repository_root, None).unwrap();
1468 assert!(
1469 discovered
1470 .source_files
1471 .contains(&"packages/core/src/index.ts".into())
1472 );
1473 assert!(
1474 !discovered
1475 .source_files
1476 .iter()
1477 .any(|file| file.contains("lib-dist")),
1478 "{:?}",
1479 discovered.source_files
1480 );
1481 fs::remove_dir_all(repository_root).unwrap();
1482
1483 let plain = repository("not-a-repository", &files);
1485 let discovered = discover_source_scope(&plain, None).unwrap();
1486 assert!(
1487 discovered
1488 .scope
1489 .entries
1490 .iter()
1491 .any(|entry| entry.file.contains("lib-dist"))
1492 );
1493 fs::remove_dir_all(plain).unwrap();
1494 }
1495
1496 #[test]
1497 fn a_project_kept_where_an_enclosing_repository_ignores_it_keeps_its_source() {
1498 let files = [
1499 ("scratch/.gitignore", "*\n"),
1501 ("scratch/app/package.json", r#"{"name":"app"}"#),
1502 ("scratch/app/.gitignore", "dist\n"),
1503 ("scratch/app/src/index.js", "export const a = 1;"),
1504 ("scratch/app/dist/bundle.js", "var bundled = 1;"),
1505 ];
1506 let repository_root = repository("enclosing-ignore", &files);
1507 fs::create_dir_all(repository_root.join(".git")).unwrap();
1508 let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1509 assert_eq!(discovered.source_files, ["src/index.js"]);
1510
1511 fs::remove_file(repository_root.join("scratch/.gitignore")).unwrap();
1513 fs::write(repository_root.join(".gitignore"), "*.gen.js\n").unwrap();
1514 fs::write(repository_root.join("scratch/app/src/api.gen.js"), "var a;").unwrap();
1515 let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1516 assert_eq!(discovered.source_files, ["src/index.js"]);
1517 fs::remove_dir_all(repository_root).unwrap();
1518 }
1519
1520 #[test]
1521 fn excludes_declared_jest_setup_without_hiding_neighboring_application_code() {
1522 let root = repository(
1523 "jest-setup",
1524 &[
1525 (
1526 "package.json",
1527 r#"{"jest":{"setupFilesAfterEnv":["<rootDir>/src/bootstrap.js"]}}"#,
1528 ),
1529 (
1530 "src/bootstrap.js",
1531 "jest.mock('storage', () => ({read: () => null}));",
1532 ),
1533 ("src/index.js", "export const value = 1;"),
1534 ],
1535 );
1536 let discovered = discover_source_scope(&root, None).unwrap();
1537 assert_eq!(discovered.source_files, ["src/index.js"]);
1538 assert_eq!(
1539 entry(&discovered, "src/bootstrap.js").reason,
1540 "declared test setup"
1541 );
1542 fs::remove_dir_all(root).unwrap();
1543 }
1544
1545 #[test]
1546 fn example_apps_and_conf_files_are_not_source() {
1547 let root = repository(
1549 "examples",
1550 &[
1551 ("package.json", r#"{"exports":{".":"./dist/index.js"}}"#),
1552 ("tsconfig.json", r#"{"exclude":["dist","examples"]}"#),
1553 ("src/v4.ts", "export const v4 = () => ''"),
1554 ("wdio.conf.js", "exports.config = {}"),
1555 ("karma.conf.cjs", "module.exports = () => {}"),
1556 ("examples/browser-esmodules/package.json", "{}"),
1557 ("examples/browser-esmodules/example.js", "console.log(1)"),
1558 ("examples/utils/testpage.js", "export default 1"),
1559 ("example/demo.js", "console.log(1)"),
1560 ("app/examples/page.tsx", "export default function Page() {}"),
1561 ],
1562 );
1563 let discovered = discover_source_scope(&root, None).unwrap();
1564 assert_eq!(
1565 discovered.source_files,
1566 ["app/examples/page.tsx", "src/v4.ts"]
1567 );
1568 for file in [
1569 "examples/browser-esmodules/example.js",
1570 "examples/utils/testpage.js",
1571 "example/demo.js",
1572 ] {
1573 assert_eq!(entry(&discovered, file).reason, "example", "{file}");
1574 }
1575 for file in ["wdio.conf.js", "karma.conf.cjs"] {
1576 assert_eq!(
1577 entry(&discovered, file).reason,
1578 "build/test/tool configuration",
1579 "{file}"
1580 );
1581 }
1582 fs::remove_dir_all(root).unwrap();
1583 }
1584
1585 #[test]
1586 fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
1587 let root = repository(
1588 "automatic",
1589 &[
1590 ("package.json", r#"{"workspaces":["packages/*"]}"#),
1591 ("src/index.ts", "export const root = true"),
1592 ("lib/helper.js", "export const helper = true"),
1593 ("src/index.test.ts", "test('root', () => {})"),
1594 ("tests/e2e.spec.ts", "test('e2e', () => {})"),
1595 ("scripts/release.mjs", "export const release = true"),
1596 ("vite.config.ts", "export default {}"),
1597 ("build.mjs", "export default async function build() {}"),
1598 (".eslintrc.cjs", "module.exports = {}"),
1599 (".graphqlrc.ts", "export default {}"),
1600 ("orphan.ts", "export const missed = true"),
1601 ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
1602 ("packages/ui/src/index.ts", "export const ui = true"),
1603 ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
1604 ("dist/generated.js", "generated"),
1605 (".cache/tool/generated.js", "cached"),
1606 ("src/parse.bench.ts", "bench('parse', () => {})"),
1607 (
1608 "benchmarks/value-slice.bench.ts",
1609 "bench('slice', () => {})",
1610 ),
1611 ("src/benchmarkReport.ts", "export const report = 1"),
1612 ],
1613 );
1614 let discovered = discover_source_scope(&root, None).unwrap();
1615 assert_eq!(
1616 discovered.source_files,
1617 [
1618 "lib/helper.js",
1619 "packages/ui/src/index.ts",
1620 "src/benchmarkReport.ts",
1621 "src/index.ts"
1622 ]
1623 );
1624 for file in ["src/parse.bench.ts", "benchmarks/value-slice.bench.ts"] {
1625 assert_eq!(entry(&discovered, file).reason, "benchmark", "{file}");
1626 }
1627 assert_eq!(
1628 entry(&discovered, "orphan.ts").status,
1629 SourceScopeStatus::Ambiguous
1630 );
1631 assert_eq!(
1632 entry(&discovered, "scripts/release.mjs").reason,
1633 "conventional tool script"
1634 );
1635 assert_eq!(
1636 entry(&discovered, "build.mjs").reason,
1637 "build/test/tool configuration"
1638 );
1639 assert_eq!(
1640 entry(&discovered, "packages/ui/src/index.ts").package_root,
1641 Some("packages/ui".into())
1642 );
1643 assert_eq!(discovered.limitations.len(), 1);
1644 assert_eq!(discovered.limitations[0].file, "orphan.ts");
1645 assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
1646 assert!(
1647 discovered
1648 .scope
1649 .entries
1650 .iter()
1651 .all(|entry| !entry.file.contains(".cache"))
1652 );
1653 fs::remove_dir_all(root).unwrap();
1654 }
1655
1656 #[test]
1657 fn declared_workspaces_outside_conventional_parents_are_package_roots() {
1658 let root = repository(
1659 "declared-workspaces",
1660 &[
1661 ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1662 ("app/main.ts", "product"),
1663 ("app_extensions/discounts/package.json", "{}"),
1664 ("app_extensions/discounts/src/index.ts", "extension"),
1665 ("app_extensions/upsells/package.json", "{}"),
1668 ("app_extensions/upsells/frontend/embed.ts", "embed"),
1669 ("app_extensions/upsells/dist/embed.js", "built"),
1670 ],
1671 );
1672 let discovered = discover_source_scope(&root, None).unwrap();
1673 assert_eq!(
1674 discovered.source_files,
1675 [
1676 "app/main.ts",
1677 "app_extensions/discounts/src/index.ts",
1678 "app_extensions/upsells/frontend/embed.ts",
1679 ]
1680 );
1681 assert!(
1682 discovered.limitations.is_empty(),
1683 "declared packages must not be blockers: {:?}",
1684 discovered.limitations
1685 );
1686 fs::remove_dir_all(root).unwrap();
1687 }
1688
1689 #[test]
1690 fn hashed_bundles_in_asset_directories_are_built_assets() {
1691 let root = repository(
1692 "built-assets",
1693 &[
1694 ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1695 ("app/main.ts", "product"),
1696 ("app_extensions/upsells/package.json", "{}"),
1697 ("app_extensions/upsells/frontend/embed.ts", "source"),
1698 (
1699 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1700 "bundle",
1701 ),
1702 ("app_extensions/upsells/assets/stylex-DAnmLURx.js", "bundle"),
1703 (
1705 "app_extensions/upsells/assets/theme-helper.js",
1706 "hand written",
1707 ),
1708 ],
1709 );
1710 let discovered = discover_source_scope(&root, None).unwrap();
1711 assert!(
1712 !discovered
1713 .source_files
1714 .iter()
1715 .any(|file| file.contains("-Be-aUw9g.js") || file.contains("-DAnmLURx.js"))
1716 );
1717 assert_eq!(
1718 entry(
1719 &discovered,
1720 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js"
1721 )
1722 .reason,
1723 "built asset"
1724 );
1725 assert!(
1726 discovered.limitations.is_empty(),
1727 "bundles are not blockers: {:?}",
1728 discovered.limitations
1729 );
1730 fs::remove_dir_all(root).unwrap();
1731 }
1732
1733 #[test]
1734 fn root_level_hidden_directories_are_tooling_not_source() {
1735 let root = repository(
1736 "root-hidden",
1737 &[
1738 ("package.json", "{}"),
1739 ("app/main.ts", "product"),
1740 (".shopify/bundle/upsells/frontend/embed.js", "cli bundle"),
1741 (".vercel/output/functions/index.js", "deploy output"),
1742 ("app/.generated/schema.ts", "generated types"),
1745 ],
1746 );
1747 let discovered = discover_source_scope(&root, None).unwrap();
1748 assert_eq!(
1749 discovered.source_files,
1750 ["app/.generated/schema.ts", "app/main.ts"]
1751 );
1752 assert!(
1753 discovered.limitations.is_empty(),
1754 "{:?}",
1755 discovered.limitations
1756 );
1757 assert!(discovered.scope.entries.iter().all(
1758 |entry| !entry.file.starts_with(".shopify/") && !entry.file.starts_with(".vercel/")
1759 ));
1760 fs::remove_dir_all(root).unwrap();
1761 }
1762
1763 #[test]
1764 fn nested_checkouts_are_neither_source_nor_limitations() {
1765 let root = repository(
1766 "nested-checkout",
1767 &[
1768 ("package.json", "{}"),
1769 ("app/main.ts", "product"),
1770 (".claude/worktrees/agent-1/.git", "gitdir: /elsewhere"),
1773 (".claude/worktrees/agent-1/app/main.ts", "copy"),
1774 ("vendor-fork/.git/HEAD", "ref: refs/heads/main"),
1775 ("vendor-fork/src/index.ts", "clone"),
1776 ],
1777 );
1778 let discovered = discover_source_scope(&root, None).unwrap();
1779 assert_eq!(discovered.source_files, ["app/main.ts"]);
1780 assert!(
1781 discovered.limitations.is_empty(),
1782 "nested checkouts must not be blocking limitations: {:?}",
1783 discovered.limitations
1784 );
1785 assert!(
1786 discovered
1787 .scope
1788 .entries
1789 .iter()
1790 .all(|entry| !entry.file.starts_with(".claude/")
1791 && !entry.file.starts_with("vendor-fork/")),
1792 "nested checkout files must not appear in scope at all"
1793 );
1794 fs::remove_dir_all(root).unwrap();
1795 }
1796
1797 #[test]
1798 fn roots_enter_a_runs_identity_only_when_named() {
1799 let mut unchanged = b"pytest\0-q".to_vec();
1802 fold_roots_into_configuration(&mut unchanged, None);
1803 assert_eq!(unchanged, b"pytest\0-q");
1804 fold_roots_into_configuration(&mut unchanged, Some(&[]));
1805 assert_eq!(unchanged, b"pytest\0-q");
1806
1807 let mut folded = b"pytest\0-q".to_vec();
1808 fold_roots_into_configuration(&mut folded, Some(&["src".to_owned(), "a.py".to_owned()]));
1809 assert_eq!(folded, b"pytest\0-q\0SUPERCOV_SOURCE_ROOTS=src,a.py");
1810 }
1811
1812 #[test]
1813 fn the_variable_is_read_the_same_way_for_every_frontend() {
1814 let read = |value: &str| {
1815 configured_source_roots(&BTreeMap::from([(
1816 SOURCE_ROOTS_VARIABLE.to_owned(),
1817 value.to_owned(),
1818 )]))
1819 };
1820 assert_eq!(
1821 read(" src , app ,"),
1822 Some(vec!["src".to_owned(), "app".to_owned()])
1823 );
1824 assert_eq!(
1825 read(" , "),
1826 None,
1827 "naming nothing leaves discovery automatic"
1828 );
1829 assert_eq!(configured_source_roots(&BTreeMap::new()), None);
1830 }
1831
1832 #[test]
1833 fn a_next_application_is_measured_where_next_keeps_its_code() {
1834 let root = repository(
1839 "next-layout",
1840 &[
1841 ("package.json", r#"{"dependencies":{"next":"16.1.1"}}"#),
1842 (
1843 "tsconfig.json",
1844 r#"{"include":["next-env.d.ts","**/*.ts",".next/types/**/*.ts"]}"#,
1845 ),
1846 ("next-env.d.ts", "declare const next: unknown;"),
1847 ("app/page.tsx", "page"),
1848 ("app/api/wordpress/test/route.ts", "route"),
1849 ("app/api/wordpress/test/route.test.ts", "test"),
1850 ("app/api/wordpress/test/helper.ts", "helper"),
1851 ("app/__tests__/page.tsx", "test"),
1852 ("components/button.tsx", "component"),
1853 ("pages/api/legacy.ts", "page"),
1854 ("proxy.ts", "proxy"),
1855 ("instrumentation.ts", "instrumentation"),
1856 ("simulators/echo.ts", "simulator"),
1857 ],
1858 );
1859 let discovered = discover_source_scope(&root, None).unwrap();
1860 assert_eq!(
1861 discovered.source_files,
1862 [
1863 "app/api/wordpress/test/route.ts",
1864 "app/page.tsx",
1865 "components/button.tsx",
1866 "instrumentation.ts",
1867 "pages/api/legacy.ts",
1868 "proxy.ts",
1869 ]
1870 );
1871 assert_eq!(
1872 discovered.source_roots,
1873 [
1874 "app",
1875 "components",
1876 "instrumentation.ts",
1877 "pages",
1878 "proxy.ts"
1879 ],
1880 "a declaration file is not a root"
1881 );
1882 for file in [
1883 "app/api/wordpress/test/route.test.ts",
1884 "app/api/wordpress/test/helper.ts",
1885 "app/__tests__/page.tsx",
1886 ] {
1887 assert_eq!(entry(&discovered, file).reason, "test or fixture source");
1888 }
1889 assert_eq!(
1891 entry(&discovered, "simulators/echo.ts").status,
1892 SourceScopeStatus::Ambiguous
1893 );
1894 let roots = vec!["app".into()];
1896 let named = discover_source_scope(&root, Some(&roots)).unwrap();
1897 assert_eq!(
1898 named.source_files,
1899 ["app/api/wordpress/test/route.ts", "app/page.tsx"]
1900 );
1901 fs::remove_dir_all(root).unwrap();
1902 }
1903
1904 #[test]
1905 fn a_route_file_name_means_nothing_outside_a_next_application() {
1906 let root = repository(
1907 "not-next",
1908 &[
1909 ("package.json", "{}"),
1910 ("app/main.ts", "main"),
1911 ("app/test/route.ts", "a test helper"),
1912 ("proxy.ts", "unknown"),
1913 ],
1914 );
1915 let discovered = discover_source_scope(&root, None).unwrap();
1916 assert_eq!(discovered.source_files, ["app/main.ts"]);
1917 assert_eq!(
1918 entry(&discovered, "app/test/route.ts").reason,
1919 "test or fixture source"
1920 );
1921 assert_eq!(
1922 entry(&discovered, "proxy.ts").status,
1923 SourceScopeStatus::Ambiguous
1924 );
1925 fs::remove_dir_all(root).unwrap();
1926 }
1927
1928 #[test]
1929 fn a_named_root_settles_what_it_names_and_guesses_only_below_it() {
1930 let root = repository(
1934 "named-roots",
1935 &[
1936 ("package.json", "{}"),
1937 ("src/main.ts", "main"),
1938 ("src/test-utils/build.ts", "helper"),
1939 ("src/test-utils/__tests__/build.test.ts", "test"),
1940 ("scripts/release.ts", "script"),
1941 ("scripts/fixtures/sample.ts", "fixture"),
1942 ("tools/one.spec.ts", "named file"),
1943 ],
1944 );
1945 let roots = vec!["src".into()];
1946 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1947 assert_eq!(discovered.source_files, ["src/main.ts"]);
1948
1949 let roots = vec![
1950 "src/test-utils".into(),
1951 "scripts".into(),
1952 "tools/one.spec.ts".into(),
1953 ];
1954 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1955 assert_eq!(
1956 discovered.source_files,
1957 [
1958 "scripts/release.ts",
1959 "src/test-utils/build.ts",
1960 "tools/one.spec.ts",
1961 ]
1962 );
1963 assert_eq!(
1964 entry(&discovered, "src/test-utils/__tests__/build.test.ts").reason,
1965 "test or fixture source"
1966 );
1967 assert_eq!(
1968 entry(&discovered, "scripts/fixtures/sample.ts").reason,
1969 "test or fixture source"
1970 );
1971 fs::remove_dir_all(root).unwrap();
1972 }
1973
1974 #[test]
1975 fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
1976 let root = repository(
1977 "explicit",
1978 &[
1979 ("package.json", "{}"),
1980 ("product/main.ts", "product"),
1981 ("orphan.ts", "outside"),
1982 ],
1983 );
1984 let roots = vec!["product".into()];
1985 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1986 assert_eq!(discovered.source_files, ["product/main.ts"]);
1987 assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
1988 assert_eq!(
1989 entry(&discovered, "orphan.ts").reason,
1990 "outside explicit source roots"
1991 );
1992 assert!(discovered.limitations.is_empty());
1993 fs::remove_dir_all(root).unwrap();
1994 }
1995
1996 #[test]
1997 fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
1998 let root = repository(
1999 "jsonc",
2000 &[
2001 ("package.json", r#"{"main":"./dist/index.js"}"#),
2002 (
2003 "tsconfig.json",
2004 "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
2005 ),
2006 ("events.ts", "event"),
2007 ("žalias.ts", "unicode"),
2008 ("library.test.ts", "test"),
2009 ],
2010 );
2011 let discovered = discover_source_scope(&root, None).unwrap();
2012 assert!(discovered.source_roots.contains(&".".into()));
2013 assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
2014 assert!(discovered.limitations.is_empty());
2015 fs::remove_dir_all(root).unwrap();
2016 }
2017
2018 #[cfg(unix)]
2019 #[test]
2020 fn never_follows_source_directory_or_explicit_root_symlinks() {
2021 use std::os::unix::fs::symlink;
2022
2023 let root = repository(
2024 "symlink",
2025 &[("package.json", "{}"), ("src/real.ts", "real")],
2026 );
2027 let outside = repository("outside", &[("secret.ts", "secret")]);
2028 symlink(&outside, root.join("linked")).unwrap();
2029 symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
2030 let discovered = discover_source_scope(&root, None).unwrap();
2031 assert_eq!(discovered.source_files, ["src/real.ts"]);
2032 let explicit = vec!["linked".into()];
2033 assert!(matches!(
2034 discover_source_scope(&root, Some(&explicit)),
2035 Err(SourceDiscoveryError::InvalidRoot(_))
2036 ));
2037 fs::remove_dir_all(root).unwrap();
2038 fs::remove_dir_all(outside).unwrap();
2039 }
2040
2041 #[test]
2042 fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
2043 let root = repository(
2044 "workspace-store",
2045 &[
2046 ("package.json", "{}"),
2047 ("src/main.ts", "main"),
2048 ("supercov/user.ts", "user code"),
2049 ],
2050 );
2051 let before = discover_source_scope(&root, None).unwrap();
2052 assert_eq!(
2053 entry(&before, "supercov/user.ts").status,
2054 SourceScopeStatus::Ambiguous
2055 );
2056 fs::write(
2057 root.join("supercov/.supercov-workspace-store"),
2058 b"Supercov instrumented workspace. Safe to delete.\n",
2059 )
2060 .unwrap();
2061 let after = discover_source_scope(&root, None).unwrap();
2062 assert!(
2063 after
2064 .scope
2065 .entries
2066 .iter()
2067 .all(|entry| !entry.file.starts_with("supercov/"))
2068 );
2069 fs::remove_dir_all(root).unwrap();
2070 }
2071}