Skip to main content

supercov_engine/
go_run.rs

1//! Public, isolated Go coverage run lifecycle.
2//!
3//! Supercov owns the probes here, which means it rewrites the module's own
4//! sources. That cannot happen in the user's tree, so the run copies the
5//! project into an isolated workspace, instruments it there, and runs the
6//! user's command against the copy. Nothing in the tree the author edits is
7//! touched.
8//!
9//! Two facts about `go test` shape the rest:
10//!
11//! - It builds and runs **one test binary per package**, each its own process
12//!   with its own probe array. So each test package writes its own evidence
13//!   file and the run merges them, rather than every process racing to write
14//!   one path.
15//! - It **caches** successful results. A cached package does not run, so it
16//!   records nothing, and a run that silently measured half a suite is worse
17//!   than one that took longer. The run adds `-count=1` when the command does
18//!   not already say otherwise, and says so.
19
20use std::{
21    collections::{BTreeMap, BTreeSet},
22    ffi::OsString,
23    fs,
24    io::Write,
25    path::{Path, PathBuf},
26    time::Instant,
27};
28
29use serde::{Deserialize, Serialize};
30
31use crate::{
32    evidence_archive::write_archive,
33    frontend_protocol::validate_frontend_report_request,
34    go_instrumenter::{RUNTIME_ALIAS, RUNTIME_IMPORT, rewrite},
35    go_project::{PreparedGoProject, go_integrity_inputs, module_path, prepare_go_project},
36    go_test_harness::{instrument_test_file, probe_array_file, synthesized_harness},
37    integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
38    lifecycle::{
39        ProjectLock, finalize_published_run, note_kept_evidence, publish_run,
40        recover_abandoned_runs, remove_stored_tree_deferred,
41    },
42    orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
43    owned_evidence::{
44        OwnedRunInputs, OwnedTestOutcome, build_frontend_run, go_coverage_model, go_declaration,
45        merge_evidence, read_evidence,
46    },
47    process_supervision::{CommandSpec, SupervisionOptions},
48    run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
49    workspace::{canonicalize_simplified, prepare_cached_workspace, recover_cached_workspace},
50};
51
52/// Where the runtime package lands inside the workspace.
53///
54/// A directory whose name begins with `_`: the Go tool leaves those out of
55/// `./...` and builds them when a file imports one, which every rewritten
56/// file does. Under a plain name the package was listed with the project's
57/// own in every run, `?  example.com/shop/supercov_runtime  [no test files]`.
58/// Go 1.22 to 1.27 import it alike.
59const RUNTIME_DIRECTORY: &str = "_supercov_runtime";
60
61const RUNTIME_SOURCE: &str = include_str!("../runtime-assets/go/supercov/supercov.go");
62
63/// The generated file that declares a package's probe array.
64const PROBE_FILE: &str = "supercov_probes.go";
65
66/// The generated file that arms the runtime and binds each test.
67const HARNESS_FILE: &str = "supercov_generated_test.go";
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase", deny_unknown_fields)]
71pub struct DirectGoRunRequest {
72    pub root: PathBuf,
73    pub command: Vec<String>,
74    pub run_id: String,
75    pub started_at: String,
76    /// Credit every test with what it reached, by running the package's tests
77    /// one at a time.
78    ///
79    /// Probes are a store into one array the whole process shares, so a test
80    /// that called `t.Parallel()` can only be credited with its own work if
81    /// nothing else is running while it does it. `-parallel 1` is what buys
82    /// that, and what it costs is the suite's own parallelism -- which is why
83    /// it is asked for rather than assumed.
84    #[serde(default)]
85    pub exact_attribution: bool,
86}
87
88#[derive(Debug, Clone, PartialEq)]
89pub struct DirectGoRunResult {
90    pub run_id: String,
91    pub run_directory: PathBuf,
92    pub exit_code: i32,
93    pub tests: usize,
94    /// Test functions `go test` ran that no evidence can be credited to: one
95    /// that called `t.Parallel()`, and every Example or Fuzz target. What they
96    /// reached counts run-wide, so a run of nothing but these is a measured
97    /// run with no per-test attribution -- which is a different thing from an
98    /// empty suite, and the summary has to be able to say which it was.
99    pub unattributed: usize,
100    pub source_files: usize,
101    /// Source files that did not parse, and so carry no obligations. A hole in
102    /// the denominator travels with the number it was taken out of.
103    pub unparseable_sources: usize,
104    /// Test files that did not parse. Their tests still run and still reach
105    /// what they reach; what is lost is the announcement that would name them
106    /// for it.
107    pub unparseable_tests: usize,
108    pub packages: usize,
109    pub recovered_runs: Vec<String>,
110    pub metadata: RunMetadata,
111}
112
113fn elapsed_ms(started: Instant) -> f64 {
114    (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
115}
116
117/// One package whose tests will run, and where its evidence lands.
118#[derive(Debug, Clone)]
119struct GoTestPackage {
120    /// Directory relative to the workspace root, `/`-separated, `.` for the
121    /// module root.
122    directory: String,
123    evidence: PathBuf,
124    /// Which file declared each test, so a result can point at its source.
125    declared_in: BTreeMap<String, String>,
126    /// What ran here and announced nothing: parallel tests, Examples, Fuzz
127    /// targets.
128    unattributed: BTreeSet<String>,
129}
130
131struct InstrumentedWorkspace {
132    project: PreparedGoProject,
133    packages: Vec<GoTestPackage>,
134    /// Test files that did not parse, with the reason. They cost the tests
135    /// they declare their attribution and nothing else.
136    unparseable_tests: Vec<(String, String)>,
137}
138
139fn directory_of(relative: &str) -> String {
140    match relative.rsplit_once('/') {
141        Some((directory, _)) => directory.to_owned(),
142        None => ".".to_owned(),
143    }
144}
145
146/// The `package` a Go file declares.
147///
148/// Scanned rather than parsed: the clause is the first thing in a Go file that
149/// is not a comment, so stripping comments and reading the next word gets the
150/// same answer as a parse for a fraction of the cost — and preparing a project
151/// already parses every file once, which is enough.
152fn package_name(source: &str) -> Option<String> {
153    let bytes = source.as_bytes();
154    let mut at = 0;
155    while at < bytes.len() {
156        let rest = &source[at..];
157        if rest.starts_with("//") {
158            at += rest.find('\n').map_or(rest.len(), |end| end + 1);
159        } else if rest.starts_with("/*") {
160            // An unterminated comment is not Go; there is no clause to find.
161            at += rest.find("*/").map_or(rest.len(), |end| end + 2);
162        } else if bytes[at].is_ascii_whitespace() {
163            at += 1;
164        } else {
165            return rest
166                .strip_prefix("package")
167                .and_then(|rest| rest.strip_prefix(|c: char| c.is_ascii_whitespace()))
168                .and_then(|rest| rest.split_whitespace().next())
169                .map(str::to_owned);
170        }
171    }
172    None
173}
174
175fn read(path: &Path) -> Result<String, String> {
176    fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))
177}
178
179/// A copy's file under the name Go is to report it by: the project's own.
180///
181/// Go puts a file's path, as the compiler read it, into every stack trace,
182/// so a panic under Supercov named the copy: `…/.supercov/workspaces/
183/// workspace/shop/crypto.go:24`. A line directive is how Go is told that a
184/// file stands for another. It is the comment form, in front of the package
185/// clause on the clause's own line and naming that line, so the file gains
186/// no line: what reads the file as it lies, a coverage profile, counts the
187/// same lines as what reads it through the directive, a stack trace. The
188/// rewriting adds no line of its own either.
189fn named_for_the_project(project: &Path, relative: &str, text: &str) -> String {
190    let Ok(tree) = crate::go_instrumenter::parse(text) else {
191        return text.to_owned();
192    };
193    let mut cursor = tree.root_node().walk();
194    let Some(package) = tree
195        .root_node()
196        .children(&mut cursor)
197        .find(|child| child.kind() == "package_clause")
198    else {
199        return text.to_owned();
200    };
201    let at = package.start_byte();
202    let line = text[..at].rfind('\n').map_or(0, |end| end + 1);
203    if text[line..at].starts_with(LINE_DIRECTIVE) {
204        return text.to_owned();
205    }
206    let position = package.start_position();
207    // With the platform's separator all along: Go writes the file's name as
208    // it is given here, and on Windows `pkg/inner/inner.go` after the
209    // project's directory came out as `pkg/inner/inner.go` in a compile
210    // error where Go alone writes `pkg\inner\inner.go`.
211    let named = relative
212        .split('/')
213        .fold(project.to_path_buf(), |path, part| path.join(part));
214    format!(
215        "{}{LINE_DIRECTIVE}{}:{}:{}*/{}",
216        &text[..at],
217        named.display(),
218        position.row + 1,
219        position.column + 1,
220        &text[at..]
221    )
222}
223
224const LINE_DIRECTIVE: &str = "/*line ";
225
226fn write(path: &Path, contents: &str) -> Result<(), String> {
227    if let Some(parent) = path.parent() {
228        fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
229    }
230    fs::write(path, contents).map_err(|error| format!("{}: {error}", path.display()))
231}
232
233/// A file-system-safe name for a package's evidence, so two packages cannot
234/// collide on one path.
235fn evidence_name(directory: &str) -> String {
236    let slug = directory
237        .chars()
238        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
239        .collect::<String>();
240    format!("{slug}.bin")
241}
242
243/// Rewrite the workspace in place: instrumented sources, the runtime package
244/// they import, a probe array per package, and a harness per test package.
245fn instrument_workspace(
246    project_root: &Path,
247    workspace: &Path,
248    evidence_directory: &Path,
249    serialised: bool,
250    roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
251) -> Result<InstrumentedWorkspace, String> {
252    let mut project = prepare_go_project(workspace, roots)?;
253    let mut unparseable_tests: Vec<(String, String)> = Vec::new();
254
255    // A go.work repository has several modules and no module at its root, so
256    // each gets a runtime of its own: an import is resolved against the module
257    // that names it, and one module cannot import a package inside another.
258    let members = crate::go_project::workspace_modules(workspace);
259    let directories = if members.is_empty() {
260        vec![".".to_owned()]
261    } else {
262        members.into_iter().collect::<Vec<_>>()
263    };
264    let mut runtimes: Vec<(String, String)> = Vec::new();
265    for directory in &directories {
266        let at = workspace.join(directory);
267        let module = module_path(&at).ok_or_else(|| {
268            format!(
269                "{}: no module path in go.mod, so Supercov cannot name the package its runtime is imported from",
270                at.display()
271            )
272        })?;
273        write(
274            &at.join(RUNTIME_DIRECTORY).join("supercov.go"),
275            RUNTIME_SOURCE,
276        )?;
277        runtimes.push((directory.clone(), format!("{module}/{RUNTIME_DIRECTORY}")));
278    }
279    // Longest first, so a nested module wins over the root it sits under.
280    runtimes.sort_by_key(|(directory, _)| std::cmp::Reverse(directory.len()));
281    let import_for = |relative: &str| -> &str {
282        runtimes
283            .iter()
284            .find(|(directory, _)| {
285                directory == "."
286                    || relative == directory
287                    || relative.starts_with(&format!("{directory}/"))
288            })
289            .map(|(_, import)| import.as_str())
290            .unwrap_or_default()
291    };
292
293    // Probe ids are handed out across the whole module, so every package
294    // reserves the same total: a probe from one package is the same index in
295    // the binary that links it as a dependency.
296    let probe_count = project
297        .probes
298        .keys()
299        .max()
300        .map_or(0, |highest| *highest as usize + 1);
301
302    let mut source_packages: BTreeMap<String, String> = BTreeMap::new();
303    for (relative, instrumented) in &project.instrumented {
304        let instrumented = instrumented.replace(RUNTIME_IMPORT, import_for(relative));
305        let path = workspace.join(relative);
306        if let Some(name) = package_name(&instrumented) {
307            source_packages
308                .entry(directory_of(relative))
309                .or_insert(name);
310        }
311        write(
312            &path,
313            &named_for_the_project(project_root, relative, &instrumented),
314        )?;
315    }
316    for (directory, package) in &source_packages {
317        write(
318            &workspace.join(directory).join(PROBE_FILE),
319            &probe_array_file(
320                package,
321                RUNTIME_ALIAS,
322                import_for(&format!("{directory}/x.go")),
323                probe_count,
324            ),
325        )?;
326    }
327
328    let mut by_directory: BTreeMap<String, Vec<&String>> = BTreeMap::new();
329    for relative in &project.files.tests {
330        by_directory
331            .entry(directory_of(relative))
332            .or_default()
333            .push(relative);
334    }
335
336    let mut packages = Vec::new();
337    for (directory, tests) in by_directory {
338        let evidence = evidence_directory.join(evidence_name(&directory));
339        let evidence_literal = evidence.to_string_lossy().replace('\\', "\\\\");
340        let mut declared_in = BTreeMap::new();
341        let mut unattributed = BTreeSet::new();
342        let mut declares_test_main = false;
343        // The harness joins whichever package the tests are in. A directory
344        // can hold both `foo` and `foo_test` files; the internal one wins,
345        // because that is where the instrumented sources are.
346        let mut harness_package: Option<String> = None;
347        for relative in tests {
348            let path = workspace.join(relative);
349            let source = read(&path)?;
350            let Some(name) = package_name(&source) else {
351                continue;
352            };
353            // The package a harness joins is read from the text, so it is
354            // known whether or not the file parses -- and a package whose only
355            // test file is unreadable still needs one.
356            let internal = source_packages.get(&directory) == Some(&name);
357            if internal || harness_package.is_none() {
358                harness_package = Some(name);
359            }
360            // A file the toolchain never builds declares nothing Supercov has
361            // to work around, and reading its `TestMain` as the package's own
362            // stood the harness down for a package that had none -- so the
363            // runtime was never armed and a passing suite published nothing.
364            let built = !build_ignored(&source);
365            let file =
366                match instrument_test_file(&source, RUNTIME_ALIAS, &evidence_literal, serialised) {
367                    Ok(file) => file,
368                    Err(error) => {
369                        // Taking the run down with it is the reflex a source file
370                        // was corrected for, and it is worse here: a test file
371                        // need not even be in the build for Supercov to refuse the
372                        // suite the toolchain just passed. What it costs is the
373                        // attribution of the tests it declares, because there is
374                        // nowhere to put the announcement. What they reach still
375                        // counts run-wide, like any execution no test could claim.
376                        project.manifest.limitations.push(
377                            crate::go_project::unparseable_limitation(relative, &error.to_string()),
378                        );
379                        unparseable_tests.push((relative.clone(), error.to_string()));
380                        // A TestMain nobody could read is still a TestMain, and Go
381                        // permits one per package. Synthesising a second does not
382                        // fail to measure, it fails to build -- so this is scanned
383                        // for rather than parsed for, which is the one question
384                        // that cannot go unanswered.
385                        declares_test_main |= built && declares_test_main_textually(&source);
386                        continue;
387                    }
388                };
389            declares_test_main |= built && file.declares_test_main;
390            for test in &file.tests {
391                declared_in.insert(test.clone(), relative.clone());
392            }
393            unattributed.extend(file.unattributed.iter().cloned());
394            // Written also when nothing in it changed, for its name.
395            write(
396                &path,
397                &named_for_the_project(
398                    project_root,
399                    relative,
400                    &rewrite(&source, &file.edits).replace(RUNTIME_IMPORT, import_for(relative)),
401                ),
402            )?;
403        }
404        let Some(package) = harness_package else {
405            continue;
406        };
407        write(
408            &workspace.join(&directory).join(HARNESS_FILE),
409            &synthesized_harness(
410                &package,
411                RUNTIME_ALIAS,
412                import_for(&format!("{directory}/x.go")),
413                probe_count,
414                &project.decision_widths,
415                &evidence_literal,
416                declares_test_main,
417            ),
418        )?;
419        packages.push(GoTestPackage {
420            directory,
421            evidence,
422            declared_in,
423            unattributed,
424        });
425    }
426    Ok(InstrumentedWorkspace {
427        project,
428        packages,
429        unparseable_tests,
430    })
431}
432
433/// Whether the toolchain will refuse to build this file whatever it is asked.
434///
435/// Build constraints in general cannot be answered without knowing the GOOS,
436/// GOARCH and tags a build will use, and guessing at them would drop files a
437/// run does measure. `ignore` needs none of that: it is not a GOOS, not a
438/// GOARCH, and nothing defines it, which is exactly why it is the convention
439/// for Go kept beside Go without being compiled -- a reference harness, a
440/// generator's input. So it is the one constraint that can be read alone, and
441/// the only one that is.
442fn build_ignored(source: &str) -> bool {
443    // Constraints live above the package clause and nowhere else: the
444    // toolchain stops reading them there, so a `//go:build ignore` in a
445    // comment further down is text about Go rather than a constraint on it.
446    let header = if source.starts_with("package ") {
447        // Nothing precedes a clause on the first line, and looking for a
448        // newline before it would find none and read the whole file.
449        ""
450    } else {
451        source
452            .find("\npackage ")
453            .map_or(source, |clause| &source[..clause])
454    };
455    header
456        .lines()
457        .map(str::trim)
458        .filter_map(|line| {
459            line.strip_prefix("//go:build")
460                // The spelling Go used before 1.17, still in trees today.
461                .or_else(|| line.strip_prefix("// +build"))
462        })
463        .any(|constraint| {
464            constraint
465                .split(|c: char| !(c.is_alphanumeric() || c == '_'))
466                .any(|term| term == "ignore")
467        })
468}
469
470/// Whether a file declares `TestMain`, read from the text rather than a tree.
471///
472/// Only ever asked of a file that did not parse, which is exactly when there
473/// is no tree to ask. It errs towards seeing one: believing in a `TestMain`
474/// that is not there costs the package Supercov's own harness, while missing
475/// one that is there declares it twice and the package stops compiling.
476fn declares_test_main_textually(source: &str) -> bool {
477    source.split("func TestMain").skip(1).any(|rest| {
478        rest.trim_start()
479            .strip_prefix('(')
480            .is_some_and(|arguments| arguments.contains("testing.M"))
481    })
482}
483
484/// `go test` caches a package that passed, and a cached package does not run.
485/// The command with Go's test parallelism turned down to one.
486///
487/// `-parallel` bounds how many tests calling `t.Parallel()` run at once. At 1
488/// they resume one at a time, so each is the only test running when its probes
489/// fire and the harness can announce it like any serial test.
490fn command_run_in_order(command: &[String]) -> (Vec<String>, bool) {
491    if command
492        .iter()
493        .any(|argument| argument == "-parallel" || argument.starts_with("-parallel="))
494    {
495        // The author already said what they wanted; saying it louder is not
496        // Supercov's call.
497        return (command.to_vec(), false);
498    }
499    let mut updated = command.to_vec();
500    let position = updated
501        .iter()
502        .position(|argument| argument == "test")
503        .map_or(updated.len(), |index| index + 1);
504    updated.insert(position, "-parallel=1".into());
505    (updated, true)
506}
507
508fn command_with_fresh_results(command: &[String]) -> (Vec<String>, bool) {
509    if command
510        .iter()
511        .any(|argument| argument == "-count" || argument.starts_with("-count="))
512    {
513        return (command.to_vec(), false);
514    }
515    let mut updated = command.to_vec();
516    // After the subcommand, so `go -count=1 test` is never produced.
517    let position = updated
518        .iter()
519        .position(|argument| argument == "test")
520        .map_or(updated.len(), |index| index + 1);
521    updated.insert(position, "-count=1".into());
522    (updated, true)
523}
524
525/// The fingerprint a later query compares against the stored run.
526pub fn current_go_integrity(
527    root: &Path,
528    command: &[String],
529    source_roots: Option<&[String]>,
530) -> Result<crate::run_store::RunIntegrity, String> {
531    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
532    let files = crate::go_project::discover_go_files(&root)?;
533    let mut inputs = go_integrity_inputs(&files, command);
534    crate::source_discovery::fold_roots_into_configuration(
535        &mut inputs.execution_configuration,
536        source_roots,
537    );
538    create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_go())
539        .map_err(|error| error.to_string())
540}
541
542pub fn run_direct_go(
543    request: &DirectGoRunRequest,
544    diagnostics: &mut dyn Write,
545) -> Result<DirectGoRunResult, String> {
546    if request.command.is_empty() {
547        return Err("test command must not be empty".into());
548    }
549    let total_started = Instant::now();
550    let initialization_started = Instant::now();
551    let root = canonicalize_simplified(&request.root)
552        .map_err(|error| format!("{}: {error}", request.root.display()))?;
553    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
554        .map_err(|error| error.to_string())?;
555    let initialization_ms = elapsed_ms(initialization_started);
556    let work_directory = root.join(".supercov/work").join(&request.run_id);
557    // Whether the tests were measured. A run that failed before that has no
558    // evidence worth keeping; one that failed after has evidence that cost the
559    // suite its wall clock.
560    let measured = std::cell::Cell::new(false);
561    let result = (|| {
562        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
563            .map_err(|error| error.to_string())?;
564        if !recovered_runs.is_empty() {
565            writeln!(
566                diagnostics,
567                "[supercov] recovered abandoned run(s): {}",
568                recovered_runs.join(", ")
569            )
570            .map_err(|error| error.to_string())?;
571        }
572
573        let adapter_started = Instant::now();
574        let files = crate::go_project::discover_go_files(&root)?;
575        let source_roots = crate::source_discovery::configured_source_roots(
576            &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
577        );
578        let mut integrity_inputs = go_integrity_inputs(&files, &request.command);
579        crate::source_discovery::fold_roots_into_configuration(
580            &mut integrity_inputs.execution_configuration,
581            source_roots.as_deref(),
582        );
583        let source_inputs =
584            crate::source_capture::capture(&root, "go", integrity_inputs.assertion_paths())?;
585        let integrity = create_explicit_run_integrity(
586            &root,
587            &integrity_inputs,
588            &FrontendIntegrityInputs::embedded_go(),
589        )
590        .map_err(|error| error.to_string())?;
591
592        let workspace_started = Instant::now();
593        recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
594        let workspace =
595            prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
596        let evidence_directory = work_directory.join("go/evidence");
597        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
598        // Decided before instrumenting, because whether a parallel test can be
599        // announced depends on whether anything will be running beside it.
600        let (command, serialised) = if request.exact_attribution {
601            command_run_in_order(&request.command)
602        } else {
603            (request.command.clone(), false)
604        };
605        let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
606        let roots = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
607            &root, &workspace, &ambient,
608        )
609        .map_err(|error| error.to_string())?;
610        let instrumented = instrument_workspace(
611            &root,
612            &workspace,
613            &evidence_directory,
614            serialised,
615            roots.as_ref(),
616        )?;
617        let workspace_preparation_ms = elapsed_ms(workspace_started);
618        let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
619        writeln!(
620            diagnostics,
621            "[supercov] detected Go; instrumenting {} source file(s) across {} test package(s) in isolated workspace {}",
622            instrumented.project.instrumented.len(),
623            instrumented.packages.len(),
624            workspace.display()
625        )
626        .map_err(|error| error.to_string())?;
627        for (file, reason) in &instrumented.project.unparseable {
628            writeln!(
629                diagnostics,
630                "[supercov] could not parse {file}: {reason}; it carries no obligations"
631            )
632            .map_err(|error| error.to_string())?;
633        }
634        for (file, reason) in &instrumented.unparseable_tests {
635            writeln!(
636                diagnostics,
637                "[supercov] could not parse {file}: {reason}; the tests it declares run unattributed and what they reach counts run-wide"
638            )
639            .map_err(|error| error.to_string())?;
640        }
641        if instrumented.packages.is_empty() {
642            return Err(
643                "no Go test packages were found, so a run would measure nothing: Supercov needs at least one _test.go file"
644                    .into(),
645            );
646        }
647        // A file that does not parse is a hole in the denominator, and one
648        // hole is reported and measured around. Every file being a hole is not
649        // a hole: there is no denominator left, and a run of nothing satisfies
650        // every floor there is. Reporting that as success with exit 0 is the
651        // failure mode most likely to go unnoticed, so it is refused here the
652        // same way a project with no test package is.
653        if instrumented.project.instrumented.is_empty()
654            && !instrumented.project.files.sources.is_empty()
655        {
656            return Err(format!(
657                "none of the {} Go source file(s) could be parsed, so the run would measure a denominator of nothing: {}",
658                instrumented.project.files.sources.len(),
659                instrumented
660                    .project
661                    .unparseable
662                    .iter()
663                    .map(|(file, reason)| format!("{file}: {reason}"))
664                    .collect::<Vec<_>>()
665                    .join("; ")
666            ));
667        }
668
669        if serialised {
670            writeln!(
671                diagnostics,
672                "[supercov] added -parallel=1 for --exact-attribution: a test that calls t.Parallel() can only be credited with what it reached when nothing else is running. Your suite runs in order, which is slower."
673            )
674            .map_err(|error| error.to_string())?;
675        }
676        let (command, forced_fresh) = command_with_fresh_results(&command);
677        if forced_fresh {
678            writeln!(
679                diagnostics,
680                "[supercov] added -count=1: `go test` caches passing packages, and a cached package does not run, so it would record no coverage"
681            )
682            .map_err(|error| error.to_string())?;
683        }
684        let test_started = Instant::now();
685        let plan = ExecutionPlan {
686            preparation: Vec::new(),
687            test: ExecutionPhase {
688                name: "test".into(),
689                kind: PhaseKind::Test,
690                command: CommandSpec {
691                    program: command[0].clone().into(),
692                    arguments: command[1..].iter().map(OsString::from).collect(),
693                    cwd: workspace.clone(),
694                    environment: None,
695                    captured_output: None,
696                    console: Some(crate::workspace::console_names(&root, &workspace)),
697                },
698            },
699        };
700        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
701        // What is in the copy now is Supercov's; what the command adds to
702        // it or changes is the command's, and belongs in the project.
703        let before = crate::workspace::workspace_output_baseline(&workspace)
704            .map_err(|error| error.to_string())?;
705        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
706            .map_err(|error| error.to_string())?;
707        let test_command_ms = elapsed_ms(test_started);
708        // `go test -coverprofile=cover.out` left no `cover.out` in the
709        // project, a golden file a test rewrote stayed in the copy, and a
710        // step after the tests that reads either found the old one or none.
711        // Every Go file in the copy is rewritten and stays there.
712        let rewritten = instrumented
713            .project
714            .files
715            .sources
716            .iter()
717            .chain(&instrumented.project.files.tests)
718            .map(PathBuf::from)
719            .collect::<BTreeSet<_>>();
720        let outputs = crate::workspace::sync_outputs_of(
721            crate::workspace::CommandOutputs::Go,
722            &root,
723            &workspace,
724            &before,
725            &rewritten,
726        )
727        .map_err(|error| error.to_string())?;
728        for line in crate::workspace::describe_command_outputs(&outputs) {
729            writeln!(diagnostics, "{line}").map_err(|error| error.to_string())?;
730        }
731        if let Some(signal) = execution.interrupted_signal {
732            return Err(format!(
733                "the test command was interrupted by {signal:?}; no run was published"
734            ));
735        }
736        let exit_code = execution.exit_code;
737
738        let publication_started = Instant::now();
739        let mut parts = Vec::new();
740        let mut outcomes = Vec::new();
741        let mut silent = Vec::new();
742        for package in &instrumented.packages {
743            let Ok(bytes) = fs::read(&package.evidence) else {
744                silent.push(package.directory.clone());
745                continue;
746            };
747            let evidence = read_evidence(&bytes)
748                .map_err(|error| format!("{}: {error}", package.evidence.display()))?;
749            for test in &evidence.tests {
750                outcomes.push(OwnedTestOutcome {
751                    name: test.name.clone(),
752                    runner: test.runner.clone(),
753                    package: package.directory.clone(),
754                    file: package.declared_in.get(&test.name).cloned(),
755                    status: test.status.clone(),
756                    attributed: !test.unattributed,
757                });
758            }
759            parts.push(evidence);
760        }
761        if !silent.is_empty() {
762            writeln!(
763                diagnostics,
764                "[supercov] {} test package(s) wrote no evidence and are absent from this run: {}",
765                silent.len(),
766                silent.join(", ")
767            )
768            .map_err(|error| error.to_string())?;
769        }
770        let evidence = merge_evidence(parts);
771        let unattributed = instrumented
772            .packages
773            .iter()
774            .map(|package| package.unattributed.len())
775            .sum::<usize>();
776        // What the run reached, whether or not a test can be named for it. A
777        // package whose every test calls `t.Parallel()` announces nothing by
778        // design -- the coverage is real and simply has no owner -- and
779        // discarding the run there threw away the lines, branches and MC/DC of
780        // a fully measured suite. `t.Parallel()` is idiomatic Go: one reported
781        // codebase calls it in 935 of its 1144 test files.
782        let reached_something = evidence.global.iter().any(|total| *total != 0);
783        if outcomes.is_empty() && !reached_something {
784            let because = if unattributed > 0 {
785                format!(
786                    "; {unattributed} test(s) ran without being able to announce themselves (t.Parallel, Example or Fuzz) and reached nothing measured either"
787                )
788            } else {
789                String::new()
790            };
791            return Err(format!(
792                "no Go test recorded evidence (the command exited {exit_code}); a run that measured nothing is not published{because}"
793            ));
794        }
795        measured.set(true);
796        let run = build_frontend_run(OwnedRunInputs {
797            declaration: go_declaration(),
798            environment: "go",
799            manifest: &instrumented.project.manifest,
800            probes: &instrumented.project.probes,
801            evidence: &evidence,
802            outcomes: &outcomes,
803            run_id: &request.run_id,
804            generated_at: &request.started_at,
805            test_exit_code: exit_code,
806            coverage_model: go_coverage_model(),
807        })
808        .map_err(|error| error.to_string())?;
809        // Before anything is written: a run that cannot be read back is not a
810        // run, and finding that out at publication is far better than finding
811        // it out when someone asks for the report.
812        validate_frontend_report_request(&run.declaration, &run.request)
813            .map_err(|error| error.to_string())?;
814        let archive_path = work_directory.join("evidence.raw.gz");
815        let raw = write_archive(
816            crate::source_capture::append(
817                run.archive_entries().map_err(|error| error.to_string())?,
818                &source_inputs,
819            )?,
820            &archive_path,
821        )
822        .map_err(|error| error.to_string())?;
823        let evidence_publication_ms = elapsed_ms(publication_started);
824
825        let timings = RunTimings {
826            initialization_ms,
827            workspace_preparation_ms,
828            adapter_setup_ms,
829            instrumented_build_ms: 0.0,
830            test_command_ms,
831            evidence_publication_ms,
832        };
833        let metadata = RunMetadata {
834            id: request.run_id.clone(),
835            started_at: request.started_at.clone(),
836            duration_ms: elapsed_ms(total_started),
837            command: request.command.clone(),
838            test_exit_code: Some(exit_code),
839            integrity,
840            raw_evidence: RawEvidenceMetadata {
841                schema_version: raw.schema_version,
842                format: raw.format.into(),
843                file: raw.file.into(),
844                files: raw.files,
845                uncompressed_bytes: raw.uncompressed_bytes,
846                compressed_bytes: raw.compressed_bytes,
847            },
848            isolated_build: Some(true),
849            instrumented_build_cache: None,
850            timings: Some(timings),
851            merged: None,
852            parents: None,
853            source_roots: source_roots.clone(),
854            copy: Some(lock.copy()),
855            merged_across: None,
856            recorded_by: None,
857        };
858        let run_directory =
859            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
860        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
861        Ok(DirectGoRunResult {
862            run_id: request.run_id.clone(),
863            run_directory,
864            exit_code,
865            tests: outcomes
866                .iter()
867                .map(|outcome| outcome.name.as_str())
868                .collect::<BTreeSet<_>>()
869                .len(),
870            unattributed,
871            source_files: instrumented.project.instrumented.len(),
872            unparseable_sources: instrumented.project.unparseable.len(),
873            unparseable_tests: instrumented.unparseable_tests.len(),
874            packages: instrumented.packages.len(),
875            recovered_runs,
876            metadata,
877        })
878    })();
879    let result = result.map_err(|error| {
880        if !measured.get() {
881            return error;
882        }
883        note_kept_evidence(
884            &root,
885            &work_directory.join("go/evidence"),
886            &request.run_id,
887            error,
888        )
889    });
890    if result.is_err() {
891        let _ = remove_stored_tree_deferred(&root, &work_directory);
892    }
893    let release = lock.release().map_err(|error| error.to_string());
894    match (result, release) {
895        (Ok(result), Ok(())) => Ok(result),
896        (Err(error), _) => Err(error),
897        (Ok(_), Err(error)) => Err(error),
898    }
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904
905    #[test]
906    fn the_package_clause_is_found_past_whatever_precedes_it() {
907        // A build tag, a licence header, a block comment: all of them sit
908        // above the clause in real Go, and a reader that took the first line
909        // would find none of them.
910        assert_eq!(package_name("package main\n").as_deref(), Some("main"));
911        assert_eq!(
912            package_name("//go:build linux\n\n// Copyright.\npackage worker\n").as_deref(),
913            Some("worker")
914        );
915        assert_eq!(
916            package_name("/*\nA package comment\nspanning lines.\n*/\npackage doc\n").as_deref(),
917            Some("doc")
918        );
919        assert_eq!(
920            package_name("/* one line */ package inline\n").as_deref(),
921            Some("inline")
922        );
923        // A trailing comment is not part of the name.
924        assert_eq!(
925            package_name("package api // the public surface\n").as_deref(),
926            Some("api")
927        );
928        assert_eq!(package_name("import \"fmt\"\n"), None);
929    }
930
931    #[test]
932    fn caching_is_disabled_unless_the_author_already_chose() {
933        // `go test` skips a package whose result it has cached, and a package
934        // that does not run records nothing. A run that measured half a suite
935        // without saying so would be worse than a slow one.
936        let (command, forced) =
937            command_with_fresh_results(&["go".into(), "test".into(), "./...".into()]);
938        assert!(forced);
939        assert_eq!(command, ["go", "test", "-count=1", "./..."]);
940
941        // An author who already said how many times to run means it.
942        let (command, forced) =
943            command_with_fresh_results(&["go".into(), "test".into(), "-count=3".into()]);
944        assert!(!forced);
945        assert_eq!(command, ["go", "test", "-count=3"]);
946    }
947
948    #[test]
949    fn each_package_gets_an_evidence_file_of_its_own() {
950        // One test binary per package, each its own process: sharing a path
951        // would mean the last one to finish overwrote everyone else.
952        assert_ne!(evidence_name("internal/auth"), evidence_name("internal/db"));
953        assert_eq!(evidence_name("."), "_.bin");
954        assert!(!evidence_name("internal/auth").contains('/'));
955    }
956
957    #[test]
958    fn only_the_constraint_nothing_ever_defines_is_read_alone() {
959        // `ignore` is not a GOOS, not a GOARCH, and nothing sets it, which is
960        // why it is the convention for Go kept beside Go without being
961        // compiled. Every other constraint needs a GOOS, a GOARCH and a tag
962        // set to answer, and guessing at those would drop files a run does
963        // measure.
964        assert!(build_ignored("//go:build ignore\n\npackage p\n"));
965        assert!(build_ignored("// +build ignore\n\npackage p\n"));
966        assert!(build_ignored(
967            "// Copyright.\n\n//go:build ignore\n\npackage p\n"
968        ));
969        // Constraints combine, and one of the terms is still the term.
970        assert!(build_ignored("//go:build ignore || linux\n\npackage p\n"));
971
972        // Everything else is a question this cannot answer, so it does not.
973        assert!(!build_ignored("//go:build linux\n\npackage p\n"));
974        assert!(!build_ignored("//go:build !windows\n\npackage p\n"));
975        assert!(!build_ignored("package p\n"));
976        // A tag that merely starts with the word is a different tag.
977        assert!(!build_ignored("//go:build ignored\n\npackage p\n"));
978        assert!(!build_ignored("//go:build ignore_me\n\npackage p\n"));
979        // And a constraint has to be above the package clause to be one. The
980        // toolchain ignores the text below it, so reading it there would drop
981        // a file the build takes.
982        assert!(!build_ignored(
983            "package p\n\n//go:build ignore\n\nfunc f() {}\n"
984        ));
985        assert!(!build_ignored(
986            "package p\n\nvar doc = \"//go:build ignore\"\n"
987        ));
988    }
989
990    #[test]
991    fn a_test_main_is_seen_in_a_file_nothing_could_parse() {
992        // Only ever asked of a file that did not parse, which is exactly when
993        // there is no tree to ask. Missing a real one declares a second
994        // TestMain and the package stops compiling, so this errs towards
995        // seeing one -- but not so far that any mention of the word counts.
996        assert!(declares_test_main_textually(
997            "package p\n\nfunc TestMain(m *testing.M) {\n\tos.Exit(m.Run())\n}\n"
998        ));
999        // Spacing is the author's business.
1000        assert!(declares_test_main_textually(
1001            "package p\n\nfunc TestMain( m  *testing.M ) {}\n"
1002        ));
1003        assert!(declares_test_main_textually(
1004            "package p\n\nfunc TestMain(m *testing.M) { broken( }\n"
1005        ));
1006
1007        // `TestMainHelper` is an ordinary function whose name starts with the
1008        // word, exactly as `Testify` is not a test.
1009        assert!(!declares_test_main_textually(
1010            "package p\n\nfunc TestMainHelper(t *testing.T) {}\n"
1011        ));
1012        // A test that merely takes a *testing.T is not the package's harness.
1013        assert!(!declares_test_main_textually(
1014            "package p\n\nfunc TestMain(t *testing.T) {}\n"
1015        ));
1016        assert!(!declares_test_main_textually(
1017            "package p\n\n// func TestMain\n"
1018        ));
1019        assert!(!declares_test_main_textually("package p\n"));
1020    }
1021}