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    // `execution` describes the run's setup, not who instrumented it. Supercov's
809    // own source used to be folded in here as well, so upgrading Supercov made
810    // every stored run stale for a checkout that had not changed. Its identity
811    // still lives in `instrumenter`, which the build caches and run merging
812    // consult directly.
813    let execution = domain_hash(
814        "supercov-run-execution-v1",
815        &[
816            ("language", frontend.language.as_bytes()),
817            ("version", frontend.version.as_bytes()),
818            ("source", source.as_bytes()),
819            ("dependencies", dependency_digest.as_bytes()),
820            ("configuration", configuration_digest.as_bytes()),
821            // Supercov once inferred settings for a build it ran. The field
822            // stays in the hash, empty, so a run stored before still compares
823            // equal to the same checkout now.
824            ("buildEnvironment", &[]),
825        ],
826    );
827    let combined = domain_hash(
828        "supercov-run-combined-v1",
829        &[
830            ("language", frontend.language.as_bytes()),
831            ("version", frontend.version.as_bytes()),
832            ("source", source.as_bytes()),
833            ("tests", tests_digest.as_bytes()),
834            ("dependencies", dependency_digest.as_bytes()),
835            ("configuration", configuration_digest.as_bytes()),
836            ("instrumenter", instrumenter.as_bytes()),
837        ],
838    );
839    Ok(RunIntegrity {
840        schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
841        instrumenter_version: frontend.version.clone(),
842        git: git_integrity(root),
843        fingerprint: RunFingerprint {
844            algorithm: "sha256".into(),
845            source,
846            tests: tests_digest,
847            dependencies: dependency_digest,
848            configuration: configuration_digest,
849            instrumenter,
850            execution,
851            combined,
852            source_files: project.source_files.len(),
853            test_files: tests.len(),
854        },
855        stale: None,
856        stale_reasons: None,
857    })
858}
859
860/// Language-neutral integrity construction for frontends whose discovery does
861/// not use the JavaScript `CoverageProject` compatibility structure.
862pub fn create_explicit_run_integrity(
863    root: &Path,
864    inputs: &ExplicitIntegrityInputs,
865    frontend: &FrontendIntegrityInputs,
866) -> Result<RunIntegrity, IntegrityError> {
867    if !valid_sha256(&frontend.engine_instrumenter_sha256) {
868        return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
869    }
870    if !valid_sha256(&frontend.engine_execution_sha256) {
871        return Err(IntegrityError::InvalidEngineDigest("execution engine"));
872    }
873    let source = digest_files(root, inputs.source_files.iter().map(|path| root.join(path)))?;
874    let tests = digest_files(root, inputs.test_files.iter().map(|path| root.join(path)))?;
875    let dependencies = digest_manifests(
876        root,
877        inputs.dependency_files.iter().map(|path| root.join(path)),
878    )?;
879    let configuration = digest_files(
880        root,
881        inputs
882            .configuration_files
883            .iter()
884            .map(|path| root.join(path)),
885    )?;
886    let frontend_instrumenter =
887        digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
888    let frontend_execution =
889        digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
890    let instrumenter = domain_hash(
891        "supercov-run-instrumenter-v1",
892        &[
893            ("language", frontend.language.as_bytes()),
894            ("version", frontend.version.as_bytes()),
895            ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
896            ("shim", frontend_instrumenter.as_bytes()),
897            // The runtime shim decides what evidence looks like, so it is part
898            // of who instrumented the run rather than of the run's setup.
899            (
900                "executionEngine",
901                frontend.engine_execution_sha256.as_bytes(),
902            ),
903            ("executionShim", frontend_execution.as_bytes()),
904        ],
905    );
906    // `execution` describes the run's setup, not who instrumented it. Supercov's
907    // own source used to be folded in here as well, so upgrading Supercov made
908    // every stored run stale for a checkout that had not changed. Its identity
909    // still lives in `instrumenter`, which the build caches and run merging
910    // consult directly.
911    let execution = domain_hash(
912        "supercov-run-execution-v1",
913        &[
914            ("language", frontend.language.as_bytes()),
915            ("version", frontend.version.as_bytes()),
916            ("source", source.as_bytes()),
917            ("dependencies", dependencies.as_bytes()),
918            ("configuration", configuration.as_bytes()),
919            ("executionConfiguration", &inputs.execution_configuration),
920        ],
921    );
922    let combined = domain_hash(
923        "supercov-run-combined-v1",
924        &[
925            ("language", frontend.language.as_bytes()),
926            ("version", frontend.version.as_bytes()),
927            ("source", source.as_bytes()),
928            ("tests", tests.as_bytes()),
929            ("dependencies", dependencies.as_bytes()),
930            ("configuration", configuration.as_bytes()),
931            ("instrumenter", instrumenter.as_bytes()),
932            ("execution", execution.as_bytes()),
933        ],
934    );
935    Ok(RunIntegrity {
936        schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
937        instrumenter_version: format!("supercov-{}-{}", frontend.language, frontend.version),
938        git: git_integrity(root),
939        fingerprint: RunFingerprint {
940            // The frozen store contract names the digest primitive here. The
941            // domain-separation version belongs to the producer implementation,
942            // not this wire field.
943            algorithm: "sha256".into(),
944            source,
945            tests,
946            dependencies,
947            configuration,
948            instrumenter,
949            execution,
950            combined,
951            source_files: inputs.source_files.len(),
952            test_files: inputs.test_files.len(),
953        },
954        stale: None,
955        stale_reasons: None,
956    })
957}
958
959#[cfg(test)]
960mod tests {
961    use std::{
962        collections::BTreeMap,
963        fs,
964        sync::atomic::{AtomicU64, Ordering},
965        time::{SystemTime, UNIX_EPOCH},
966    };
967
968    use crate::{project_discovery::discover_coverage_project, run_store::compare_run_integrity};
969
970    use super::*;
971
972    static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
973
974    fn directory(label: &str) -> PathBuf {
975        let nonce = SystemTime::now()
976            .duration_since(UNIX_EPOCH)
977            .unwrap()
978            .as_nanos();
979        let root = std::env::temp_dir().join(format!(
980            "supercov-integrity-{label}-{}-{nonce}-{}",
981            std::process::id(),
982            TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed)
983        ));
984        fs::create_dir_all(&root).unwrap();
985        root
986    }
987
988    #[test]
989    fn git_context_records_branch_and_local_changes_without_guessing_detached_heads() {
990        let root = directory("git-context");
991        assert!(git_integrity(&root).is_none());
992        let git = |args: &[&str]| {
993            let output = Command::new("git")
994                .args(args)
995                .current_dir(&root)
996                .output()
997                .unwrap();
998            assert!(
999                output.status.success(),
1000                "{}",
1001                String::from_utf8_lossy(&output.stderr)
1002            );
1003        };
1004        git(&["init", "--initial-branch=report-test"]);
1005        git(&[
1006            "-c",
1007            "user.name=Report Test",
1008            "-c",
1009            "user.email=report@example.invalid",
1010            "commit",
1011            "--allow-empty",
1012            "-m",
1013            "fixture",
1014        ]);
1015        let clean = git_integrity(&root).unwrap();
1016        assert_eq!(clean.branch.as_deref(), Some("report-test"));
1017        assert!(!clean.dirty);
1018        fs::write(root.join("local.txt"), "uncommitted").unwrap();
1019        let dirty = git_integrity(&root).unwrap();
1020        assert!(dirty.dirty);
1021        assert_eq!(dirty.revision, clean.revision);
1022        git(&["checkout", "--detach"]);
1023        let detached = git_integrity(&root).unwrap();
1024        assert!(detached.branch.is_none());
1025        assert_eq!(detached.revision, clean.revision);
1026        // Older stored runs remain readable without a branch field.
1027        let old: GitIntegrity = serde_json::from_value(serde_json::json!({
1028            "revision": clean.revision, "dirty": false
1029        }))
1030        .unwrap();
1031        assert!(old.branch.is_none());
1032        fs::remove_dir_all(root).unwrap();
1033    }
1034
1035    const PACKAGE: &str = r#"{"name":"g","version":"1.0.0","dependencies":{"a":"^1.2.3"}}"#;
1036    const LOCK: &str = r#"{"name":"g","version":"1.0.0","packages":{"":{"name":"g","version":"1.0.0"},"node_modules/a":{"version":"1.2.3"}}}"#;
1037    const CARGO: &str =
1038        "[package]\nname = \"g\"\nversion = \"1.0.0\"\n\n[dependencies]\na = \"1.2.3\"\n";
1039    const PYPROJECT: &str =
1040        "[project]\nname = \"g\"\nversion = \"1.0.0\"\ndependencies = [\"a==1.2.3\"]\n";
1041
1042    #[test]
1043    fn a_release_bump_leaves_a_manifest_digest_alone() {
1044        // A version number says what the project calls itself, not what it
1045        // depends on, and it lives in the same file as the dependencies. Hashing
1046        // it meant every release invalidated every claim in the map.
1047        for (kind, before, after) in [
1048            (
1049                ManifestKind::PackageJson,
1050                PACKAGE.to_owned(),
1051                PACKAGE.replace("1.0.0", "2.0.0"),
1052            ),
1053            (
1054                ManifestKind::PackageLock,
1055                LOCK.to_owned(),
1056                LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
1057            ),
1058            (
1059                ManifestKind::CargoToml,
1060                CARGO.to_owned(),
1061                CARGO.replace("1.0.0", "2.0.0"),
1062            ),
1063            (
1064                ManifestKind::PyprojectToml,
1065                PYPROJECT.to_owned(),
1066                PYPROJECT.replace("1.0.0", "2.0.0"),
1067            ),
1068        ] {
1069            let stable = behavioural_manifest(kind, before.as_bytes());
1070            assert!(stable.is_some());
1071            assert_eq!(stable, behavioural_manifest(kind, after.as_bytes()));
1072        }
1073    }
1074
1075    #[test]
1076    fn a_dependency_change_still_moves_a_manifest_digest() {
1077        // Dropping metadata must not drop the signal. A lockfile keeps every
1078        // dependency's version and loses only the project's own.
1079        for (kind, before, after) in [
1080            (
1081                ManifestKind::PackageJson,
1082                PACKAGE.to_owned(),
1083                PACKAGE.replace("^1.2.3", "^2.0.0"),
1084            ),
1085            (
1086                ManifestKind::PackageLock,
1087                LOCK.to_owned(),
1088                LOCK.replace(
1089                    "\"node_modules/a\":{\"version\":\"1.2.3\"}",
1090                    "\"node_modules/a\":{\"version\":\"9.9.9\"}",
1091                ),
1092            ),
1093            (
1094                ManifestKind::CargoToml,
1095                CARGO.to_owned(),
1096                CARGO.replace("a = \"1.2.3\"", "a = \"9.9.9\""),
1097            ),
1098            (
1099                ManifestKind::PyprojectToml,
1100                PYPROJECT.to_owned(),
1101                PYPROJECT.replace("a==1.2.3", "a==9.9.9"),
1102            ),
1103        ] {
1104            assert_ne!(
1105                behavioural_manifest(kind, before.as_bytes()),
1106                behavioural_manifest(kind, after.as_bytes())
1107            );
1108        }
1109    }
1110
1111    #[test]
1112    fn the_dependency_fingerprint_survives_a_release_but_not_an_upgrade() {
1113        // The whole point, at the seam the fingerprint actually uses: cutting a
1114        // release must cost nothing, and changing a dependency must still cost
1115        // a recheck.
1116        let root = directory("manifest-fingerprint");
1117        let paths = || [root.join("package.json"), root.join("package-lock.json")];
1118        write(&root, "package.json", PACKAGE);
1119        write(&root, "package-lock.json", LOCK);
1120        let before = digest_manifests(&root, paths()).unwrap();
1121
1122        write(&root, "package.json", &PACKAGE.replace("1.0.0", "2.0.0"));
1123        write(
1124            &root,
1125            "package-lock.json",
1126            &LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
1127        );
1128        assert_eq!(
1129            before,
1130            digest_manifests(&root, paths()).unwrap(),
1131            "a release must not move the dependency fingerprint"
1132        );
1133
1134        write(&root, "package.json", &PACKAGE.replace("^1.2.3", "^2.0.0"));
1135        assert_ne!(
1136            before,
1137            digest_manifests(&root, paths()).unwrap(),
1138            "an upgrade must still move it"
1139        );
1140        fs::remove_dir_all(root).unwrap();
1141    }
1142
1143    #[test]
1144    fn reformatting_a_manifest_leaves_its_digest_alone() {
1145        // Key order and whitespace are not meaning, and a formatter rewriting
1146        // either should not cost the author a re-acknowledgement.
1147        let reordered = r#"{"dependencies":{"a":"^1.2.3"},  "version":"1.0.0",
1148            "name":"g"}"#;
1149        assert_eq!(
1150            behavioural_manifest(ManifestKind::PackageJson, PACKAGE.as_bytes()),
1151            behavioural_manifest(ManifestKind::PackageJson, reordered.as_bytes())
1152        );
1153    }
1154
1155    #[test]
1156    fn an_unreadable_manifest_falls_back_to_its_bytes() {
1157        // A file we cannot parse is hashed whole, so a format we do not
1158        // understand stays conservative instead of silently hashing nothing.
1159        assert!(behavioural_manifest(ManifestKind::PackageJson, b"{ not json").is_none());
1160        assert!(behavioural_manifest(ManifestKind::CargoToml, b"[[[").is_none());
1161        let root = directory("manifest-fallback");
1162        write(&root, "package.json", "{ not json");
1163        let first = digest_manifests(&root, [root.join("package.json")]).unwrap();
1164        write(&root, "package.json", "{ still not json");
1165        assert_ne!(
1166            first,
1167            digest_manifests(&root, [root.join("package.json")]).unwrap()
1168        );
1169        fs::remove_dir_all(root).unwrap();
1170    }
1171
1172    #[test]
1173    fn linter_and_formatter_settings_are_not_execution_context() {
1174        // Neither tool changes what runs, so neither belongs in a fingerprint
1175        // that answers whether the execution context moved. Transpiler config
1176        // is a different matter and stays.
1177        for inert in [
1178            ".prettierrc",
1179            ".prettierrc.json",
1180            "prettier.config.js",
1181            ".eslintrc",
1182            ".eslintrc.json",
1183            "eslint.config.mjs",
1184        ] {
1185            assert!(!configuration_file(Path::new(inert)), "{inert}");
1186        }
1187        for real in ["tsconfig.json", ".babelrc.js", "vite.config.ts", ".npmrc"] {
1188            assert!(configuration_file(Path::new(real)), "{real}");
1189        }
1190    }
1191
1192    #[test]
1193    fn globally_tracked_names_what_the_fingerprint_already_covers() {
1194        for tracked in [
1195            "package-lock.json",
1196            "package.json",
1197            "Cargo.toml",
1198            "Gemfile.lock",
1199            "requirements-dev.txt",
1200            "supercov.gemspec",
1201            "tsconfig.json",
1202            "nested/pyproject.toml",
1203        ] {
1204            assert!(globally_tracked(tracked), "{tracked}");
1205        }
1206        for own in [
1207            "src/index.ts",
1208            "tests/helpers/gateway-process.ts",
1209            "README.md",
1210        ] {
1211            assert!(!globally_tracked(own), "{own}");
1212        }
1213    }
1214
1215    fn write(root: &Path, path: &str, contents: &str) {
1216        let path = root.join(path);
1217        fs::create_dir_all(path.parent().unwrap()).unwrap();
1218        fs::write(path, contents).unwrap();
1219    }
1220
1221    fn frontend(root: &Path) -> FrontendIntegrityInputs {
1222        FrontendIntegrityInputs {
1223            language: "javascript".into(),
1224            version: "javascript-v1".into(),
1225            root: root.to_owned(),
1226            instrumenter_files: vec![root.join("instrumenter.js")],
1227            execution_files: vec![root.join("runtime.mjs")],
1228            engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
1229            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
1230        }
1231    }
1232
1233    fn fixture() -> (PathBuf, PathBuf) {
1234        let root = directory("project");
1235        let shim = directory("shim");
1236        write(
1237            &root,
1238            "package.json",
1239            r#"{"scripts":{"build":"vite build","test":"node --test"}}"#,
1240        );
1241        write(&root, "package-lock.json", "lock");
1242        write(&root, "src/index.ts", "export const ready = true");
1243        write(&root, "tests/index.test.ts", "test('ready', () => {})");
1244        write(&root, "vite.config.ts", "export default {}");
1245        write(&root, ".cache/test262/fake.test.js", "ignored");
1246        write(
1247            &root,
1248            "supercov/.supercov-workspace-store",
1249            "Supercov instrumented workspace. Safe to delete.\n",
1250        );
1251        write(
1252            &root,
1253            "supercov/workspace/copy/tests/copied.test.ts",
1254            "ignored copied test",
1255        );
1256        write(&shim, "instrumenter.js", "instrument");
1257        write(&shim, "runtime.mjs", "runtime");
1258        (root, shim)
1259    }
1260
1261    fn integrity(root: &Path, shim: &Path, environment: &BTreeMap<String, String>) -> RunIntegrity {
1262        let project = discover_coverage_project(root, environment, &[]).unwrap();
1263        create_run_integrity(root, &project, &frontend(shim)).unwrap()
1264    }
1265
1266    #[test]
1267    fn built_assets_the_command_regenerates_do_not_move_the_source_fingerprint() {
1268        // A theme extension's Vite build lands hashed bundles in `assets/`
1269        // and the run syncs them back into the project. They are excluded
1270        // from instrumentation, so a rebuild must not read as a source change.
1271        let (root, shim) = fixture();
1272        write(
1273            &root,
1274            "package.json",
1275            r#"{"workspaces":["app_extensions/*"],"scripts":{"test":"node --test"}}"#,
1276        );
1277        write(&root, "app_extensions/upsells/package.json", "{}");
1278        write(&root, "app_extensions/upsells/frontend/embed.ts", "source");
1279        write(
1280            &root,
1281            "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1282            "bundle one",
1283        );
1284        let first = integrity(&root, &shim, &BTreeMap::new());
1285
1286        fs::remove_file(root.join("app_extensions/upsells/assets/app-embed-Be-aUw9g.js")).unwrap();
1287        write(
1288            &root,
1289            "app_extensions/upsells/assets/app-embed-CygpnWPQ.js",
1290            "bundle two",
1291        );
1292        let rebuilt = integrity(&root, &shim, &BTreeMap::new());
1293        assert_eq!(rebuilt.fingerprint.source, first.fingerprint.source);
1294        assert!(!compare_run_integrity(Some(&first), &rebuilt).stale);
1295
1296        write(
1297            &root,
1298            "app_extensions/upsells/frontend/embed.ts",
1299            "edited source",
1300        );
1301        let edited = integrity(&root, &shim, &BTreeMap::new());
1302        assert_ne!(edited.fingerprint.source, first.fingerprint.source);
1303        fs::remove_dir_all(root).unwrap();
1304        fs::remove_dir_all(shim).unwrap();
1305    }
1306
1307    #[test]
1308    fn fingerprints_every_independent_input_domain_deterministically() {
1309        let (root, shim) = fixture();
1310        let first = integrity(&root, &shim, &BTreeMap::new());
1311        let second = integrity(&root, &shim, &BTreeMap::new());
1312        assert_eq!(first, second);
1313        assert_eq!(first.fingerprint.source_files, 1);
1314        assert_eq!(first.fingerprint.test_files, 1);
1315        for digest in [
1316            &first.fingerprint.source,
1317            &first.fingerprint.tests,
1318            &first.fingerprint.dependencies,
1319            &first.fingerprint.configuration,
1320            &first.fingerprint.instrumenter,
1321            &first.fingerprint.execution,
1322            &first.fingerprint.combined,
1323        ] {
1324            assert!(valid_sha256(digest));
1325        }
1326
1327        write(&root, "src/index.ts", "export const ready = false");
1328        let source = integrity(&root, &shim, &BTreeMap::new());
1329        assert_ne!(source.fingerprint.source, first.fingerprint.source);
1330        assert_eq!(source.fingerprint.tests, first.fingerprint.tests);
1331        assert_ne!(source.fingerprint.execution, first.fingerprint.execution);
1332
1333        write(&root, "src/index.ts", "export const ready = true");
1334        write(&root, "tests/index.test.ts", "test('changed', () => {})");
1335        let tests = integrity(&root, &shim, &BTreeMap::new());
1336        assert_eq!(tests.fingerprint.source, first.fingerprint.source);
1337        assert_ne!(tests.fingerprint.tests, first.fingerprint.tests);
1338        assert_eq!(tests.fingerprint.execution, first.fingerprint.execution);
1339
1340        write(&root, "tests/index.test.ts", "test('ready', () => {})");
1341        write(&root, "package-lock.json", "changed lock");
1342        let dependencies = integrity(&root, &shim, &BTreeMap::new());
1343        assert_ne!(
1344            dependencies.fingerprint.dependencies,
1345            first.fingerprint.dependencies
1346        );
1347        assert_ne!(
1348            dependencies.fingerprint.execution,
1349            first.fingerprint.execution
1350        );
1351
1352        write(&root, "package-lock.json", "lock");
1353        write(&root, "vite.config.ts", "export default { changed: true }");
1354        let configuration = integrity(&root, &shim, &BTreeMap::new());
1355        assert_ne!(
1356            configuration.fingerprint.configuration,
1357            first.fingerprint.configuration
1358        );
1359
1360        write(&root, "vite.config.ts", "export default {}");
1361        write(&shim, "instrumenter.js", "changed instrumenter");
1362        let instrumenter = integrity(&root, &shim, &BTreeMap::new());
1363        assert_ne!(
1364            instrumenter.fingerprint.instrumenter,
1365            first.fingerprint.instrumenter
1366        );
1367        assert_ne!(
1368            instrumenter.fingerprint.combined,
1369            first.fingerprint.combined
1370        );
1371        fs::remove_dir_all(root).unwrap();
1372        fs::remove_dir_all(shim).unwrap();
1373    }
1374
1375    #[test]
1376    fn assertion_inputs_ignore_tool_worktrees_and_nested_repositories() {
1377        let (root, shim) = fixture();
1378        write(&root, "packages/ui/package.json", r#"{"name":"ui"}"#);
1379        write(
1380            &root,
1381            "packages/ui/tests/ui.test.ts",
1382            "import assert from 'node:assert/strict'; assert.equal(1, 1);",
1383        );
1384        let before = integrity(&root, &shim, &BTreeMap::new());
1385        for base in [".claude/worktrees/other", "nested-fork"] {
1386            write(
1387                &root,
1388                &format!("{base}/.git"),
1389                "gitdir: /unrelated/repository",
1390            );
1391            write(
1392                &root,
1393                &format!("{base}/tests/other.test.ts"),
1394                "assert.equal(2, 2);",
1395            );
1396            write(&root, &format!("{base}/package.json"), "{}");
1397            write(&root, &format!("{base}/tsconfig.json"), "{}");
1398        }
1399        let after = integrity(&root, &shim, &BTreeMap::new());
1400        assert_eq!(before.fingerprint, after.fingerprint);
1401        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1402        let paths = javascript_assertion_paths(&root, &project).unwrap();
1403        let inputs = crate::source_capture::capture(&root, "javascript", paths).unwrap();
1404        assert!(inputs.files.contains_key("packages/ui/tests/ui.test.ts"));
1405        assert!(
1406            !inputs
1407                .files
1408                .keys()
1409                .any(|p| p.starts_with(".claude/") || p.starts_with("nested-fork/"))
1410        );
1411        fs::remove_dir_all(root).unwrap();
1412        fs::remove_dir_all(shim).unwrap();
1413    }
1414
1415    #[test]
1416    fn a_new_supercov_moves_its_own_identity_and_nothing_else() {
1417        // An upgrade must still stop a merge and bust the build caches, because
1418        // evidence from two different instrumenters is not comparable. It must
1419        // not touch the run's setup, which is what decides whether a stored run
1420        // still matches the checkout.
1421        let (root, shim) = fixture();
1422        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1423        let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1424        let mut newer = frontend(&shim);
1425        newer.engine_instrumenter_sha256 = "b".repeat(64);
1426        newer.engine_execution_sha256 = "c".repeat(64);
1427        let upgraded = create_run_integrity(&root, &project, &newer).unwrap();
1428
1429        assert_ne!(
1430            baseline.fingerprint.instrumenter, upgraded.fingerprint.instrumenter,
1431            "a merge and the build caches still have to see this"
1432        );
1433        assert_eq!(
1434            baseline.fingerprint.execution, upgraded.fingerprint.execution,
1435            "the run's setup did not change"
1436        );
1437        assert!(
1438            !compare_run_integrity(Some(&baseline), &upgraded).stale,
1439            "upgrading Supercov must not discard a recorded run"
1440        );
1441        fs::remove_dir_all(root).unwrap();
1442        fs::remove_dir_all(shim).unwrap();
1443    }
1444
1445    #[test]
1446    fn fingerprints_nested_workspace_manifests_and_execution_environment() {
1447        let (root, shim) = fixture();
1448        write(
1449            &root,
1450            "packages/ui/package.json",
1451            r#"{"dependencies":{"react":"1"}}"#,
1452        );
1453        write(&root, "packages/ui/src/index.ts", "export const ui = true");
1454        let first = integrity(&root, &shim, &BTreeMap::new());
1455        write(
1456            &root,
1457            "packages/ui/package.json",
1458            r#"{"dependencies":{"react":"2"}}"#,
1459        );
1460        let dependency = integrity(&root, &shim, &BTreeMap::new());
1461        assert_ne!(
1462            first.fingerprint.dependencies,
1463            dependency.fingerprint.dependencies
1464        );
1465
1466        let mut environment = BTreeMap::new();
1467        environment.insert("SUPERCOV_SOURCE_ROOTS".into(), "src,packages/ui/src".into());
1468        let project = discover_coverage_project(&root, &environment, &[]).unwrap();
1469        let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1470        let again = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1471        assert_eq!(baseline.fingerprint.execution, again.fingerprint.execution);
1472        assert!(
1473            compare_run_integrity(Some(&baseline), &again)
1474                .reasons
1475                .is_empty()
1476        );
1477        fs::remove_dir_all(root).unwrap();
1478        fs::remove_dir_all(shim).unwrap();
1479    }
1480
1481    #[test]
1482    fn explicit_language_integrity_uses_the_frozen_store_digest_label() {
1483        let root = directory("rust-project");
1484        write(&root, "src/lib.rs", "pub fn ready() -> bool { true }");
1485        write(
1486            &root,
1487            "Cargo.toml",
1488            "[package]\nname='fixture'\nversion='0.0.0'\n",
1489        );
1490        let inputs = ExplicitIntegrityInputs {
1491            source_files: vec!["src/lib.rs".into()],
1492            test_files: vec!["src/lib.rs".into()],
1493            dependency_files: vec!["Cargo.toml".into()],
1494            configuration_files: Vec::new(),
1495            execution_configuration: b"cargo\0test".to_vec(),
1496        };
1497        let result = create_explicit_run_integrity(
1498            &root,
1499            &inputs,
1500            &FrontendIntegrityInputs::embedded_rust(),
1501        )
1502        .unwrap();
1503        assert_eq!(result.fingerprint.algorithm, "sha256");
1504        fs::remove_dir_all(root).unwrap();
1505    }
1506
1507    #[cfg(unix)]
1508    #[test]
1509    fn rejects_linked_frontend_identity_files() {
1510        use std::os::unix::fs::symlink;
1511
1512        let (root, shim) = fixture();
1513        let outside = shim.join("outside.js");
1514        fs::write(&outside, "outside").unwrap();
1515        fs::remove_file(shim.join("instrumenter.js")).unwrap();
1516        symlink(&outside, shim.join("instrumenter.js")).unwrap();
1517        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1518        assert!(matches!(
1519            create_run_integrity(&root, &project, &frontend(&shim)),
1520            Err(IntegrityError::UnsafeFile(_))
1521        ));
1522        fs::remove_dir_all(root).unwrap();
1523        fs::remove_dir_all(shim).unwrap();
1524    }
1525}