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
679fn benchmark(file: &str) -> bool {
684 let lower = file.to_ascii_lowercase();
685 let (directories, name) = lower.rsplit_once('/').unwrap_or(("", lower.as_str()));
686 directories
687 .split('/')
688 .any(|segment| matches!(segment, "bench" | "benches" | "benchmark" | "benchmarks"))
689 || name
690 .split('.')
691 .skip(1)
692 .any(|part| matches!(part, "bench" | "benchmark"))
693}
694
695fn example(file: &str, package_root: Option<&str>) -> bool {
701 let local = package_root
702 .and_then(|package| file.strip_prefix(package))
703 .and_then(|rest| rest.strip_prefix('/'))
704 .unwrap_or(file);
705 local.split_once('/').is_some_and(|(first, _)| {
706 matches!(first.to_ascii_lowercase().as_str(), "example" | "examples")
707 })
708}
709
710fn tool_script(file: &str) -> bool {
711 file.to_ascii_lowercase()
712 .split('/')
713 .any(|segment| segment == "scripts")
714}
715
716fn config_file(file: &str) -> bool {
717 let lower = file.to_ascii_lowercase();
718 let name = lower.rsplit('/').next().unwrap_or(&lower);
719 source_file(name)
720 && (CONFIG_TOOLS
721 .iter()
722 .any(|tool| name.starts_with(&format!("{tool}.config.")))
723 || DOT_CONFIG_TOOLS
724 .iter()
725 .any(|tool| name.starts_with(&format!(".{tool}rc.")))
726 || (!lower.contains('/')
727 && (name.contains(".config.")
728 || name.contains(".conf.")
730 || name.starts_with("build.")
731 || name.starts_with("gulpfile.")
732 || name.starts_with("gruntfile."))))
733}
734
735fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, SourceDiscoveryError> {
736 let mut entries = fs::read_dir(path)
737 .map_err(|error| io_error(path, error))?
738 .collect::<Result<Vec<_>, _>>()
739 .map_err(|error| io_error(path, error))?;
740 entries.sort_by_key(fs::DirEntry::file_name);
741 Ok(entries)
742}
743
744fn ignored_by_enclosing_repository(root: &Path) -> bool {
749 if root.join(".git").exists() {
750 return false;
751 }
752 let Some(repository) = root
753 .ancestors()
754 .skip(1)
755 .position(|ancestor| ancestor.join(".git").exists())
756 else {
757 return false;
758 };
759 root.ancestors()
761 .skip(1)
762 .take(repository + 1)
763 .collect::<Vec<_>>()
764 .into_iter()
765 .rev()
766 .fold(false, |ignored, directory| {
767 let (rules, _) = ignore::gitignore::Gitignore::new(directory.join(".gitignore"));
768 match rules.matched_path_or_any_parents(root, true) {
769 ignore::Match::Ignore(_) => true,
770 ignore::Match::Whitelist(_) => false,
771 ignore::Match::None => ignored,
772 }
773 })
774}
775
776fn files_under(
785 root: &Path,
786 directory: &Path,
787 output: &mut Vec<PathBuf>,
788) -> Result<(), SourceDiscoveryError> {
789 let metadata = fs::symlink_metadata(directory).map_err(|error| io_error(directory, error))?;
790 if !metadata.file_type().is_dir() {
791 return Err(SourceDiscoveryError::InvalidRoot(directory.to_owned()));
792 }
793 let project = root.to_owned();
794 let walker = ignore::WalkBuilder::new(directory)
795 .standard_filters(false)
796 .git_ignore(true)
797 .parents(!ignored_by_enclosing_repository(directory))
798 .require_git(true)
799 .follow_links(false)
800 .sort_by_file_name(|left, right| left.cmp(right))
801 .filter_entry(move |entry| {
802 entry.depth() == 0
803 || !entry.file_type().is_some_and(|kind| kind.is_dir())
804 || entry.file_name().to_str().is_some_and(|name| {
805 !generated_directory(name)
806 && !owned_workspace_store(entry.path())
807 && !nested_checkout(entry.path())
808 && !root_tool_directory(&project, entry.path())
809 })
810 })
811 .build();
812 for entry in walker {
813 let entry =
814 entry.map_err(|error| io_error(directory, io::Error::other(error.to_string())))?;
815 let Some(kind) = entry.file_type() else {
816 continue;
817 };
818 if kind.is_symlink() || !kind.is_file() {
819 continue;
820 }
821 let name = entry
822 .file_name()
823 .to_str()
824 .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(entry.path().to_owned()))?;
825 if source_file(name) {
826 output.push(entry.into_path());
827 }
828 }
829 Ok(())
830}
831
832fn read_json(path: &Path) -> Option<Value> {
833 serde_json::from_slice(&fs::read(path).ok()?).ok()
834}
835
836fn package_directories(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
837 fn visit(
838 root: &Path,
839 directory: &Path,
840 depth: usize,
841 found: &mut BTreeSet<PathBuf>,
842 ) -> Result<(), SourceDiscoveryError> {
843 if depth > 5 {
844 return Ok(());
845 }
846 for entry in read_directory(directory)? {
847 let name = entry
848 .file_name()
849 .into_string()
850 .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
851 let path = entry.path();
852 let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
853 if !file_type.is_dir()
854 || file_type.is_symlink()
855 || generated_directory(&name)
856 || owned_workspace_store(&path)
857 || nested_checkout(&path)
858 || root_tool_directory(root, &path)
859 {
860 continue;
861 }
862 let local = local_path(root, &path)?;
863 let under_package_parent = local
864 .split('/')
865 .any(|segment| PACKAGE_PARENTS.contains(&segment));
866 if path.join("package.json").is_file() && (depth == 0 || under_package_parent) {
867 found.insert(path.clone());
868 }
869 visit(root, &path, depth + 1, found)?;
870 }
871 Ok(())
872 }
873
874 let mut found = BTreeSet::from([root.to_owned()]);
875 visit(root, root, 0, &mut found)?;
876 for directory in declared_workspace_packages(root)? {
877 found.insert(directory);
878 }
879 Ok(found.into_iter().collect())
880}
881
882fn declared_workspace_packages(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
889 let mut patterns = Vec::new();
890 let manifest = read_json(&root.join("package.json")).unwrap_or(Value::Null);
891 let declared = match manifest.get("workspaces") {
892 Some(Value::Array(items)) => Some(items),
893 Some(Value::Object(object)) => object.get("packages").and_then(Value::as_array),
894 _ => None,
895 };
896 if let Some(items) = declared {
897 patterns.extend(items.iter().filter_map(Value::as_str).map(str::to_owned));
898 }
899 if let Ok(pnpm) = fs::read_to_string(root.join("pnpm-workspace.yaml")) {
900 let mut in_packages = false;
901 for line in pnpm.lines() {
902 let trimmed = line.trim();
903 if trimmed.starts_with("packages:") {
904 in_packages = true;
905 continue;
906 }
907 if in_packages {
908 if let Some(item) = trimmed.strip_prefix("- ") {
909 patterns.push(item.trim_matches(|c| c == '"' || c == '\'').to_owned());
910 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
911 in_packages = false;
912 }
913 }
914 }
915 }
916 let mut found = Vec::new();
917 for pattern in patterns {
918 let pattern = pattern.trim_start_matches("./").trim_end_matches('/');
919 if pattern.starts_with('!') || pattern.contains("**") {
920 continue;
921 }
922 let (parent, wildcard) = match pattern.strip_suffix("/*") {
923 Some(parent) => (parent, true),
924 None => (pattern, false),
925 };
926 if parent.is_empty()
927 || parent
928 .split('/')
929 .any(|segment| segment.is_empty() || segment == ".." || segment.contains('*'))
930 {
931 continue;
932 }
933 let base = root.join(parent);
934 let candidates: Vec<PathBuf> = if wildcard {
935 match fs::read_dir(&base) {
936 Ok(entries) => entries
937 .filter_map(Result::ok)
938 .map(|entry| entry.path())
939 .collect(),
940 Err(_) => Vec::new(),
941 }
942 } else {
943 vec![base]
944 };
945 for candidate in candidates {
946 let is_dir = fs::symlink_metadata(&candidate)
947 .is_ok_and(|metadata| metadata.file_type().is_dir());
948 if is_dir
949 && candidate.join("package.json").is_file()
950 && !nested_checkout(&candidate)
951 && !owned_workspace_store(&candidate)
952 {
953 found.push(candidate);
954 }
955 }
956 }
957 Ok(found)
958}
959
960fn string_targets(value: &Value, depth: usize, output: &mut Vec<String>) {
961 if depth > 8 {
962 return;
963 }
964 match value {
965 Value::String(value) => output.push(value.clone()),
966 Value::Array(values) => {
967 for value in values {
968 string_targets(value, depth + 1, output);
969 }
970 }
971 Value::Object(values) => {
972 for value in values.values() {
973 string_targets(value, depth + 1, output);
974 }
975 }
976 _ => {}
977 }
978}
979
980fn entry_targets(directory: &Path, manifest: &Value) -> Vec<PathBuf> {
981 let mut targets = Vec::new();
982 for key in ["main", "module", "browser", "bin", "exports"] {
983 if let Some(value) = manifest.get(key) {
984 string_targets(value, 0, &mut targets);
985 }
986 }
987 targets
988 .into_iter()
989 .filter_map(|target| {
990 if !target.starts_with('.') || target.contains("node_modules") {
991 return None;
992 }
993 let prefix = target.split('*').next()?.trim_end_matches('/');
994 (!prefix.is_empty()).then(|| resolve(directory, prefix))
995 })
996 .collect()
997}
998
999pub(crate) fn strip_jsonc_comments(contents: &str) -> String {
1000 let mut output = String::with_capacity(contents.len());
1001 let mut chars = contents.chars().peekable();
1002 let mut string = false;
1003 let mut escaped = false;
1004 while let Some(character) = chars.next() {
1005 if string {
1006 output.push(character);
1007 if escaped {
1008 escaped = false;
1009 } else if character == '\\' {
1010 escaped = true;
1011 } else if character == '"' {
1012 string = false;
1013 }
1014 continue;
1015 }
1016 if character == '"' {
1017 string = true;
1018 output.push(character);
1019 } else if character == '/' && chars.peek() == Some(&'/') {
1020 chars.next();
1021 for comment in chars.by_ref() {
1022 if comment == '\n' {
1023 output.push('\n');
1024 break;
1025 }
1026 }
1027 } else if character == '/' && chars.peek() == Some(&'*') {
1028 chars.next();
1029 let mut previous = '\0';
1030 for comment in chars.by_ref() {
1031 if previous == '*' && comment == '/' {
1032 break;
1033 }
1034 previous = comment;
1035 }
1036 } else {
1037 output.push(character);
1038 }
1039 }
1040 output
1041}
1042
1043pub(crate) fn strip_trailing_commas(contents: &str) -> String {
1044 let chars = contents.chars().collect::<Vec<_>>();
1045 let mut output = String::with_capacity(contents.len());
1046 let mut string = false;
1047 let mut escaped = false;
1048 for (index, character) in chars.iter().copied().enumerate() {
1049 if string {
1050 output.push(character);
1051 if escaped {
1052 escaped = false;
1053 } else if character == '\\' {
1054 escaped = true;
1055 } else if character == '"' {
1056 string = false;
1057 }
1058 continue;
1059 }
1060 if character == '"' {
1061 string = true;
1062 output.push(character);
1063 } else if character == ','
1064 && chars[index + 1..]
1065 .iter()
1066 .find(|character| !character.is_whitespace())
1067 .is_some_and(|character| matches!(character, '}' | ']'))
1068 {
1069 } else {
1070 output.push(character);
1071 }
1072 }
1073 output
1074}
1075
1076fn tsconfig_roots(directory: &Path) -> Vec<PathBuf> {
1077 let path = directory.join("tsconfig.json");
1078 let Some(contents) = fs::read_to_string(path).ok() else {
1079 return Vec::new();
1080 };
1081 let jsonc = strip_trailing_commas(&strip_jsonc_comments(&contents));
1082 let Some(config) = serde_json::from_str::<Value>(&jsonc).ok() else {
1083 return Vec::new();
1084 };
1085 let mut values = Vec::new();
1086 if let Some(root_dir) = config
1087 .get("compilerOptions")
1088 .and_then(|options| options.get("rootDir"))
1089 .and_then(Value::as_str)
1090 {
1091 values.push(root_dir.to_owned());
1092 }
1093 if let Some(include) = config.get("include").and_then(Value::as_array) {
1094 values.extend(include.iter().filter_map(Value::as_str).map(str::to_owned));
1095 }
1096 if values.is_empty() {
1097 return vec![directory.to_owned()];
1098 }
1099 values
1100 .into_iter()
1101 .filter_map(|value| {
1102 if value.starts_with('!') {
1103 return None;
1104 }
1105 let prefix = value
1106 .find(['?', '*', '{', '['])
1107 .map_or(value.as_str(), |index| &value[..index])
1108 .trim_end_matches('/');
1109 (!prefix.is_empty() && !declaration_file(prefix)).then(|| resolve(directory, prefix))
1112 })
1113 .collect()
1114}
1115
1116fn within(parent: &Path, child: &Path) -> bool {
1117 child == parent || child.starts_with(parent)
1118}
1119
1120fn nearest_package_root<'a>(path: &Path, packages: &'a [PathBuf]) -> Option<&'a PathBuf> {
1121 packages
1122 .iter()
1123 .filter(|directory| within(directory, path))
1124 .max_by_key(|directory| directory.components().count())
1125}
1126
1127fn scope_limitation(file: &str) -> SourceLimitation {
1128 let digest = Sha256::digest(file.as_bytes());
1129 let id = digest[..10]
1130 .iter()
1131 .map(|byte| format!("{byte:02x}"))
1132 .collect::<String>();
1133 SourceLimitation {
1134 id: format!("scope:{id}"),
1135 kind: "source-scope".into(),
1136 file: file.into(),
1137 line: 1,
1138 column: 1,
1139 source: file.into(),
1140 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(),
1141 }
1142}
1143
1144fn declared_test_setup(packages: &[PathBuf]) -> BTreeSet<PathBuf> {
1148 let mut paths = BTreeSet::new();
1149 for package in packages {
1150 let manifest = read_json(&package.join("package.json")).unwrap_or(Value::Null);
1151 let Some(jest) = manifest.get("jest") else {
1152 continue;
1153 };
1154 let root = jest
1155 .get("rootDir")
1156 .and_then(Value::as_str)
1157 .map(|value| {
1158 resolve(
1159 package,
1160 value.replace("<rootDir>", &package.to_string_lossy()),
1161 )
1162 })
1163 .unwrap_or_else(|| package.clone());
1164 for field in [
1165 "setupFiles",
1166 "setupFilesAfterEnv",
1167 "globalSetup",
1168 "globalTeardown",
1169 ] {
1170 let values = match jest.get(field) {
1171 Some(Value::Array(values)) => values.iter().collect::<Vec<_>>(),
1172 Some(value) => vec![value],
1173 None => Vec::new(),
1174 };
1175 for value in values.into_iter().filter_map(Value::as_str) {
1176 let path = resolve(&root, value.replace("<rootDir>", &root.to_string_lossy()));
1177 if path.is_file() {
1178 paths.insert(path);
1179 }
1180 }
1181 }
1182 }
1183 paths
1184}
1185
1186pub fn discover_source_scope(
1187 root: &Path,
1188 configured_roots: Option<&[String]>,
1189) -> Result<DiscoveredSourceScope, SourceDiscoveryError> {
1190 let root = lexical_normalize(root);
1191 let root_metadata = fs::symlink_metadata(&root).map_err(|error| io_error(&root, error))?;
1192 if !root_metadata.file_type().is_dir() {
1193 return Err(SourceDiscoveryError::InvalidRoot(root));
1194 }
1195 let packages = package_directories(&root)?;
1196 let test_setup = declared_test_setup(&packages);
1197 let explicit = configured_roots.is_some_and(|roots| !roots.is_empty());
1198 let include_roots = if explicit {
1199 configured_roots
1200 .unwrap_or_default()
1201 .iter()
1202 .map(|directory| resolve(&root, directory))
1203 .collect::<Vec<_>>()
1204 } else {
1205 packages
1206 .iter()
1207 .flat_map(|directory| {
1208 let manifest = read_json(&directory.join("package.json")).unwrap_or(Value::Null);
1209 let next_files = depends_on_next(&manifest)
1210 .then_some(NEXT_ROOT_FILES)
1211 .into_iter()
1212 .flatten()
1213 .flat_map(|name| {
1214 ["ts", "tsx", "js", "jsx", "mjs"]
1215 .map(|extension| directory.join(format!("{name}.{extension}")))
1216 });
1217 let candidates = SOURCE_DIRECTORIES
1218 .iter()
1219 .map(|name| directory.join(name))
1220 .chain(next_files)
1221 .chain(entry_targets(directory, &manifest))
1222 .chain(tsconfig_roots(directory))
1223 .collect::<Vec<_>>();
1224 if directory != &root
1230 && !candidates
1231 .iter()
1232 .any(|candidate| fs::symlink_metadata(candidate).is_ok())
1233 {
1234 return vec![directory.clone()];
1235 }
1236 candidates
1237 })
1238 .collect()
1239 };
1240 let existing_roots = existing_roots(include_roots, explicit)?;
1241 let app_directories = packages
1242 .iter()
1243 .filter(|directory| {
1244 depends_on_next(&read_json(&directory.join("package.json")).unwrap_or(Value::Null))
1245 })
1246 .flat_map(|directory| [directory.join("app"), directory.join("src/app")])
1247 .collect::<Vec<_>>();
1248 let mut all_files = Vec::new();
1249 files_under(&root, &root, &mut all_files)?;
1250 all_files.sort();
1251
1252 let mut entries = Vec::new();
1253 let mut included = Vec::new();
1254 let mut limitations = Vec::new();
1255 for path in all_files {
1256 let file = local_path(&root, &path)?;
1257 let package_root = nearest_package_root(&path, &packages)
1258 .map(|package| local_path(&root, package))
1259 .transpose()?
1260 .filter(|package| package != ".");
1261 let entry = |status, reason: &str| SourceScopeEntry {
1262 file: file.clone(),
1263 status,
1264 reason: reason.into(),
1265 package_root: package_root.clone(),
1266 };
1267 let named = explicit
1268 .then(|| below_named_root(&existing_roots, &path))
1269 .flatten();
1270 let guessed = match named {
1271 Some(local) => local.to_string_lossy().replace('\\', "/"),
1272 None => file.clone(),
1273 };
1274 if declaration_file(&file) {
1275 entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
1276 } else if test_setup.contains(&path) {
1277 entries.push(entry(SourceScopeStatus::Excluded, "declared test setup"));
1278 } else if test_or_fixture(&guessed) && !next_app_file(&app_directories, &path) {
1279 entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
1280 } else if tool_script(&guessed) {
1281 entries.push(entry(
1282 SourceScopeStatus::Excluded,
1283 "conventional tool script",
1284 ));
1285 } else if benchmark(&guessed) {
1286 entries.push(entry(SourceScopeStatus::Excluded, "benchmark"));
1287 } else if example(&file, package_root.as_deref()) {
1288 entries.push(entry(SourceScopeStatus::Excluded, "example"));
1289 } else if config_file(&file) {
1290 entries.push(entry(
1291 SourceScopeStatus::Excluded,
1292 "build/test/tool configuration",
1293 ));
1294 } else if built_asset(&file) {
1295 entries.push(entry(SourceScopeStatus::Excluded, BUILT_ASSET_REASON));
1296 } else if root_contains(&existing_roots, &path) {
1297 included.push(path);
1298 entries.push(entry(
1299 SourceScopeStatus::Included,
1300 if explicit {
1301 "explicit source root"
1302 } else {
1303 "discovered package source root"
1304 },
1305 ));
1306 } else if explicit {
1307 entries.push(entry(
1308 SourceScopeStatus::Excluded,
1309 "outside explicit source roots",
1310 ));
1311 } else {
1312 entries.push(entry(
1313 SourceScopeStatus::Ambiguous,
1314 "unclassified first-party source",
1315 ));
1316 limitations.push(scope_limitation(&file));
1317 }
1318 }
1319 let source_files = included
1320 .iter()
1321 .map(|path| local_path(&root, path))
1322 .collect::<Result<Vec<_>, _>>()?;
1323 let source_roots = existing_roots
1324 .iter()
1325 .map(|path| local_path(&root, path))
1326 .collect::<Result<Vec<_>, _>>()?;
1327 Ok(DiscoveredSourceScope {
1328 source_files,
1329 source_roots: source_roots.clone(),
1330 scope: SourceScope {
1331 version: 1,
1332 mode: if explicit {
1333 SourceScopeMode::Explicit
1334 } else {
1335 SourceScopeMode::Automatic
1336 },
1337 roots: source_roots,
1338 entries,
1339 },
1340 limitations,
1341 })
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346 use std::{
1347 fs,
1348 time::{SystemTime, UNIX_EPOCH},
1349 };
1350
1351 use super::*;
1352
1353 fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
1354 let nonce = SystemTime::now()
1355 .duration_since(UNIX_EPOCH)
1356 .unwrap()
1357 .as_nanos();
1358 let root = std::env::temp_dir().join(format!(
1359 "supercov-source-{label}-{}-{nonce}",
1360 std::process::id()
1361 ));
1362 fs::create_dir_all(&root).unwrap();
1363 for (file, contents) in files {
1364 let path = root.join(file);
1365 fs::create_dir_all(path.parent().unwrap()).unwrap();
1366 fs::write(path, contents).unwrap();
1367 }
1368 root
1369 }
1370
1371 fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
1372 scope
1373 .scope
1374 .entries
1375 .iter()
1376 .find(|entry| entry.file == file)
1377 .unwrap()
1378 }
1379
1380 #[test]
1381 fn a_file_the_project_tells_git_to_ignore_is_not_its_source() {
1382 let files = [
1385 ("package.json", r#"{"workspaces":["packages/*"]}"#),
1386 ("packages/core/package.json", r#"{"name":"core"}"#),
1387 ("packages/core/.gitignore", "lib-dist\n"),
1388 ("packages/core/src/index.ts", "export const a = 1;"),
1389 (
1390 "packages/core/lib-dist/browser/worker.dev.js",
1391 "var bundled = 1;",
1392 ),
1393 ];
1394 let repository_root = repository("gitignored", &files);
1395 fs::create_dir_all(repository_root.join(".git")).unwrap();
1396 let discovered = discover_source_scope(&repository_root, None).unwrap();
1397 assert!(
1398 discovered
1399 .source_files
1400 .contains(&"packages/core/src/index.ts".into())
1401 );
1402 assert!(
1403 !discovered
1404 .source_files
1405 .iter()
1406 .any(|file| file.contains("lib-dist")),
1407 "{:?}",
1408 discovered.source_files
1409 );
1410 fs::remove_dir_all(repository_root).unwrap();
1411
1412 let plain = repository("not-a-repository", &files);
1414 let discovered = discover_source_scope(&plain, None).unwrap();
1415 assert!(
1416 discovered
1417 .scope
1418 .entries
1419 .iter()
1420 .any(|entry| entry.file.contains("lib-dist"))
1421 );
1422 fs::remove_dir_all(plain).unwrap();
1423 }
1424
1425 #[test]
1426 fn a_project_kept_where_an_enclosing_repository_ignores_it_keeps_its_source() {
1427 let files = [
1428 ("scratch/.gitignore", "*\n"),
1430 ("scratch/app/package.json", r#"{"name":"app"}"#),
1431 ("scratch/app/.gitignore", "dist\n"),
1432 ("scratch/app/src/index.js", "export const a = 1;"),
1433 ("scratch/app/dist/bundle.js", "var bundled = 1;"),
1434 ];
1435 let repository_root = repository("enclosing-ignore", &files);
1436 fs::create_dir_all(repository_root.join(".git")).unwrap();
1437 let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1438 assert_eq!(discovered.source_files, ["src/index.js"]);
1439
1440 fs::remove_file(repository_root.join("scratch/.gitignore")).unwrap();
1442 fs::write(repository_root.join(".gitignore"), "*.gen.js\n").unwrap();
1443 fs::write(repository_root.join("scratch/app/src/api.gen.js"), "var a;").unwrap();
1444 let discovered = discover_source_scope(&repository_root.join("scratch/app"), None).unwrap();
1445 assert_eq!(discovered.source_files, ["src/index.js"]);
1446 fs::remove_dir_all(repository_root).unwrap();
1447 }
1448
1449 #[test]
1450 fn excludes_declared_jest_setup_without_hiding_neighboring_application_code() {
1451 let root = repository(
1452 "jest-setup",
1453 &[
1454 (
1455 "package.json",
1456 r#"{"jest":{"setupFilesAfterEnv":["<rootDir>/src/bootstrap.js"]}}"#,
1457 ),
1458 (
1459 "src/bootstrap.js",
1460 "jest.mock('storage', () => ({read: () => null}));",
1461 ),
1462 ("src/index.js", "export const value = 1;"),
1463 ],
1464 );
1465 let discovered = discover_source_scope(&root, None).unwrap();
1466 assert_eq!(discovered.source_files, ["src/index.js"]);
1467 assert_eq!(
1468 entry(&discovered, "src/bootstrap.js").reason,
1469 "declared test setup"
1470 );
1471 fs::remove_dir_all(root).unwrap();
1472 }
1473
1474 #[test]
1475 fn example_apps_and_conf_files_are_not_source() {
1476 let root = repository(
1478 "examples",
1479 &[
1480 ("package.json", r#"{"exports":{".":"./dist/index.js"}}"#),
1481 ("tsconfig.json", r#"{"exclude":["dist","examples"]}"#),
1482 ("src/v4.ts", "export const v4 = () => ''"),
1483 ("wdio.conf.js", "exports.config = {}"),
1484 ("karma.conf.cjs", "module.exports = () => {}"),
1485 ("examples/browser-esmodules/package.json", "{}"),
1486 ("examples/browser-esmodules/example.js", "console.log(1)"),
1487 ("examples/utils/testpage.js", "export default 1"),
1488 ("example/demo.js", "console.log(1)"),
1489 ("app/examples/page.tsx", "export default function Page() {}"),
1490 ],
1491 );
1492 let discovered = discover_source_scope(&root, None).unwrap();
1493 assert_eq!(
1494 discovered.source_files,
1495 ["app/examples/page.tsx", "src/v4.ts"]
1496 );
1497 for file in [
1498 "examples/browser-esmodules/example.js",
1499 "examples/utils/testpage.js",
1500 "example/demo.js",
1501 ] {
1502 assert_eq!(entry(&discovered, file).reason, "example", "{file}");
1503 }
1504 for file in ["wdio.conf.js", "karma.conf.cjs"] {
1505 assert_eq!(
1506 entry(&discovered, file).reason,
1507 "build/test/tool configuration",
1508 "{file}"
1509 );
1510 }
1511 fs::remove_dir_all(root).unwrap();
1512 }
1513
1514 #[test]
1515 fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
1516 let root = repository(
1517 "automatic",
1518 &[
1519 ("package.json", r#"{"workspaces":["packages/*"]}"#),
1520 ("src/index.ts", "export const root = true"),
1521 ("lib/helper.js", "export const helper = true"),
1522 ("src/index.test.ts", "test('root', () => {})"),
1523 ("tests/e2e.spec.ts", "test('e2e', () => {})"),
1524 ("scripts/release.mjs", "export const release = true"),
1525 ("vite.config.ts", "export default {}"),
1526 ("build.mjs", "export default async function build() {}"),
1527 (".eslintrc.cjs", "module.exports = {}"),
1528 (".graphqlrc.ts", "export default {}"),
1529 ("orphan.ts", "export const missed = true"),
1530 ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
1531 ("packages/ui/src/index.ts", "export const ui = true"),
1532 ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
1533 ("dist/generated.js", "generated"),
1534 (".cache/tool/generated.js", "cached"),
1535 ("src/parse.bench.ts", "bench('parse', () => {})"),
1536 (
1537 "benchmarks/value-slice.bench.ts",
1538 "bench('slice', () => {})",
1539 ),
1540 ("src/benchmarkReport.ts", "export const report = 1"),
1541 ],
1542 );
1543 let discovered = discover_source_scope(&root, None).unwrap();
1544 assert_eq!(
1545 discovered.source_files,
1546 [
1547 "lib/helper.js",
1548 "packages/ui/src/index.ts",
1549 "src/benchmarkReport.ts",
1550 "src/index.ts"
1551 ]
1552 );
1553 for file in ["src/parse.bench.ts", "benchmarks/value-slice.bench.ts"] {
1554 assert_eq!(entry(&discovered, file).reason, "benchmark", "{file}");
1555 }
1556 assert_eq!(
1557 entry(&discovered, "orphan.ts").status,
1558 SourceScopeStatus::Ambiguous
1559 );
1560 assert_eq!(
1561 entry(&discovered, "scripts/release.mjs").reason,
1562 "conventional tool script"
1563 );
1564 assert_eq!(
1565 entry(&discovered, "build.mjs").reason,
1566 "build/test/tool configuration"
1567 );
1568 assert_eq!(
1569 entry(&discovered, "packages/ui/src/index.ts").package_root,
1570 Some("packages/ui".into())
1571 );
1572 assert_eq!(discovered.limitations.len(), 1);
1573 assert_eq!(discovered.limitations[0].file, "orphan.ts");
1574 assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
1575 assert!(
1576 discovered
1577 .scope
1578 .entries
1579 .iter()
1580 .all(|entry| !entry.file.contains(".cache"))
1581 );
1582 fs::remove_dir_all(root).unwrap();
1583 }
1584
1585 #[test]
1586 fn declared_workspaces_outside_conventional_parents_are_package_roots() {
1587 let root = repository(
1588 "declared-workspaces",
1589 &[
1590 ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1591 ("app/main.ts", "product"),
1592 ("app_extensions/discounts/package.json", "{}"),
1593 ("app_extensions/discounts/src/index.ts", "extension"),
1594 ("app_extensions/upsells/package.json", "{}"),
1597 ("app_extensions/upsells/frontend/embed.ts", "embed"),
1598 ("app_extensions/upsells/dist/embed.js", "built"),
1599 ],
1600 );
1601 let discovered = discover_source_scope(&root, None).unwrap();
1602 assert_eq!(
1603 discovered.source_files,
1604 [
1605 "app/main.ts",
1606 "app_extensions/discounts/src/index.ts",
1607 "app_extensions/upsells/frontend/embed.ts",
1608 ]
1609 );
1610 assert!(
1611 discovered.limitations.is_empty(),
1612 "declared packages must not be blockers: {:?}",
1613 discovered.limitations
1614 );
1615 fs::remove_dir_all(root).unwrap();
1616 }
1617
1618 #[test]
1619 fn hashed_bundles_in_asset_directories_are_built_assets() {
1620 let root = repository(
1621 "built-assets",
1622 &[
1623 ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1624 ("app/main.ts", "product"),
1625 ("app_extensions/upsells/package.json", "{}"),
1626 ("app_extensions/upsells/frontend/embed.ts", "source"),
1627 (
1628 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1629 "bundle",
1630 ),
1631 ("app_extensions/upsells/assets/stylex-DAnmLURx.js", "bundle"),
1632 (
1634 "app_extensions/upsells/assets/theme-helper.js",
1635 "hand written",
1636 ),
1637 ],
1638 );
1639 let discovered = discover_source_scope(&root, None).unwrap();
1640 assert!(
1641 !discovered
1642 .source_files
1643 .iter()
1644 .any(|file| file.contains("-Be-aUw9g.js") || file.contains("-DAnmLURx.js"))
1645 );
1646 assert_eq!(
1647 entry(
1648 &discovered,
1649 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js"
1650 )
1651 .reason,
1652 "built asset"
1653 );
1654 assert!(
1655 discovered.limitations.is_empty(),
1656 "bundles are not blockers: {:?}",
1657 discovered.limitations
1658 );
1659 fs::remove_dir_all(root).unwrap();
1660 }
1661
1662 #[test]
1663 fn root_level_hidden_directories_are_tooling_not_source() {
1664 let root = repository(
1665 "root-hidden",
1666 &[
1667 ("package.json", "{}"),
1668 ("app/main.ts", "product"),
1669 (".shopify/bundle/upsells/frontend/embed.js", "cli bundle"),
1670 (".vercel/output/functions/index.js", "deploy output"),
1671 ("app/.generated/schema.ts", "generated types"),
1674 ],
1675 );
1676 let discovered = discover_source_scope(&root, None).unwrap();
1677 assert_eq!(
1678 discovered.source_files,
1679 ["app/.generated/schema.ts", "app/main.ts"]
1680 );
1681 assert!(
1682 discovered.limitations.is_empty(),
1683 "{:?}",
1684 discovered.limitations
1685 );
1686 assert!(discovered.scope.entries.iter().all(
1687 |entry| !entry.file.starts_with(".shopify/") && !entry.file.starts_with(".vercel/")
1688 ));
1689 fs::remove_dir_all(root).unwrap();
1690 }
1691
1692 #[test]
1693 fn nested_checkouts_are_neither_source_nor_limitations() {
1694 let root = repository(
1695 "nested-checkout",
1696 &[
1697 ("package.json", "{}"),
1698 ("app/main.ts", "product"),
1699 (".claude/worktrees/agent-1/.git", "gitdir: /elsewhere"),
1702 (".claude/worktrees/agent-1/app/main.ts", "copy"),
1703 ("vendor-fork/.git/HEAD", "ref: refs/heads/main"),
1704 ("vendor-fork/src/index.ts", "clone"),
1705 ],
1706 );
1707 let discovered = discover_source_scope(&root, None).unwrap();
1708 assert_eq!(discovered.source_files, ["app/main.ts"]);
1709 assert!(
1710 discovered.limitations.is_empty(),
1711 "nested checkouts must not be blocking limitations: {:?}",
1712 discovered.limitations
1713 );
1714 assert!(
1715 discovered
1716 .scope
1717 .entries
1718 .iter()
1719 .all(|entry| !entry.file.starts_with(".claude/")
1720 && !entry.file.starts_with("vendor-fork/")),
1721 "nested checkout files must not appear in scope at all"
1722 );
1723 fs::remove_dir_all(root).unwrap();
1724 }
1725
1726 #[test]
1727 fn roots_enter_a_runs_identity_only_when_named() {
1728 let mut unchanged = b"pytest\0-q".to_vec();
1731 fold_roots_into_configuration(&mut unchanged, None);
1732 assert_eq!(unchanged, b"pytest\0-q");
1733 fold_roots_into_configuration(&mut unchanged, Some(&[]));
1734 assert_eq!(unchanged, b"pytest\0-q");
1735
1736 let mut folded = b"pytest\0-q".to_vec();
1737 fold_roots_into_configuration(&mut folded, Some(&["src".to_owned(), "a.py".to_owned()]));
1738 assert_eq!(folded, b"pytest\0-q\0SUPERCOV_SOURCE_ROOTS=src,a.py");
1739 }
1740
1741 #[test]
1742 fn the_variable_is_read_the_same_way_for_every_frontend() {
1743 let read = |value: &str| {
1744 configured_source_roots(&BTreeMap::from([(
1745 SOURCE_ROOTS_VARIABLE.to_owned(),
1746 value.to_owned(),
1747 )]))
1748 };
1749 assert_eq!(
1750 read(" src , app ,"),
1751 Some(vec!["src".to_owned(), "app".to_owned()])
1752 );
1753 assert_eq!(
1754 read(" , "),
1755 None,
1756 "naming nothing leaves discovery automatic"
1757 );
1758 assert_eq!(configured_source_roots(&BTreeMap::new()), None);
1759 }
1760
1761 #[test]
1762 fn a_next_application_is_measured_where_next_keeps_its_code() {
1763 let root = repository(
1768 "next-layout",
1769 &[
1770 ("package.json", r#"{"dependencies":{"next":"16.1.1"}}"#),
1771 (
1772 "tsconfig.json",
1773 r#"{"include":["next-env.d.ts","**/*.ts",".next/types/**/*.ts"]}"#,
1774 ),
1775 ("next-env.d.ts", "declare const next: unknown;"),
1776 ("app/page.tsx", "page"),
1777 ("app/api/wordpress/test/route.ts", "route"),
1778 ("app/api/wordpress/test/route.test.ts", "test"),
1779 ("app/api/wordpress/test/helper.ts", "helper"),
1780 ("app/__tests__/page.tsx", "test"),
1781 ("components/button.tsx", "component"),
1782 ("pages/api/legacy.ts", "page"),
1783 ("proxy.ts", "proxy"),
1784 ("instrumentation.ts", "instrumentation"),
1785 ("simulators/echo.ts", "simulator"),
1786 ],
1787 );
1788 let discovered = discover_source_scope(&root, None).unwrap();
1789 assert_eq!(
1790 discovered.source_files,
1791 [
1792 "app/api/wordpress/test/route.ts",
1793 "app/page.tsx",
1794 "components/button.tsx",
1795 "instrumentation.ts",
1796 "pages/api/legacy.ts",
1797 "proxy.ts",
1798 ]
1799 );
1800 assert_eq!(
1801 discovered.source_roots,
1802 [
1803 "app",
1804 "components",
1805 "instrumentation.ts",
1806 "pages",
1807 "proxy.ts"
1808 ],
1809 "a declaration file is not a root"
1810 );
1811 for file in [
1812 "app/api/wordpress/test/route.test.ts",
1813 "app/api/wordpress/test/helper.ts",
1814 "app/__tests__/page.tsx",
1815 ] {
1816 assert_eq!(entry(&discovered, file).reason, "test or fixture source");
1817 }
1818 assert_eq!(
1820 entry(&discovered, "simulators/echo.ts").status,
1821 SourceScopeStatus::Ambiguous
1822 );
1823 let roots = vec!["app".into()];
1825 let named = discover_source_scope(&root, Some(&roots)).unwrap();
1826 assert_eq!(
1827 named.source_files,
1828 ["app/api/wordpress/test/route.ts", "app/page.tsx"]
1829 );
1830 fs::remove_dir_all(root).unwrap();
1831 }
1832
1833 #[test]
1834 fn a_route_file_name_means_nothing_outside_a_next_application() {
1835 let root = repository(
1836 "not-next",
1837 &[
1838 ("package.json", "{}"),
1839 ("app/main.ts", "main"),
1840 ("app/test/route.ts", "a test helper"),
1841 ("proxy.ts", "unknown"),
1842 ],
1843 );
1844 let discovered = discover_source_scope(&root, None).unwrap();
1845 assert_eq!(discovered.source_files, ["app/main.ts"]);
1846 assert_eq!(
1847 entry(&discovered, "app/test/route.ts").reason,
1848 "test or fixture source"
1849 );
1850 assert_eq!(
1851 entry(&discovered, "proxy.ts").status,
1852 SourceScopeStatus::Ambiguous
1853 );
1854 fs::remove_dir_all(root).unwrap();
1855 }
1856
1857 #[test]
1858 fn a_named_root_settles_what_it_names_and_guesses_only_below_it() {
1859 let root = repository(
1863 "named-roots",
1864 &[
1865 ("package.json", "{}"),
1866 ("src/main.ts", "main"),
1867 ("src/test-utils/build.ts", "helper"),
1868 ("src/test-utils/__tests__/build.test.ts", "test"),
1869 ("scripts/release.ts", "script"),
1870 ("scripts/fixtures/sample.ts", "fixture"),
1871 ("tools/one.spec.ts", "named file"),
1872 ],
1873 );
1874 let roots = vec!["src".into()];
1875 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1876 assert_eq!(discovered.source_files, ["src/main.ts"]);
1877
1878 let roots = vec![
1879 "src/test-utils".into(),
1880 "scripts".into(),
1881 "tools/one.spec.ts".into(),
1882 ];
1883 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1884 assert_eq!(
1885 discovered.source_files,
1886 [
1887 "scripts/release.ts",
1888 "src/test-utils/build.ts",
1889 "tools/one.spec.ts",
1890 ]
1891 );
1892 assert_eq!(
1893 entry(&discovered, "src/test-utils/__tests__/build.test.ts").reason,
1894 "test or fixture source"
1895 );
1896 assert_eq!(
1897 entry(&discovered, "scripts/fixtures/sample.ts").reason,
1898 "test or fixture source"
1899 );
1900 fs::remove_dir_all(root).unwrap();
1901 }
1902
1903 #[test]
1904 fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
1905 let root = repository(
1906 "explicit",
1907 &[
1908 ("package.json", "{}"),
1909 ("product/main.ts", "product"),
1910 ("orphan.ts", "outside"),
1911 ],
1912 );
1913 let roots = vec!["product".into()];
1914 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1915 assert_eq!(discovered.source_files, ["product/main.ts"]);
1916 assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
1917 assert_eq!(
1918 entry(&discovered, "orphan.ts").reason,
1919 "outside explicit source roots"
1920 );
1921 assert!(discovered.limitations.is_empty());
1922 fs::remove_dir_all(root).unwrap();
1923 }
1924
1925 #[test]
1926 fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
1927 let root = repository(
1928 "jsonc",
1929 &[
1930 ("package.json", r#"{"main":"./dist/index.js"}"#),
1931 (
1932 "tsconfig.json",
1933 "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
1934 ),
1935 ("events.ts", "event"),
1936 ("žalias.ts", "unicode"),
1937 ("library.test.ts", "test"),
1938 ],
1939 );
1940 let discovered = discover_source_scope(&root, None).unwrap();
1941 assert!(discovered.source_roots.contains(&".".into()));
1942 assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
1943 assert!(discovered.limitations.is_empty());
1944 fs::remove_dir_all(root).unwrap();
1945 }
1946
1947 #[cfg(unix)]
1948 #[test]
1949 fn never_follows_source_directory_or_explicit_root_symlinks() {
1950 use std::os::unix::fs::symlink;
1951
1952 let root = repository(
1953 "symlink",
1954 &[("package.json", "{}"), ("src/real.ts", "real")],
1955 );
1956 let outside = repository("outside", &[("secret.ts", "secret")]);
1957 symlink(&outside, root.join("linked")).unwrap();
1958 symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
1959 let discovered = discover_source_scope(&root, None).unwrap();
1960 assert_eq!(discovered.source_files, ["src/real.ts"]);
1961 let explicit = vec!["linked".into()];
1962 assert!(matches!(
1963 discover_source_scope(&root, Some(&explicit)),
1964 Err(SourceDiscoveryError::InvalidRoot(_))
1965 ));
1966 fs::remove_dir_all(root).unwrap();
1967 fs::remove_dir_all(outside).unwrap();
1968 }
1969
1970 #[test]
1971 fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
1972 let root = repository(
1973 "workspace-store",
1974 &[
1975 ("package.json", "{}"),
1976 ("src/main.ts", "main"),
1977 ("supercov/user.ts", "user code"),
1978 ],
1979 );
1980 let before = discover_source_scope(&root, None).unwrap();
1981 assert_eq!(
1982 entry(&before, "supercov/user.ts").status,
1983 SourceScopeStatus::Ambiguous
1984 );
1985 fs::write(
1986 root.join("supercov/.supercov-workspace-store"),
1987 b"Supercov instrumented workspace. Safe to delete.\n",
1988 )
1989 .unwrap();
1990 let after = discover_source_scope(&root, None).unwrap();
1991 assert!(
1992 after
1993 .scope
1994 .entries
1995 .iter()
1996 .all(|entry| !entry.file.starts_with("supercov/"))
1997 );
1998 fs::remove_dir_all(root).unwrap();
1999 }
2000}