Skip to main content

supercov_engine/
integrity.rs

1//! Language-neutral run integrity fingerprints.
2//!
3//! A frontend contributes only its transformation/runtime shim identity. The
4//! Rust engine owns source, test, dependency, configuration and execution
5//! fingerprints for every language.
6
7use std::{
8    collections::BTreeSet,
9    fs,
10    io::{self, Read},
11    path::{Path, PathBuf},
12    process::Command,
13};
14
15use sha2::{Digest, Sha256};
16
17use crate::{
18    project_discovery::CoverageProject,
19    run_store::{GitIntegrity, RunFingerprint, RunIntegrity},
20};
21
22pub const RUN_INTEGRITY_SCHEMA_VERSION: u32 = 2;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct FrontendIntegrityInputs {
26    pub language: String,
27    pub version: String,
28    pub root: PathBuf,
29    pub instrumenter_files: Vec<PathBuf>,
30    pub execution_files: Vec<PathBuf>,
31    pub engine_instrumenter_sha256: String,
32    pub engine_execution_sha256: String,
33}
34
35impl FrontendIntegrityInputs {
36    pub fn javascript(root: PathBuf, runtime_files: Vec<PathBuf>) -> Self {
37        Self {
38            language: "javascript".into(),
39            version: "javascript-v1".into(),
40            root,
41            instrumenter_files: runtime_files.clone(),
42            execution_files: runtime_files,
43            engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
44            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
45        }
46    }
47
48    pub fn embedded_javascript() -> Self {
49        Self {
50            language: "javascript".into(),
51            version: "javascript-v1".into(),
52            root: PathBuf::from("."),
53            instrumenter_files: Vec::new(),
54            execution_files: Vec::new(),
55            engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
56            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
57        }
58    }
59
60    pub fn embedded_rust() -> Self {
61        Self {
62            language: "rust".into(),
63            version: "rust-owned-v1".into(),
64            root: PathBuf::from("."),
65            instrumenter_files: Vec::new(),
66            execution_files: Vec::new(),
67            engine_instrumenter_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
68            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
69        }
70    }
71
72    pub fn embedded_python() -> Self {
73        Self {
74            language: "python".into(),
75            version: crate::python_evidence::PYTHON_FRONTEND_VERSION.into(),
76            root: PathBuf::from("."),
77            instrumenter_files: Vec::new(),
78            execution_files: Vec::new(),
79            engine_instrumenter_sha256: env!("SUPERCOV_PYTHON_FRONTEND_SOURCE_SHA256").into(),
80            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
81        }
82    }
83
84    pub fn embedded_go() -> Self {
85        Self {
86            language: "go".into(),
87            version: "go-owned-v1".into(),
88            root: PathBuf::from("."),
89            instrumenter_files: Vec::new(),
90            execution_files: Vec::new(),
91            engine_instrumenter_sha256: env!("SUPERCOV_GO_FRONTEND_SOURCE_SHA256").into(),
92            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
93        }
94    }
95
96    pub fn embedded_jvm() -> Self {
97        Self {
98            language: "jvm".into(),
99            version: "jvm-owned-v1".into(),
100            root: PathBuf::from("."),
101            instrumenter_files: Vec::new(),
102            execution_files: Vec::new(),
103            engine_instrumenter_sha256: env!("SUPERCOV_JVM_FRONTEND_SOURCE_SHA256").into(),
104            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
105        }
106    }
107
108    pub fn embedded_ruby() -> Self {
109        Self {
110            language: "ruby".into(),
111            version: "ruby-coverage-v1".into(),
112            root: PathBuf::from("."),
113            instrumenter_files: Vec::new(),
114            execution_files: Vec::new(),
115            engine_instrumenter_sha256: env!("SUPERCOV_RUBY_FRONTEND_SOURCE_SHA256").into(),
116            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
117        }
118    }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct ExplicitIntegrityInputs {
123    pub source_files: Vec<PathBuf>,
124    pub test_files: Vec<PathBuf>,
125    pub dependency_files: Vec<PathBuf>,
126    pub configuration_files: Vec<PathBuf>,
127    pub execution_configuration: Vec<u8>,
128}
129
130impl ExplicitIntegrityInputs {
131    pub(crate) fn assertion_paths(&self) -> Vec<PathBuf> {
132        self.source_files
133            .iter()
134            .chain(&self.test_files)
135            .chain(&self.dependency_files)
136            .chain(&self.configuration_files)
137            .cloned()
138            .collect()
139    }
140}
141
142pub(crate) fn javascript_assertion_paths(
143    root: &Path,
144    project: &CoverageProject,
145) -> Result<Vec<PathBuf>, IntegrityError> {
146    let mut paths = test_files(root)?;
147    paths.extend(dependency_files(root)?);
148    paths.extend(configuration_files(root, project)?);
149    paths.extend(crate::typescript_imports::config_paths(
150        root,
151        &project.source_files,
152    ));
153    paths.extend(project.source_files.iter().map(|p| root.join(p)));
154    paths.extend(
155        project
156            .source_scope
157            .entries
158            .iter()
159            .filter(|e| !e.is_generated_output())
160            .map(|e| root.join(&e.file)),
161    );
162    Ok(paths)
163}
164
165#[derive(Debug)]
166pub enum IntegrityError {
167    Io { path: PathBuf, source: io::Error },
168    UnsafeFile(PathBuf),
169    NonUtf8Path(PathBuf),
170    OutsideRoot { root: PathBuf, path: PathBuf },
171    InvalidEngineDigest(&'static str),
172}
173
174impl std::fmt::Display for IntegrityError {
175    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        match self {
177            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
178            Self::UnsafeFile(path) => {
179                write!(
180                    formatter,
181                    "fingerprint input is not a regular file: {}",
182                    path.display()
183                )
184            }
185            Self::NonUtf8Path(path) => {
186                write!(
187                    formatter,
188                    "fingerprint path is not valid UTF-8: {}",
189                    path.display()
190                )
191            }
192            Self::OutsideRoot { root, path } => write!(
193                formatter,
194                "fingerprint input {} is outside {}",
195                path.display(),
196                root.display()
197            ),
198            Self::InvalidEngineDigest(field) => write!(formatter, "invalid {field} SHA-256"),
199        }
200    }
201}
202
203impl std::error::Error for IntegrityError {}
204
205fn io_error(path: &Path, source: io::Error) -> IntegrityError {
206    IntegrityError::Io {
207        path: path.to_owned(),
208        source,
209    }
210}
211
212fn valid_sha256(value: &str) -> bool {
213    value.len() == 64
214        && value
215            .bytes()
216            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
217}
218
219fn local_path(root: &Path, path: &Path) -> Result<String, IntegrityError> {
220    let path = path
221        .strip_prefix(root)
222        .map_err(|_| IntegrityError::OutsideRoot {
223            root: root.to_owned(),
224            path: path.to_owned(),
225        })?;
226    path.components()
227        .map(|component| {
228            component
229                .as_os_str()
230                .to_str()
231                .map(str::to_owned)
232                .ok_or_else(|| IntegrityError::NonUtf8Path(path.to_owned()))
233        })
234        .collect::<Result<Vec<_>, _>>()
235        .map(|parts| parts.join("/"))
236}
237
238/// Fields that name the project rather than its dependencies. None of them can
239/// change how installed code behaves, so none of them belong in a fingerprint
240/// whose only job is to say whether the execution context moved.
241const RELEASE_METADATA: &[&str] = &[
242    "author",
243    "authors",
244    "bugs",
245    "categories",
246    "classifiers",
247    "contributors",
248    "description",
249    "documentation",
250    "funding",
251    "homepage",
252    "keywords",
253    "license",
254    "license-file",
255    "maintainers",
256    "man",
257    "readme",
258    "repository",
259    "urls",
260    "version",
261];
262
263#[derive(Clone, Copy, PartialEq, Eq)]
264enum ManifestKind {
265    PackageJson,
266    PackageLock,
267    CargoToml,
268    PyprojectToml,
269}
270
271fn manifest_kind(path: &Path) -> Option<ManifestKind> {
272    match path.file_name()?.to_str()? {
273        "package.json" => Some(ManifestKind::PackageJson),
274        "package-lock.json" | "npm-shrinkwrap.json" => Some(ManifestKind::PackageLock),
275        "Cargo.toml" => Some(ManifestKind::CargoToml),
276        "pyproject.toml" => Some(ManifestKind::PyprojectToml),
277        _ => None,
278    }
279}
280
281/// The part of a manifest that decides behaviour, in a canonical encoding.
282///
283/// Only release metadata is dropped, and only from the tables that describe
284/// this project. Every other field survives, including one a future npm or
285/// cargo invents, so an unrecognised key is conservative by default. A lockfile
286/// keeps every dependency's version and loses only the project's own, which it
287/// mirrors from `package.json`.
288fn behavioural_manifest(kind: ManifestKind, raw: &[u8]) -> Option<Vec<u8>> {
289    let mut value: serde_json::Value = match kind {
290        ManifestKind::PackageJson | ManifestKind::PackageLock => {
291            serde_json::from_slice(raw).ok()?
292        }
293        ManifestKind::CargoToml | ManifestKind::PyprojectToml => {
294            let text = std::str::from_utf8(raw).ok()?;
295            serde_json::to_value(toml::from_str::<toml::Value>(text).ok()?).ok()?
296        }
297    };
298    match kind {
299        ManifestKind::PackageJson => strip_metadata(&mut value, &[]),
300        // A lockfile repeats this project's own version at its root and again
301        // in `packages[""]`. Every other entry is a real dependency whose
302        // version must still be hashed.
303        ManifestKind::PackageLock => {
304            strip_metadata(&mut value, &[]);
305            strip_metadata(&mut value, &["packages", ""]);
306        }
307        ManifestKind::CargoToml => {
308            strip_metadata(&mut value, &["package"]);
309            strip_metadata(&mut value, &["workspace", "package"]);
310        }
311        ManifestKind::PyprojectToml => {
312            strip_metadata(&mut value, &["project"]);
313            strip_metadata(&mut value, &["tool", "poetry"]);
314        }
315    }
316    let mut bytes = Vec::new();
317    canonical(&value, &mut bytes);
318    Some(bytes)
319}
320
321fn strip_metadata(value: &mut serde_json::Value, path: &[&str]) {
322    let mut table = value;
323    for key in path {
324        match table.get_mut(*key) {
325            Some(next) => table = next,
326            None => return,
327        }
328    }
329    let Some(table) = table.as_object_mut() else {
330        return;
331    };
332    for key in RELEASE_METADATA {
333        table.remove(*key);
334    }
335}
336
337/// A length-prefixed, key-sorted encoding, so the digest does not move when a
338/// formatter reorders keys or rewrites whitespace.
339fn canonical(value: &serde_json::Value, out: &mut Vec<u8>) {
340    match value {
341        serde_json::Value::Null => out.push(0),
342        serde_json::Value::Bool(flag) => out.extend([1, u8::from(*flag)]),
343        serde_json::Value::Number(number) => tagged(out, 2, number.to_string().as_bytes()),
344        serde_json::Value::String(text) => tagged(out, 3, text.as_bytes()),
345        serde_json::Value::Array(items) => {
346            tagged(out, 4, &(items.len() as u64).to_le_bytes());
347            for item in items {
348                canonical(item, out);
349            }
350        }
351        serde_json::Value::Object(table) => {
352            let mut keys = table.keys().collect::<Vec<_>>();
353            keys.sort();
354            tagged(out, 5, &(keys.len() as u64).to_le_bytes());
355            for key in keys {
356                tagged(out, 6, key.as_bytes());
357                canonical(&table[key], out);
358            }
359        }
360    }
361}
362
363fn tagged(out: &mut Vec<u8>, tag: u8, bytes: &[u8]) {
364    out.push(tag);
365    out.extend((bytes.len() as u64).to_le_bytes());
366    out.extend(bytes);
367}
368
369fn digest_files(
370    root: &Path,
371    paths: impl IntoIterator<Item = PathBuf>,
372) -> Result<String, IntegrityError> {
373    digest_paths(root, paths, false)
374}
375
376/// Digest a set of files the way this module digests any set of files.
377///
378/// This is not a run's source fingerprint and cannot be compared with one. That
379/// fingerprint keys the instrumented build cache, so it deliberately covers
380/// every file the frontend may rewrite and reuse, which is wider than any
381/// caller here is describing.
382///
383/// Exported so a caller outside a run gets the same construction rather than a
384/// second one: two copies of a hash agree until one of them is changed.
385pub fn digest_source_files(
386    root: &Path,
387    paths: impl IntoIterator<Item = PathBuf>,
388) -> Result<String, IntegrityError> {
389    digest_files(root, paths)
390}
391
392/// Dependency manifests, hashed by what they say rather than by their bytes.
393///
394/// A manifest carries two unrelated things: what the project depends on, and
395/// how the project describes itself. Hashing both means every release
396/// invalidates every claim in the map, because a manifest is where the version
397/// number lives. Across supergateway's last sixty commits thirty percent
398/// touched a manifest, and seven of those eighteen changed nothing but a
399/// version string.
400fn digest_manifests(
401    root: &Path,
402    paths: impl IntoIterator<Item = PathBuf>,
403) -> Result<String, IntegrityError> {
404    digest_paths(root, paths, true)
405}
406
407fn digest_paths(
408    root: &Path,
409    paths: impl IntoIterator<Item = PathBuf>,
410    manifests: bool,
411) -> Result<String, IntegrityError> {
412    let paths = paths.into_iter().collect::<BTreeSet<_>>();
413    let mut labeled = paths
414        .into_iter()
415        .map(|path| local_path(root, &path).map(|label| (label, path)))
416        .collect::<Result<Vec<_>, _>>()?;
417    labeled.sort_by(|left, right| left.0.cmp(&right.0));
418    let mut hash = Sha256::new();
419    let mut buffer = [0_u8; 128 * 1024];
420    for (label, path) in labeled {
421        let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
422        if !metadata.file_type().is_file() {
423            return Err(IntegrityError::UnsafeFile(path));
424        }
425        hash.update(label.as_bytes());
426        hash.update([0]);
427        // A manifest we can parse is hashed by meaning; anything else, and any
428        // manifest we fail to parse, is hashed whole. Falling back to the bytes
429        // keeps an unfamiliar or malformed file conservative.
430        let meaning = manifests
431            .then(|| manifest_kind(&path))
432            .flatten()
433            .and_then(|kind| behavioural_manifest(kind, &fs::read(&path).ok()?));
434        if let Some(bytes) = meaning {
435            hash.update(&bytes);
436        } else {
437            let mut file = fs::File::open(&path).map_err(|source| io_error(&path, source))?;
438            loop {
439                let read = file
440                    .read(&mut buffer)
441                    .map_err(|source| io_error(&path, source))?;
442                if read == 0 {
443                    break;
444                }
445                hash.update(&buffer[..read]);
446            }
447        }
448        hash.update([0]);
449    }
450    Ok(format!("{:x}", hash.finalize()))
451}
452
453fn domain_hash(domain: &str, fields: &[(&str, &[u8])]) -> String {
454    let mut hash = Sha256::new();
455    hash.update(domain.as_bytes());
456    hash.update([0]);
457    for (name, value) in fields {
458        hash.update((*name).len().to_le_bytes());
459        hash.update(name.as_bytes());
460        hash.update(value.len().to_le_bytes());
461        hash.update(value);
462    }
463    format!("{:x}", hash.finalize())
464}
465
466fn source_file(path: &Path) -> bool {
467    let lower = path
468        .file_name()
469        .and_then(|name| name.to_str())
470        .unwrap_or("")
471        .to_ascii_lowercase();
472    [
473        ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
474        ".mtsx",
475    ]
476    .iter()
477    .any(|extension| lower.ends_with(extension))
478}
479
480fn skipped_directory(name: &str) -> bool {
481    [
482        ".cache",
483        ".git",
484        ".mcdc-pool",
485        ".next",
486        ".nuxt",
487        ".output",
488        ".supercov",
489        "build",
490        "coverage",
491        "dist",
492        "node_modules",
493        "out",
494        "playwright-report",
495        "results",
496        "test-results",
497        "vendor",
498    ]
499    .contains(&name)
500}
501
502fn owned_workspace_store(path: &Path) -> bool {
503    crate::workspace::owned_workspace_path(path)
504}
505
506fn walk_files(
507    directory: &Path,
508    predicate: &impl Fn(&Path) -> bool,
509    output: &mut Vec<PathBuf>,
510) -> Result<(), IntegrityError> {
511    let metadata = match fs::symlink_metadata(directory) {
512        Ok(metadata) => metadata,
513        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
514        Err(source) => return Err(io_error(directory, source)),
515    };
516    if !metadata.file_type().is_dir() {
517        return Err(IntegrityError::UnsafeFile(directory.to_owned()));
518    }
519    let mut entries = fs::read_dir(directory)
520        .map_err(|source| io_error(directory, source))?
521        .collect::<Result<Vec<_>, _>>()
522        .map_err(|source| io_error(directory, source))?;
523    entries.sort_by_key(fs::DirEntry::file_name);
524    for entry in entries {
525        let path = entry.path();
526        let file_type = entry
527            .file_type()
528            .map_err(|source| io_error(&path, source))?;
529        if file_type.is_symlink() {
530            continue;
531        }
532        if file_type.is_dir() {
533            let name = entry.file_name();
534            if !name
535                .to_str()
536                .is_some_and(|name| name.starts_with('.') || skipped_directory(name))
537                && !path.join(".git").exists()
538                && !owned_workspace_store(&path)
539            {
540                walk_files(&path, predicate, output)?;
541            }
542        } else if file_type.is_file() && predicate(&path) {
543            output.push(path);
544        }
545    }
546    Ok(())
547}
548
549fn test_file(root: &Path, path: &Path) -> bool {
550    if !source_file(path) {
551        return false;
552    }
553    let local = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
554    local
555        .to_ascii_lowercase()
556        .split(['/', '\\', '_', '.', '-'])
557        .any(|part| matches!(part, "test" | "spec"))
558}
559
560fn test_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
561    let mut files = Vec::new();
562    for directory in ["test", "tests", "__tests__"] {
563        walk_files(&root.join(directory), &source_file, &mut files)?;
564    }
565    walk_files(root, &|path| test_file(root, path), &mut files)?;
566    files.sort();
567    files.dedup();
568    Ok(files)
569}
570
571fn dependency_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
572    let mut files = Vec::new();
573    walk_files(
574        root,
575        &|path| path.file_name().is_some_and(|name| name == "package.json"),
576        &mut files,
577    )?;
578    for name in [
579        "package-lock.json",
580        "npm-shrinkwrap.json",
581        "pnpm-lock.yaml",
582        "yarn.lock",
583        "bun.lock",
584        "bun.lockb",
585    ] {
586        let path = root.join(name);
587        if path.is_file() {
588            files.push(path);
589        }
590    }
591    files.sort();
592    files.dedup();
593    Ok(files)
594}
595
596fn configuration_file(path: &Path) -> bool {
597    let name = path
598        .file_name()
599        .and_then(|name| name.to_str())
600        .unwrap_or("")
601        .to_ascii_lowercase();
602    if formatting_only(&name) {
603        return false;
604    }
605    name == ".npmrc"
606        || (name.starts_with("tsconfig") && name.ends_with(".json"))
607        || name.contains(".config.")
608        || name.starts_with(".babelrc.")
609}
610
611/// Linters and formatters do not change what the code does when it runs, so
612/// editing their settings is not a change of execution context. Counting it as
613/// one meant a Prettier tweak invalidated every flow in the map. Transpiler
614/// configuration is a different matter and stays: Babel and tsconfig decide
615/// what actually executes.
616fn formatting_only(name: &str) -> bool {
617    let stem = name.strip_prefix('.').unwrap_or(name);
618    stem.starts_with("eslint") || stem.starts_with("prettier")
619}
620
621/// Files whose content already reaches the run fingerprint, for any language
622/// Supercov supports.
623///
624/// When one of these changes the whole map is marked dirty, so a flow that also
625/// names one in `watch` buys nothing. Worse, it teaches the author a model of
626/// the tool that is not true: that per-flow watching is what catches dependency
627/// drift.
628const TRACKED_MANIFESTS: &[&str] = &[
629    "Cargo.lock",
630    "Cargo.toml",
631    "Gemfile",
632    "Gemfile.lock",
633    "Pipfile",
634    "Pipfile.lock",
635    "bun.lock",
636    "bun.lockb",
637    "npm-shrinkwrap.json",
638    "package-lock.json",
639    "package.json",
640    "pdm.lock",
641    "pnpm-lock.yaml",
642    "poetry.lock",
643    "pyproject.toml",
644    "setup.cfg",
645    "setup.py",
646    "uv.lock",
647    "yarn.lock",
648    ".ruby-version",
649    ".tool-versions",
650];
651
652/// A dependency manifest or lockfile, for any language Supercov supports.
653///
654/// These already have a dedicated signal: the run's dependency fingerprint,
655/// which reads what a manifest declares rather than its bytes. Reporting their
656/// raw bytes a second time would say a release changed something when it
657/// changed nothing.
658pub fn tracked_manifest(path: &str) -> bool {
659    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
660    TRACKED_MANIFESTS.contains(&name)
661        || name.ends_with(".gemspec")
662        || (name.starts_with("requirements") && name.ends_with(".txt"))
663}
664
665pub fn globally_tracked(path: &str) -> bool {
666    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
667    tracked_manifest(name) || configuration_file(Path::new(name))
668}
669
670fn configuration_files(
671    root: &Path,
672    project: &CoverageProject,
673) -> Result<Vec<PathBuf>, IntegrityError> {
674    let mut files = Vec::new();
675    walk_files(root, &configuration_file, &mut files)?;
676    files.extend(
677        [
678            project.playwright_config.as_ref(),
679            project.vitest_config.as_ref(),
680            project.jest_config.as_ref(),
681        ]
682        .into_iter()
683        .flatten()
684        .cloned(),
685    );
686    files.sort();
687    files.dedup();
688    Ok(files)
689}
690
691fn git_integrity(root: &Path) -> Option<GitIntegrity> {
692    let revision = Command::new("git")
693        .args(["rev-parse", "HEAD"])
694        .current_dir(root)
695        .output()
696        .ok();
697    let status = Command::new("git")
698        .args(["status", "--porcelain=v1"])
699        .current_dir(root)
700        .output()
701        .ok();
702    if !revision
703        .as_ref()
704        .is_some_and(|output| output.status.success())
705        && !status
706            .as_ref()
707            .is_some_and(|output| output.status.success())
708    {
709        return None;
710    }
711    Some(GitIntegrity {
712        branch: Command::new("git")
713            .args(["symbolic-ref", "--quiet", "--short", "HEAD"])
714            .current_dir(root)
715            .output()
716            .ok()
717            .filter(|output| output.status.success())
718            .and_then(|output| String::from_utf8(output.stdout).ok())
719            .map(|branch| branch.trim().to_owned())
720            .filter(|branch| !branch.is_empty()),
721        revision: revision
722            .filter(|output| output.status.success())
723            .and_then(|output| String::from_utf8(output.stdout).ok())
724            .map(|revision| revision.trim().to_owned()),
725        dirty: !status
726            .as_ref()
727            .is_some_and(|output| output.status.success() && output.stdout.is_empty()),
728    })
729}
730
731pub fn create_run_integrity(
732    root: &Path,
733    project: &CoverageProject,
734    frontend: &FrontendIntegrityInputs,
735) -> Result<RunIntegrity, IntegrityError> {
736    if !valid_sha256(&frontend.engine_instrumenter_sha256) {
737        return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
738    }
739    if !valid_sha256(&frontend.engine_execution_sha256) {
740        return Err(IntegrityError::InvalidEngineDigest("execution engine"));
741    }
742    let tests = test_files(root)?;
743    let dependencies = dependency_files(root)?;
744    let configuration = configuration_files(root, project)?;
745    // Scope entries outside the instrumented set still execute in the run,
746    // and ones that carry assertions or capability imports are rewritten and
747    // cached. Everything the frontend may cache must feed the fingerprint,
748    // or an edit to such a file would be overwritten by a stale cached copy.
749    // Entries that another domain already digests stay out of the source
750    // domain so each stale reason keeps naming exactly one kind of change.
751    //
752    // Generated outputs stay out too. A theme extension's hashed bundles are
753    // rebuilt by the wrapped command and synced back into the project, with a
754    // new name every build, so digesting them marked every run stale with
755    // "instrumented source changed" the moment it finished -- while nothing
756    // instrumented had changed at all.
757    let covered_elsewhere = tests
758        .iter()
759        .chain(dependencies.iter())
760        .chain(configuration.iter())
761        .collect::<std::collections::BTreeSet<_>>();
762    let source_paths = project
763        .source_files
764        .iter()
765        .map(|path| root.join(path))
766        .chain(
767            project
768                .source_scope
769                .entries
770                .iter()
771                .filter(|entry| !entry.is_generated_output())
772                .map(|entry| root.join(&entry.file))
773                .filter(|path| !covered_elsewhere.contains(path)),
774        )
775        .collect::<Vec<_>>();
776    let source = digest_files(root, source_paths)?;
777    let tests_digest = digest_files(root, tests.iter().cloned())?;
778    let dependency_digest = digest_manifests(root, dependencies)?;
779    let configuration_digest = digest_files(
780        root,
781        configuration
782            .into_iter()
783            .chain(crate::typescript_imports::config_paths(
784                root,
785                &project.source_files,
786            )),
787    )?;
788    let frontend_instrumenter =
789        digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
790    let frontend_execution =
791        digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
792    let instrumenter = domain_hash(
793        "supercov-run-instrumenter-v1",
794        &[
795            ("language", frontend.language.as_bytes()),
796            ("version", frontend.version.as_bytes()),
797            ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
798            ("shim", frontend_instrumenter.as_bytes()),
799            // The runtime shim decides what evidence looks like, so it is part
800            // of who instrumented the run rather than of the run's setup.
801            (
802                "executionEngine",
803                frontend.engine_execution_sha256.as_bytes(),
804            ),
805            ("executionShim", frontend_execution.as_bytes()),
806        ],
807    );
808    let build_environment = frontend_map_bytes(&project.build_environment);
809    // `execution` describes the run's setup, not who instrumented it. Supercov's
810    // own source used to be folded in here as well, so upgrading Supercov made
811    // every stored run stale for a checkout that had not changed. Its identity
812    // still lives in `instrumenter`, which the build caches and run merging
813    // consult directly.
814    let execution = domain_hash(
815        "supercov-run-execution-v1",
816        &[
817            ("language", frontend.language.as_bytes()),
818            ("version", frontend.version.as_bytes()),
819            ("source", source.as_bytes()),
820            ("dependencies", dependency_digest.as_bytes()),
821            ("configuration", configuration_digest.as_bytes()),
822            ("buildEnvironment", &build_environment),
823        ],
824    );
825    let combined = domain_hash(
826        "supercov-run-combined-v1",
827        &[
828            ("language", frontend.language.as_bytes()),
829            ("version", frontend.version.as_bytes()),
830            ("source", source.as_bytes()),
831            ("tests", tests_digest.as_bytes()),
832            ("dependencies", dependency_digest.as_bytes()),
833            ("configuration", configuration_digest.as_bytes()),
834            ("instrumenter", instrumenter.as_bytes()),
835        ],
836    );
837    Ok(RunIntegrity {
838        schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
839        instrumenter_version: frontend.version.clone(),
840        git: git_integrity(root),
841        fingerprint: RunFingerprint {
842            algorithm: "sha256".into(),
843            source,
844            tests: tests_digest,
845            dependencies: dependency_digest,
846            configuration: configuration_digest,
847            instrumenter,
848            execution,
849            combined,
850            source_files: project.source_files.len(),
851            test_files: tests.len(),
852        },
853        stale: None,
854        stale_reasons: None,
855    })
856}
857
858/// Language-neutral integrity construction for frontends whose discovery does
859/// not use the JavaScript `CoverageProject` compatibility structure.
860pub fn create_explicit_run_integrity(
861    root: &Path,
862    inputs: &ExplicitIntegrityInputs,
863    frontend: &FrontendIntegrityInputs,
864) -> Result<RunIntegrity, IntegrityError> {
865    if !valid_sha256(&frontend.engine_instrumenter_sha256) {
866        return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
867    }
868    if !valid_sha256(&frontend.engine_execution_sha256) {
869        return Err(IntegrityError::InvalidEngineDigest("execution engine"));
870    }
871    let source = digest_files(root, inputs.source_files.iter().map(|path| root.join(path)))?;
872    let tests = digest_files(root, inputs.test_files.iter().map(|path| root.join(path)))?;
873    let dependencies = digest_manifests(
874        root,
875        inputs.dependency_files.iter().map(|path| root.join(path)),
876    )?;
877    let configuration = digest_files(
878        root,
879        inputs
880            .configuration_files
881            .iter()
882            .map(|path| root.join(path)),
883    )?;
884    let frontend_instrumenter =
885        digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
886    let frontend_execution =
887        digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
888    let instrumenter = domain_hash(
889        "supercov-run-instrumenter-v1",
890        &[
891            ("language", frontend.language.as_bytes()),
892            ("version", frontend.version.as_bytes()),
893            ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
894            ("shim", frontend_instrumenter.as_bytes()),
895            // The runtime shim decides what evidence looks like, so it is part
896            // of who instrumented the run rather than of the run's setup.
897            (
898                "executionEngine",
899                frontend.engine_execution_sha256.as_bytes(),
900            ),
901            ("executionShim", frontend_execution.as_bytes()),
902        ],
903    );
904    // `execution` describes the run's setup, not who instrumented it. Supercov's
905    // own source used to be folded in here as well, so upgrading Supercov made
906    // every stored run stale for a checkout that had not changed. Its identity
907    // still lives in `instrumenter`, which the build caches and run merging
908    // consult directly.
909    let execution = domain_hash(
910        "supercov-run-execution-v1",
911        &[
912            ("language", frontend.language.as_bytes()),
913            ("version", frontend.version.as_bytes()),
914            ("source", source.as_bytes()),
915            ("dependencies", dependencies.as_bytes()),
916            ("configuration", configuration.as_bytes()),
917            ("executionConfiguration", &inputs.execution_configuration),
918        ],
919    );
920    let combined = domain_hash(
921        "supercov-run-combined-v1",
922        &[
923            ("language", frontend.language.as_bytes()),
924            ("version", frontend.version.as_bytes()),
925            ("source", source.as_bytes()),
926            ("tests", tests.as_bytes()),
927            ("dependencies", dependencies.as_bytes()),
928            ("configuration", configuration.as_bytes()),
929            ("instrumenter", instrumenter.as_bytes()),
930            ("execution", execution.as_bytes()),
931        ],
932    );
933    Ok(RunIntegrity {
934        schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
935        instrumenter_version: format!("supercov-{}-{}", frontend.language, frontend.version),
936        git: git_integrity(root),
937        fingerprint: RunFingerprint {
938            // The frozen store contract names the digest primitive here. The
939            // domain-separation version belongs to the producer implementation,
940            // not this wire field.
941            algorithm: "sha256".into(),
942            source,
943            tests,
944            dependencies,
945            configuration,
946            instrumenter,
947            execution,
948            combined,
949            source_files: inputs.source_files.len(),
950            test_files: inputs.test_files.len(),
951        },
952        stale: None,
953        stale_reasons: None,
954    })
955}
956
957fn frontend_map_bytes(values: &std::collections::BTreeMap<String, String>) -> Vec<u8> {
958    let mut bytes = Vec::new();
959    for (key, value) in values {
960        bytes.extend_from_slice(&key.len().to_le_bytes());
961        bytes.extend_from_slice(key.as_bytes());
962        bytes.extend_from_slice(&value.len().to_le_bytes());
963        bytes.extend_from_slice(value.as_bytes());
964    }
965    bytes
966}
967
968#[cfg(test)]
969mod tests {
970    use std::{
971        collections::BTreeMap,
972        fs,
973        sync::atomic::{AtomicU64, Ordering},
974        time::{SystemTime, UNIX_EPOCH},
975    };
976
977    use crate::{project_discovery::discover_coverage_project, run_store::compare_run_integrity};
978
979    use super::*;
980
981    static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
982
983    fn directory(label: &str) -> PathBuf {
984        let nonce = SystemTime::now()
985            .duration_since(UNIX_EPOCH)
986            .unwrap()
987            .as_nanos();
988        let root = std::env::temp_dir().join(format!(
989            "supercov-integrity-{label}-{}-{nonce}-{}",
990            std::process::id(),
991            TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed)
992        ));
993        fs::create_dir_all(&root).unwrap();
994        root
995    }
996
997    #[test]
998    fn git_context_records_branch_and_local_changes_without_guessing_detached_heads() {
999        let root = directory("git-context");
1000        assert!(git_integrity(&root).is_none());
1001        let git = |args: &[&str]| {
1002            let output = Command::new("git")
1003                .args(args)
1004                .current_dir(&root)
1005                .output()
1006                .unwrap();
1007            assert!(
1008                output.status.success(),
1009                "{}",
1010                String::from_utf8_lossy(&output.stderr)
1011            );
1012        };
1013        git(&["init", "--initial-branch=report-test"]);
1014        git(&[
1015            "-c",
1016            "user.name=Report Test",
1017            "-c",
1018            "user.email=report@example.invalid",
1019            "commit",
1020            "--allow-empty",
1021            "-m",
1022            "fixture",
1023        ]);
1024        let clean = git_integrity(&root).unwrap();
1025        assert_eq!(clean.branch.as_deref(), Some("report-test"));
1026        assert!(!clean.dirty);
1027        fs::write(root.join("local.txt"), "uncommitted").unwrap();
1028        let dirty = git_integrity(&root).unwrap();
1029        assert!(dirty.dirty);
1030        assert_eq!(dirty.revision, clean.revision);
1031        git(&["checkout", "--detach"]);
1032        let detached = git_integrity(&root).unwrap();
1033        assert!(detached.branch.is_none());
1034        assert_eq!(detached.revision, clean.revision);
1035        // Older stored runs remain readable without a branch field.
1036        let old: GitIntegrity = serde_json::from_value(serde_json::json!({
1037            "revision": clean.revision, "dirty": false
1038        }))
1039        .unwrap();
1040        assert!(old.branch.is_none());
1041        fs::remove_dir_all(root).unwrap();
1042    }
1043
1044    const PACKAGE: &str = r#"{"name":"g","version":"1.0.0","dependencies":{"a":"^1.2.3"}}"#;
1045    const LOCK: &str = r#"{"name":"g","version":"1.0.0","packages":{"":{"name":"g","version":"1.0.0"},"node_modules/a":{"version":"1.2.3"}}}"#;
1046    const CARGO: &str =
1047        "[package]\nname = \"g\"\nversion = \"1.0.0\"\n\n[dependencies]\na = \"1.2.3\"\n";
1048    const PYPROJECT: &str =
1049        "[project]\nname = \"g\"\nversion = \"1.0.0\"\ndependencies = [\"a==1.2.3\"]\n";
1050
1051    #[test]
1052    fn a_release_bump_leaves_a_manifest_digest_alone() {
1053        // A version number says what the project calls itself, not what it
1054        // depends on, and it lives in the same file as the dependencies. Hashing
1055        // it meant every release invalidated every claim in the map.
1056        for (kind, before, after) in [
1057            (
1058                ManifestKind::PackageJson,
1059                PACKAGE.to_owned(),
1060                PACKAGE.replace("1.0.0", "2.0.0"),
1061            ),
1062            (
1063                ManifestKind::PackageLock,
1064                LOCK.to_owned(),
1065                LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
1066            ),
1067            (
1068                ManifestKind::CargoToml,
1069                CARGO.to_owned(),
1070                CARGO.replace("1.0.0", "2.0.0"),
1071            ),
1072            (
1073                ManifestKind::PyprojectToml,
1074                PYPROJECT.to_owned(),
1075                PYPROJECT.replace("1.0.0", "2.0.0"),
1076            ),
1077        ] {
1078            let stable = behavioural_manifest(kind, before.as_bytes());
1079            assert!(stable.is_some());
1080            assert_eq!(stable, behavioural_manifest(kind, after.as_bytes()));
1081        }
1082    }
1083
1084    #[test]
1085    fn a_dependency_change_still_moves_a_manifest_digest() {
1086        // Dropping metadata must not drop the signal. A lockfile keeps every
1087        // dependency's version and loses only the project's own.
1088        for (kind, before, after) in [
1089            (
1090                ManifestKind::PackageJson,
1091                PACKAGE.to_owned(),
1092                PACKAGE.replace("^1.2.3", "^2.0.0"),
1093            ),
1094            (
1095                ManifestKind::PackageLock,
1096                LOCK.to_owned(),
1097                LOCK.replace(
1098                    "\"node_modules/a\":{\"version\":\"1.2.3\"}",
1099                    "\"node_modules/a\":{\"version\":\"9.9.9\"}",
1100                ),
1101            ),
1102            (
1103                ManifestKind::CargoToml,
1104                CARGO.to_owned(),
1105                CARGO.replace("a = \"1.2.3\"", "a = \"9.9.9\""),
1106            ),
1107            (
1108                ManifestKind::PyprojectToml,
1109                PYPROJECT.to_owned(),
1110                PYPROJECT.replace("a==1.2.3", "a==9.9.9"),
1111            ),
1112        ] {
1113            assert_ne!(
1114                behavioural_manifest(kind, before.as_bytes()),
1115                behavioural_manifest(kind, after.as_bytes())
1116            );
1117        }
1118    }
1119
1120    #[test]
1121    fn the_dependency_fingerprint_survives_a_release_but_not_an_upgrade() {
1122        // The whole point, at the seam the fingerprint actually uses: cutting a
1123        // release must cost nothing, and changing a dependency must still cost
1124        // a recheck.
1125        let root = directory("manifest-fingerprint");
1126        let paths = || [root.join("package.json"), root.join("package-lock.json")];
1127        write(&root, "package.json", PACKAGE);
1128        write(&root, "package-lock.json", LOCK);
1129        let before = digest_manifests(&root, paths()).unwrap();
1130
1131        write(&root, "package.json", &PACKAGE.replace("1.0.0", "2.0.0"));
1132        write(
1133            &root,
1134            "package-lock.json",
1135            &LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
1136        );
1137        assert_eq!(
1138            before,
1139            digest_manifests(&root, paths()).unwrap(),
1140            "a release must not move the dependency fingerprint"
1141        );
1142
1143        write(&root, "package.json", &PACKAGE.replace("^1.2.3", "^2.0.0"));
1144        assert_ne!(
1145            before,
1146            digest_manifests(&root, paths()).unwrap(),
1147            "an upgrade must still move it"
1148        );
1149        fs::remove_dir_all(root).unwrap();
1150    }
1151
1152    #[test]
1153    fn reformatting_a_manifest_leaves_its_digest_alone() {
1154        // Key order and whitespace are not meaning, and a formatter rewriting
1155        // either should not cost the author a re-acknowledgement.
1156        let reordered = r#"{"dependencies":{"a":"^1.2.3"},  "version":"1.0.0",
1157            "name":"g"}"#;
1158        assert_eq!(
1159            behavioural_manifest(ManifestKind::PackageJson, PACKAGE.as_bytes()),
1160            behavioural_manifest(ManifestKind::PackageJson, reordered.as_bytes())
1161        );
1162    }
1163
1164    #[test]
1165    fn an_unreadable_manifest_falls_back_to_its_bytes() {
1166        // A file we cannot parse is hashed whole, so a format we do not
1167        // understand stays conservative instead of silently hashing nothing.
1168        assert!(behavioural_manifest(ManifestKind::PackageJson, b"{ not json").is_none());
1169        assert!(behavioural_manifest(ManifestKind::CargoToml, b"[[[").is_none());
1170        let root = directory("manifest-fallback");
1171        write(&root, "package.json", "{ not json");
1172        let first = digest_manifests(&root, [root.join("package.json")]).unwrap();
1173        write(&root, "package.json", "{ still not json");
1174        assert_ne!(
1175            first,
1176            digest_manifests(&root, [root.join("package.json")]).unwrap()
1177        );
1178        fs::remove_dir_all(root).unwrap();
1179    }
1180
1181    #[test]
1182    fn linter_and_formatter_settings_are_not_execution_context() {
1183        // Neither tool changes what runs, so neither belongs in a fingerprint
1184        // that answers whether the execution context moved. Transpiler config
1185        // is a different matter and stays.
1186        for inert in [
1187            ".prettierrc",
1188            ".prettierrc.json",
1189            "prettier.config.js",
1190            ".eslintrc",
1191            ".eslintrc.json",
1192            "eslint.config.mjs",
1193        ] {
1194            assert!(!configuration_file(Path::new(inert)), "{inert}");
1195        }
1196        for real in ["tsconfig.json", ".babelrc.js", "vite.config.ts", ".npmrc"] {
1197            assert!(configuration_file(Path::new(real)), "{real}");
1198        }
1199    }
1200
1201    #[test]
1202    fn globally_tracked_names_what_the_fingerprint_already_covers() {
1203        for tracked in [
1204            "package-lock.json",
1205            "package.json",
1206            "Cargo.toml",
1207            "Gemfile.lock",
1208            "requirements-dev.txt",
1209            "supercov.gemspec",
1210            "tsconfig.json",
1211            "nested/pyproject.toml",
1212        ] {
1213            assert!(globally_tracked(tracked), "{tracked}");
1214        }
1215        for own in [
1216            "src/index.ts",
1217            "tests/helpers/gateway-process.ts",
1218            "README.md",
1219        ] {
1220            assert!(!globally_tracked(own), "{own}");
1221        }
1222    }
1223
1224    fn write(root: &Path, path: &str, contents: &str) {
1225        let path = root.join(path);
1226        fs::create_dir_all(path.parent().unwrap()).unwrap();
1227        fs::write(path, contents).unwrap();
1228    }
1229
1230    fn frontend(root: &Path) -> FrontendIntegrityInputs {
1231        FrontendIntegrityInputs {
1232            language: "javascript".into(),
1233            version: "javascript-v1".into(),
1234            root: root.to_owned(),
1235            instrumenter_files: vec![root.join("instrumenter.js")],
1236            execution_files: vec![root.join("runtime.mjs")],
1237            engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
1238            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
1239        }
1240    }
1241
1242    fn fixture() -> (PathBuf, PathBuf) {
1243        let root = directory("project");
1244        let shim = directory("shim");
1245        write(
1246            &root,
1247            "package.json",
1248            r#"{"scripts":{"build":"vite build","test":"node --test"}}"#,
1249        );
1250        write(&root, "package-lock.json", "lock");
1251        write(&root, "src/index.ts", "export const ready = true");
1252        write(&root, "tests/index.test.ts", "test('ready', () => {})");
1253        write(&root, "vite.config.ts", "export default {}");
1254        write(&root, ".cache/test262/fake.test.js", "ignored");
1255        write(
1256            &root,
1257            "supercov/.supercov-workspace-store",
1258            "Supercov instrumented workspace. Safe to delete.\n",
1259        );
1260        write(
1261            &root,
1262            "supercov/workspace/copy/tests/copied.test.ts",
1263            "ignored copied test",
1264        );
1265        write(&shim, "instrumenter.js", "instrument");
1266        write(&shim, "runtime.mjs", "runtime");
1267        (root, shim)
1268    }
1269
1270    fn integrity(root: &Path, shim: &Path, environment: &BTreeMap<String, String>) -> RunIntegrity {
1271        let project = discover_coverage_project(root, environment, &[]).unwrap();
1272        create_run_integrity(root, &project, &frontend(shim)).unwrap()
1273    }
1274
1275    #[test]
1276    fn built_assets_the_command_regenerates_do_not_move_the_source_fingerprint() {
1277        // A theme extension's Vite build lands hashed bundles in `assets/`
1278        // and the run syncs them back into the project. They are excluded
1279        // from instrumentation, so a rebuild must not read as a source change.
1280        let (root, shim) = fixture();
1281        write(
1282            &root,
1283            "package.json",
1284            r#"{"workspaces":["app_extensions/*"],"scripts":{"test":"node --test"}}"#,
1285        );
1286        write(&root, "app_extensions/upsells/package.json", "{}");
1287        write(&root, "app_extensions/upsells/frontend/embed.ts", "source");
1288        write(
1289            &root,
1290            "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1291            "bundle one",
1292        );
1293        let first = integrity(&root, &shim, &BTreeMap::new());
1294
1295        fs::remove_file(root.join("app_extensions/upsells/assets/app-embed-Be-aUw9g.js")).unwrap();
1296        write(
1297            &root,
1298            "app_extensions/upsells/assets/app-embed-CygpnWPQ.js",
1299            "bundle two",
1300        );
1301        let rebuilt = integrity(&root, &shim, &BTreeMap::new());
1302        assert_eq!(rebuilt.fingerprint.source, first.fingerprint.source);
1303        assert!(!compare_run_integrity(Some(&first), &rebuilt).stale);
1304
1305        write(
1306            &root,
1307            "app_extensions/upsells/frontend/embed.ts",
1308            "edited source",
1309        );
1310        let edited = integrity(&root, &shim, &BTreeMap::new());
1311        assert_ne!(edited.fingerprint.source, first.fingerprint.source);
1312        fs::remove_dir_all(root).unwrap();
1313        fs::remove_dir_all(shim).unwrap();
1314    }
1315
1316    #[test]
1317    fn fingerprints_every_independent_input_domain_deterministically() {
1318        let (root, shim) = fixture();
1319        let first = integrity(&root, &shim, &BTreeMap::new());
1320        let second = integrity(&root, &shim, &BTreeMap::new());
1321        assert_eq!(first, second);
1322        assert_eq!(first.fingerprint.source_files, 1);
1323        assert_eq!(first.fingerprint.test_files, 1);
1324        for digest in [
1325            &first.fingerprint.source,
1326            &first.fingerprint.tests,
1327            &first.fingerprint.dependencies,
1328            &first.fingerprint.configuration,
1329            &first.fingerprint.instrumenter,
1330            &first.fingerprint.execution,
1331            &first.fingerprint.combined,
1332        ] {
1333            assert!(valid_sha256(digest));
1334        }
1335
1336        write(&root, "src/index.ts", "export const ready = false");
1337        let source = integrity(&root, &shim, &BTreeMap::new());
1338        assert_ne!(source.fingerprint.source, first.fingerprint.source);
1339        assert_eq!(source.fingerprint.tests, first.fingerprint.tests);
1340        assert_ne!(source.fingerprint.execution, first.fingerprint.execution);
1341
1342        write(&root, "src/index.ts", "export const ready = true");
1343        write(&root, "tests/index.test.ts", "test('changed', () => {})");
1344        let tests = integrity(&root, &shim, &BTreeMap::new());
1345        assert_eq!(tests.fingerprint.source, first.fingerprint.source);
1346        assert_ne!(tests.fingerprint.tests, first.fingerprint.tests);
1347        assert_eq!(tests.fingerprint.execution, first.fingerprint.execution);
1348
1349        write(&root, "tests/index.test.ts", "test('ready', () => {})");
1350        write(&root, "package-lock.json", "changed lock");
1351        let dependencies = integrity(&root, &shim, &BTreeMap::new());
1352        assert_ne!(
1353            dependencies.fingerprint.dependencies,
1354            first.fingerprint.dependencies
1355        );
1356        assert_ne!(
1357            dependencies.fingerprint.execution,
1358            first.fingerprint.execution
1359        );
1360
1361        write(&root, "package-lock.json", "lock");
1362        write(&root, "vite.config.ts", "export default { changed: true }");
1363        let configuration = integrity(&root, &shim, &BTreeMap::new());
1364        assert_ne!(
1365            configuration.fingerprint.configuration,
1366            first.fingerprint.configuration
1367        );
1368
1369        write(&root, "vite.config.ts", "export default {}");
1370        write(&shim, "instrumenter.js", "changed instrumenter");
1371        let instrumenter = integrity(&root, &shim, &BTreeMap::new());
1372        assert_ne!(
1373            instrumenter.fingerprint.instrumenter,
1374            first.fingerprint.instrumenter
1375        );
1376        assert_ne!(
1377            instrumenter.fingerprint.combined,
1378            first.fingerprint.combined
1379        );
1380        fs::remove_dir_all(root).unwrap();
1381        fs::remove_dir_all(shim).unwrap();
1382    }
1383
1384    #[test]
1385    fn assertion_inputs_ignore_tool_worktrees_and_nested_repositories() {
1386        let (root, shim) = fixture();
1387        write(&root, "packages/ui/package.json", r#"{"name":"ui"}"#);
1388        write(
1389            &root,
1390            "packages/ui/tests/ui.test.ts",
1391            "import assert from 'node:assert/strict'; assert.equal(1, 1);",
1392        );
1393        let before = integrity(&root, &shim, &BTreeMap::new());
1394        for base in [".claude/worktrees/other", "nested-fork"] {
1395            write(
1396                &root,
1397                &format!("{base}/.git"),
1398                "gitdir: /unrelated/repository",
1399            );
1400            write(
1401                &root,
1402                &format!("{base}/tests/other.test.ts"),
1403                "assert.equal(2, 2);",
1404            );
1405            write(&root, &format!("{base}/package.json"), "{}");
1406            write(&root, &format!("{base}/tsconfig.json"), "{}");
1407        }
1408        let after = integrity(&root, &shim, &BTreeMap::new());
1409        assert_eq!(before.fingerprint, after.fingerprint);
1410        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1411        let paths = javascript_assertion_paths(&root, &project).unwrap();
1412        let inputs = crate::assertion_inputs::capture(&root, "javascript", paths).unwrap();
1413        assert!(inputs.files.contains_key("packages/ui/tests/ui.test.ts"));
1414        assert!(
1415            !inputs
1416                .files
1417                .keys()
1418                .any(|p| p.starts_with(".claude/") || p.starts_with("nested-fork/"))
1419        );
1420        fs::remove_dir_all(root).unwrap();
1421        fs::remove_dir_all(shim).unwrap();
1422    }
1423
1424    #[test]
1425    fn a_new_supercov_moves_its_own_identity_and_nothing_else() {
1426        // An upgrade must still stop a merge and bust the build caches, because
1427        // evidence from two different instrumenters is not comparable. It must
1428        // not touch the run's setup, which is what decides whether a stored run
1429        // still matches the checkout.
1430        let (root, shim) = fixture();
1431        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1432        let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1433        let mut newer = frontend(&shim);
1434        newer.engine_instrumenter_sha256 = "b".repeat(64);
1435        newer.engine_execution_sha256 = "c".repeat(64);
1436        let upgraded = create_run_integrity(&root, &project, &newer).unwrap();
1437
1438        assert_ne!(
1439            baseline.fingerprint.instrumenter, upgraded.fingerprint.instrumenter,
1440            "a merge and the build caches still have to see this"
1441        );
1442        assert_eq!(
1443            baseline.fingerprint.execution, upgraded.fingerprint.execution,
1444            "the run's setup did not change"
1445        );
1446        assert!(
1447            !compare_run_integrity(Some(&baseline), &upgraded).stale,
1448            "upgrading Supercov must not discard a recorded run"
1449        );
1450        fs::remove_dir_all(root).unwrap();
1451        fs::remove_dir_all(shim).unwrap();
1452    }
1453
1454    #[test]
1455    fn fingerprints_nested_workspace_manifests_and_execution_environment() {
1456        let (root, shim) = fixture();
1457        write(
1458            &root,
1459            "packages/ui/package.json",
1460            r#"{"dependencies":{"react":"1"}}"#,
1461        );
1462        write(&root, "packages/ui/src/index.ts", "export const ui = true");
1463        let first = integrity(&root, &shim, &BTreeMap::new());
1464        write(
1465            &root,
1466            "packages/ui/package.json",
1467            r#"{"dependencies":{"react":"2"}}"#,
1468        );
1469        let dependency = integrity(&root, &shim, &BTreeMap::new());
1470        assert_ne!(
1471            first.fingerprint.dependencies,
1472            dependency.fingerprint.dependencies
1473        );
1474
1475        let mut environment = BTreeMap::new();
1476        environment.insert("SUPERCOV_SOURCE_ROOTS".into(), "src,packages/ui/src".into());
1477        let project = discover_coverage_project(&root, &environment, &[]).unwrap();
1478        let mut project_with_build_environment = project.clone();
1479        project_with_build_environment
1480            .build_environment
1481            .insert("MODE".into(), "test".into());
1482        let changed =
1483            create_run_integrity(&root, &project_with_build_environment, &frontend(&shim)).unwrap();
1484        let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1485        assert_ne!(
1486            baseline.fingerprint.execution,
1487            changed.fingerprint.execution
1488        );
1489        assert_eq!(baseline.fingerprint.combined, changed.fingerprint.combined);
1490        assert_eq!(
1491            compare_run_integrity(Some(&baseline), &changed).reasons,
1492            ["execution environment changed"]
1493        );
1494        fs::remove_dir_all(root).unwrap();
1495        fs::remove_dir_all(shim).unwrap();
1496    }
1497
1498    #[test]
1499    fn explicit_language_integrity_uses_the_frozen_store_digest_label() {
1500        let root = directory("rust-project");
1501        write(&root, "src/lib.rs", "pub fn ready() -> bool { true }");
1502        write(
1503            &root,
1504            "Cargo.toml",
1505            "[package]\nname='fixture'\nversion='0.0.0'\n",
1506        );
1507        let inputs = ExplicitIntegrityInputs {
1508            source_files: vec!["src/lib.rs".into()],
1509            test_files: vec!["src/lib.rs".into()],
1510            dependency_files: vec!["Cargo.toml".into()],
1511            configuration_files: Vec::new(),
1512            execution_configuration: b"cargo\0test".to_vec(),
1513        };
1514        let result = create_explicit_run_integrity(
1515            &root,
1516            &inputs,
1517            &FrontendIntegrityInputs::embedded_rust(),
1518        )
1519        .unwrap();
1520        assert_eq!(result.fingerprint.algorithm, "sha256");
1521        fs::remove_dir_all(root).unwrap();
1522    }
1523
1524    #[cfg(unix)]
1525    #[test]
1526    fn rejects_linked_frontend_identity_files() {
1527        use std::os::unix::fs::symlink;
1528
1529        let (root, shim) = fixture();
1530        let outside = shim.join("outside.js");
1531        fs::write(&outside, "outside").unwrap();
1532        fs::remove_file(shim.join("instrumenter.js")).unwrap();
1533        symlink(&outside, shim.join("instrumenter.js")).unwrap();
1534        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1535        assert!(matches!(
1536            create_run_integrity(&root, &project, &frontend(&shim)),
1537            Err(IntegrityError::UnsafeFile(_))
1538        ));
1539        fs::remove_dir_all(root).unwrap();
1540        fs::remove_dir_all(shim).unwrap();
1541    }
1542}