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 ".git",
19 ".mcdc-pool",
20 ".next",
21 ".nuxt",
22 ".output",
23 ".supercov",
24 "build",
25 "coverage",
26 "dist",
27 "node_modules",
28 "out",
29 "playwright-report",
30 "results",
31 "test-results",
32 "vendor",
33];
34const SOURCE_DIRECTORIES: &[&str] = &["app", "src", "lib", "server", "client", "functions", "api"];
35const PACKAGE_PARENTS: &[&str] = &["apps", "packages", "services", "workspaces"];
36const TEST_DIRECTORIES: &[&str] = &[
37 "__tests__",
38 "test",
39 "tests",
40 "spec",
41 "specs",
42 "e2e",
43 "fixture",
44 "fixtures",
45 "mock",
46 "mocks",
47 "__mocks__",
48];
49const CONFIG_TOOLS: &[&str] = &[
50 "babel",
51 "eslint",
52 "graphql",
53 "jest",
54 "next",
55 "nuxt",
56 "playwright",
57 "postcss",
58 "prettier",
59 "remix",
60 "rollup",
61 "stylelint",
62 "tailwind",
63 "tsup",
64 "vite",
65 "vitest",
66 "webpack",
67];
68const DOT_CONFIG_TOOLS: &[&str] = &["babel", "eslint", "graphql", "prettier", "stylelint"];
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum SourceScopeStatus {
73 Included,
74 Excluded,
75 Ambiguous,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase", deny_unknown_fields)]
80pub struct SourceScopeEntry {
81 pub file: String,
82 pub status: SourceScopeStatus,
83 pub reason: String,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub package_root: Option<String>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum SourceScopeMode {
91 Automatic,
92 Explicit,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase", deny_unknown_fields)]
97pub struct SourceScope {
98 pub version: u32,
99 pub mode: SourceScopeMode,
100 pub roots: Vec<String>,
101 pub entries: Vec<SourceScopeEntry>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase", deny_unknown_fields)]
106pub struct SourceLimitation {
107 pub id: String,
108 pub kind: String,
109 pub file: String,
110 pub line: usize,
111 pub column: usize,
112 pub source: String,
113 pub reason: String,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase", deny_unknown_fields)]
118pub struct DiscoveredSourceScope {
119 pub source_files: Vec<String>,
120 pub source_roots: Vec<String>,
121 pub scope: SourceScope,
122 pub limitations: Vec<SourceLimitation>,
123}
124
125#[derive(Debug)]
126pub enum SourceDiscoveryError {
127 Io { path: PathBuf, source: io::Error },
128 NonUtf8Path(PathBuf),
129 InvalidRoot(PathBuf),
130}
131
132impl std::fmt::Display for SourceDiscoveryError {
133 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 match self {
135 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
136 Self::NonUtf8Path(path) => {
137 write!(
138 formatter,
139 "source path is not valid UTF-8: {}",
140 path.display()
141 )
142 }
143 Self::InvalidRoot(path) => write!(
144 formatter,
145 "source root is not a regular file or directory: {}",
146 path.display()
147 ),
148 }
149 }
150}
151
152impl std::error::Error for SourceDiscoveryError {}
153
154fn io_error(path: &Path, source: io::Error) -> SourceDiscoveryError {
155 SourceDiscoveryError::Io {
156 path: path.to_owned(),
157 source,
158 }
159}
160
161fn lexical_normalize(path: &Path) -> PathBuf {
162 let mut output = PathBuf::new();
163 for component in path.components() {
164 match component {
165 Component::CurDir => {}
166 Component::ParentDir => {
167 if !output.pop() {
168 output.push(component.as_os_str());
169 }
170 }
171 _ => output.push(component.as_os_str()),
172 }
173 }
174 output
175}
176
177fn resolve(root: &Path, value: impl AsRef<Path>) -> PathBuf {
178 let value = value.as_ref();
179 let joined;
180 let path = if value.is_absolute() {
181 value
182 } else {
183 joined = root.join(value);
184 &joined
185 };
186 lexical_normalize(path)
187}
188
189fn local_path(root: &Path, path: &Path) -> Result<String, SourceDiscoveryError> {
190 let local = path
191 .strip_prefix(root)
192 .map_err(|_| SourceDiscoveryError::InvalidRoot(path.to_owned()))?;
193 if local.as_os_str().is_empty() {
194 return Ok(".".into());
195 }
196 local
197 .components()
198 .map(|component| {
199 component
200 .as_os_str()
201 .to_str()
202 .map(str::to_owned)
203 .ok_or_else(|| SourceDiscoveryError::NonUtf8Path(path.to_owned()))
204 })
205 .collect::<Result<Vec<_>, _>>()
206 .map(|parts| parts.join("/"))
207}
208
209fn generated_directory(name: &str) -> bool {
210 GENERATED_DIRECTORIES.contains(&name)
211}
212
213fn owned_workspace_store(path: &Path) -> bool {
214 path.file_name().is_some_and(|name| name == "supercov")
215 && path.join(".supercov-workspace-store").is_file()
216}
217
218fn source_file(name: &str) -> bool {
219 let lower = name.to_ascii_lowercase();
220 [
221 ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
222 ".mtsx",
223 ]
224 .iter()
225 .any(|extension| lower.ends_with(extension))
226}
227
228fn declaration_file(file: &str) -> bool {
229 let lower = file.to_ascii_lowercase();
230 lower.ends_with(".d.ts") || lower.ends_with(".d.cts") || lower.ends_with(".d.mts")
231}
232
233fn test_or_fixture(file: &str) -> bool {
234 let lower = file.to_ascii_lowercase();
235 if lower
236 .split('/')
237 .any(|segment| TEST_DIRECTORIES.contains(&segment))
238 {
239 return true;
240 }
241 lower
242 .split(['/', '_', '.', '-'])
243 .any(|part| matches!(part, "test" | "spec"))
244}
245
246fn tool_script(file: &str) -> bool {
247 file.to_ascii_lowercase()
248 .split('/')
249 .any(|segment| segment == "scripts")
250}
251
252fn config_file(file: &str) -> bool {
253 let lower = file.to_ascii_lowercase();
254 let name = lower.rsplit('/').next().unwrap_or(&lower);
255 source_file(name)
256 && (CONFIG_TOOLS
257 .iter()
258 .any(|tool| name.starts_with(&format!("{tool}.config.")))
259 || DOT_CONFIG_TOOLS
260 .iter()
261 .any(|tool| name.starts_with(&format!(".{tool}rc.")))
262 || (!lower.contains('/')
263 && (name.contains(".config.")
264 || name.starts_with("build.")
265 || name.starts_with("gulpfile.")
266 || name.starts_with("gruntfile."))))
267}
268
269fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, SourceDiscoveryError> {
270 let mut entries = fs::read_dir(path)
271 .map_err(|error| io_error(path, error))?
272 .collect::<Result<Vec<_>, _>>()
273 .map_err(|error| io_error(path, error))?;
274 entries.sort_by_key(fs::DirEntry::file_name);
275 Ok(entries)
276}
277
278fn files_under(directory: &Path, output: &mut Vec<PathBuf>) -> Result<(), SourceDiscoveryError> {
279 let metadata = fs::symlink_metadata(directory).map_err(|error| io_error(directory, error))?;
280 if !metadata.file_type().is_dir() {
281 return Err(SourceDiscoveryError::InvalidRoot(directory.to_owned()));
282 }
283 for entry in read_directory(directory)? {
284 let name = entry
285 .file_name()
286 .into_string()
287 .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
288 let path = entry.path();
289 let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
290 if file_type.is_symlink() {
291 continue;
292 }
293 if file_type.is_dir() {
294 if !generated_directory(&name) && !owned_workspace_store(&path) {
295 files_under(&path, output)?;
296 }
297 } else if file_type.is_file() && source_file(&name) {
298 output.push(path);
299 }
300 }
301 Ok(())
302}
303
304fn read_json(path: &Path) -> Option<Value> {
305 serde_json::from_slice(&fs::read(path).ok()?).ok()
306}
307
308fn package_directories(root: &Path) -> Result<Vec<PathBuf>, SourceDiscoveryError> {
309 fn visit(
310 root: &Path,
311 directory: &Path,
312 depth: usize,
313 found: &mut BTreeSet<PathBuf>,
314 ) -> Result<(), SourceDiscoveryError> {
315 if depth > 5 {
316 return Ok(());
317 }
318 for entry in read_directory(directory)? {
319 let name = entry
320 .file_name()
321 .into_string()
322 .map_err(|name| SourceDiscoveryError::NonUtf8Path(directory.join(name)))?;
323 let path = entry.path();
324 let file_type = entry.file_type().map_err(|error| io_error(&path, error))?;
325 if !file_type.is_dir()
326 || file_type.is_symlink()
327 || generated_directory(&name)
328 || owned_workspace_store(&path)
329 {
330 continue;
331 }
332 let local = local_path(root, &path)?;
333 let under_package_parent = local
334 .split('/')
335 .any(|segment| PACKAGE_PARENTS.contains(&segment));
336 if path.join("package.json").is_file() && (depth == 0 || under_package_parent) {
337 found.insert(path.clone());
338 }
339 visit(root, &path, depth + 1, found)?;
340 }
341 Ok(())
342 }
343
344 let mut found = BTreeSet::from([root.to_owned()]);
345 visit(root, root, 0, &mut found)?;
346 Ok(found.into_iter().collect())
347}
348
349fn string_targets(value: &Value, depth: usize, output: &mut Vec<String>) {
350 if depth > 8 {
351 return;
352 }
353 match value {
354 Value::String(value) => output.push(value.clone()),
355 Value::Array(values) => {
356 for value in values {
357 string_targets(value, depth + 1, output);
358 }
359 }
360 Value::Object(values) => {
361 for value in values.values() {
362 string_targets(value, depth + 1, output);
363 }
364 }
365 _ => {}
366 }
367}
368
369fn entry_targets(directory: &Path, manifest: &Value) -> Vec<PathBuf> {
370 let mut targets = Vec::new();
371 for key in ["main", "module", "browser", "bin", "exports"] {
372 if let Some(value) = manifest.get(key) {
373 string_targets(value, 0, &mut targets);
374 }
375 }
376 targets
377 .into_iter()
378 .filter_map(|target| {
379 if !target.starts_with('.') || target.contains("node_modules") {
380 return None;
381 }
382 let prefix = target.split('*').next()?.trim_end_matches('/');
383 (!prefix.is_empty()).then(|| resolve(directory, prefix))
384 })
385 .collect()
386}
387
388fn strip_jsonc_comments(contents: &str) -> String {
389 let mut output = String::with_capacity(contents.len());
390 let mut chars = contents.chars().peekable();
391 let mut string = false;
392 let mut escaped = false;
393 while let Some(character) = chars.next() {
394 if string {
395 output.push(character);
396 if escaped {
397 escaped = false;
398 } else if character == '\\' {
399 escaped = true;
400 } else if character == '"' {
401 string = false;
402 }
403 continue;
404 }
405 if character == '"' {
406 string = true;
407 output.push(character);
408 } else if character == '/' && chars.peek() == Some(&'/') {
409 chars.next();
410 for comment in chars.by_ref() {
411 if comment == '\n' {
412 output.push('\n');
413 break;
414 }
415 }
416 } else if character == '/' && chars.peek() == Some(&'*') {
417 chars.next();
418 let mut previous = '\0';
419 for comment in chars.by_ref() {
420 if previous == '*' && comment == '/' {
421 break;
422 }
423 previous = comment;
424 }
425 } else {
426 output.push(character);
427 }
428 }
429 output
430}
431
432fn strip_trailing_commas(contents: &str) -> String {
433 let chars = contents.chars().collect::<Vec<_>>();
434 let mut output = String::with_capacity(contents.len());
435 let mut string = false;
436 let mut escaped = false;
437 for (index, character) in chars.iter().copied().enumerate() {
438 if string {
439 output.push(character);
440 if escaped {
441 escaped = false;
442 } else if character == '\\' {
443 escaped = true;
444 } else if character == '"' {
445 string = false;
446 }
447 continue;
448 }
449 if character == '"' {
450 string = true;
451 output.push(character);
452 } else if character == ','
453 && chars[index + 1..]
454 .iter()
455 .find(|character| !character.is_whitespace())
456 .is_some_and(|character| matches!(character, '}' | ']'))
457 {
458 } else {
459 output.push(character);
460 }
461 }
462 output
463}
464
465fn tsconfig_roots(directory: &Path) -> Vec<PathBuf> {
466 let path = directory.join("tsconfig.json");
467 let Some(contents) = fs::read_to_string(path).ok() else {
468 return Vec::new();
469 };
470 let jsonc = strip_trailing_commas(&strip_jsonc_comments(&contents));
471 let Some(config) = serde_json::from_str::<Value>(&jsonc).ok() else {
472 return Vec::new();
473 };
474 let mut values = Vec::new();
475 if let Some(root_dir) = config
476 .get("compilerOptions")
477 .and_then(|options| options.get("rootDir"))
478 .and_then(Value::as_str)
479 {
480 values.push(root_dir.to_owned());
481 }
482 if let Some(include) = config.get("include").and_then(Value::as_array) {
483 values.extend(include.iter().filter_map(Value::as_str).map(str::to_owned));
484 }
485 if values.is_empty() {
486 return vec![directory.to_owned()];
487 }
488 values
489 .into_iter()
490 .filter_map(|value| {
491 if value.starts_with('!') {
492 return None;
493 }
494 let prefix = value
495 .find(['?', '*', '{', '['])
496 .map_or(value.as_str(), |index| &value[..index])
497 .trim_end_matches('/');
498 (!prefix.is_empty()).then(|| resolve(directory, prefix))
499 })
500 .collect()
501}
502
503fn within(parent: &Path, child: &Path) -> bool {
504 child == parent || child.starts_with(parent)
505}
506
507fn nearest_package_root<'a>(path: &Path, packages: &'a [PathBuf]) -> Option<&'a PathBuf> {
508 packages
509 .iter()
510 .filter(|directory| within(directory, path))
511 .max_by_key(|directory| directory.components().count())
512}
513
514fn scope_limitation(file: &str) -> SourceLimitation {
515 let digest = Sha256::digest(file.as_bytes());
516 let id = digest[..10]
517 .iter()
518 .map(|byte| format!("{byte:02x}"))
519 .collect::<String>();
520 SourceLimitation {
521 id: format!("scope:{id}"),
522 kind: "source-scope".into(),
523 file: file.into(),
524 line: 1,
525 column: 1,
526 source: file.into(),
527 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(),
528 }
529}
530
531pub fn discover_source_scope(
532 root: &Path,
533 configured_roots: Option<&[String]>,
534) -> Result<DiscoveredSourceScope, SourceDiscoveryError> {
535 let root = lexical_normalize(root);
536 let root_metadata = fs::symlink_metadata(&root).map_err(|error| io_error(&root, error))?;
537 if !root_metadata.file_type().is_dir() {
538 return Err(SourceDiscoveryError::InvalidRoot(root));
539 }
540 let packages = package_directories(&root)?;
541 let explicit = configured_roots.is_some_and(|roots| !roots.is_empty());
542 let include_roots = if explicit {
543 configured_roots
544 .unwrap_or_default()
545 .iter()
546 .map(|directory| resolve(&root, directory))
547 .collect::<Vec<_>>()
548 } else {
549 packages
550 .iter()
551 .flat_map(|directory| {
552 let manifest = read_json(&directory.join("package.json")).unwrap_or(Value::Null);
553 SOURCE_DIRECTORIES
554 .iter()
555 .map(|name| directory.join(name))
556 .chain(entry_targets(directory, &manifest))
557 .chain(tsconfig_roots(directory))
558 .collect::<Vec<_>>()
559 })
560 .collect()
561 };
562 let mut existing_roots = BTreeSet::new();
563 for path in include_roots {
564 match fs::symlink_metadata(&path) {
565 Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_dir() => {
566 existing_roots.insert(path);
567 }
568 Ok(_) => return Err(SourceDiscoveryError::InvalidRoot(path)),
569 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
570 Err(error) => return Err(io_error(&path, error)),
571 }
572 }
573 let existing_roots = existing_roots.into_iter().collect::<Vec<_>>();
574 let mut all_files = Vec::new();
575 files_under(&root, &mut all_files)?;
576 all_files.sort();
577
578 let mut entries = Vec::new();
579 let mut included = Vec::new();
580 let mut limitations = Vec::new();
581 for path in all_files {
582 let file = local_path(&root, &path)?;
583 let package_root = nearest_package_root(&path, &packages)
584 .map(|package| local_path(&root, package))
585 .transpose()?
586 .filter(|package| package != ".");
587 let entry = |status, reason: &str| SourceScopeEntry {
588 file: file.clone(),
589 status,
590 reason: reason.into(),
591 package_root: package_root.clone(),
592 };
593 if declaration_file(&file) {
594 entries.push(entry(SourceScopeStatus::Excluded, "TypeScript declaration"));
595 } else if test_or_fixture(&file) {
596 entries.push(entry(SourceScopeStatus::Excluded, "test or fixture source"));
597 } else if tool_script(&file) {
598 entries.push(entry(
599 SourceScopeStatus::Excluded,
600 "conventional tool script",
601 ));
602 } else if config_file(&file) {
603 entries.push(entry(
604 SourceScopeStatus::Excluded,
605 "build/test/tool configuration",
606 ));
607 } else if existing_roots.iter().any(|directory| {
608 fs::symlink_metadata(directory)
609 .map(|metadata| {
610 if metadata.file_type().is_dir() {
611 within(directory, &path)
612 } else {
613 directory == &path
614 }
615 })
616 .unwrap_or(false)
617 }) {
618 included.push(path);
619 entries.push(entry(
620 SourceScopeStatus::Included,
621 if explicit {
622 "explicit source root"
623 } else {
624 "discovered package source root"
625 },
626 ));
627 } else if explicit {
628 entries.push(entry(
629 SourceScopeStatus::Excluded,
630 "outside explicit source roots",
631 ));
632 } else {
633 entries.push(entry(
634 SourceScopeStatus::Ambiguous,
635 "unclassified first-party source",
636 ));
637 limitations.push(scope_limitation(&file));
638 }
639 }
640 let source_files = included
641 .iter()
642 .map(|path| local_path(&root, path))
643 .collect::<Result<Vec<_>, _>>()?;
644 let source_roots = existing_roots
645 .iter()
646 .map(|path| local_path(&root, path))
647 .collect::<Result<Vec<_>, _>>()?;
648 Ok(DiscoveredSourceScope {
649 source_files,
650 source_roots: source_roots.clone(),
651 scope: SourceScope {
652 version: 1,
653 mode: if explicit {
654 SourceScopeMode::Explicit
655 } else {
656 SourceScopeMode::Automatic
657 },
658 roots: source_roots,
659 entries,
660 },
661 limitations,
662 })
663}
664
665#[cfg(test)]
666mod tests {
667 use std::{
668 fs,
669 time::{SystemTime, UNIX_EPOCH},
670 };
671
672 use super::*;
673
674 fn repository(label: &str, files: &[(&str, &str)]) -> PathBuf {
675 let nonce = SystemTime::now()
676 .duration_since(UNIX_EPOCH)
677 .unwrap()
678 .as_nanos();
679 let root = std::env::temp_dir().join(format!(
680 "supercov-source-{label}-{}-{nonce}",
681 std::process::id()
682 ));
683 fs::create_dir_all(&root).unwrap();
684 for (file, contents) in files {
685 let path = root.join(file);
686 fs::create_dir_all(path.parent().unwrap()).unwrap();
687 fs::write(path, contents).unwrap();
688 }
689 root
690 }
691
692 fn entry<'a>(scope: &'a DiscoveredSourceScope, file: &str) -> &'a SourceScopeEntry {
693 scope
694 .scope
695 .entries
696 .iter()
697 .find(|entry| entry.file == file)
698 .unwrap()
699 }
700
701 #[test]
702 fn discovers_conventional_and_workspace_sources_and_blocks_ambiguity() {
703 let root = repository(
704 "automatic",
705 &[
706 ("package.json", r#"{"workspaces":["packages/*"]}"#),
707 ("src/index.ts", "export const root = true"),
708 ("lib/helper.js", "export const helper = true"),
709 ("src/index.test.ts", "test('root', () => {})"),
710 ("tests/e2e.spec.ts", "test('e2e', () => {})"),
711 ("scripts/release.mjs", "export const release = true"),
712 ("vite.config.ts", "export default {}"),
713 ("build.mjs", "export default async function build() {}"),
714 (".eslintrc.cjs", "module.exports = {}"),
715 (".graphqlrc.ts", "export default {}"),
716 ("orphan.ts", "export const missed = true"),
717 ("packages/ui/package.json", r#"{"module":"./src/index.ts"}"#),
718 ("packages/ui/src/index.ts", "export const ui = true"),
719 ("packages/ui/tests/ui.spec.ts", "test('ui', () => {})"),
720 ("dist/generated.js", "generated"),
721 (".cache/tool/generated.js", "cached"),
722 ],
723 );
724 let discovered = discover_source_scope(&root, None).unwrap();
725 assert_eq!(
726 discovered.source_files,
727 ["lib/helper.js", "packages/ui/src/index.ts", "src/index.ts"]
728 );
729 assert_eq!(
730 entry(&discovered, "orphan.ts").status,
731 SourceScopeStatus::Ambiguous
732 );
733 assert_eq!(
734 entry(&discovered, "scripts/release.mjs").reason,
735 "conventional tool script"
736 );
737 assert_eq!(
738 entry(&discovered, "build.mjs").reason,
739 "build/test/tool configuration"
740 );
741 assert_eq!(
742 entry(&discovered, "packages/ui/src/index.ts").package_root,
743 Some("packages/ui".into())
744 );
745 assert_eq!(discovered.limitations.len(), 1);
746 assert_eq!(discovered.limitations[0].file, "orphan.ts");
747 assert_eq!(discovered.limitations[0].id.len(), "scope:".len() + 20);
748 assert!(
749 discovered
750 .scope
751 .entries
752 .iter()
753 .all(|entry| !entry.file.contains(".cache"))
754 );
755 fs::remove_dir_all(root).unwrap();
756 }
757
758 #[test]
759 fn explicit_roots_are_authoritative_and_outside_files_are_not_limitations() {
760 let root = repository(
761 "explicit",
762 &[
763 ("package.json", "{}"),
764 ("product/main.ts", "product"),
765 ("orphan.ts", "outside"),
766 ],
767 );
768 let roots = vec!["product".into()];
769 let discovered = discover_source_scope(&root, Some(&roots)).unwrap();
770 assert_eq!(discovered.source_files, ["product/main.ts"]);
771 assert_eq!(discovered.scope.mode, SourceScopeMode::Explicit);
772 assert_eq!(
773 entry(&discovered, "orphan.ts").reason,
774 "outside explicit source roots"
775 );
776 assert!(discovered.limitations.is_empty());
777 fs::remove_dir_all(root).unwrap();
778 }
779
780 #[test]
781 fn parses_jsonc_tsconfig_defaults_and_unicode_paths_without_byte_corruption() {
782 let root = repository(
783 "jsonc",
784 &[
785 ("package.json", r#"{"main":"./dist/index.js"}"#),
786 (
787 "tsconfig.json",
788 "{ // unicode survives: ž\n \"compilerOptions\": {\"target\": \"es2022\",},\n}",
789 ),
790 ("events.ts", "event"),
791 ("žalias.ts", "unicode"),
792 ("library.test.ts", "test"),
793 ],
794 );
795 let discovered = discover_source_scope(&root, None).unwrap();
796 assert!(discovered.source_roots.contains(&".".into()));
797 assert_eq!(discovered.source_files, ["events.ts", "žalias.ts"]);
798 assert!(discovered.limitations.is_empty());
799 fs::remove_dir_all(root).unwrap();
800 }
801
802 #[cfg(unix)]
803 #[test]
804 fn never_follows_source_directory_or_explicit_root_symlinks() {
805 use std::os::unix::fs::symlink;
806
807 let root = repository(
808 "symlink",
809 &[("package.json", "{}"), ("src/real.ts", "real")],
810 );
811 let outside = repository("outside", &[("secret.ts", "secret")]);
812 symlink(&outside, root.join("linked")).unwrap();
813 symlink(outside.join("secret.ts"), root.join("src/linked.ts")).unwrap();
814 let discovered = discover_source_scope(&root, None).unwrap();
815 assert_eq!(discovered.source_files, ["src/real.ts"]);
816 let explicit = vec!["linked".into()];
817 assert!(matches!(
818 discover_source_scope(&root, Some(&explicit)),
819 Err(SourceDiscoveryError::InvalidRoot(_))
820 ));
821 fs::remove_dir_all(root).unwrap();
822 fs::remove_dir_all(outside).unwrap();
823 }
824
825 #[test]
826 fn skips_only_marker_owned_workspace_stores_not_user_supercov_directories() {
827 let root = repository(
828 "workspace-store",
829 &[
830 ("package.json", "{}"),
831 ("src/main.ts", "main"),
832 ("supercov/user.ts", "user code"),
833 ],
834 );
835 let before = discover_source_scope(&root, None).unwrap();
836 assert_eq!(
837 entry(&before, "supercov/user.ts").status,
838 SourceScopeStatus::Ambiguous
839 );
840 fs::write(root.join("supercov/.supercov-workspace-store"), "owned").unwrap();
841 let after = discover_source_scope(&root, None).unwrap();
842 assert!(
843 after
844 .scope
845 .entries
846 .iter()
847 .all(|entry| !entry.file.starts_with("supercov/"))
848 );
849 fs::remove_dir_all(root).unwrap();
850 }
851}