Skip to main content

supercov_engine/
jvm_project.rs

1//! JVM project discovery for Maven and Gradle layouts.
2//!
3//! Both build systems put tests under `src/test` and product source under
4//! `src/main`, and have done for twenty years. That convention is stronger
5//! than any heuristic a coverage tool could invent, so it is the rule here:
6//! a file's place in the tree decides what it is, not its name.
7//!
8//! Where the convention is absent — a loose file outside `src` — the file is
9//! reported as out of scope rather than guessed at. Measuring source this
10//! project's tests were never pointed at would put obligations in the
11//! denominator nobody agreed to.
12
13use std::path::{Path, PathBuf};
14
15use crate::coverage_report::CoverageManifest;
16use crate::go_instrumenter::GoProbe;
17use crate::integrity::ExplicitIntegrityInputs;
18use crate::jvm_instrumenter::{JvmLanguage, build_jvm_obligations};
19
20/// Directories that never hold this project's measured source.
21const EXCLUDED_DIRECTORIES: &[&str] = &[
22    ".git",
23    ".gradle",
24    ".idea",
25    ".mvn",
26    ".vscode",
27    "build",
28    "node_modules",
29    "out",
30    "target",
31];
32
33/// Files that pin what the build resolves to.
34const DEPENDENCY_FILES: &[&str] = &[
35    "build.gradle",
36    "build.gradle.kts",
37    "gradle.properties",
38    "libs.versions.toml",
39    "pom.xml",
40    "settings.gradle",
41    "settings.gradle.kts",
42    "gradle-wrapper.properties",
43];
44
45/// Configuration that decides what actually executes.
46const CONFIGURATION_FILES: &[&str] = &[".java-version", ".sdkmanrc", "junit-platform.properties"];
47
48#[derive(Debug, Clone, PartialEq, Eq, Default)]
49pub struct JvmFiles {
50    /// Relative, `/`-separated paths of measured application sources, paired
51    /// with the language that reads them.
52    pub sources: Vec<(String, JvmLanguage)>,
53    pub tests: Vec<(String, JvmLanguage)>,
54    pub dependency_files: Vec<PathBuf>,
55    pub configuration_files: Vec<PathBuf>,
56    pub excluded: Vec<(String, &'static str)>,
57}
58
59#[derive(Debug, Clone, PartialEq)]
60pub struct PreparedJvmProject {
61    pub root: PathBuf,
62    pub files: JvmFiles,
63    pub manifest: CoverageManifest,
64    pub probes: std::collections::BTreeMap<u64, GoProbe>,
65    pub decision_widths: Vec<u8>,
66    /// Each measured source and what it becomes once instrumented, in
67    /// discovery order. Carried rather than recomputed: preparing the
68    /// obligations already parsed and rewrote every file.
69    pub instrumented: Vec<(String, String)>,
70    /// Files that did not parse, with the reason. Reported rather than skipped:
71    /// a hole in the denominator nobody is told about is a wrong number.
72    pub unparseable: Vec<(String, String)>,
73}
74
75/// Which build system, decided by what is actually in the tree.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum JvmBuild {
78    Maven,
79    Gradle,
80    /// Sources laid out conventionally with no build file; still measurable.
81    Plain,
82}
83
84pub fn detect_build(root: &Path) -> JvmBuild {
85    if root.join("pom.xml").is_file() {
86        return JvmBuild::Maven;
87    }
88    for name in [
89        "build.gradle",
90        "build.gradle.kts",
91        "settings.gradle",
92        "settings.gradle.kts",
93    ] {
94        if root.join(name).is_file() {
95            return JvmBuild::Gradle;
96        }
97    }
98    JvmBuild::Plain
99}
100
101fn language_of(name: &str) -> Option<JvmLanguage> {
102    if name.ends_with(".java") {
103        return Some(JvmLanguage::Java);
104    }
105    if name.ends_with(".kt") {
106        return Some(JvmLanguage::Kotlin);
107    }
108    None
109}
110
111/// Maven and Gradle both mean the same thing by these paths, and have for
112/// long enough that the convention is more reliable than any name-based guess.
113fn role(relative: &str) -> Option<&'static str> {
114    let mut parts = relative.split('/');
115    while let Some(part) = parts.next() {
116        if part != "src" {
117            continue;
118        }
119        // `src/<sourceSet>/<language>/...`, where the source set is `main`,
120        // `test`, or a custom one a project defined.
121        return match parts.next() {
122            Some("test") | Some("integrationTest") | Some("testFixtures") => Some("test"),
123            Some("main") => Some("main"),
124            // A Kotlin Multiplatform project names its source sets by target:
125            // `jvmMain` builds for the JVM and nothing else, so it is measured
126            // like any other main. `commonMain` builds for every target the
127            // project declares, and a probe there is a call to a runtime that
128            // exists only on the JVM -- so it is measured only where the JVM
129            // is the only target, which `discover_jvm_files` decides.
130            Some("jvmMain") => Some("main"),
131            Some("commonMain") => Some("common"),
132            // Tests are never instrumented on the JVM -- attribution comes
133            // from the framework's own lifecycle -- so recognising a test
134            // source set only decides whether a module has tests at all.
135            Some(set) if set.ends_with("Test") => Some("test"),
136            Some(_) => Some("other"),
137            None => None,
138        };
139    }
140    None
141}
142
143/// Whether the JVM is the only target this build produces.
144///
145/// A Kotlin Multiplatform build compiles `commonMain` for every target it
146/// declares. Instrumenting it inserts a call to a runtime that exists on the
147/// JVM and nowhere else, so where anything but the JVM is declared that source
148/// is left alone: losing its coverage is a cost, and breaking the build that
149/// produces the other targets is not a trade worth making.
150fn targets_only_the_jvm(root: &Path) -> bool {
151    let mut text = String::new();
152    for name in ["build.gradle.kts", "build.gradle", "pom.xml"] {
153        if let Ok(own) = std::fs::read_to_string(root.join(name)) {
154            text.push_str(&own);
155            text.push('\n');
156        }
157    }
158    ![
159        "js(",
160        "wasmJs(",
161        "wasmWasi(",
162        "linuxX64(",
163        "macosX64(",
164        "macosArm64(",
165        "mingwX64(",
166        "iosArm64(",
167        "iosX64(",
168        "iosSimulatorArm64(",
169        "watchos",
170        "tvos",
171        "androidNativeArm64(",
172    ]
173    .iter()
174    .any(|target| text.contains(target))
175}
176
177fn walk(root: &Path, directory: &Path, files: &mut JvmFiles) -> Result<(), String> {
178    let jvm_only = targets_only_the_jvm(root);
179    let entries = std::fs::read_dir(directory)
180        .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
181    let mut sorted = entries
182        .collect::<Result<Vec<_>, _>>()
183        .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
184    sorted.sort_by_key(std::fs::DirEntry::path);
185    for entry in sorted {
186        let path = entry.path();
187        let Ok(relative) = path.strip_prefix(root) else {
188            continue;
189        };
190        let relative = relative.to_string_lossy().replace('\\', "/");
191        let name = path
192            .file_name()
193            .map(|name| name.to_string_lossy().into_owned())
194            .unwrap_or_default();
195        let file_type = entry
196            .file_type()
197            .map_err(|error| format!("could not inspect {}: {error}", path.display()))?;
198        if file_type.is_dir() {
199            if EXCLUDED_DIRECTORIES.contains(&name.as_str()) || name.starts_with('.') {
200                files
201                    .excluded
202                    .push((relative, "build output or tooling directory"));
203                continue;
204            }
205            walk(root, &path, files)?;
206            continue;
207        }
208        if !file_type.is_file() {
209            continue;
210        }
211        if DEPENDENCY_FILES.contains(&name.as_str()) {
212            files.dependency_files.push(PathBuf::from(&relative));
213            continue;
214        }
215        if CONFIGURATION_FILES.contains(&name.as_str()) {
216            files.configuration_files.push(PathBuf::from(&relative));
217            continue;
218        }
219        let Some(language) = language_of(&name) else {
220            continue;
221        };
222        match role(&relative) {
223            Some("main") => files.sources.push((relative, language)),
224            Some("test") => files.tests.push((relative, language)),
225            Some("common") if jvm_only => files.sources.push((relative, language)),
226            Some("common") => files.excluded.push((
227                relative,
228                "shared with a target that has no Supercov runtime",
229            )),
230            Some(other) => files.excluded.push((
231                relative,
232                if other == "other" {
233                    "a source set that is neither main nor test"
234                } else {
235                    other
236                },
237            )),
238            // A loose file outside `src` was never pointed at by this
239            // project's build, so measuring it would add obligations nobody
240            // agreed to.
241            None => files.excluded.push((relative, "outside a src source set")),
242        }
243    }
244    Ok(())
245}
246
247pub fn discover_jvm_files(root: &Path) -> Result<JvmFiles, String> {
248    let mut files = JvmFiles::default();
249    walk(root, root, &mut files)?;
250    files.sources.sort();
251    files.tests.sort();
252    files.dependency_files.sort();
253    files.configuration_files.sort();
254    files.excluded.sort();
255    Ok(files)
256}
257
258const LANGUAGE: &str = "jvm";
259
260/// A file the parser could not read is a hole in the denominator, and a hole
261/// nobody can see is worse than one they can. A diagnostic line scrolls past;
262/// this puts the file in the manifest, so it reaches the declaration's
263/// structural limitations and `supercov runs latest` can still name it long
264/// after the build log is gone.
265fn unparseable_limitation(file: &str, reason: &str) -> serde_json::Value {
266    serde_json::json!({
267        "id": crate::go_instrumenter::stable_obligation_id(LANGUAGE, file, "unparseable", 0, 0),
268        "kind": "file-does-not-parse",
269        "file": file,
270        // The surface is the whole file: there is no construct to quote,
271        // because nothing in it parsed.
272        "source": file,
273        "line": 1,
274        "column": 1,
275        "reason": format!(
276            "{reason}; the file carries no obligations and nothing in it counts towards this run"
277        ),
278    })
279}
280
281pub fn prepare_jvm_project(
282    root: &Path,
283    roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
284) -> Result<PreparedJvmProject, String> {
285    let mut files = discover_jvm_files(root)?;
286    if let Some(roots) = roots {
287        roots.narrow(
288            &mut files.sources,
289            &mut files.excluded,
290            |(file, _): &(String, JvmLanguage)| file.as_str(),
291        );
292        roots.refuse_if_empty(files.sources.len(), "Java or Kotlin")?;
293    }
294    if files.sources.is_empty() && files.tests.is_empty() {
295        return Err(
296            "no Java or Kotlin sources were found under src/main or src/test; Supercov measures the source sets Maven and Gradle define"
297                .into(),
298        );
299    }
300    let mut manifest = CoverageManifest {
301        decisions: Vec::new(),
302        points: Vec::new(),
303        branches: Vec::new(),
304        limitations: Vec::new(),
305        unmeasured: Vec::new(),
306        scope: None,
307        assertion_sites: Vec::new(),
308    };
309    let mut probes = std::collections::BTreeMap::new();
310    let mut widths = Vec::new();
311    let mut instrumented = Vec::new();
312    let mut unparseable = Vec::new();
313    let mut next_probe = 0_u64;
314    let mut next_decision = 0_u32;
315    for (relative, language) in &files.sources {
316        let path = root.join(relative);
317        let Ok(source) = std::fs::read_to_string(&path) else {
318            let reason = "file could not be read as UTF-8";
319            manifest
320                .limitations
321                .push(unparseable_limitation(relative, reason));
322            unparseable.push((relative.clone(), reason.to_owned()));
323            continue;
324        };
325        match build_jvm_obligations(
326            relative,
327            &source,
328            *language,
329            &mut next_probe,
330            &mut next_decision,
331        ) {
332            Ok(obligations) => {
333                manifest.decisions.extend(obligations.manifest.decisions);
334                manifest.points.extend(obligations.manifest.points);
335                manifest.branches.extend(obligations.manifest.branches);
336                manifest
337                    .limitations
338                    .extend(obligations.manifest.limitations);
339                probes.extend(obligations.probes);
340                widths.extend(obligations.decision_widths);
341                instrumented.push((
342                    relative.clone(),
343                    crate::jvm_instrumenter::rewrite(&source, &obligations.edits),
344                ));
345            }
346            Err(error) => {
347                manifest
348                    .limitations
349                    .push(unparseable_limitation(relative, &error.to_string()));
350                unparseable.push((relative.clone(), error.to_string()));
351            }
352        }
353    }
354    let scope =
355        match roots {
356            Some(roots) => {
357                let kept = files
358                    .sources
359                    .iter()
360                    .map(|(file, _)| file.clone())
361                    .collect::<Vec<_>>();
362                roots
363                    .scope(&kept, &files.excluded)
364                    .map_err(|error| error.to_string())?
365            }
366            None => {
367                let unread = |file: &String| unparseable.iter().any(|(path, _)| path == file);
368                let named = |language: &JvmLanguage| match language {
369                    JvmLanguage::Java => "Java",
370                    JvmLanguage::Kotlin => "Kotlin",
371                };
372                crate::source_discovery::automatic_scope(
373                    files.sources.iter().filter(|(file, _)| !unread(file)).map(
374                        |(file, language)| {
375                            (
376                                file.clone(),
377                                format!("{} application source", named(language)),
378                            )
379                        },
380                    ),
381                    files
382                        .sources
383                        .iter()
384                        .filter(|(file, _)| unread(file))
385                        .map(|(file, _)| (file.clone(), "could not be parsed".to_owned()))
386                        .chain(
387                            files
388                                .tests
389                                .iter()
390                                .map(|(file, _)| (file.clone(), "test file".to_owned())),
391                        )
392                        .chain(
393                            files
394                                .excluded
395                                .iter()
396                                .filter(|(file, _)| language_of(file).is_some())
397                                .map(|(file, reason)| (file.clone(), (*reason).to_owned())),
398                        ),
399                )
400            }
401        };
402    manifest.scope = Some(serde_json::to_value(scope).map_err(|error| error.to_string())?);
403    Ok(PreparedJvmProject {
404        root: root.to_owned(),
405        files,
406        manifest,
407        probes,
408        decision_widths: widths,
409        instrumented,
410        unparseable,
411    })
412}
413
414/// Integrity inputs: the ambient environment is absent, as it is everywhere.
415pub fn jvm_integrity_inputs(files: &JvmFiles, command: &[String]) -> ExplicitIntegrityInputs {
416    ExplicitIntegrityInputs {
417        source_files: files
418            .sources
419            .iter()
420            .map(|(path, _)| PathBuf::from(path))
421            .collect(),
422        test_files: files
423            .tests
424            .iter()
425            .map(|(path, _)| PathBuf::from(path))
426            .collect(),
427        dependency_files: files.dependency_files.clone(),
428        configuration_files: files.configuration_files.clone(),
429        execution_configuration: command.join("\0").into_bytes(),
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use std::fs;
437
438    fn fixture(label: &str) -> PathBuf {
439        let root = std::env::temp_dir().join(format!(
440            "supercov-jvm-project-{label}-{}-{}",
441            std::process::id(),
442            std::time::SystemTime::now()
443                .duration_since(std::time::UNIX_EPOCH)
444                .unwrap()
445                .as_nanos()
446        ));
447        fs::create_dir_all(&root).unwrap();
448        root
449    }
450
451    fn write(root: &Path, relative: &str, contents: &str) {
452        let path = root.join(relative);
453        fs::create_dir_all(path.parent().unwrap()).unwrap();
454        fs::write(path, contents).unwrap();
455    }
456
457    fn explicit_roots(root: &Path, roots: &[&str]) -> crate::source_discovery::ExplicitSourceRoots {
458        let roots = roots
459            .iter()
460            .map(|root| (*root).to_owned())
461            .collect::<Vec<_>>();
462        crate::source_discovery::ExplicitSourceRoots::resolve(root, &roots).unwrap()
463    }
464
465    #[test]
466    fn explicit_roots_narrow_the_jvm_to_the_code_they_name() {
467        let root = fixture("explicit-roots");
468        write(&root, "pom.xml", "<project/>");
469        write(
470            &root,
471            "src/main/java/app/Good.java",
472            "class Good { int f(int a) { return a; } }",
473        );
474        write(
475            &root,
476            "src/main/java/shaded/Dep.java",
477            "class Dep { int g() { return 1; } }",
478        );
479
480        let project =
481            prepare_jvm_project(&root, Some(&explicit_roots(&root, &["src/main/java/app"])))
482                .unwrap();
483        assert_eq!(
484            project
485                .files
486                .sources
487                .iter()
488                .map(|(file, _)| file.as_str())
489                .collect::<Vec<_>>(),
490            ["src/main/java/app/Good.java"],
491            "{:?}",
492            project.files
493        );
494        assert!(
495            project.files.excluded.contains(&(
496                "src/main/java/shaded/Dep.java".into(),
497                "outside explicit source roots"
498            )),
499            "{:?}",
500            project.files.excluded
501        );
502        let scope: crate::source_discovery::SourceScope = serde_json::from_value(
503            project
504                .manifest
505                .scope
506                .clone()
507                .expect("explicit roots leave a scope"),
508        )
509        .unwrap();
510        assert_eq!(
511            scope.mode,
512            crate::source_discovery::SourceScopeMode::Explicit
513        );
514        // With no roots named the run lists what it measures all the same:
515        // `runs <id> scope` answered that it held no inventory.
516        write(
517            &root,
518            "src/test/java/app/GoodTest.java",
519            "class GoodTest {}",
520        );
521        write(&root, "Loose.java", "class Loose {}");
522        let scope: crate::source_discovery::SourceScope = serde_json::from_value(
523            prepare_jvm_project(&root, None)
524                .unwrap()
525                .manifest
526                .scope
527                .expect("a scope"),
528        )
529        .unwrap();
530        assert_eq!(
531            scope.mode,
532            crate::source_discovery::SourceScopeMode::Automatic
533        );
534        assert_eq!(
535            scope
536                .entries
537                .iter()
538                .map(|entry| (
539                    entry.file.as_str(),
540                    entry.status == crate::source_discovery::SourceScopeStatus::Included,
541                    entry.reason.as_str()
542                ))
543                .collect::<Vec<_>>(),
544            [
545                ("Loose.java", false, "outside a src source set"),
546                (
547                    "src/main/java/app/Good.java",
548                    true,
549                    "Java application source"
550                ),
551                (
552                    "src/main/java/shaded/Dep.java",
553                    true,
554                    "Java application source"
555                ),
556                ("src/test/java/app/GoodTest.java", false, "test file"),
557            ]
558        );
559        fs::remove_dir_all(root).ok();
560    }
561
562    #[test]
563    fn the_source_set_decides_what_a_file_is_not_its_name() {
564        // Maven and Gradle have meant the same thing by these paths for
565        // twenty years, which is a stronger signal than any name-based guess a
566        // coverage tool could invent. `TestHelper.java` under src/main is
567        // product source; `Calculator.java` under src/test is a test.
568        let root = fixture("layout");
569        write(&root, "pom.xml", "<project/>");
570        write(
571            &root,
572            "src/main/java/app/TestHelper.java",
573            "class TestHelper {}",
574        );
575        write(&root, "src/main/kotlin/app/Api.kt", "class Api");
576        write(
577            &root,
578            "src/test/java/app/Calculator.java",
579            "class Calculator {}",
580        );
581        write(&root, "src/test/kotlin/app/ApiTest.kt", "class ApiTest");
582
583        let files = discover_jvm_files(&root).unwrap();
584        assert_eq!(
585            files.sources,
586            [
587                (
588                    "src/main/java/app/TestHelper.java".to_owned(),
589                    JvmLanguage::Java
590                ),
591                ("src/main/kotlin/app/Api.kt".to_owned(), JvmLanguage::Kotlin),
592            ]
593        );
594        assert_eq!(
595            files.tests,
596            [
597                (
598                    "src/test/java/app/Calculator.java".to_owned(),
599                    JvmLanguage::Java
600                ),
601                (
602                    "src/test/kotlin/app/ApiTest.kt".to_owned(),
603                    JvmLanguage::Kotlin
604                ),
605            ]
606        );
607        assert_eq!(detect_build(&root), JvmBuild::Maven);
608        fs::remove_dir_all(root).unwrap();
609    }
610
611    #[test]
612    fn build_output_and_loose_files_are_out_of_scope_rather_than_guessed_at() {
613        // `target` and `build` hold compiled copies of the same source, and a
614        // file outside `src` was never pointed at by the build. Measuring
615        // either adds obligations nobody agreed to.
616        let root = fixture("scope");
617        write(&root, "build.gradle.kts", "plugins { java }");
618        write(&root, "gradle/libs.versions.toml", "[versions]");
619        write(&root, ".java-version", "21");
620        write(&root, "src/main/java/app/Api.java", "class Api {}");
621        write(&root, "target/classes/app/Api.java", "class Api {}");
622        write(&root, "build/generated/app/Gen.java", "class Gen {}");
623        write(&root, "Scratch.java", "class Scratch {}");
624
625        let files = discover_jvm_files(&root).unwrap();
626        assert_eq!(files.sources.len(), 1);
627        assert_eq!(files.sources[0].0, "src/main/java/app/Api.java");
628        assert!(
629            files
630                .excluded
631                .iter()
632                .any(|(path, why)| path == "Scratch.java" && *why == "outside a src source set")
633        );
634        assert_eq!(detect_build(&root), JvmBuild::Gradle);
635        assert!(
636            files
637                .dependency_files
638                .contains(&PathBuf::from("gradle/libs.versions.toml"))
639        );
640        assert_eq!(files.configuration_files, [PathBuf::from(".java-version")]);
641        fs::remove_dir_all(root).unwrap();
642    }
643
644    #[test]
645    fn a_file_that_does_not_parse_is_reported_rather_than_skipped() {
646        let root = fixture("unparseable");
647        write(&root, "pom.xml", "<project/>");
648        write(
649            &root,
650            "src/main/java/app/Good.java",
651            "class Good { boolean f(int a, boolean b) { if (a > 1 && b) { return true; } return false; } }",
652        );
653        write(
654            &root,
655            "src/main/java/app/Broken.java",
656            "class Broken { void f( {",
657        );
658
659        let project = prepare_jvm_project(&root, None).unwrap();
660        assert_eq!(project.unparseable.len(), 1);
661        assert!(project.unparseable[0].0.ends_with("Broken.java"));
662        // And the hole it leaves is declared, not merely printed: a
663        // diagnostic scrolls past, a limitation reaches the stored run.
664        let declared = project
665            .manifest
666            .limitations
667            .iter()
668            .filter(|limitation| limitation["kind"] == "file-does-not-parse")
669            .collect::<Vec<_>>();
670        assert_eq!(declared.len(), 1, "{declared:?}");
671        assert!(
672            declared[0]["file"]
673                .as_str()
674                .unwrap()
675                .ends_with("Broken.java"),
676            "{declared:?}"
677        );
678        assert!(
679            declared[0]["id"].as_str().is_some_and(|id| !id.is_empty()),
680            "the declaration needs an id to reference: {declared:?}"
681        );
682        assert_eq!(project.manifest.decisions.len(), 1);
683        assert_eq!(project.decision_widths, [2]);
684        assert!(!project.probes.is_empty());
685        fs::remove_dir_all(root).unwrap();
686    }
687
688    #[test]
689    fn a_project_with_nothing_to_measure_is_refused() {
690        // Zero of zero satisfies every floor, so reporting success for a
691        // project that proved nothing is worse than refusing to start.
692        let root = fixture("empty");
693        write(&root, "pom.xml", "<project/>");
694        assert!(prepare_jvm_project(&root, None).is_err());
695        fs::remove_dir_all(root).unwrap();
696    }
697
698    #[test]
699    fn a_multiplatform_layout_is_measured_where_the_jvm_is_the_only_target() {
700        // Kotlin Multiplatform names its source sets by target, so `src/main`
701        // never appears and the whole project used to be invisible. jvmMain
702        // builds for the JVM and nothing else, so it is measured like any
703        // other main; commonMain builds for every target declared, and a probe
704        // there calls a runtime that exists only on the JVM.
705        let root = fixture("kmp-jvm");
706        fs::write(root.join("build.gradle.kts"), "kotlin {\n    jvm()\n}\n").unwrap();
707        for (path, body) in [
708            ("src/commonMain/kotlin/Shared.kt", "fun shared() {}"),
709            ("src/jvmMain/kotlin/Jvm.kt", "fun onlyJvm() {}"),
710            ("src/commonTest/kotlin/SharedTest.kt", "fun sharedTest() {}"),
711            ("src/jvmTest/kotlin/JvmTest.kt", "fun jvmTest() {}"),
712        ] {
713            let full = root.join(path);
714            fs::create_dir_all(full.parent().unwrap()).unwrap();
715            fs::write(full, body).unwrap();
716        }
717        let files = discover_jvm_files(&root).expect("discovery");
718        assert_eq!(
719            files
720                .sources
721                .iter()
722                .map(|(p, _)| p.as_str())
723                .collect::<Vec<_>>(),
724            [
725                "src/commonMain/kotlin/Shared.kt",
726                "src/jvmMain/kotlin/Jvm.kt"
727            ]
728        );
729        assert_eq!(
730            files
731                .tests
732                .iter()
733                .map(|(p, _)| p.as_str())
734                .collect::<Vec<_>>(),
735            [
736                "src/commonTest/kotlin/SharedTest.kt",
737                "src/jvmTest/kotlin/JvmTest.kt"
738            ]
739        );
740
741        // Declare a second target and the shared source is left alone, because
742        // instrumenting it would stop the build that produces that target.
743        // Losing its coverage costs a measurement; breaking the build costs
744        // the thing being measured.
745        fs::write(
746            root.join("build.gradle.kts"),
747            "kotlin {\n    jvm()\n    js(IR) { nodejs() }\n}\n",
748        )
749        .unwrap();
750        let files = discover_jvm_files(&root).expect("discovery");
751        assert_eq!(
752            files
753                .sources
754                .iter()
755                .map(|(p, _)| p.as_str())
756                .collect::<Vec<_>>(),
757            ["src/jvmMain/kotlin/Jvm.kt"]
758        );
759        assert!(
760            files
761                .excluded
762                .iter()
763                .any(|(path, reason)| path == "src/commonMain/kotlin/Shared.kt"
764                    && *reason == "shared with a target that has no Supercov runtime"),
765            "{:?}",
766            files.excluded
767        );
768        fs::remove_dir_all(root).unwrap();
769    }
770}