1use std::{
7 collections::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
174fn lexical_normalize(path: &Path) -> PathBuf {
175 let mut output = PathBuf::new();
176 for component in path.components() {
177 match component {
178 Component::CurDir => {}
179 Component::ParentDir => {
180 if !output.pop() {
181 output.push(component.as_os_str());
182 }
183 }
184 _ => output.push(component.as_os_str()),
185 }
186 }
187 output
188}
189
190fn resolve(root: &Path, value: impl AsRef<Path>) -> PathBuf {
191 let value = value.as_ref();
192 let joined;
193 let path = if value.is_absolute() {
194 value
195 } else {
196 joined = root.join(value);
197 &joined
198 };
199 lexical_normalize(path)
200}
201
202fn local_path(root: &Path, path: &Path) -> Result<String, SourceDiscoveryError> {
203 let local = path
204 .strip_prefix(root)
205 .map_err(|_| SourceDiscoveryError::InvalidRoot(path.to_owned()))?;
206 if local.as_os_str().is_empty() {
207 return Ok(".".into());
208 }
209 local
210 .components()
211 .map(|component| {
212 component
213 .as_os_str()
214 .to_str()
215 .map(str::to_owned)
216 .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(path.to_owned()))
217 })
218 .collect::<Result<Vec<_>, _>>()
219 .map(|parts| parts.join("/"))
220}
221
222fn generated_directory(name: &str) -> bool {
223 GENERATED_DIRECTORIES.contains(&name)
224}
225
226fn owned_workspace_store(path: &Path) -> bool {
227 crate::workspace::owned_workspace_path(path)
228}
229
230fn nested_checkout(path: &Path) -> bool {
235 fs::symlink_metadata(path.join(".git")).is_ok()
236}
237
238fn built_asset(file: &str) -> bool {
246 let mut segments = file.rsplit('/');
247 let Some(name) = segments.next() else {
248 return false;
249 };
250 let in_asset_directory =
251 segments.any(|segment| matches!(segment, "assets" | "static" | "public"));
252 let Some(stem) = name
253 .strip_suffix(".js")
254 .or_else(|| name.strip_suffix(".mjs"))
255 .or_else(|| name.strip_suffix(".cjs"))
256 else {
257 return false;
258 };
259 if stem.len() < 10 || stem.as_bytes()[stem.len() - 9] != b'-' {
262 return false;
263 }
264 let hash = &stem[stem.len() - 8..];
265 let looks_hashed = hash
266 .bytes()
267 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
268 && (hash.bytes().any(|byte| byte.is_ascii_digit())
269 || (hash.bytes().any(|byte| byte.is_ascii_uppercase())
270 && hash.bytes().any(|byte| byte.is_ascii_lowercase())));
271 in_asset_directory && looks_hashed
272}
273
274fn root_tool_directory(root: &Path, path: &Path) -> bool {
275 path.parent() == Some(root)
276 && path
277 .file_name()
278 .and_then(|name| name.to_str())
279 .is_some_and(|name| name.starts_with('.'))
280}
281
282fn source_file(name: &str) -> bool {
283 let lower = name.to_ascii_lowercase();
284 [
285 ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
286 ".mtsx",
287 ]
288 .iter()
289 .any(|extension| lower.ends_with(extension))
290}
291
292fn declaration_file(file: &str) -> bool {
293 let lower = file.to_ascii_lowercase();
294 lower.ends_with(".d.ts") || lower.ends_with(".d.cts") || lower.ends_with(".d.mts")
295}
296
297fn test_or_fixture(file: &str) -> bool {
298 let lower = file.to_ascii_lowercase();
299 if lower
300 .split('/')
301 .any(|segment| TEST_DIRECTORIES.contains(&segment))
302 {
303 return true;
304 }
305 lower
306 .split(['/', '_', '.', '-'])
307 .any(|part| matches!(part, "test" | "spec"))
308}
309
310fn tool_script(file: &str) -> bool {
311 file.to_ascii_lowercase()
312 .split('/')
313 .any(|segment| segment == "scripts")
314}
315
316fn config_file(file: &str) -> bool {
317 let lower = file.to_ascii_lowercase();
318 let name = lower.rsplit('/').next().unwrap_or(&lower);
319 source_file(name)
320 && (CONFIG_TOOLS
321 .iter()
322 .any(|tool| name.starts_with(&format!("{tool}.config.")))
323 || DOT_CONFIG_TOOLS
324 .iter()
325 .any(|tool| name.starts_with(&format!(".{tool}rc.")))
326 || (!lower.contains('/')
327 && (name.contains(".config.")
328 || name.starts_with("build.")
329 || name.starts_with("gulpfile.")
330 || name.starts_with("gruntfile."))))
331}
332
333fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, SourceDiscoveryError> {
334 let mut entries = fs::read_dir(path)
335 .map_err(|error| io_error(path, error))?
336 .collect::<Result<Vec<_>, _>>()
337 .map_err(|error| io_error(path, error))?;
338 entries.sort_by_key(fs::DirEntry::file_name);
339 Ok(entries)
340}
341
342fn files_under(
343 root: &Path,
344 directory: &Path,
345 output: &mut Vec<PathBuf>,
346) -> Result<(), SourceDiscoveryError> {
347 let metadata = fs::symlink_metadata(directory).map_err(|error| io_error(directory, error))?;
348 if !metadata.file_type().is_dir() {
349 return Err(SourceDiscoveryError::InvalidRoot(directory.to_owned()));
350 }
351 for entry in read_directory(directory)? {
352 let name = entry
353 .file_name()
354 .into_string()
355 .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
356 let path = entry.path();
357 let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
358 if file_type.is_symlink() {
359 continue;
360 }
361 if file_type.is_dir() {
362 if !generated_directory(&name)
363 && !owned_workspace_store(&path)
364 && !nested_checkout(&path)
365 && !root_tool_directory(root, &path)
366 {
367 files_under(root, &path, output)?;
368 }
369 } else if file_type.is_file() && source_file(&name) {
370 output.push(path);
371 }
372 }
373 Ok(())
374}
375
376fn read_json(path: &Path) -> Option<Value> {
377 serde_json::from_slice(&fs::read(path).ok()?).ok()
378}
379
380fn package_directories(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
381 fn visit(
382 root: &Path,
383 directory: &Path,
384 depth: usize,
385 found: &mut BTreeSet<PathBuf>,
386 ) -> Result<(), SourceDiscoveryError> {
387 if depth > 5 {
388 return Ok(());
389 }
390 for entry in read_directory(directory)? {
391 let name = entry
392 .file_name()
393 .into_string()
394 .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
395 let path = entry.path();
396 let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
397 if !file_type.is_dir()
398 || file_type.is_symlink()
399 || generated_directory(&name)
400 || owned_workspace_store(&path)
401 || nested_checkout(&path)
402 || root_tool_directory(root, &path)
403 {
404 continue;
405 }
406 let local = local_path(root, &path)?;
407 let under_package_parent = local
408 .split('/')
409 .any(|segment| PACKAGE_PARENTS.contains(&segment));
410 if path.join("package.json").is_file() && (depth == 0 || under_package_parent) {
411 found.insert(path.clone());
412 }
413 visit(root, &path, depth + 1, found)?;
414 }
415 Ok(())
416 }
417
418 let mut found = BTreeSet::from([root.to_owned()]);
419 visit(root, root, 0, &mut found)?;
420 for directory in declared_workspace_packages(root)? {
421 found.insert(directory);
422 }
423 Ok(found.into_iter().collect())
424}
425
426fn declared_workspace_packages(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
433 let mut patterns = Vec::new();
434 let manifest = read_json(&root.join("package.json")).unwrap_or(Value::Null);
435 let declared = match manifest.get("workspaces") {
436 Some(Value::Array(items)) => Some(items),
437 Some(Value::Object(object)) => object.get("packages").and_then(Value::as_array),
438 _ => None,
439 };
440 if let Some(items) = declared {
441 patterns.extend(items.iter().filter_map(Value::as_str).map(str::to_owned));
442 }
443 if let Ok(pnpm) = fs::read_to_string(root.join("pnpm-workspace.yaml")) {
444 let mut in_packages = false;
445 for line in pnpm.lines() {
446 let trimmed = line.trim();
447 if trimmed.starts_with("packages:") {
448 in_packages = true;
449 continue;
450 }
451 if in_packages {
452 if let Some(item) = trimmed.strip_prefix("- ") {
453 patterns.push(item.trim_matches(|c| c == '"' || c == '\'').to_owned());
454 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
455 in_packages = false;
456 }
457 }
458 }
459 }
460 let mut found = Vec::new();
461 for pattern in patterns {
462 let pattern = pattern.trim_start_matches("./").trim_end_matches('/');
463 if pattern.starts_with('!') || pattern.contains("**") {
464 continue;
465 }
466 let (parent, wildcard) = match pattern.strip_suffix("/*") {
467 Some(parent) => (parent, true),
468 None => (pattern, false),
469 };
470 if parent.is_empty()
471 || parent
472 .split('/')
473 .any(|segment| segment.is_empty() || segment == ".." || segment.contains('*'))
474 {
475 continue;
476 }
477 let base = root.join(parent);
478 let candidates: Vec<PathBuf> = if wildcard {
479 match fs::read_dir(&base) {
480 Ok(entries) => entries
481 .filter_map(Result::ok)
482 .map(|entry| entry.path())
483 .collect(),
484 Err(_) => Vec::new(),
485 }
486 } else {
487 vec![base]
488 };
489 for candidate in candidates {
490 let is_dir = fs::symlink_metadata(&candidate)
491 .is_ok_and(|metadata| metadata.file_type().is_dir());
492 if is_dir
493 && candidate.join("package.json").is_file()
494 && !nested_checkout(&candidate)
495 && !owned_workspace_store(&candidate)
496 {
497 found.push(candidate);
498 }
499 }
500 }
501 Ok(found)
502}
503
504fn string_targets(value: &Value, depth: usize, output: &mut Vec<String>) {
505 if depth > 8 {
506 return;
507 }
508 match value {
509 Value::String(value) => output.push(value.clone()),
510 Value::Array(values) => {
511 for value in values {
512 string_targets(value, depth + 1, output);
513 }
514 }
515 Value::Object(values) => {
516 for value in values.values() {
517 string_targets(value, depth + 1, output);
518 }
519 }
520 _ => {}
521 }
522}
523
524fn entry_targets(directory: &Path, manifest: &Value) -> Vec<PathBuf> {
525 let mut targets = Vec::new();
526 for key in ["main", "module", "browser", "bin", "exports"] {
527 if let Some(value) = manifest.get(key) {
528 string_targets(value, 0, &mut targets);
529 }
530 }
531 targets
532 .into_iter()
533 .filter_map(|target| {
534 if !target.starts_with('.') || target.contains("node_modules") {
535 return None;
536 }
537 let prefix = target.split('*').next()?.trim_end_matches('/');
538 (!prefix.is_empty()).then(|| resolve(directory, prefix))
539 })
540 .collect()
541}
542
543pub(crate) fn strip_jsonc_comments(contents: &str) -> String {
544 let mut output = String::with_capacity(contents.len());
545 let mut chars = contents.chars().peekable();
546 let mut string = false;
547 let mut escaped = false;
548 while let Some(character) = chars.next() {
549 if string {
550 output.push(character);
551 if escaped {
552 escaped = false;
553 } else if character == '\\' {
554 escaped = true;
555 } else if character == '"' {
556 string = false;
557 }
558 continue;
559 }
560 if character == '"' {
561 string = true;
562 output.push(character);
563 } else if character == '/' && chars.peek() == Some(&'/') {
564 chars.next();
565 for comment in chars.by_ref() {
566 if comment == '\n' {
567 output.push('\n');
568 break;
569 }
570 }
571 } else if character == '/' && chars.peek() == Some(&'*') {
572 chars.next();
573 let mut previous = '\0';
574 for comment in chars.by_ref() {
575 if previous == '*' && comment == '/' {
576 break;
577 }
578 previous = comment;
579 }
580 } else {
581 output.push(character);
582 }
583 }
584 output
585}
586
587pub(crate) fn strip_trailing_commas(contents: &str) -> String {
588 let chars = contents.chars().collect::<Vec<_>>();
589 let mut output = String::with_capacity(contents.len());
590 let mut string = false;
591 let mut escaped = false;
592 for (index, character) in chars.iter().copied().enumerate() {
593 if string {
594 output.push(character);
595 if escaped {
596 escaped = false;
597 } else if character == '\\' {
598 escaped = true;
599 } else if character == '"' {
600 string = false;
601 }
602 continue;
603 }
604 if character == '"' {
605 string = true;
606 output.push(character);
607 } else if character == ','
608 && chars[index + 1..]
609 .iter()
610 .find(|character| !character.is_whitespace())
611 .is_some_and(|character| matches!(character, '}' | ']'))
612 {
613 } else {
614 output.push(character);
615 }
616 }
617 output
618}
619
620fn tsconfig_roots(directory: &Path) -> Vec<PathBuf> {
621 let path = directory.join("tsconfig.json");
622 let Some(contents) = fs::read_to_string(path).ok() else {
623 return Vec::new();
624 };
625 let jsonc = strip_trailing_commas(&strip_jsonc_comments(&contents));
626 let Some(config) = serde_json::from_str::<Value>(&jsonc).ok() else {
627 return Vec::new();
628 };
629 let mut values = Vec::new();
630 if let Some(root_dir) = config
631 .get("compilerOptions")
632 .and_then(|options| options.get("rootDir"))
633 .and_then(Value::as_str)
634 {
635 values.push(root_dir.to_owned());
636 }
637 if let Some(include) = config.get("include").and_then(Value::as_array) {
638 values.extend(include.iter().filter_map(Value::as_str).map(str::to_owned));
639 }
640 if values.is_empty() {
641 return vec![directory.to_owned()];
642 }
643 values
644 .into_iter()
645 .filter_map(|value| {
646 if value.starts_with('!') {
647 return None;
648 }
649 let prefix = value
650 .find(['?', '*', '{', '['])
651 .map_or(value.as_str(), |index| &value[..index])
652 .trim_end_matches('/');
653 (!prefix.is_empty()).then(|| resolve(directory, prefix))
654 })
655 .collect()
656}
657
658fn within(parent: &Path, child: &Path) -> bool {
659 child == parent || child.starts_with(parent)
660}
661
662fn nearest_package_root<'a>(path: &Path, packages: &'a [PathBuf]) -> Option<&'a PathBuf> {
663 packages
664 .iter()
665 .filter(|directory| within(directory, path))
666 .max_by_key(|directory| directory.components().count())
667}
668
669fn scope_limitation(file: &str) -> SourceLimitation {
670 let digest = Sha256::digest(file.as_bytes());
671 let id = digest[..10]
672 .iter()
673 .map(|byte| format!("{byte:02x}"))
674 .collect::<String>();
675 SourceLimitation {
676 id: format!("scope:{id}"),
677 kind: "source-scope".into(),
678 file: file.into(),
679 line: 1,
680 column: 1,
681 source: file.into(),
682 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(),
683 }
684}
685
686pub fn discover_source_scope(
687 root: &Path,
688 configured_roots: Option<&[String]>,
689) -> Result<DiscoveredSourceScope, SourceDiscoveryError> {
690 let root = lexical_normalize(root);
691 let root_metadata = fs::symlink_metadata(&root).map_err(|error| io_error(&root, error))?;
692 if !root_metadata.file_type().is_dir() {
693 return Err(SourceDiscoveryError::InvalidRoot(root));
694 }
695 let packages = package_directories(&root)?;
696 let explicit = configured_roots.is_some_and(|roots| !roots.is_empty());
697 let include_roots = if explicit {
698 configured_roots
699 .unwrap_or_default()
700 .iter()
701 .map(|directory| resolve(&root, directory))
702 .collect::<Vec<_>>()
703 } else {
704 packages
705 .iter()
706 .flat_map(|directory| {
707 let manifest = read_json(&directory.join("package.json")).unwrap_or(Value::Null);
708 let candidates = SOURCE_DIRECTORIES
709 .iter()
710 .map(|name| directory.join(name))
711 .chain(entry_targets(directory, &manifest))
712 .chain(tsconfig_roots(directory))
713 .collect::<Vec<_>>();
714 if directory != &root
720 && !candidates
721 .iter()
722 .any(|candidate| fs::symlink_metadata(candidate).is_ok())
723 {
724 return vec![directory.clone()];
725 }
726 candidates
727 })
728 .collect()
729 };
730 let mut existing_roots = BTreeSet::new();
731 for path in include_roots {
732 match fs::symlink_metadata(&path) {
733 Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_dir() => {
734 existing_roots.insert(path);
735 }
736 Ok(_) => return Err(SourceDiscoveryError::InvalidRoot(path)),
737 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
738 Err(error) => return Err(io_error(&path, error)),
739 }
740 }
741 let existing_roots = existing_roots.into_iter().collect::<Vec<_>>();
742 let mut all_files = Vec::new();
743 files_under(&root, &root, &mut all_files)?;
744 all_files.sort();
745
746 let mut entries = Vec::new();
747 let mut included = Vec::new();
748 let mut limitations = Vec::new();
749 for path in all_files {
750 let file = local_path(&root, &path)?;
751 let package_root = nearest_package_root(&path, &packages)
752 .map(|package| local_path(&root, package))
753 .transpose()?
754 .filter(|package| package != ".");
755 let entry = |status, reason: &str| SourceScopeEntry {
756 file: file.clone(),
757 status,
758 reason: reason.into(),
759 package_root: package_root.clone(),
760 };
761 if declaration_file(&file) {
762 entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
763 } else if test_or_fixture(&file) {
764 entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
765 } else if tool_script(&file) {
766 entries.push(entry(
767 SourceScopeStatus::Excluded,
768 "conventional tool script",
769 ));
770 } else if config_file(&file) {
771 entries.push(entry(
772 SourceScopeStatus::Excluded,
773 "build/test/tool configuration",
774 ));
775 } else if built_asset(&file) {
776 entries.push(entry(SourceScopeStatus::Excluded, BUILT_ASSET_REASON));
777 } else if existing_roots.iter().any(|directory| {
778 fs::symlink_metadata(directory)
779 .map(|metadata| {
780 if metadata.file_type().is_dir() {
781 within(directory, &path)
782 } else {
783 directory == &path
784 }
785 })
786 .unwrap_or(false)
787 }) {
788 included.push(path);
789 entries.push(entry(
790 SourceScopeStatus::Included,
791 if explicit {
792 "explicit source root"
793 } else {
794 "discovered package source root"
795 },
796 ));
797 } else if explicit {
798 entries.push(entry(
799 SourceScopeStatus::Excluded,
800 "outside explicit source roots",
801 ));
802 } else {
803 entries.push(entry(
804 SourceScopeStatus::Ambiguous,
805 "unclassified first-party source",
806 ));
807 limitations.push(scope_limitation(&file));
808 }
809 }
810 let source_files = included
811 .iter()
812 .map(|path| local_path(&root, path))
813 .collect::<Result<Vec<_>, _>>()?;
814 let source_roots = existing_roots
815 .iter()
816 .map(|path| local_path(&root, path))
817 .collect::<Result<Vec<_>, _>>()?;
818 Ok(DiscoveredSourceScope {
819 source_files,
820 source_roots: source_roots.clone(),
821 scope: SourceScope {
822 version: 1,
823 mode: if explicit {
824 SourceScopeMode::Explicit
825 } else {
826 SourceScopeMode::Automatic
827 },
828 roots: source_roots,
829 entries,
830 },
831 limitations,
832 })
833}
834
835#[cfg(test)]
836mod tests {
837 use std::{
838 fs,
839 time::{SystemTime, UNIX_EPOCH},
840 };
841
842 use super::*;
843
844 fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
845 let nonce = SystemTime::now()
846 .duration_since(UNIX_EPOCH)
847 .unwrap()
848 .as_nanos();
849 let root = std::env::temp_dir().join(format!(
850 "supercov-source-{label}-{}-{nonce}",
851 std::process::id()
852 ));
853 fs::create_dir_all(&root).unwrap();
854 for (file, contents) in files {
855 let path = root.join(file);
856 fs::create_dir_all(path.parent().unwrap()).unwrap();
857 fs::write(path, contents).unwrap();
858 }
859 root
860 }
861
862 fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
863 scope
864 .scope
865 .entries
866 .iter()
867 .find(|entry| entry.file == file)
868 .unwrap()
869 }
870
871 #[test]
872 fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
873 let root = repository(
874 "automatic",
875 &[
876 ("package.json", r#"{"workspaces":["packages/*"]}"#),
877 ("src/index.ts", "export const root = true"),
878 ("lib/helper.js", "export const helper = true"),
879 ("src/index.test.ts", "test('root', () => {})"),
880 ("tests/e2e.spec.ts", "test('e2e', () => {})"),
881 ("scripts/release.mjs", "export const release = true"),
882 ("vite.config.ts", "export default {}"),
883 ("build.mjs", "export default async function build() {}"),
884 (".eslintrc.cjs", "module.exports = {}"),
885 (".graphqlrc.ts", "export default {}"),
886 ("orphan.ts", "export const missed = true"),
887 ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
888 ("packages/ui/src/index.ts", "export const ui = true"),
889 ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
890 ("dist/generated.js", "generated"),
891 (".cache/tool/generated.js", "cached"),
892 ],
893 );
894 let discovered = discover_source_scope(&root, None).unwrap();
895 assert_eq!(
896 discovered.source_files,
897 ["lib/helper.js", "packages/ui/src/index.ts", "src/index.ts"]
898 );
899 assert_eq!(
900 entry(&discovered, "orphan.ts").status,
901 SourceScopeStatus::Ambiguous
902 );
903 assert_eq!(
904 entry(&discovered, "scripts/release.mjs").reason,
905 "conventional tool script"
906 );
907 assert_eq!(
908 entry(&discovered, "build.mjs").reason,
909 "build/test/tool configuration"
910 );
911 assert_eq!(
912 entry(&discovered, "packages/ui/src/index.ts").package_root,
913 Some("packages/ui".into())
914 );
915 assert_eq!(discovered.limitations.len(), 1);
916 assert_eq!(discovered.limitations[0].file, "orphan.ts");
917 assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
918 assert!(
919 discovered
920 .scope
921 .entries
922 .iter()
923 .all(|entry| !entry.file.contains(".cache"))
924 );
925 fs::remove_dir_all(root).unwrap();
926 }
927
928 #[test]
929 fn declared_workspaces_outside_conventional_parents_are_package_roots() {
930 let root = repository(
931 "declared-workspaces",
932 &[
933 ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
934 ("app/main.ts", "product"),
935 ("app_extensions/discounts/package.json", "{}"),
936 ("app_extensions/discounts/src/index.ts", "extension"),
937 ("app_extensions/upsells/package.json", "{}"),
940 ("app_extensions/upsells/frontend/embed.ts", "embed"),
941 ("app_extensions/upsells/dist/embed.js", "built"),
942 ],
943 );
944 let discovered = discover_source_scope(&root, None).unwrap();
945 assert_eq!(
946 discovered.source_files,
947 [
948 "app/main.ts",
949 "app_extensions/discounts/src/index.ts",
950 "app_extensions/upsells/frontend/embed.ts",
951 ]
952 );
953 assert!(
954 discovered.limitations.is_empty(),
955 "declared packages must not be blockers: {:?}",
956 discovered.limitations
957 );
958 fs::remove_dir_all(root).unwrap();
959 }
960
961 #[test]
962 fn hashed_bundles_in_asset_directories_are_built_assets() {
963 let root = repository(
964 "built-assets",
965 &[
966 ("package.json", r#"{"workspaces":["app_extensions/*"]}"#),
967 ("app/main.ts", "product"),
968 ("app_extensions/upsells/package.json", "{}"),
969 ("app_extensions/upsells/frontend/embed.ts", "source"),
970 (
971 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
972 "bundle",
973 ),
974 ("app_extensions/upsells/assets/stylex-DAnmLURx.js", "bundle"),
975 (
977 "app_extensions/upsells/assets/theme-helper.js",
978 "hand written",
979 ),
980 ],
981 );
982 let discovered = discover_source_scope(&root, None).unwrap();
983 assert!(
984 !discovered
985 .source_files
986 .iter()
987 .any(|file| file.contains("-Be-aUw9g.js") || file.contains("-DAnmLURx.js"))
988 );
989 assert_eq!(
990 entry(
991 &discovered,
992 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js"
993 )
994 .reason,
995 "built asset"
996 );
997 assert!(
998 discovered.limitations.is_empty(),
999 "bundles are not blockers: {:?}",
1000 discovered.limitations
1001 );
1002 fs::remove_dir_all(root).unwrap();
1003 }
1004
1005 #[test]
1006 fn root_level_hidden_directories_are_tooling_not_source() {
1007 let root = repository(
1008 "root-hidden",
1009 &[
1010 ("package.json", "{}"),
1011 ("app/main.ts", "product"),
1012 (".shopify/bundle/upsells/frontend/embed.js", "cli bundle"),
1013 (".vercel/output/functions/index.js", "deploy output"),
1014 ("app/.generated/schema.ts", "generated types"),
1017 ],
1018 );
1019 let discovered = discover_source_scope(&root, None).unwrap();
1020 assert_eq!(
1021 discovered.source_files,
1022 ["app/.generated/schema.ts", "app/main.ts"]
1023 );
1024 assert!(
1025 discovered.limitations.is_empty(),
1026 "{:?}",
1027 discovered.limitations
1028 );
1029 assert!(discovered.scope.entries.iter().all(
1030 |entry| !entry.file.starts_with(".shopify/") && !entry.file.starts_with(".vercel/")
1031 ));
1032 fs::remove_dir_all(root).unwrap();
1033 }
1034
1035 #[test]
1036 fn nested_checkouts_are_neither_source_nor_limitations() {
1037 let root = repository(
1038 "nested-checkout",
1039 &[
1040 ("package.json", "{}"),
1041 ("app/main.ts", "product"),
1042 (".claude/worktrees/agent-1/.git", "gitdir: /elsewhere"),
1045 (".claude/worktrees/agent-1/app/main.ts", "copy"),
1046 ("vendor-fork/.git/HEAD", "ref: refs/heads/main"),
1047 ("vendor-fork/src/index.ts", "clone"),
1048 ],
1049 );
1050 let discovered = discover_source_scope(&root, None).unwrap();
1051 assert_eq!(discovered.source_files, ["app/main.ts"]);
1052 assert!(
1053 discovered.limitations.is_empty(),
1054 "nested checkouts must not be blocking limitations: {:?}",
1055 discovered.limitations
1056 );
1057 assert!(
1058 discovered
1059 .scope
1060 .entries
1061 .iter()
1062 .all(|entry| !entry.file.starts_with(".claude/")
1063 && !entry.file.starts_with("vendor-fork/")),
1064 "nested checkout files must not appear in scope at all"
1065 );
1066 fs::remove_dir_all(root).unwrap();
1067 }
1068
1069 #[test]
1070 fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
1071 let root = repository(
1072 "explicit",
1073 &[
1074 ("package.json", "{}"),
1075 ("product/main.ts", "product"),
1076 ("orphan.ts", "outside"),
1077 ],
1078 );
1079 let roots = vec!["product".into()];
1080 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
1081 assert_eq!(discovered.source_files, ["product/main.ts"]);
1082 assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
1083 assert_eq!(
1084 entry(&discovered, "orphan.ts").reason,
1085 "outside explicit source roots"
1086 );
1087 assert!(discovered.limitations.is_empty());
1088 fs::remove_dir_all(root).unwrap();
1089 }
1090
1091 #[test]
1092 fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
1093 let root = repository(
1094 "jsonc",
1095 &[
1096 ("package.json", r#"{"main":"./dist/index.js"}"#),
1097 (
1098 "tsconfig.json",
1099 "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
1100 ),
1101 ("events.ts", "event"),
1102 ("žalias.ts", "unicode"),
1103 ("library.test.ts", "test"),
1104 ],
1105 );
1106 let discovered = discover_source_scope(&root, None).unwrap();
1107 assert!(discovered.source_roots.contains(&".".into()));
1108 assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
1109 assert!(discovered.limitations.is_empty());
1110 fs::remove_dir_all(root).unwrap();
1111 }
1112
1113 #[cfg(unix)]
1114 #[test]
1115 fn never_follows_source_directory_or_explicit_root_symlinks() {
1116 use std::os::unix::fs::symlink;
1117
1118 let root = repository(
1119 "symlink",
1120 &[("package.json", "{}"), ("src/real.ts", "real")],
1121 );
1122 let outside = repository("outside", &[("secret.ts", "secret")]);
1123 symlink(&outside, root.join("linked")).unwrap();
1124 symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
1125 let discovered = discover_source_scope(&root, None).unwrap();
1126 assert_eq!(discovered.source_files, ["src/real.ts"]);
1127 let explicit = vec!["linked".into()];
1128 assert!(matches!(
1129 discover_source_scope(&root, Some(&explicit)),
1130 Err(SourceDiscoveryError::InvalidRoot(_))
1131 ));
1132 fs::remove_dir_all(root).unwrap();
1133 fs::remove_dir_all(outside).unwrap();
1134 }
1135
1136 #[test]
1137 fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
1138 let root = repository(
1139 "workspace-store",
1140 &[
1141 ("package.json", "{}"),
1142 ("src/main.ts", "main"),
1143 ("supercov/user.ts", "user code"),
1144 ],
1145 );
1146 let before = discover_source_scope(&root, None).unwrap();
1147 assert_eq!(
1148 entry(&before, "supercov/user.ts").status,
1149 SourceScopeStatus::Ambiguous
1150 );
1151 fs::write(
1152 root.join("supercov/.supercov-workspace-store"),
1153 b"Supercov instrumented workspace. Safe to delete.\n",
1154 )
1155 .unwrap();
1156 let after = discover_source_scope(&root, None).unwrap();
1157 assert!(
1158 after
1159 .scope
1160 .entries
1161 .iter()
1162 .all(|entry| !entry.file.starts_with("supercov/"))
1163 );
1164 fs::remove_dir_all(root).unwrap();
1165 }
1166}