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 .collect::<Vec<_>>();
1290 if directory != &root
1296 && !candidates
1297 .iter()
1298 .any(|candidate| fs::symlink_metadata(candidate).is_ok())
1299 {
1300 return vec![directory.clone()];
1301 }
1302 candidates
1303 })
1304 .collect()
1305 };
1306 let existing_roots = existing_roots(include_roots, explicit)?;
1307 let app_directories = packages
1308 .iter()
1309 .filter(|directory| {
1310 depends_on_next(&read_json(&directory.join("package.json")).unwrap_or(Value::Null))
1311 })
1312 .flat_map(|directory| [directory.join("app"), directory.join("src/app")])
1313 .collect::<Vec<_>>();
1314 let mut all_files = Vec::new();
1315 files_under(&root, &root, &mut all_files)?;
1316 all_files.sort();
1317
1318 let mut entries = Vec::new();
1319 let mut included = Vec::new();
1320 let mut limitations = Vec::new();
1321 for path in all_files {
1322 let file = local_path(&root, &path)?;
1323 let package_root = nearest_package_root(&path, &packages)
1324 .map(|package| local_path(&root, package))
1325 .transpose()?
1326 .filter(|package| package != ".");
1327 let entry = |status, reason: &str| SourceScopeEntry {
1328 file: file.clone(),
1329 status,
1330 reason: reason.into(),
1331 package_root: package_root.clone(),
1332 };
1333 let named = explicit
1334 .then(|| below_named_root(&existing_roots, &path))
1335 .flatten();
1336 let guessed = match named {
1337 Some(local) => local.to_string_lossy().replace('\\', "/"),
1338 None => file.clone(),
1339 };
1340 if declaration_file(&file) {
1341 entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
1342 } else if test_setup.contains(&path) {
1343 entries.push(entry(SourceScopeStatus::Excluded, "declared test setup"));
1344 } else if test_or_fixture(&guessed) && !next_app_file(&app_directories, &path) {
1345 entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
1346 } else if tool_script(&guessed) {
1347 entries.push(entry(
1348 SourceScopeStatus::Excluded,
1349 "conventional tool script",
1350 ));
1351 } else if benchmark(&guessed) {
1352 entries.push(entry(SourceScopeStatus::Excluded, "benchmark"));
1353 } else if example(&file, package_root.as_deref()) {
1354 entries.push(entry(SourceScopeStatus::Excluded, "example"));
1355 } else if config_file(&file) {
1356 entries.push(entry(
1357 SourceScopeStatus::Excluded,
1358 "build/test/tool configuration",
1359 ));
1360 } else if built_asset(&file) {
1361 entries.push(entry(SourceScopeStatus::Excluded, BUILT_ASSET_REASON));
1362 } else if root_contains(&existing_roots, &path) {
1363 included.push(path);
1364 entries.push(entry(
1365 SourceScopeStatus::Included,
1366 if explicit {
1367 "explicit source root"
1368 } else {
1369 "discovered package source root"
1370 },
1371 ));
1372 } else if explicit {
1373 entries.push(entry(
1374 SourceScopeStatus::Excluded,
1375 "outside explicit source roots",
1376 ));
1377 } else {
1378 entries.push(entry(
1379 SourceScopeStatus::Ambiguous,
1380 "unclassified first-party source",
1381 ));
1382 limitations.push(scope_limitation(&file));
1383 }
1384 }
1385 let source_files = included
1386 .iter()
1387 .map(|path| local_path(&root, path))
1388 .collect::<Result<Vec<_>, _>>()?;
1389 let source_roots = existing_roots
1390 .iter()
1391 .map(|path| local_path(&root, path))
1392 .collect::<Result<Vec<_>, _>>()?;
1393 Ok(DiscoveredSourceScope {
1394 source_files,
1395 source_roots: source_roots.clone(),
1396 scope: SourceScope {
1397 version: 1,
1398 mode: if explicit {
1399 SourceScopeMode::Explicit
1400 } else {
1401 SourceScopeMode::Automatic
1402 },
1403 roots: source_roots,
1404 entries,
1405 },
1406 limitations,
1407 })
1408}
1409
1410#[cfg(test)]
1411mod tests {
1412 use std::{
1413 fs,
1414 time::{SystemTime, UNIX_EPOCH},
1415 };
1416
1417 use super::*;
1418
1419 fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
1420 let nonce = SystemTime::now()
1421 .duration_since(UNIX_EPOCH)
1422 .unwrap()
1423 .as_nanos();
1424 let root = std::env::temp_dir().join(format!(
1425 "supercov-source-{label}-{}-{nonce}",
1426 std::process::id()
1427 ));
1428 fs::create_dir_all(&root).unwrap();
1429 for (file, contents) in files {
1430 let path = root.join(file);
1431 fs::create_dir_all(path.parent().unwrap()).unwrap();
1432 fs::write(path, contents).unwrap();
1433 }
1434 root
1435 }
1436
1437 fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
1438 scope
1439 .scope
1440 .entries
1441 .iter()
1442 .find(|entry| entry.file == file)
1443 .unwrap()
1444 }
1445
1446 #[test]
1447 fn a_file_the_project_tells_git_to_ignore_is_not_its_source() {
1448 let files = [
1451 ("package.json", r#"{"workspaces":["packages/*"]}"#),
1452 ("packages/core/package.json", r#"{"name":"core"}"#),
1453 ("packages/core/.gitignore", "lib-dist\n"),
1454 ("packages/core/src/index.ts", "export const a = 1;"),
1455 (
1456 "packages/core/lib-dist/browser/worker.dev.js",
1457 "var bundled = 1;",
1458 ),
1459 ];
1460 let repository_root = repository("gitignored", &files);
1461 fs::create_dir_all(repository_root.join(".git")).unwrap();
1462 let discovered = discover_source_scope(&repository_root, None).unwrap();
1463 assert!(
1464 discovered
1465 .source_files
1466 .contains(&"packages/core/src/index.ts".into())
1467 );
1468 assert!(
1469 !discovered
1470 .source_files
1471 .iter()
1472 .any(|file| file.contains("lib-dist")),
1473 "{:?}",
1474 discovered.source_files
1475 );
1476 fs::remove_dir_all(repository_root).unwrap();
1477
1478 let plain = repository("not-a-repository", &files);
1480 let discovered = discover_source_scope(&plain, None).unwrap();
1481 assert!(
1482 discovered
1483 .scope
1484 .entries
1485 .iter()
1486 .any(|entry| entry.file.contains("lib-dist"))
1487 );
1488 fs::remove_dir_all(plain).unwrap();
1489 }
1490
1491 #[test]
1492 fn a_project_kept_where_an_enclosing_repository_ignores_it_keeps_its_source() {
1493 let files = [
1494 ("scratch/.gitignore", "*\n"),
1496 ("scratch/app/package.json", r#"{"name":"app"}"#),
1497 ("scratch/app/.gitignore", "dist\n"),
1498 ("scratch/app/src/index.js", "export const a = 1;"),
1499 ("scratch/app/dist/bundle.js", "var bundled = 1;"),
1500 ];
1501 let repository_root = repository("enclosing-ignore", &files);
1502 fs::create_dir_all(repository_root.join(".git")).unwrap();
1503 let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1504 assert_eq!(discovered.source_files, ["src/index.js"]);
1505
1506 fs::remove_file(repository_root.join("scratch/.gitignore")).unwrap();
1508 fs::write(repository_root.join(".gitignore"), "*.gen.js\n").unwrap();
1509 fs::write(repository_root.join("scratch/app/src/api.gen.js"), "var a;").unwrap();
1510 let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1511 assert_eq!(discovered.source_files, ["src/index.js"]);
1512 fs::remove_dir_all(repository_root).unwrap();
1513 }
1514
1515 #[test]
1516 fn excludes_declared_jest_setup_without_hiding_neighboring_application_code() {
1517 let root = repository(
1518 "jest-setup",
1519 &[
1520 (
1521 "package.json",
1522 r#"{"jest":{"setupFilesAfterEnv":["<rootDir>/src/bootstrap.js"]}}"#,
1523 ),
1524 (
1525 "src/bootstrap.js",
1526 "jest.mock('storage', () => ({read: () => null}));",
1527 ),
1528 ("src/index.js", "export const value = 1;"),
1529 ],
1530 );
1531 let discovered = discover_source_scope(&root, None).unwrap();
1532 assert_eq!(discovered.source_files, ["src/index.js"]);
1533 assert_eq!(
1534 entry(&discovered, "src/bootstrap.js").reason,
1535 "declared test setup"
1536 );
1537 fs::remove_dir_all(root).unwrap();
1538 }
1539
1540 #[test]
1541 fn example_apps_and_conf_files_are_not_source() {
1542 let root = repository(
1544 "examples",
1545 &[
1546 ("package.json", r#"{"exports":{".":"./dist/index.js"}}"#),
1547 ("tsconfig.json", r#"{"exclude":["dist","examples"]}"#),
1548 ("src/v4.ts", "export const v4 = () => ''"),
1549 ("wdio.conf.js", "exports.config = {}"),
1550 ("karma.conf.cjs", "module.exports = () => {}"),
1551 ("examples/browser-esmodules/package.json", "{}"),
1552 ("examples/browser-esmodules/example.js", "console.log(1)"),
1553 ("examples/utils/testpage.js", "export default 1"),
1554 ("example/demo.js", "console.log(1)"),
1555 ("app/examples/page.tsx", "export default function Page() {}"),
1556 ],
1557 );
1558 let discovered = discover_source_scope(&root, None).unwrap();
1559 assert_eq!(
1560 discovered.source_files,
1561 ["app/examples/page.tsx", "src/v4.ts"]
1562 );
1563 for file in [
1564 "examples/browser-esmodules/example.js",
1565 "examples/utils/testpage.js",
1566 "example/demo.js",
1567 ] {
1568 assert_eq!(entry(&discovered, file).reason, "example", "{file}");
1569 }
1570 for file in ["wdio.conf.js", "karma.conf.cjs"] {
1571 assert_eq!(
1572 entry(&discovered, file).reason,
1573 "build/test/tool configuration",
1574 "{file}"
1575 );
1576 }
1577 fs::remove_dir_all(root).unwrap();
1578 }
1579
1580 #[test]
1581 fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
1582 let root = repository(
1583 "automatic",
1584 &[
1585 ("package.json", r#"{"workspaces":["packages/*"]}"#),
1586 ("src/index.ts", "export const root = true"),
1587 ("lib/helper.js", "export const helper = true"),
1588 ("src/index.test.ts", "test('root', () => {})"),
1589 ("tests/e2e.spec.ts", "test('e2e', () => {})"),
1590 ("scripts/release.mjs", "export const release = true"),
1591 ("vite.config.ts", "export default {}"),
1592 ("build.mjs", "export default async function build() {}"),
1593 (".eslintrc.cjs", "module.exports = {}"),
1594 (".graphqlrc.ts", "export default {}"),
1595 ("orphan.ts", "export const missed = true"),
1596 ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
1597 ("packages/ui/src/index.ts", "export const ui = true"),
1598 ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
1599 ("dist/generated.js", "generated"),
1600 (".cache/tool/generated.js", "cached"),
1601 ("src/parse.bench.ts", "bench('parse', () => {})"),
1602 (
1603 "benchmarks/value-slice.bench.ts",
1604 "bench('slice', () => {})",
1605 ),
1606 ("src/benchmarkReport.ts", "export const report = 1"),
1607 ],
1608 );
1609 let discovered = discover_source_scope(&root, None).unwrap();
1610 assert_eq!(
1611 discovered.source_files,
1612 [
1613 "lib/helper.js",
1614 "packages/ui/src/index.ts",
1615 "src/benchmarkReport.ts",
1616 "src/index.ts"
1617 ]
1618 );
1619 for file in ["src/parse.bench.ts", "benchmarks/value-slice.bench.ts"] {
1620 assert_eq!(entry(&discovered, file).reason, "benchmark", "{file}");
1621 }
1622 assert_eq!(
1623 entry(&discovered, "orphan.ts").status,
1624 SourceScopeStatus::Ambiguous
1625 );
1626 assert_eq!(
1627 entry(&discovered, "scripts/release.mjs").reason,
1628 "conventional tool script"
1629 );
1630 assert_eq!(
1631 entry(&discovered, "build.mjs").reason,
1632 "build/test/tool configuration"
1633 );
1634 assert_eq!(
1635 entry(&discovered, "packages/ui/src/index.ts").package_root,
1636 Some("packages/ui".into())
1637 );
1638 assert_eq!(discovered.limitations.len(), 1);
1639 assert_eq!(discovered.limitations[0].file, "orphan.ts");
1640 assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
1641 assert!(
1642 discovered
1643 .scope
1644 .entries
1645 .iter()
1646 .all(|entry| !entry.file.contains(".cache"))
1647 );
1648 fs::remove_dir_all(root).unwrap();
1649 }
1650
1651 #[test]
1652 fn declared_workspaces_outside_conventional_parents_are_package_roots() {
1653 let root = repository(
1654 "declared-workspaces",
1655 &[
1656 ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1657 ("app/main.ts", "product"),
1658 ("app_extensions/discounts/package.json", "{}"),
1659 ("app_extensions/discounts/src/index.ts", "extension"),
1660 ("app_extensions/upsells/package.json", "{}"),
1663 ("app_extensions/upsells/frontend/embed.ts", "embed"),
1664 ("app_extensions/upsells/dist/embed.js", "built"),
1665 ],
1666 );
1667 let discovered = discover_source_scope(&root, None).unwrap();
1668 assert_eq!(
1669 discovered.source_files,
1670 [
1671 "app/main.ts",
1672 "app_extensions/discounts/src/index.ts",
1673 "app_extensions/upsells/frontend/embed.ts",
1674 ]
1675 );
1676 assert!(
1677 discovered.limitations.is_empty(),
1678 "declared packages must not be blockers: {:?}",
1679 discovered.limitations
1680 );
1681 fs::remove_dir_all(root).unwrap();
1682 }
1683
1684 #[test]
1685 fn hashed_bundles_in_asset_directories_are_built_assets() {
1686 let root = repository(
1687 "built-assets",
1688 &[
1689 ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1690 ("app/main.ts", "product"),
1691 ("app_extensions/upsells/package.json", "{}"),
1692 ("app_extensions/upsells/frontend/embed.ts", "source"),
1693 (
1694 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1695 "bundle",
1696 ),
1697 ("app_extensions/upsells/assets/stylex-DAnmLURx.js", "bundle"),
1698 (
1700 "app_extensions/upsells/assets/theme-helper.js",
1701 "hand written",
1702 ),
1703 ],
1704 );
1705 let discovered = discover_source_scope(&root, None).unwrap();
1706 assert!(
1707 !discovered
1708 .source_files
1709 .iter()
1710 .any(|file| file.contains("-Be-aUw9g.js") || file.contains("-DAnmLURx.js"))
1711 );
1712 assert_eq!(
1713 entry(
1714 &discovered,
1715 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js"
1716 )
1717 .reason,
1718 "built asset"
1719 );
1720 assert!(
1721 discovered.limitations.is_empty(),
1722 "bundles are not blockers: {:?}",
1723 discovered.limitations
1724 );
1725 fs::remove_dir_all(root).unwrap();
1726 }
1727
1728 #[test]
1729 fn root_level_hidden_directories_are_tooling_not_source() {
1730 let root = repository(
1731 "root-hidden",
1732 &[
1733 ("package.json", "{}"),
1734 ("app/main.ts", "product"),
1735 (".shopify/bundle/upsells/frontend/embed.js", "cli bundle"),
1736 (".vercel/output/functions/index.js", "deploy output"),
1737 ("app/.generated/schema.ts", "generated types"),
1740 ],
1741 );
1742 let discovered = discover_source_scope(&root, None).unwrap();
1743 assert_eq!(
1744 discovered.source_files,
1745 ["app/.generated/schema.ts", "app/main.ts"]
1746 );
1747 assert!(
1748 discovered.limitations.is_empty(),
1749 "{:?}",
1750 discovered.limitations
1751 );
1752 assert!(discovered.scope.entries.iter().all(
1753 |entry| !entry.file.starts_with(".shopify/") && !entry.file.starts_with(".vercel/")
1754 ));
1755 fs::remove_dir_all(root).unwrap();
1756 }
1757
1758 #[test]
1759 fn nested_checkouts_are_neither_source_nor_limitations() {
1760 let root = repository(
1761 "nested-checkout",
1762 &[
1763 ("package.json", "{}"),
1764 ("app/main.ts", "product"),
1765 (".claude/worktrees/agent-1/.git", "gitdir: /elsewhere"),
1768 (".claude/worktrees/agent-1/app/main.ts", "copy"),
1769 ("vendor-fork/.git/HEAD", "ref: refs/heads/main"),
1770 ("vendor-fork/src/index.ts", "clone"),
1771 ],
1772 );
1773 let discovered = discover_source_scope(&root, None).unwrap();
1774 assert_eq!(discovered.source_files, ["app/main.ts"]);
1775 assert!(
1776 discovered.limitations.is_empty(),
1777 "nested checkouts must not be blocking limitations: {:?}",
1778 discovered.limitations
1779 );
1780 assert!(
1781 discovered
1782 .scope
1783 .entries
1784 .iter()
1785 .all(|entry| !entry.file.starts_with(".claude/")
1786 && !entry.file.starts_with("vendor-fork/")),
1787 "nested checkout files must not appear in scope at all"
1788 );
1789 fs::remove_dir_all(root).unwrap();
1790 }
1791
1792 #[test]
1793 fn roots_enter_a_runs_identity_only_when_named() {
1794 let mut unchanged = b"pytest\0-q".to_vec();
1797 fold_roots_into_configuration(&mut unchanged, None);
1798 assert_eq!(unchanged, b"pytest\0-q");
1799 fold_roots_into_configuration(&mut unchanged, Some(&[]));
1800 assert_eq!(unchanged, b"pytest\0-q");
1801
1802 let mut folded = b"pytest\0-q".to_vec();
1803 fold_roots_into_configuration(&mut folded, Some(&["src".to_owned(), "a.py".to_owned()]));
1804 assert_eq!(folded, b"pytest\0-q\0SUPERCOV_SOURCE_ROOTS=src,a.py");
1805 }
1806
1807 #[test]
1808 fn the_variable_is_read_the_same_way_for_every_frontend() {
1809 let read = |value: &str| {
1810 configured_source_roots(&BTreeMap::from([(
1811 SOURCE_ROOTS_VARIABLE.to_owned(),
1812 value.to_owned(),
1813 )]))
1814 };
1815 assert_eq!(
1816 read(" src , app ,"),
1817 Some(vec!["src".to_owned(), "app".to_owned()])
1818 );
1819 assert_eq!(
1820 read(" , "),
1821 None,
1822 "naming nothing leaves discovery automatic"
1823 );
1824 assert_eq!(configured_source_roots(&BTreeMap::new()), None);
1825 }
1826
1827 #[test]
1828 fn a_next_application_is_measured_where_next_keeps_its_code() {
1829 let root = repository(
1834 "next-layout",
1835 &[
1836 ("package.json", r#"{"dependencies":{"next":"16.1.1"}}"#),
1837 (
1838 "tsconfig.json",
1839 r#"{"include":["next-env.d.ts","**/*.ts",".next/types/**/*.ts"]}"#,
1840 ),
1841 ("next-env.d.ts", "declare const next: unknown;"),
1842 ("app/page.tsx", "page"),
1843 ("app/api/wordpress/test/route.ts", "route"),
1844 ("app/api/wordpress/test/route.test.ts", "test"),
1845 ("app/api/wordpress/test/helper.ts", "helper"),
1846 ("app/__tests__/page.tsx", "test"),
1847 ("components/button.tsx", "component"),
1848 ("pages/api/legacy.ts", "page"),
1849 ("proxy.ts", "proxy"),
1850 ("instrumentation.ts", "instrumentation"),
1851 ("simulators/echo.ts", "simulator"),
1852 ],
1853 );
1854 let discovered = discover_source_scope(&root, None).unwrap();
1855 assert_eq!(
1856 discovered.source_files,
1857 [
1858 "app/api/wordpress/test/route.ts",
1859 "app/page.tsx",
1860 "components/button.tsx",
1861 "instrumentation.ts",
1862 "pages/api/legacy.ts",
1863 "proxy.ts",
1864 ]
1865 );
1866 assert_eq!(
1867 discovered.source_roots,
1868 [
1869 "app",
1870 "components",
1871 "instrumentation.ts",
1872 "pages",
1873 "proxy.ts"
1874 ],
1875 "a declaration file is not a root"
1876 );
1877 for file in [
1878 "app/api/wordpress/test/route.test.ts",
1879 "app/api/wordpress/test/helper.ts",
1880 "app/__tests__/page.tsx",
1881 ] {
1882 assert_eq!(entry(&discovered, file).reason, "test or fixture source");
1883 }
1884 assert_eq!(
1886 entry(&discovered, "simulators/echo.ts").status,
1887 SourceScopeStatus::Ambiguous
1888 );
1889 let roots = vec!["app".into()];
1891 let named = discover_source_scope(&root, Some(&roots)).unwrap();
1892 assert_eq!(
1893 named.source_files,
1894 ["app/api/wordpress/test/route.ts", "app/page.tsx"]
1895 );
1896 fs::remove_dir_all(root).unwrap();
1897 }
1898
1899 #[test]
1900 fn a_route_file_name_means_nothing_outside_a_next_application() {
1901 let root = repository(
1902 "not-next",
1903 &[
1904 ("package.json", "{}"),
1905 ("app/main.ts", "main"),
1906 ("app/test/route.ts", "a test helper"),
1907 ("proxy.ts", "unknown"),
1908 ],
1909 );
1910 let discovered = discover_source_scope(&root, None).unwrap();
1911 assert_eq!(discovered.source_files, ["app/main.ts"]);
1912 assert_eq!(
1913 entry(&discovered, "app/test/route.ts").reason,
1914 "test or fixture source"
1915 );
1916 assert_eq!(
1917 entry(&discovered, "proxy.ts").status,
1918 SourceScopeStatus::Ambiguous
1919 );
1920 fs::remove_dir_all(root).unwrap();
1921 }
1922
1923 #[test]
1924 fn a_named_root_settles_what_it_names_and_guesses_only_below_it() {
1925 let root = repository(
1929 "named-roots",
1930 &[
1931 ("package.json", "{}"),
1932 ("src/main.ts", "main"),
1933 ("src/test-utils/build.ts", "helper"),
1934 ("src/test-utils/__tests__/build.test.ts", "test"),
1935 ("scripts/release.ts", "script"),
1936 ("scripts/fixtures/sample.ts", "fixture"),
1937 ("tools/one.spec.ts", "named file"),
1938 ],
1939 );
1940 let roots = vec!["src".into()];
1941 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1942 assert_eq!(discovered.source_files, ["src/main.ts"]);
1943
1944 let roots = vec![
1945 "src/test-utils".into(),
1946 "scripts".into(),
1947 "tools/one.spec.ts".into(),
1948 ];
1949 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1950 assert_eq!(
1951 discovered.source_files,
1952 [
1953 "scripts/release.ts",
1954 "src/test-utils/build.ts",
1955 "tools/one.spec.ts",
1956 ]
1957 );
1958 assert_eq!(
1959 entry(&discovered, "src/test-utils/__tests__/build.test.ts").reason,
1960 "test or fixture source"
1961 );
1962 assert_eq!(
1963 entry(&discovered, "scripts/fixtures/sample.ts").reason,
1964 "test or fixture source"
1965 );
1966 fs::remove_dir_all(root).unwrap();
1967 }
1968
1969 #[test]
1970 fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
1971 let root = repository(
1972 "explicit",
1973 &[
1974 ("package.json", "{}"),
1975 ("product/main.ts", "product"),
1976 ("orphan.ts", "outside"),
1977 ],
1978 );
1979 let roots = vec!["product".into()];
1980 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1981 assert_eq!(discovered.source_files, ["product/main.ts"]);
1982 assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
1983 assert_eq!(
1984 entry(&discovered, "orphan.ts").reason,
1985 "outside explicit source roots"
1986 );
1987 assert!(discovered.limitations.is_empty());
1988 fs::remove_dir_all(root).unwrap();
1989 }
1990
1991 #[test]
1992 fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
1993 let root = repository(
1994 "jsonc",
1995 &[
1996 ("package.json", r#"{"main":"./dist/index.js"}"#),
1997 (
1998 "tsconfig.json",
1999 "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
2000 ),
2001 ("events.ts", "event"),
2002 ("žalias.ts", "unicode"),
2003 ("library.test.ts", "test"),
2004 ],
2005 );
2006 let discovered = discover_source_scope(&root, None).unwrap();
2007 assert!(discovered.source_roots.contains(&".".into()));
2008 assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
2009 assert!(discovered.limitations.is_empty());
2010 fs::remove_dir_all(root).unwrap();
2011 }
2012
2013 #[cfg(unix)]
2014 #[test]
2015 fn never_follows_source_directory_or_explicit_root_symlinks() {
2016 use std::os::unix::fs::symlink;
2017
2018 let root = repository(
2019 "symlink",
2020 &[("package.json", "{}"), ("src/real.ts", "real")],
2021 );
2022 let outside = repository("outside", &[("secret.ts", "secret")]);
2023 symlink(&outside, root.join("linked")).unwrap();
2024 symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
2025 let discovered = discover_source_scope(&root, None).unwrap();
2026 assert_eq!(discovered.source_files, ["src/real.ts"]);
2027 let explicit = vec!["linked".into()];
2028 assert!(matches!(
2029 discover_source_scope(&root, Some(&explicit)),
2030 Err(SourceDiscoveryError::InvalidRoot(_))
2031 ));
2032 fs::remove_dir_all(root).unwrap();
2033 fs::remove_dir_all(outside).unwrap();
2034 }
2035
2036 #[test]
2037 fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
2038 let root = repository(
2039 "workspace-store",
2040 &[
2041 ("package.json", "{}"),
2042 ("src/main.ts", "main"),
2043 ("supercov/user.ts", "user code"),
2044 ],
2045 );
2046 let before = discover_source_scope(&root, None).unwrap();
2047 assert_eq!(
2048 entry(&before, "supercov/user.ts").status,
2049 SourceScopeStatus::Ambiguous
2050 );
2051 fs::write(
2052 root.join("supercov/.supercov-workspace-store"),
2053 b"Supercov instrumented workspace. Safe to delete.\n",
2054 )
2055 .unwrap();
2056 let after = discover_source_scope(&root, None).unwrap();
2057 assert!(
2058 after
2059 .scope
2060 .entries
2061 .iter()
2062 .all(|entry| !entry.file.starts_with("supercov/"))
2063 );
2064 fs::remove_dir_all(root).unwrap();
2065 }
2066}