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];
35const SOURCE_DIRECTORIES: &[&str] = &["app", "src", "lib", "server", "client", "functions", "api"];
36const PACKAGE_PARENTS: &[&str] = &["apps", "packages", "services", "workspaces"];
37const TEST_DIRECTORIES: &[&str] = &[
38 "__tests__",
39 "test",
40 "tests",
41 "spec",
42 "specs",
43 "e2e",
44 "fixture",
45 "fixtures",
46 "mock",
47 "mocks",
48 "__mocks__",
49];
50const CONFIG_TOOLS: &[&str] = &[
51 "babel",
52 "eslint",
53 "graphql",
54 "jest",
55 "next",
56 "nuxt",
57 "playwright",
58 "postcss",
59 "prettier",
60 "remix",
61 "rollup",
62 "stylelint",
63 "tailwind",
64 "tsup",
65 "vite",
66 "vitest",
67 "webpack",
68];
69const DOT_CONFIG_TOOLS: &[&str] = &["babel", "eslint", "graphql", "prettier", "stylelint"];
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "lowercase")]
73pub enum SourceScopeStatus {
74 Included,
75 Excluded,
76 Ambiguous,
77}
78
79pub const BUILT_ASSET_REASON: &str = "built asset";
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct SourceScopeEntry {
85 pub file: String,
86 pub status: SourceScopeStatus,
87 pub reason: String,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub package_root: Option<String>,
90}
91
92impl SourceScopeEntry {
93 pub fn is_generated_output(&self) -> bool {
97 self.status == SourceScopeStatus::Excluded && self.reason == BUILT_ASSET_REASON
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum SourceScopeMode {
104 Automatic,
105 Explicit,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase", deny_unknown_fields)]
110pub struct SourceScope {
111 pub version: u32,
112 pub mode: SourceScopeMode,
113 pub roots: Vec<String>,
114 pub entries: Vec<SourceScopeEntry>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase", deny_unknown_fields)]
119pub struct SourceLimitation {
120 pub id: String,
121 pub kind: String,
122 pub file: String,
123 pub line: usize,
124 pub column: usize,
125 pub source: String,
126 pub reason: String,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase", deny_unknown_fields)]
131pub struct DiscoveredSourceScope {
132 pub source_files: Vec<String>,
133 pub source_roots: Vec<String>,
134 pub scope: SourceScope,
135 pub limitations: Vec<SourceLimitation>,
136}
137
138#[derive(Debug)]
139pub enum SourceDiscoveryError {
140 Io { path: PathBuf, source: io::Error },
141 NonUtf8Path(PathBuf),
142 InvalidRoot(PathBuf),
143}
144
145impl std::fmt::Display for SourceDiscoveryError {
146 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 match self {
148 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
149 Self::NonUtf8Path(path) => {
150 write!(
151 formatter,
152 "source path is not valid UTF-8: {}",
153 path.display()
154 )
155 }
156 Self::InvalidRoot(path) => write!(
157 formatter,
158 "source root is not a regular file or directory: {}",
159 path.display()
160 ),
161 }
162 }
163}
164
165impl std::error::Error for SourceDiscoveryError {}
166
167fn io_error(path: &Path, source: io::Error) -> SourceDiscoveryError {
168 SourceDiscoveryError::Io {
169 path: path.to_owned(),
170 source,
171 }
172}
173
174pub const SOURCE_ROOTS_VARIABLE: &str = "SUPERCOV_SOURCE_ROOTS";
176
177pub const OUTSIDE_EXPLICIT_ROOTS: &str = "outside explicit source roots";
179
180pub fn configured_source_roots(environment: &BTreeMap<String, String>) -> Option<Vec<String>> {
190 let roots = environment
191 .get(SOURCE_ROOTS_VARIABLE)?
192 .split(',')
193 .map(str::trim)
194 .filter(|root| !root.is_empty())
195 .map(str::to_owned)
196 .collect::<Vec<_>>();
197 (!roots.is_empty()).then_some(roots)
198}
199
200pub fn fold_roots_into_configuration(configuration: &mut Vec<u8>, roots: Option<&[String]>) {
206 let Some(roots) = roots.filter(|roots| !roots.is_empty()) else {
207 return;
208 };
209 configuration.push(0);
210 configuration
211 .extend_from_slice(format!("{SOURCE_ROOTS_VARIABLE}={}", roots.join(",")).as_bytes());
212}
213
214#[derive(Debug, Clone)]
220pub struct ExplicitSourceRoots {
221 root: PathBuf,
222 roots: Vec<PathBuf>,
223 configured: Vec<String>,
224}
225
226impl ExplicitSourceRoots {
227 pub fn resolve(root: &Path, configured: &[String]) -> Result<Self, SourceDiscoveryError> {
228 let root = lexical_normalize(root);
229 let roots = existing_roots(configured.iter().map(|value| resolve(&root, value)))?;
230 Ok(Self {
231 root,
232 roots,
233 configured: configured.to_vec(),
234 })
235 }
236
237 pub fn from_environment(
239 root: &Path,
240 environment: &BTreeMap<String, String>,
241 ) -> Result<Option<Self>, SourceDiscoveryError> {
242 configured_source_roots(environment)
243 .map(|configured| Self::resolve(root, &configured))
244 .transpose()
245 }
246
247 pub fn from_environment_in(
254 project: &Path,
255 workspace: &Path,
256 environment: &BTreeMap<String, String>,
257 ) -> Result<Option<Self>, SourceDiscoveryError> {
258 let Some(configured) = configured_source_roots(environment) else {
259 return Ok(None);
260 };
261 let project = lexical_normalize(project);
262 let rebased = configured
263 .iter()
264 .map(|value| {
265 let path = Path::new(value);
266 if !path.is_absolute() {
267 return value.clone();
268 }
269 lexical_normalize(path)
270 .strip_prefix(&project)
271 .map(|rest| {
272 let rest = rest.to_string_lossy().into_owned();
273 if rest.is_empty() { ".".into() } else { rest }
274 })
275 .unwrap_or_else(|_| value.clone())
276 })
277 .collect::<Vec<_>>();
278 let mut roots = Self::resolve(workspace, &rebased)?;
279 roots.configured = configured;
280 Ok(Some(roots))
281 }
282
283 pub fn contains(&self, relative: &str) -> bool {
286 root_contains(&self.roots, &lexical_normalize(&self.root.join(relative)))
287 }
288
289 pub fn configured(&self) -> &[String] {
293 &self.configured
294 }
295
296 pub fn refuse_if_empty(&self, remaining: usize, language: &str) -> Result<(), String> {
300 if remaining > 0 {
301 return Ok(());
302 }
303 let missing = self
304 .configured
305 .iter()
306 .filter(|value| fs::symlink_metadata(resolve(&self.root, value)).is_err())
307 .cloned()
308 .collect::<Vec<_>>();
309 let mut message = format!(
310 "{SOURCE_ROOTS_VARIABLE}={} left no {language} source to measure",
311 self.configured.join(",")
312 );
313 if !missing.is_empty() {
314 message.push_str(&format!("; {} does not exist", missing.join(", ")));
315 }
316 message.push_str("; name the directories or files that hold your own code");
317 Err(message)
318 }
319
320 pub fn scope(
326 &self,
327 included: &[String],
328 excluded: &[(String, &'static str)],
329 ) -> Result<SourceScope, SourceDiscoveryError> {
330 let mut entries = included
331 .iter()
332 .map(|file| SourceScopeEntry {
333 file: file.clone(),
334 status: SourceScopeStatus::Included,
335 reason: "explicit source root".into(),
336 package_root: None,
337 })
338 .chain(
339 excluded
340 .iter()
341 .filter(|(_, reason)| *reason == OUTSIDE_EXPLICIT_ROOTS)
342 .map(|(file, reason)| SourceScopeEntry {
343 file: file.clone(),
344 status: SourceScopeStatus::Excluded,
345 reason: (*reason).into(),
346 package_root: None,
347 }),
348 )
349 .collect::<Vec<_>>();
350 entries.sort_by(|left, right| left.file.cmp(&right.file));
351 Ok(SourceScope {
352 version: 1,
353 mode: SourceScopeMode::Explicit,
354 roots: self.local_roots()?,
355 entries,
356 })
357 }
358
359 pub fn local_roots(&self) -> Result<Vec<String>, SourceDiscoveryError> {
361 self.roots
362 .iter()
363 .map(|path| local_path(&self.root, path))
364 .collect()
365 }
366
367 pub fn narrow<T>(
372 &self,
373 sources: &mut Vec<T>,
374 excluded: &mut Vec<(String, &'static str)>,
375 file: impl Fn(&T) -> &str,
376 ) {
377 let (kept, outside): (Vec<T>, Vec<T>) = sources
378 .drain(..)
379 .partition(|source| self.contains(file(source)));
380 excluded.extend(
381 outside
382 .iter()
383 .map(|source| (file(source).to_owned(), OUTSIDE_EXPLICIT_ROOTS)),
384 );
385 *sources = kept;
386 }
387}
388
389fn existing_roots(
393 candidates: impl IntoIterator<Item = PathBuf>,
394) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
395 let mut existing = BTreeSet::new();
396 for path in candidates {
397 match fs::symlink_metadata(&path) {
398 Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_dir() => {
399 existing.insert(path);
400 }
401 Ok(_) => return Err(SourceDiscoveryError::InvalidRoot(path)),
402 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
403 Err(error) => return Err(io_error(&path, error)),
404 }
405 }
406 Ok(existing.into_iter().collect())
407}
408
409fn root_contains(roots: &[PathBuf], path: &Path) -> bool {
412 roots.iter().any(|directory| {
413 fs::symlink_metadata(directory)
414 .map(|metadata| {
415 if metadata.file_type().is_dir() {
416 within(directory, path)
417 } else {
418 directory == path
419 }
420 })
421 .unwrap_or(false)
422 })
423}
424
425fn lexical_normalize(path: &Path) -> PathBuf {
426 let mut output = PathBuf::new();
427 for component in path.components() {
428 match component {
429 Component::CurDir => {}
430 Component::ParentDir => {
431 if !output.pop() {
432 output.push(component.as_os_str());
433 }
434 }
435 _ => output.push(component.as_os_str()),
436 }
437 }
438 output
439}
440
441fn resolve(root: &Path, value: impl AsRef<Path>) -> PathBuf {
442 let value = value.as_ref();
443 let joined;
444 let path = if value.is_absolute() {
445 value
446 } else {
447 joined = root.join(value);
448 &joined
449 };
450 lexical_normalize(path)
451}
452
453fn local_path(root: &Path, path: &Path) -> Result<String, SourceDiscoveryError> {
454 let local = path
455 .strip_prefix(root)
456 .map_err(|_| SourceDiscoveryError::InvalidRoot(path.to_owned()))?;
457 if local.as_os_str().is_empty() {
458 return Ok(".".into());
459 }
460 local
461 .components()
462 .map(|component| {
463 component
464 .as_os_str()
465 .to_str()
466 .map(str::to_owned)
467 .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(path.to_owned()))
468 })
469 .collect::<Result<Vec<_>, _>>()
470 .map(|parts| parts.join("/"))
471}
472
473fn generated_directory(name: &str) -> bool {
474 GENERATED_DIRECTORIES.contains(&name)
475}
476
477fn owned_workspace_store(path: &Path) -> bool {
478 crate::workspace::owned_workspace_path(path)
479}
480
481fn nested_checkout(path: &Path) -> bool {
486 fs::symlink_metadata(path.join(".git")).is_ok()
487}
488
489fn built_asset(file: &str) -> bool {
497 let mut segments = file.rsplit('/');
498 let Some(name) = segments.next() else {
499 return false;
500 };
501 let in_asset_directory =
502 segments.any(|segment| matches!(segment, "assets" | "static" | "public"));
503 let Some(stem) = name
504 .strip_suffix(".js")
505 .or_else(|| name.strip_suffix(".mjs"))
506 .or_else(|| name.strip_suffix(".cjs"))
507 else {
508 return false;
509 };
510 if stem.len() < 10 || stem.as_bytes()[stem.len() - 9] != b'-' {
513 return false;
514 }
515 let hash = &stem[stem.len() - 8..];
516 let looks_hashed = hash
517 .bytes()
518 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
519 && (hash.bytes().any(|byte| byte.is_ascii_digit())
520 || (hash.bytes().any(|byte| byte.is_ascii_uppercase())
521 && hash.bytes().any(|byte| byte.is_ascii_lowercase())));
522 in_asset_directory && looks_hashed
523}
524
525fn root_tool_directory(root: &Path, path: &Path) -> bool {
526 path.parent() == Some(root)
527 && path
528 .file_name()
529 .and_then(|name| name.to_str())
530 .is_some_and(|name| name.starts_with('.'))
531}
532
533fn source_file(name: &str) -> bool {
534 let lower = name.to_ascii_lowercase();
535 [
536 ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
537 ".mtsx",
538 ]
539 .iter()
540 .any(|extension| lower.ends_with(extension))
541}
542
543fn declaration_file(file: &str) -> bool {
544 let lower = file.to_ascii_lowercase();
545 lower.ends_with(".d.ts") || lower.ends_with(".d.cts") || lower.ends_with(".d.mts")
546}
547
548fn test_or_fixture(file: &str) -> bool {
549 let lower = file.to_ascii_lowercase();
550 if lower
551 .split('/')
552 .any(|segment| TEST_DIRECTORIES.contains(&segment))
553 {
554 return true;
555 }
556 lower
557 .split(['/', '_', '.', '-'])
558 .any(|part| matches!(part, "test" | "spec"))
559}
560
561fn tool_script(file: &str) -> bool {
562 file.to_ascii_lowercase()
563 .split('/')
564 .any(|segment| segment == "scripts")
565}
566
567fn config_file(file: &str) -> bool {
568 let lower = file.to_ascii_lowercase();
569 let name = lower.rsplit('/').next().unwrap_or(&lower);
570 source_file(name)
571 && (CONFIG_TOOLS
572 .iter()
573 .any(|tool| name.starts_with(&format!("{tool}.config.")))
574 || DOT_CONFIG_TOOLS
575 .iter()
576 .any(|tool| name.starts_with(&format!(".{tool}rc.")))
577 || (!lower.contains('/')
578 && (name.contains(".config.")
579 || name.starts_with("build.")
580 || name.starts_with("gulpfile.")
581 || name.starts_with("gruntfile."))))
582}
583
584fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, SourceDiscoveryError> {
585 let mut entries = fs::read_dir(path)
586 .map_err(|error| io_error(path, error))?
587 .collect::<Result<Vec<_>, _>>()
588 .map_err(|error| io_error(path, error))?;
589 entries.sort_by_key(fs::DirEntry::file_name);
590 Ok(entries)
591}
592
593fn files_under(
594 root: &Path,
595 directory: &Path,
596 output: &mut Vec<PathBuf>,
597) -> Result<(), SourceDiscoveryError> {
598 let metadata = fs::symlink_metadata(directory).map_err(|error| io_error(directory, error))?;
599 if !metadata.file_type().is_dir() {
600 return Err(SourceDiscoveryError::InvalidRoot(directory.to_owned()));
601 }
602 for entry in read_directory(directory)? {
603 let name = entry
604 .file_name()
605 .into_string()
606 .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
607 let path = entry.path();
608 let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
609 if file_type.is_symlink() {
610 continue;
611 }
612 if file_type.is_dir() {
613 if !generated_directory(&name)
614 && !owned_workspace_store(&path)
615 && !nested_checkout(&path)
616 && !root_tool_directory(root, &path)
617 {
618 files_under(root, &path, output)?;
619 }
620 } else if file_type.is_file() && source_file(&name) {
621 output.push(path);
622 }
623 }
624 Ok(())
625}
626
627fn read_json(path: &Path) -> Option<Value> {
628 serde_json::from_slice(&fs::read(path).ok()?).ok()
629}
630
631fn package_directories(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
632 fn visit(
633 root: &Path,
634 directory: &Path,
635 depth: usize,
636 found: &mut BTreeSet<PathBuf>,
637 ) -> Result<(), SourceDiscoveryError> {
638 if depth > 5 {
639 return Ok(());
640 }
641 for entry in read_directory(directory)? {
642 let name = entry
643 .file_name()
644 .into_string()
645 .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
646 let path = entry.path();
647 let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
648 if !file_type.is_dir()
649 || file_type.is_symlink()
650 || generated_directory(&name)
651 || owned_workspace_store(&path)
652 || nested_checkout(&path)
653 || root_tool_directory(root, &path)
654 {
655 continue;
656 }
657 let local = local_path(root, &path)?;
658 let under_package_parent = local
659 .split('/')
660 .any(|segment| PACKAGE_PARENTS.contains(&segment));
661 if path.join("package.json").is_file() && (depth == 0 || under_package_parent) {
662 found.insert(path.clone());
663 }
664 visit(root, &path, depth + 1, found)?;
665 }
666 Ok(())
667 }
668
669 let mut found = BTreeSet::from([root.to_owned()]);
670 visit(root, root, 0, &mut found)?;
671 for directory in declared_workspace_packages(root)? {
672 found.insert(directory);
673 }
674 Ok(found.into_iter().collect())
675}
676
677fn declared_workspace_packages(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
684 let mut patterns = Vec::new();
685 let manifest = read_json(&root.join("package.json")).unwrap_or(Value::Null);
686 let declared = match manifest.get("workspaces") {
687 Some(Value::Array(items)) => Some(items),
688 Some(Value::Object(object)) => object.get("packages").and_then(Value::as_array),
689 _ => None,
690 };
691 if let Some(items) = declared {
692 patterns.extend(items.iter().filter_map(Value::as_str).map(str::to_owned));
693 }
694 if let Ok(pnpm) = fs::read_to_string(root.join("pnpm-workspace.yaml")) {
695 let mut in_packages = false;
696 for line in pnpm.lines() {
697 let trimmed = line.trim();
698 if trimmed.starts_with("packages:") {
699 in_packages = true;
700 continue;
701 }
702 if in_packages {
703 if let Some(item) = trimmed.strip_prefix("- ") {
704 patterns.push(item.trim_matches(|c| c == '"' || c == '\'').to_owned());
705 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
706 in_packages = false;
707 }
708 }
709 }
710 }
711 let mut found = Vec::new();
712 for pattern in patterns {
713 let pattern = pattern.trim_start_matches("./").trim_end_matches('/');
714 if pattern.starts_with('!') || pattern.contains("**") {
715 continue;
716 }
717 let (parent, wildcard) = match pattern.strip_suffix("/*") {
718 Some(parent) => (parent, true),
719 None => (pattern, false),
720 };
721 if parent.is_empty()
722 || parent
723 .split('/')
724 .any(|segment| segment.is_empty() || segment == ".." || segment.contains('*'))
725 {
726 continue;
727 }
728 let base = root.join(parent);
729 let candidates: Vec<PathBuf> = if wildcard {
730 match fs::read_dir(&base) {
731 Ok(entries) => entries
732 .filter_map(Result::ok)
733 .map(|entry| entry.path())
734 .collect(),
735 Err(_) => Vec::new(),
736 }
737 } else {
738 vec![base]
739 };
740 for candidate in candidates {
741 let is_dir = fs::symlink_metadata(&candidate)
742 .is_ok_and(|metadata| metadata.file_type().is_dir());
743 if is_dir
744 && candidate.join("package.json").is_file()
745 && !nested_checkout(&candidate)
746 && !owned_workspace_store(&candidate)
747 {
748 found.push(candidate);
749 }
750 }
751 }
752 Ok(found)
753}
754
755fn string_targets(value: &Value, depth: usize, output: &mut Vec<String>) {
756 if depth > 8 {
757 return;
758 }
759 match value {
760 Value::String(value) => output.push(value.clone()),
761 Value::Array(values) => {
762 for value in values {
763 string_targets(value, depth + 1, output);
764 }
765 }
766 Value::Object(values) => {
767 for value in values.values() {
768 string_targets(value, depth + 1, output);
769 }
770 }
771 _ => {}
772 }
773}
774
775fn entry_targets(directory: &Path, manifest: &Value) -> Vec<PathBuf> {
776 let mut targets = Vec::new();
777 for key in ["main", "module", "browser", "bin", "exports"] {
778 if let Some(value) = manifest.get(key) {
779 string_targets(value, 0, &mut targets);
780 }
781 }
782 targets
783 .into_iter()
784 .filter_map(|target| {
785 if !target.starts_with('.') || target.contains("node_modules") {
786 return None;
787 }
788 let prefix = target.split('*').next()?.trim_end_matches('/');
789 (!prefix.is_empty()).then(|| resolve(directory, prefix))
790 })
791 .collect()
792}
793
794pub(crate) fn strip_jsonc_comments(contents: &str) -> String {
795 let mut output = String::with_capacity(contents.len());
796 let mut chars = contents.chars().peekable();
797 let mut string = false;
798 let mut escaped = false;
799 while let Some(character) = chars.next() {
800 if string {
801 output.push(character);
802 if escaped {
803 escaped = false;
804 } else if character == '\\' {
805 escaped = true;
806 } else if character == '"' {
807 string = false;
808 }
809 continue;
810 }
811 if character == '"' {
812 string = true;
813 output.push(character);
814 } else if character == '/' && chars.peek() == Some(&'/') {
815 chars.next();
816 for comment in chars.by_ref() {
817 if comment == '\n' {
818 output.push('\n');
819 break;
820 }
821 }
822 } else if character == '/' && chars.peek() == Some(&'*') {
823 chars.next();
824 let mut previous = '\0';
825 for comment in chars.by_ref() {
826 if previous == '*' && comment == '/' {
827 break;
828 }
829 previous = comment;
830 }
831 } else {
832 output.push(character);
833 }
834 }
835 output
836}
837
838pub(crate) fn strip_trailing_commas(contents: &str) -> String {
839 let chars = contents.chars().collect::<Vec<_>>();
840 let mut output = String::with_capacity(contents.len());
841 let mut string = false;
842 let mut escaped = false;
843 for (index, character) in chars.iter().copied().enumerate() {
844 if string {
845 output.push(character);
846 if escaped {
847 escaped = false;
848 } else if character == '\\' {
849 escaped = true;
850 } else if character == '"' {
851 string = false;
852 }
853 continue;
854 }
855 if character == '"' {
856 string = true;
857 output.push(character);
858 } else if character == ','
859 && chars[index + 1..]
860 .iter()
861 .find(|character| !character.is_whitespace())
862 .is_some_and(|character| matches!(character, '}' | ']'))
863 {
864 } else {
865 output.push(character);
866 }
867 }
868 output
869}
870
871fn tsconfig_roots(directory: &Path) -> Vec<PathBuf> {
872 let path = directory.join("tsconfig.json");
873 let Some(contents) = fs::read_to_string(path).ok() else {
874 return Vec::new();
875 };
876 let jsonc = strip_trailing_commas(&strip_jsonc_comments(&contents));
877 let Some(config) = serde_json::from_str::<Value>(&jsonc).ok() else {
878 return Vec::new();
879 };
880 let mut values = Vec::new();
881 if let Some(root_dir) = config
882 .get("compilerOptions")
883 .and_then(|options| options.get("rootDir"))
884 .and_then(Value::as_str)
885 {
886 values.push(root_dir.to_owned());
887 }
888 if let Some(include) = config.get("include").and_then(Value::as_array) {
889 values.extend(include.iter().filter_map(Value::as_str).map(str::to_owned));
890 }
891 if values.is_empty() {
892 return vec![directory.to_owned()];
893 }
894 values
895 .into_iter()
896 .filter_map(|value| {
897 if value.starts_with('!') {
898 return None;
899 }
900 let prefix = value
901 .find(['?', '*', '{', '['])
902 .map_or(value.as_str(), |index| &value[..index])
903 .trim_end_matches('/');
904 (!prefix.is_empty()).then(|| resolve(directory, prefix))
905 })
906 .collect()
907}
908
909fn within(parent: &Path, child: &Path) -> bool {
910 child == parent || child.starts_with(parent)
911}
912
913fn nearest_package_root<'a>(path: &Path, packages: &'a [PathBuf]) -> Option<&'a PathBuf> {
914 packages
915 .iter()
916 .filter(|directory| within(directory, path))
917 .max_by_key(|directory| directory.components().count())
918}
919
920fn scope_limitation(file: &str) -> SourceLimitation {
921 let digest = Sha256::digest(file.as_bytes());
922 let id = digest[..10]
923 .iter()
924 .map(|byte| format!("{byte:02x}"))
925 .collect::<String>();
926 SourceLimitation {
927 id: format!("scope:{id}"),
928 kind: "source-scope".into(),
929 file: file.into(),
930 line: 1,
931 column: 1,
932 source: file.into(),
933 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(),
934 }
935}
936
937fn declared_test_setup(packages: &[PathBuf]) -> BTreeSet<PathBuf> {
941 let mut paths = BTreeSet::new();
942 for package in packages {
943 let manifest = read_json(&package.join("package.json")).unwrap_or(Value::Null);
944 let Some(jest) = manifest.get("jest") else {
945 continue;
946 };
947 let root = jest
948 .get("rootDir")
949 .and_then(Value::as_str)
950 .map(|value| {
951 resolve(
952 package,
953 value.replace("<rootDir>", &package.to_string_lossy()),
954 )
955 })
956 .unwrap_or_else(|| package.clone());
957 for field in [
958 "setupFiles",
959 "setupFilesAfterEnv",
960 "globalSetup",
961 "globalTeardown",
962 ] {
963 let values = match jest.get(field) {
964 Some(Value::Array(values)) => values.iter().collect::<Vec<_>>(),
965 Some(value) => vec![value],
966 None => Vec::new(),
967 };
968 for value in values.into_iter().filter_map(Value::as_str) {
969 let path = resolve(&root, value.replace("<rootDir>", &root.to_string_lossy()));
970 if path.is_file() {
971 paths.insert(path);
972 }
973 }
974 }
975 }
976 paths
977}
978
979pub fn discover_source_scope(
980 root: &Path,
981 configured_roots: Option<&[String]>,
982) -> Result<DiscoveredSourceScope, SourceDiscoveryError> {
983 let root = lexical_normalize(root);
984 let root_metadata = fs::symlink_metadata(&root).map_err(|error| io_error(&root, error))?;
985 if !root_metadata.file_type().is_dir() {
986 return Err(SourceDiscoveryError::InvalidRoot(root));
987 }
988 let packages = package_directories(&root)?;
989 let test_setup = declared_test_setup(&packages);
990 let explicit = configured_roots.is_some_and(|roots| !roots.is_empty());
991 let include_roots = if explicit {
992 configured_roots
993 .unwrap_or_default()
994 .iter()
995 .map(|directory| resolve(&root, directory))
996 .collect::<Vec<_>>()
997 } else {
998 packages
999 .iter()
1000 .flat_map(|directory| {
1001 let manifest = read_json(&directory.join("package.json")).unwrap_or(Value::Null);
1002 let candidates = SOURCE_DIRECTORIES
1003 .iter()
1004 .map(|name| directory.join(name))
1005 .chain(entry_targets(directory, &manifest))
1006 .chain(tsconfig_roots(directory))
1007 .collect::<Vec<_>>();
1008 if directory != &root
1014 && !candidates
1015 .iter()
1016 .any(|candidate| fs::symlink_metadata(candidate).is_ok())
1017 {
1018 return vec![directory.clone()];
1019 }
1020 candidates
1021 })
1022 .collect()
1023 };
1024 let existing_roots = existing_roots(include_roots)?;
1025 let mut all_files = Vec::new();
1026 files_under(&root, &root, &mut all_files)?;
1027 all_files.sort();
1028
1029 let mut entries = Vec::new();
1030 let mut included = Vec::new();
1031 let mut limitations = Vec::new();
1032 for path in all_files {
1033 let file = local_path(&root, &path)?;
1034 let package_root = nearest_package_root(&path, &packages)
1035 .map(|package| local_path(&root, package))
1036 .transpose()?
1037 .filter(|package| package != ".");
1038 let entry = |status, reason: &str| SourceScopeEntry {
1039 file: file.clone(),
1040 status,
1041 reason: reason.into(),
1042 package_root: package_root.clone(),
1043 };
1044 if declaration_file(&file) {
1045 entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
1046 } else if test_setup.contains(&path) {
1047 entries.push(entry(SourceScopeStatus::Excluded, "declared test setup"));
1048 } else if test_or_fixture(&file) {
1049 entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
1050 } else if tool_script(&file) {
1051 entries.push(entry(
1052 SourceScopeStatus::Excluded,
1053 "conventional tool script",
1054 ));
1055 } else if config_file(&file) {
1056 entries.push(entry(
1057 SourceScopeStatus::Excluded,
1058 "build/test/tool configuration",
1059 ));
1060 } else if built_asset(&file) {
1061 entries.push(entry(SourceScopeStatus::Excluded, BUILT_ASSET_REASON));
1062 } else if root_contains(&existing_roots, &path) {
1063 included.push(path);
1064 entries.push(entry(
1065 SourceScopeStatus::Included,
1066 if explicit {
1067 "explicit source root"
1068 } else {
1069 "discovered package source root"
1070 },
1071 ));
1072 } else if explicit {
1073 entries.push(entry(
1074 SourceScopeStatus::Excluded,
1075 "outside explicit source roots",
1076 ));
1077 } else {
1078 entries.push(entry(
1079 SourceScopeStatus::Ambiguous,
1080 "unclassified first-party source",
1081 ));
1082 limitations.push(scope_limitation(&file));
1083 }
1084 }
1085 let source_files = included
1086 .iter()
1087 .map(|path| local_path(&root, path))
1088 .collect::<Result<Vec<_>, _>>()?;
1089 let source_roots = existing_roots
1090 .iter()
1091 .map(|path| local_path(&root, path))
1092 .collect::<Result<Vec<_>, _>>()?;
1093 Ok(DiscoveredSourceScope {
1094 source_files,
1095 source_roots: source_roots.clone(),
1096 scope: SourceScope {
1097 version: 1,
1098 mode: if explicit {
1099 SourceScopeMode::Explicit
1100 } else {
1101 SourceScopeMode::Automatic
1102 },
1103 roots: source_roots,
1104 entries,
1105 },
1106 limitations,
1107 })
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112 use std::{
1113 fs,
1114 time::{SystemTime, UNIX_EPOCH},
1115 };
1116
1117 use super::*;
1118
1119 fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
1120 let nonce = SystemTime::now()
1121 .duration_since(UNIX_EPOCH)
1122 .unwrap()
1123 .as_nanos();
1124 let root = std::env::temp_dir().join(format!(
1125 "supercov-source-{label}-{}-{nonce}",
1126 std::process::id()
1127 ));
1128 fs::create_dir_all(&root).unwrap();
1129 for (file, contents) in files {
1130 let path = root.join(file);
1131 fs::create_dir_all(path.parent().unwrap()).unwrap();
1132 fs::write(path, contents).unwrap();
1133 }
1134 root
1135 }
1136
1137 fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
1138 scope
1139 .scope
1140 .entries
1141 .iter()
1142 .find(|entry| entry.file == file)
1143 .unwrap()
1144 }
1145
1146 #[test]
1147 fn excludes_declared_jest_setup_without_hiding_neighboring_application_code() {
1148 let root = repository(
1149 "jest-setup",
1150 &[
1151 (
1152 "package.json",
1153 r#"{"jest":{"setupFilesAfterEnv":["<rootDir>/src/bootstrap.js"]}}"#,
1154 ),
1155 (
1156 "src/bootstrap.js",
1157 "jest.mock('storage', () => ({read: () => null}));",
1158 ),
1159 ("src/index.js", "export const value = 1;"),
1160 ],
1161 );
1162 let discovered = discover_source_scope(&root, None).unwrap();
1163 assert_eq!(discovered.source_files, ["src/index.js"]);
1164 assert_eq!(
1165 entry(&discovered, "src/bootstrap.js").reason,
1166 "declared test setup"
1167 );
1168 fs::remove_dir_all(root).unwrap();
1169 }
1170
1171 #[test]
1172 fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
1173 let root = repository(
1174 "automatic",
1175 &[
1176 ("package.json", r#"{"workspaces":["packages/*"]}"#),
1177 ("src/index.ts", "export const root = true"),
1178 ("lib/helper.js", "export const helper = true"),
1179 ("src/index.test.ts", "test('root', () => {})"),
1180 ("tests/e2e.spec.ts", "test('e2e', () => {})"),
1181 ("scripts/release.mjs", "export const release = true"),
1182 ("vite.config.ts", "export default {}"),
1183 ("build.mjs", "export default async function build() {}"),
1184 (".eslintrc.cjs", "module.exports = {}"),
1185 (".graphqlrc.ts", "export default {}"),
1186 ("orphan.ts", "export const missed = true"),
1187 ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
1188 ("packages/ui/src/index.ts", "export const ui = true"),
1189 ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
1190 ("dist/generated.js", "generated"),
1191 (".cache/tool/generated.js", "cached"),
1192 ],
1193 );
1194 let discovered = discover_source_scope(&root, None).unwrap();
1195 assert_eq!(
1196 discovered.source_files,
1197 ["lib/helper.js", "packages/ui/src/index.ts", "src/index.ts"]
1198 );
1199 assert_eq!(
1200 entry(&discovered, "orphan.ts").status,
1201 SourceScopeStatus::Ambiguous
1202 );
1203 assert_eq!(
1204 entry(&discovered, "scripts/release.mjs").reason,
1205 "conventional tool script"
1206 );
1207 assert_eq!(
1208 entry(&discovered, "build.mjs").reason,
1209 "build/test/tool configuration"
1210 );
1211 assert_eq!(
1212 entry(&discovered, "packages/ui/src/index.ts").package_root,
1213 Some("packages/ui".into())
1214 );
1215 assert_eq!(discovered.limitations.len(), 1);
1216 assert_eq!(discovered.limitations[0].file, "orphan.ts");
1217 assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
1218 assert!(
1219 discovered
1220 .scope
1221 .entries
1222 .iter()
1223 .all(|entry| !entry.file.contains(".cache"))
1224 );
1225 fs::remove_dir_all(root).unwrap();
1226 }
1227
1228 #[test]
1229 fn declared_workspaces_outside_conventional_parents_are_package_roots() {
1230 let root = repository(
1231 "declared-workspaces",
1232 &[
1233 ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1234 ("app/main.ts", "product"),
1235 ("app_extensions/discounts/package.json", "{}"),
1236 ("app_extensions/discounts/src/index.ts", "extension"),
1237 ("app_extensions/upsells/package.json", "{}"),
1240 ("app_extensions/upsells/frontend/embed.ts", "embed"),
1241 ("app_extensions/upsells/dist/embed.js", "built"),
1242 ],
1243 );
1244 let discovered = discover_source_scope(&root, None).unwrap();
1245 assert_eq!(
1246 discovered.source_files,
1247 [
1248 "app/main.ts",
1249 "app_extensions/discounts/src/index.ts",
1250 "app_extensions/upsells/frontend/embed.ts",
1251 ]
1252 );
1253 assert!(
1254 discovered.limitations.is_empty(),
1255 "declared packages must not be blockers: {:?}",
1256 discovered.limitations
1257 );
1258 fs::remove_dir_all(root).unwrap();
1259 }
1260
1261 #[test]
1262 fn hashed_bundles_in_asset_directories_are_built_assets() {
1263 let root = repository(
1264 "built-assets",
1265 &[
1266 ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
1267 ("app/main.ts", "product"),
1268 ("app_extensions/upsells/package.json", "{}"),
1269 ("app_extensions/upsells/frontend/embed.ts", "source"),
1270 (
1271 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1272 "bundle",
1273 ),
1274 ("app_extensions/upsells/assets/stylex-DAnmLURx.js", "bundle"),
1275 (
1277 "app_extensions/upsells/assets/theme-helper.js",
1278 "hand written",
1279 ),
1280 ],
1281 );
1282 let discovered = discover_source_scope(&root, None).unwrap();
1283 assert!(
1284 !discovered
1285 .source_files
1286 .iter()
1287 .any(|file| file.contains("-Be-aUw9g.js") || file.contains("-DAnmLURx.js"))
1288 );
1289 assert_eq!(
1290 entry(
1291 &discovered,
1292 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js"
1293 )
1294 .reason,
1295 "built asset"
1296 );
1297 assert!(
1298 discovered.limitations.is_empty(),
1299 "bundles are not blockers: {:?}",
1300 discovered.limitations
1301 );
1302 fs::remove_dir_all(root).unwrap();
1303 }
1304
1305 #[test]
1306 fn root_level_hidden_directories_are_tooling_not_source() {
1307 let root = repository(
1308 "root-hidden",
1309 &[
1310 ("package.json", "{}"),
1311 ("app/main.ts", "product"),
1312 (".shopify/bundle/upsells/frontend/embed.js", "cli bundle"),
1313 (".vercel/output/functions/index.js", "deploy output"),
1314 ("app/.generated/schema.ts", "generated types"),
1317 ],
1318 );
1319 let discovered = discover_source_scope(&root, None).unwrap();
1320 assert_eq!(
1321 discovered.source_files,
1322 ["app/.generated/schema.ts", "app/main.ts"]
1323 );
1324 assert!(
1325 discovered.limitations.is_empty(),
1326 "{:?}",
1327 discovered.limitations
1328 );
1329 assert!(discovered.scope.entries.iter().all(
1330 |entry| !entry.file.starts_with(".shopify/") && !entry.file.starts_with(".vercel/")
1331 ));
1332 fs::remove_dir_all(root).unwrap();
1333 }
1334
1335 #[test]
1336 fn nested_checkouts_are_neither_source_nor_limitations() {
1337 let root = repository(
1338 "nested-checkout",
1339 &[
1340 ("package.json", "{}"),
1341 ("app/main.ts", "product"),
1342 (".claude/worktrees/agent-1/.git", "gitdir: /elsewhere"),
1345 (".claude/worktrees/agent-1/app/main.ts", "copy"),
1346 ("vendor-fork/.git/HEAD", "ref: refs/heads/main"),
1347 ("vendor-fork/src/index.ts", "clone"),
1348 ],
1349 );
1350 let discovered = discover_source_scope(&root, None).unwrap();
1351 assert_eq!(discovered.source_files, ["app/main.ts"]);
1352 assert!(
1353 discovered.limitations.is_empty(),
1354 "nested checkouts must not be blocking limitations: {:?}",
1355 discovered.limitations
1356 );
1357 assert!(
1358 discovered
1359 .scope
1360 .entries
1361 .iter()
1362 .all(|entry| !entry.file.starts_with(".claude/")
1363 && !entry.file.starts_with("vendor-fork/")),
1364 "nested checkout files must not appear in scope at all"
1365 );
1366 fs::remove_dir_all(root).unwrap();
1367 }
1368
1369 #[test]
1370 fn roots_enter_a_runs_identity_only_when_named() {
1371 let mut unchanged = b"pytest\0-q".to_vec();
1374 fold_roots_into_configuration(&mut unchanged, None);
1375 assert_eq!(unchanged, b"pytest\0-q");
1376 fold_roots_into_configuration(&mut unchanged, Some(&[]));
1377 assert_eq!(unchanged, b"pytest\0-q");
1378
1379 let mut folded = b"pytest\0-q".to_vec();
1380 fold_roots_into_configuration(&mut folded, Some(&["src".to_owned(), "a.py".to_owned()]));
1381 assert_eq!(folded, b"pytest\0-q\0SUPERCOV_SOURCE_ROOTS=src,a.py");
1382 }
1383
1384 #[test]
1385 fn the_variable_is_read_the_same_way_for_every_frontend() {
1386 let read = |value: &str| {
1387 configured_source_roots(&BTreeMap::from([(
1388 SOURCE_ROOTS_VARIABLE.to_owned(),
1389 value.to_owned(),
1390 )]))
1391 };
1392 assert_eq!(
1393 read(" src , app ,"),
1394 Some(vec!["src".to_owned(), "app".to_owned()])
1395 );
1396 assert_eq!(
1397 read(" , "),
1398 None,
1399 "naming nothing leaves discovery automatic"
1400 );
1401 assert_eq!(configured_source_roots(&BTreeMap::new()), None);
1402 }
1403
1404 #[test]
1405 fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
1406 let root = repository(
1407 "explicit",
1408 &[
1409 ("package.json", "{}"),
1410 ("product/main.ts", "product"),
1411 ("orphan.ts", "outside"),
1412 ],
1413 );
1414 let roots = vec!["product".into()];
1415 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1416 assert_eq!(discovered.source_files, ["product/main.ts"]);
1417 assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
1418 assert_eq!(
1419 entry(&discovered, "orphan.ts").reason,
1420 "outside explicit source roots"
1421 );
1422 assert!(discovered.limitations.is_empty());
1423 fs::remove_dir_all(root).unwrap();
1424 }
1425
1426 #[test]
1427 fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
1428 let root = repository(
1429 "jsonc",
1430 &[
1431 ("package.json", r#"{"main":"./dist/index.js"}"#),
1432 (
1433 "tsconfig.json",
1434 "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
1435 ),
1436 ("events.ts", "event"),
1437 ("žalias.ts", "unicode"),
1438 ("library.test.ts", "test"),
1439 ],
1440 );
1441 let discovered = discover_source_scope(&root, None).unwrap();
1442 assert!(discovered.source_roots.contains(&".".into()));
1443 assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
1444 assert!(discovered.limitations.is_empty());
1445 fs::remove_dir_all(root).unwrap();
1446 }
1447
1448 #[cfg(unix)]
1449 #[test]
1450 fn never_follows_source_directory_or_explicit_root_symlinks() {
1451 use std::os::unix::fs::symlink;
1452
1453 let root = repository(
1454 "symlink",
1455 &[("package.json", "{}"), ("src/real.ts", "real")],
1456 );
1457 let outside = repository("outside", &[("secret.ts", "secret")]);
1458 symlink(&outside, root.join("linked")).unwrap();
1459 symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
1460 let discovered = discover_source_scope(&root, None).unwrap();
1461 assert_eq!(discovered.source_files, ["src/real.ts"]);
1462 let explicit = vec!["linked".into()];
1463 assert!(matches!(
1464 discover_source_scope(&root, Some(&explicit)),
1465 Err(SourceDiscoveryError::InvalidRoot(_))
1466 ));
1467 fs::remove_dir_all(root).unwrap();
1468 fs::remove_dir_all(outside).unwrap();
1469 }
1470
1471 #[test]
1472 fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
1473 let root = repository(
1474 "workspace-store",
1475 &[
1476 ("package.json", "{}"),
1477 ("src/main.ts", "main"),
1478 ("supercov/user.ts", "user code"),
1479 ],
1480 );
1481 let before = discover_source_scope(&root, None).unwrap();
1482 assert_eq!(
1483 entry(&before, "supercov/user.ts").status,
1484 SourceScopeStatus::Ambiguous
1485 );
1486 fs::write(
1487 root.join("supercov/.supercov-workspace-store"),
1488 b"Supercov instrumented workspace. Safe to delete.\n",
1489 )
1490 .unwrap();
1491 let after = discover_source_scope(&root, None).unwrap();
1492 assert!(
1493 after
1494 .scope
1495 .entries
1496 .iter()
1497 .all(|entry| !entry.file.starts_with("supercov/"))
1498 );
1499 fs::remove_dir_all(root).unwrap();
1500 }
1501}