supercov-engine 0.0.54

Rust instrumentation, evidence, attribution, and query engine for Supercov
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
//! JVM project discovery for Maven and Gradle layouts.
//!
//! Both build systems put tests under `src/test` and product source under
//! `src/main`, and have done for twenty years. That convention is stronger
//! than any heuristic a coverage tool could invent, so it is the rule here:
//! a file's place in the tree decides what it is, not its name.
//!
//! Where the convention is absent — a loose file outside `src` — the file is
//! reported as out of scope rather than guessed at. Measuring source this
//! project's tests were never pointed at would put obligations in the
//! denominator nobody agreed to.

use std::path::{Path, PathBuf};

use crate::coverage_report::CoverageManifest;
use crate::go_instrumenter::GoProbe;
use crate::integrity::ExplicitIntegrityInputs;
use crate::jvm_instrumenter::{JvmLanguage, build_jvm_obligations};

/// Directories that never hold this project's measured source.
const EXCLUDED_DIRECTORIES: &[&str] = &[
    ".git",
    ".gradle",
    ".idea",
    ".mvn",
    ".vscode",
    "build",
    "node_modules",
    "out",
    "target",
];

/// Files that pin what the build resolves to.
const DEPENDENCY_FILES: &[&str] = &[
    "build.gradle",
    "build.gradle.kts",
    "gradle.properties",
    "libs.versions.toml",
    "pom.xml",
    "settings.gradle",
    "settings.gradle.kts",
    "gradle-wrapper.properties",
];

/// Configuration that decides what actually executes.
const CONFIGURATION_FILES: &[&str] = &[".java-version", ".sdkmanrc", "junit-platform.properties"];

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct JvmFiles {
    /// Relative, `/`-separated paths of measured application sources, paired
    /// with the language that reads them.
    pub sources: Vec<(String, JvmLanguage)>,
    pub tests: Vec<(String, JvmLanguage)>,
    pub dependency_files: Vec<PathBuf>,
    pub configuration_files: Vec<PathBuf>,
    pub excluded: Vec<(String, &'static str)>,
}

#[derive(Debug, Clone, PartialEq)]
pub struct PreparedJvmProject {
    pub root: PathBuf,
    pub files: JvmFiles,
    pub manifest: CoverageManifest,
    pub probes: std::collections::BTreeMap<u64, GoProbe>,
    pub decision_widths: Vec<u8>,
    /// Each measured source and what it becomes once instrumented, in
    /// discovery order. Carried rather than recomputed: preparing the
    /// obligations already parsed and rewrote every file.
    pub instrumented: Vec<(String, String)>,
    /// Files that did not parse, with the reason. Reported rather than skipped:
    /// a hole in the denominator nobody is told about is a wrong number.
    pub unparseable: Vec<(String, String)>,
}

/// Which build system, decided by what is actually in the tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JvmBuild {
    Maven,
    Gradle,
    /// Sources laid out conventionally with no build file; still measurable.
    Plain,
}

pub fn detect_build(root: &Path) -> JvmBuild {
    if root.join("pom.xml").is_file() {
        return JvmBuild::Maven;
    }
    for name in [
        "build.gradle",
        "build.gradle.kts",
        "settings.gradle",
        "settings.gradle.kts",
    ] {
        if root.join(name).is_file() {
            return JvmBuild::Gradle;
        }
    }
    JvmBuild::Plain
}

fn language_of(name: &str) -> Option<JvmLanguage> {
    if name.ends_with(".java") {
        return Some(JvmLanguage::Java);
    }
    if name.ends_with(".kt") {
        return Some(JvmLanguage::Kotlin);
    }
    None
}

/// Maven and Gradle both mean the same thing by these paths, and have for
/// long enough that the convention is more reliable than any name-based guess.
fn role(relative: &str) -> Option<&'static str> {
    let mut parts = relative.split('/');
    while let Some(part) = parts.next() {
        if part != "src" {
            continue;
        }
        // `src/<sourceSet>/<language>/...`, where the source set is `main`,
        // `test`, or a custom one a project defined.
        return match parts.next() {
            Some("test") | Some("integrationTest") | Some("testFixtures") => Some("test"),
            Some("main") => Some("main"),
            // A Kotlin Multiplatform project names its source sets by target:
            // `jvmMain` builds for the JVM and nothing else, so it is measured
            // like any other main. `commonMain` builds for every target the
            // project declares, and a probe there is a call to a runtime that
            // exists only on the JVM -- so it is measured only where the JVM
            // is the only target, which `discover_jvm_files` decides.
            Some("jvmMain") => Some("main"),
            Some("commonMain") => Some("common"),
            // Tests are never instrumented on the JVM -- attribution comes
            // from the framework's own lifecycle -- so recognising a test
            // source set only decides whether a module has tests at all.
            Some(set) if set.ends_with("Test") => Some("test"),
            Some(_) => Some("other"),
            None => None,
        };
    }
    None
}

/// Whether the JVM is the only target this build produces.
///
/// A Kotlin Multiplatform build compiles `commonMain` for every target it
/// declares. Instrumenting it inserts a call to a runtime that exists on the
/// JVM and nowhere else, so where anything but the JVM is declared that source
/// is left alone: losing its coverage is a cost, and breaking the build that
/// produces the other targets is not a trade worth making.
fn targets_only_the_jvm(root: &Path) -> bool {
    let mut text = String::new();
    for name in ["build.gradle.kts", "build.gradle", "pom.xml"] {
        if let Ok(own) = std::fs::read_to_string(root.join(name)) {
            text.push_str(&own);
            text.push('\n');
        }
    }
    ![
        "js(",
        "wasmJs(",
        "wasmWasi(",
        "linuxX64(",
        "macosX64(",
        "macosArm64(",
        "mingwX64(",
        "iosArm64(",
        "iosX64(",
        "iosSimulatorArm64(",
        "watchos",
        "tvos",
        "androidNativeArm64(",
    ]
    .iter()
    .any(|target| text.contains(target))
}

fn walk(root: &Path, directory: &Path, files: &mut JvmFiles) -> Result<(), String> {
    let jvm_only = targets_only_the_jvm(root);
    let entries = std::fs::read_dir(directory)
        .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
    let mut sorted = entries
        .collect::<Result<Vec<_>, _>>()
        .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
    sorted.sort_by_key(std::fs::DirEntry::path);
    for entry in sorted {
        let path = entry.path();
        let Ok(relative) = path.strip_prefix(root) else {
            continue;
        };
        let relative = relative.to_string_lossy().replace('\\', "/");
        let name = path
            .file_name()
            .map(|name| name.to_string_lossy().into_owned())
            .unwrap_or_default();
        let file_type = entry
            .file_type()
            .map_err(|error| format!("could not inspect {}: {error}", path.display()))?;
        if file_type.is_dir() {
            if EXCLUDED_DIRECTORIES.contains(&name.as_str()) || name.starts_with('.') {
                files
                    .excluded
                    .push((relative, "build output or tooling directory"));
                continue;
            }
            walk(root, &path, files)?;
            continue;
        }
        if !file_type.is_file() {
            continue;
        }
        if DEPENDENCY_FILES.contains(&name.as_str()) {
            files.dependency_files.push(PathBuf::from(&relative));
            continue;
        }
        if CONFIGURATION_FILES.contains(&name.as_str()) {
            files.configuration_files.push(PathBuf::from(&relative));
            continue;
        }
        let Some(language) = language_of(&name) else {
            continue;
        };
        match role(&relative) {
            Some("main") => files.sources.push((relative, language)),
            Some("test") => files.tests.push((relative, language)),
            Some("common") if jvm_only => files.sources.push((relative, language)),
            Some("common") => files.excluded.push((
                relative,
                "shared with a target that has no Supercov runtime",
            )),
            Some(other) => files.excluded.push((
                relative,
                if other == "other" {
                    "a source set that is neither main nor test"
                } else {
                    other
                },
            )),
            // A loose file outside `src` was never pointed at by this
            // project's build, so measuring it would add obligations nobody
            // agreed to.
            None => files.excluded.push((relative, "outside a src source set")),
        }
    }
    Ok(())
}

pub fn discover_jvm_files(root: &Path) -> Result<JvmFiles, String> {
    let mut files = JvmFiles::default();
    walk(root, root, &mut files)?;
    files.sources.sort();
    files.tests.sort();
    files.dependency_files.sort();
    files.configuration_files.sort();
    files.excluded.sort();
    Ok(files)
}

const LANGUAGE: &str = "jvm";

/// A file the parser could not read is a hole in the denominator, and a hole
/// nobody can see is worse than one they can. A diagnostic line scrolls past;
/// this puts the file in the manifest, so it reaches the declaration's
/// structural limitations and `supercov runs latest` can still name it long
/// after the build log is gone.
fn unparseable_limitation(file: &str, reason: &str) -> serde_json::Value {
    serde_json::json!({
        "id": crate::go_instrumenter::stable_obligation_id(LANGUAGE, file, "unparseable", 0, 0),
        "kind": "file-does-not-parse",
        "file": file,
        // The surface is the whole file: there is no construct to quote,
        // because nothing in it parsed.
        "source": file,
        "line": 1,
        "column": 1,
        "reason": format!(
            "{reason}; the file carries no obligations and nothing in it counts towards this run"
        ),
    })
}

pub fn prepare_jvm_project(root: &Path) -> Result<PreparedJvmProject, String> {
    let files = discover_jvm_files(root)?;
    if files.sources.is_empty() && files.tests.is_empty() {
        return Err(
            "no Java or Kotlin sources were found under src/main or src/test; Supercov measures the source sets Maven and Gradle define"
                .into(),
        );
    }
    let mut manifest = CoverageManifest {
        decisions: Vec::new(),
        points: Vec::new(),
        branches: Vec::new(),
        limitations: Vec::new(),
        unmeasured: Vec::new(),
        scope: None,
    };
    let mut probes = std::collections::BTreeMap::new();
    let mut widths = Vec::new();
    let mut instrumented = Vec::new();
    let mut unparseable = Vec::new();
    let mut next_probe = 0_u64;
    let mut next_decision = 0_u32;
    for (relative, language) in &files.sources {
        let path = root.join(relative);
        let Ok(source) = std::fs::read_to_string(&path) else {
            let reason = "file could not be read as UTF-8";
            manifest
                .limitations
                .push(unparseable_limitation(relative, reason));
            unparseable.push((relative.clone(), reason.to_owned()));
            continue;
        };
        match build_jvm_obligations(
            relative,
            &source,
            *language,
            &mut next_probe,
            &mut next_decision,
        ) {
            Ok(obligations) => {
                manifest.decisions.extend(obligations.manifest.decisions);
                manifest.points.extend(obligations.manifest.points);
                manifest.branches.extend(obligations.manifest.branches);
                manifest
                    .limitations
                    .extend(obligations.manifest.limitations);
                probes.extend(obligations.probes);
                widths.extend(obligations.decision_widths);
                instrumented.push((
                    relative.clone(),
                    crate::jvm_instrumenter::rewrite(&source, &obligations.edits),
                ));
            }
            Err(error) => {
                manifest
                    .limitations
                    .push(unparseable_limitation(relative, &error.to_string()));
                unparseable.push((relative.clone(), error.to_string()));
            }
        }
    }
    Ok(PreparedJvmProject {
        root: root.to_owned(),
        files,
        manifest,
        probes,
        decision_widths: widths,
        instrumented,
        unparseable,
    })
}

/// Integrity inputs: the ambient environment is absent, as it is everywhere.
pub fn jvm_integrity_inputs(files: &JvmFiles, command: &[String]) -> ExplicitIntegrityInputs {
    ExplicitIntegrityInputs {
        source_files: files
            .sources
            .iter()
            .map(|(path, _)| PathBuf::from(path))
            .collect(),
        test_files: files
            .tests
            .iter()
            .map(|(path, _)| PathBuf::from(path))
            .collect(),
        dependency_files: files.dependency_files.clone(),
        configuration_files: files.configuration_files.clone(),
        execution_configuration: command.join("\0").into_bytes(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    fn fixture(label: &str) -> PathBuf {
        let root = std::env::temp_dir().join(format!(
            "supercov-jvm-project-{label}-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        fs::create_dir_all(&root).unwrap();
        root
    }

    fn write(root: &Path, relative: &str, contents: &str) {
        let path = root.join(relative);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(path, contents).unwrap();
    }

    #[test]
    fn the_source_set_decides_what_a_file_is_not_its_name() {
        // Maven and Gradle have meant the same thing by these paths for
        // twenty years, which is a stronger signal than any name-based guess a
        // coverage tool could invent. `TestHelper.java` under src/main is
        // product source; `Calculator.java` under src/test is a test.
        let root = fixture("layout");
        write(&root, "pom.xml", "<project/>");
        write(
            &root,
            "src/main/java/app/TestHelper.java",
            "class TestHelper {}",
        );
        write(&root, "src/main/kotlin/app/Api.kt", "class Api");
        write(
            &root,
            "src/test/java/app/Calculator.java",
            "class Calculator {}",
        );
        write(&root, "src/test/kotlin/app/ApiTest.kt", "class ApiTest");

        let files = discover_jvm_files(&root).unwrap();
        assert_eq!(
            files.sources,
            [
                (
                    "src/main/java/app/TestHelper.java".to_owned(),
                    JvmLanguage::Java
                ),
                ("src/main/kotlin/app/Api.kt".to_owned(), JvmLanguage::Kotlin),
            ]
        );
        assert_eq!(
            files.tests,
            [
                (
                    "src/test/java/app/Calculator.java".to_owned(),
                    JvmLanguage::Java
                ),
                (
                    "src/test/kotlin/app/ApiTest.kt".to_owned(),
                    JvmLanguage::Kotlin
                ),
            ]
        );
        assert_eq!(detect_build(&root), JvmBuild::Maven);
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn build_output_and_loose_files_are_out_of_scope_rather_than_guessed_at() {
        // `target` and `build` hold compiled copies of the same source, and a
        // file outside `src` was never pointed at by the build. Measuring
        // either adds obligations nobody agreed to.
        let root = fixture("scope");
        write(&root, "build.gradle.kts", "plugins { java }");
        write(&root, "gradle/libs.versions.toml", "[versions]");
        write(&root, ".java-version", "21");
        write(&root, "src/main/java/app/Api.java", "class Api {}");
        write(&root, "target/classes/app/Api.java", "class Api {}");
        write(&root, "build/generated/app/Gen.java", "class Gen {}");
        write(&root, "Scratch.java", "class Scratch {}");

        let files = discover_jvm_files(&root).unwrap();
        assert_eq!(files.sources.len(), 1);
        assert_eq!(files.sources[0].0, "src/main/java/app/Api.java");
        assert!(
            files
                .excluded
                .iter()
                .any(|(path, why)| path == "Scratch.java" && *why == "outside a src source set")
        );
        assert_eq!(detect_build(&root), JvmBuild::Gradle);
        assert!(
            files
                .dependency_files
                .contains(&PathBuf::from("gradle/libs.versions.toml"))
        );
        assert_eq!(files.configuration_files, [PathBuf::from(".java-version")]);
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn a_file_that_does_not_parse_is_reported_rather_than_skipped() {
        let root = fixture("unparseable");
        write(&root, "pom.xml", "<project/>");
        write(
            &root,
            "src/main/java/app/Good.java",
            "class Good { boolean f(int a, boolean b) { if (a > 1 && b) { return true; } return false; } }",
        );
        write(
            &root,
            "src/main/java/app/Broken.java",
            "class Broken { void f( {",
        );

        let project = prepare_jvm_project(&root).unwrap();
        assert_eq!(project.unparseable.len(), 1);
        assert!(project.unparseable[0].0.ends_with("Broken.java"));
        // And the hole it leaves is declared, not merely printed: a
        // diagnostic scrolls past, a limitation reaches the stored run.
        let declared = project
            .manifest
            .limitations
            .iter()
            .filter(|limitation| limitation["kind"] == "file-does-not-parse")
            .collect::<Vec<_>>();
        assert_eq!(declared.len(), 1, "{declared:?}");
        assert!(
            declared[0]["file"]
                .as_str()
                .unwrap()
                .ends_with("Broken.java"),
            "{declared:?}"
        );
        assert!(
            declared[0]["id"].as_str().is_some_and(|id| !id.is_empty()),
            "the declaration needs an id to reference: {declared:?}"
        );
        assert_eq!(project.manifest.decisions.len(), 1);
        assert_eq!(project.decision_widths, [2]);
        assert!(!project.probes.is_empty());
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn a_project_with_nothing_to_measure_is_refused() {
        // Zero of zero satisfies every floor, so reporting success for a
        // project that proved nothing is worse than refusing to start.
        let root = fixture("empty");
        write(&root, "pom.xml", "<project/>");
        assert!(prepare_jvm_project(&root).is_err());
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn a_multiplatform_layout_is_measured_where_the_jvm_is_the_only_target() {
        // Kotlin Multiplatform names its source sets by target, so `src/main`
        // never appears and the whole project used to be invisible. jvmMain
        // builds for the JVM and nothing else, so it is measured like any
        // other main; commonMain builds for every target declared, and a probe
        // there calls a runtime that exists only on the JVM.
        let root = fixture("kmp-jvm");
        fs::write(root.join("build.gradle.kts"), "kotlin {\n    jvm()\n}\n").unwrap();
        for (path, body) in [
            ("src/commonMain/kotlin/Shared.kt", "fun shared() {}"),
            ("src/jvmMain/kotlin/Jvm.kt", "fun onlyJvm() {}"),
            ("src/commonTest/kotlin/SharedTest.kt", "fun sharedTest() {}"),
            ("src/jvmTest/kotlin/JvmTest.kt", "fun jvmTest() {}"),
        ] {
            let full = root.join(path);
            fs::create_dir_all(full.parent().unwrap()).unwrap();
            fs::write(full, body).unwrap();
        }
        let files = discover_jvm_files(&root).expect("discovery");
        assert_eq!(
            files
                .sources
                .iter()
                .map(|(p, _)| p.as_str())
                .collect::<Vec<_>>(),
            [
                "src/commonMain/kotlin/Shared.kt",
                "src/jvmMain/kotlin/Jvm.kt"
            ]
        );
        assert_eq!(
            files
                .tests
                .iter()
                .map(|(p, _)| p.as_str())
                .collect::<Vec<_>>(),
            [
                "src/commonTest/kotlin/SharedTest.kt",
                "src/jvmTest/kotlin/JvmTest.kt"
            ]
        );

        // Declare a second target and the shared source is left alone, because
        // instrumenting it would stop the build that produces that target.
        // Losing its coverage costs a measurement; breaking the build costs
        // the thing being measured.
        fs::write(
            root.join("build.gradle.kts"),
            "kotlin {\n    jvm()\n    js(IR) { nodejs() }\n}\n",
        )
        .unwrap();
        let files = discover_jvm_files(&root).expect("discovery");
        assert_eq!(
            files
                .sources
                .iter()
                .map(|(p, _)| p.as_str())
                .collect::<Vec<_>>(),
            ["src/jvmMain/kotlin/Jvm.kt"]
        );
        assert!(
            files
                .excluded
                .iter()
                .any(|(path, reason)| path == "src/commonMain/kotlin/Shared.kt"
                    && *reason == "shared with a target that has no Supercov runtime"),
            "{:?}",
            files.excluded
        );
        fs::remove_dir_all(root).unwrap();
    }
}