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: "python-monitoring-v1".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/// Dependency manifests, hashed by what they say rather than by their bytes.
377///
378/// A manifest carries two unrelated things: what the project depends on, and
379/// how the project describes itself. Hashing both means every release
380/// invalidates every claim in the map, because a manifest is where the version
381/// number lives. Across supergateway's last sixty commits thirty percent
382/// touched a manifest, and seven of those eighteen changed nothing but a
383/// version string.
384fn digest_manifests(
385    root: &Path,
386    paths: impl IntoIterator<Item = PathBuf>,
387) -> Result<String, IntegrityError> {
388    digest_paths(root, paths, true)
389}
390
391fn digest_paths(
392    root: &Path,
393    paths: impl IntoIterator<Item = PathBuf>,
394    manifests: bool,
395) -> Result<String, IntegrityError> {
396    let paths = paths.into_iter().collect::<BTreeSet<_>>();
397    let mut labeled = paths
398        .into_iter()
399        .map(|path| local_path(root, &path).map(|label| (label, path)))
400        .collect::<Result<Vec<_>, _>>()?;
401    labeled.sort_by(|left, right| left.0.cmp(&right.0));
402    let mut hash = Sha256::new();
403    let mut buffer = [0_u8; 128 * 1024];
404    for (label, path) in labeled {
405        let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
406        if !metadata.file_type().is_file() {
407            return Err(IntegrityError::UnsafeFile(path));
408        }
409        hash.update(label.as_bytes());
410        hash.update([0]);
411        // A manifest we can parse is hashed by meaning; anything else, and any
412        // manifest we fail to parse, is hashed whole. Falling back to the bytes
413        // keeps an unfamiliar or malformed file conservative.
414        let meaning = manifests
415            .then(|| manifest_kind(&path))
416            .flatten()
417            .and_then(|kind| behavioural_manifest(kind, &fs::read(&path).ok()?));
418        if let Some(bytes) = meaning {
419            hash.update(&bytes);
420        } else {
421            let mut file = fs::File::open(&path).map_err(|source| io_error(&path, source))?;
422            loop {
423                let read = file
424                    .read(&mut buffer)
425                    .map_err(|source| io_error(&path, source))?;
426                if read == 0 {
427                    break;
428                }
429                hash.update(&buffer[..read]);
430            }
431        }
432        hash.update([0]);
433    }
434    Ok(format!("{:x}", hash.finalize()))
435}
436
437fn domain_hash(domain: &str, fields: &[(&str, &[u8])]) -> String {
438    let mut hash = Sha256::new();
439    hash.update(domain.as_bytes());
440    hash.update([0]);
441    for (name, value) in fields {
442        hash.update((*name).len().to_le_bytes());
443        hash.update(name.as_bytes());
444        hash.update(value.len().to_le_bytes());
445        hash.update(value);
446    }
447    format!("{:x}", hash.finalize())
448}
449
450fn source_file(path: &Path) -> bool {
451    let lower = path
452        .file_name()
453        .and_then(|name| name.to_str())
454        .unwrap_or("")
455        .to_ascii_lowercase();
456    [
457        ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
458        ".mtsx",
459    ]
460    .iter()
461    .any(|extension| lower.ends_with(extension))
462}
463
464fn skipped_directory(name: &str) -> bool {
465    [
466        ".cache",
467        ".git",
468        ".mcdc-pool",
469        ".next",
470        ".nuxt",
471        ".output",
472        ".supercov",
473        "build",
474        "coverage",
475        "dist",
476        "node_modules",
477        "out",
478        "playwright-report",
479        "results",
480        "test-results",
481        "vendor",
482    ]
483    .contains(&name)
484}
485
486fn owned_workspace_store(path: &Path) -> bool {
487    crate::workspace::owned_workspace_path(path)
488}
489
490fn walk_files(
491    directory: &Path,
492    predicate: &impl Fn(&Path) -> bool,
493    output: &mut Vec<PathBuf>,
494) -> Result<(), IntegrityError> {
495    let metadata = match fs::symlink_metadata(directory) {
496        Ok(metadata) => metadata,
497        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
498        Err(source) => return Err(io_error(directory, source)),
499    };
500    if !metadata.file_type().is_dir() {
501        return Err(IntegrityError::UnsafeFile(directory.to_owned()));
502    }
503    let mut entries = fs::read_dir(directory)
504        .map_err(|source| io_error(directory, source))?
505        .collect::<Result<Vec<_>, _>>()
506        .map_err(|source| io_error(directory, source))?;
507    entries.sort_by_key(fs::DirEntry::file_name);
508    for entry in entries {
509        let path = entry.path();
510        let file_type = entry
511            .file_type()
512            .map_err(|source| io_error(&path, source))?;
513        if file_type.is_symlink() {
514            continue;
515        }
516        if file_type.is_dir() {
517            let name = entry.file_name();
518            if !name
519                .to_str()
520                .is_some_and(|name| name.starts_with('.') || skipped_directory(name))
521                && !path.join(".git").exists()
522                && !owned_workspace_store(&path)
523            {
524                walk_files(&path, predicate, output)?;
525            }
526        } else if file_type.is_file() && predicate(&path) {
527            output.push(path);
528        }
529    }
530    Ok(())
531}
532
533fn test_file(root: &Path, path: &Path) -> bool {
534    if !source_file(path) {
535        return false;
536    }
537    let local = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
538    local
539        .to_ascii_lowercase()
540        .split(['/', '\\', '_', '.', '-'])
541        .any(|part| matches!(part, "test" | "spec"))
542}
543
544fn test_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
545    let mut files = Vec::new();
546    for directory in ["test", "tests", "__tests__"] {
547        walk_files(&root.join(directory), &source_file, &mut files)?;
548    }
549    walk_files(root, &|path| test_file(root, path), &mut files)?;
550    files.sort();
551    files.dedup();
552    Ok(files)
553}
554
555fn dependency_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
556    let mut files = Vec::new();
557    walk_files(
558        root,
559        &|path| path.file_name().is_some_and(|name| name == "package.json"),
560        &mut files,
561    )?;
562    for name in [
563        "package-lock.json",
564        "npm-shrinkwrap.json",
565        "pnpm-lock.yaml",
566        "yarn.lock",
567        "bun.lock",
568        "bun.lockb",
569    ] {
570        let path = root.join(name);
571        if path.is_file() {
572            files.push(path);
573        }
574    }
575    files.sort();
576    files.dedup();
577    Ok(files)
578}
579
580fn configuration_file(path: &Path) -> bool {
581    let name = path
582        .file_name()
583        .and_then(|name| name.to_str())
584        .unwrap_or("")
585        .to_ascii_lowercase();
586    if formatting_only(&name) {
587        return false;
588    }
589    name == ".npmrc"
590        || (name.starts_with("tsconfig") && name.ends_with(".json"))
591        || name.contains(".config.")
592        || name.starts_with(".babelrc.")
593}
594
595/// Linters and formatters do not change what the code does when it runs, so
596/// editing their settings is not a change of execution context. Counting it as
597/// one meant a Prettier tweak invalidated every flow in the map. Transpiler
598/// configuration is a different matter and stays: Babel and tsconfig decide
599/// what actually executes.
600fn formatting_only(name: &str) -> bool {
601    let stem = name.strip_prefix('.').unwrap_or(name);
602    stem.starts_with("eslint") || stem.starts_with("prettier")
603}
604
605/// Files whose content already reaches the run fingerprint, for any language
606/// Supercov supports.
607///
608/// When one of these changes the whole map is marked dirty, so a flow that also
609/// names one in `watch` buys nothing. Worse, it teaches the author a model of
610/// the tool that is not true: that per-flow watching is what catches dependency
611/// drift.
612const TRACKED_MANIFESTS: &[&str] = &[
613    "Cargo.lock",
614    "Cargo.toml",
615    "Gemfile",
616    "Gemfile.lock",
617    "Pipfile",
618    "Pipfile.lock",
619    "bun.lock",
620    "bun.lockb",
621    "npm-shrinkwrap.json",
622    "package-lock.json",
623    "package.json",
624    "pdm.lock",
625    "pnpm-lock.yaml",
626    "poetry.lock",
627    "pyproject.toml",
628    "setup.cfg",
629    "setup.py",
630    "uv.lock",
631    "yarn.lock",
632    ".ruby-version",
633    ".tool-versions",
634];
635
636/// A dependency manifest or lockfile, for any language Supercov supports.
637///
638/// These already have a dedicated signal: the run's dependency fingerprint,
639/// which reads what a manifest declares rather than its bytes. Reporting their
640/// raw bytes a second time would say a release changed something when it
641/// changed nothing.
642pub fn tracked_manifest(path: &str) -> bool {
643    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
644    TRACKED_MANIFESTS.contains(&name)
645        || name.ends_with(".gemspec")
646        || (name.starts_with("requirements") && name.ends_with(".txt"))
647}
648
649pub fn globally_tracked(path: &str) -> bool {
650    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
651    tracked_manifest(name) || configuration_file(Path::new(name))
652}
653
654fn configuration_files(
655    root: &Path,
656    project: &CoverageProject,
657) -> Result<Vec<PathBuf>, IntegrityError> {
658    let mut files = Vec::new();
659    walk_files(root, &configuration_file, &mut files)?;
660    files.extend(
661        [
662            project.playwright_config.as_ref(),
663            project.vitest_config.as_ref(),
664            project.jest_config.as_ref(),
665        ]
666        .into_iter()
667        .flatten()
668        .cloned(),
669    );
670    files.sort();
671    files.dedup();
672    Ok(files)
673}
674
675fn git_integrity(root: &Path) -> Option<GitIntegrity> {
676    let revision = Command::new("git")
677        .args(["rev-parse", "HEAD"])
678        .current_dir(root)
679        .output()
680        .ok();
681    let status = Command::new("git")
682        .args(["status", "--porcelain=v1"])
683        .current_dir(root)
684        .output()
685        .ok();
686    if !revision
687        .as_ref()
688        .is_some_and(|output| output.status.success())
689        && !status
690            .as_ref()
691            .is_some_and(|output| output.status.success())
692    {
693        return None;
694    }
695    Some(GitIntegrity {
696        revision: revision
697            .filter(|output| output.status.success())
698            .and_then(|output| String::from_utf8(output.stdout).ok())
699            .map(|revision| revision.trim().to_owned()),
700        dirty: !status
701            .as_ref()
702            .is_some_and(|output| output.status.success() && output.stdout.is_empty()),
703    })
704}
705
706pub fn create_run_integrity(
707    root: &Path,
708    project: &CoverageProject,
709    frontend: &FrontendIntegrityInputs,
710) -> Result<RunIntegrity, IntegrityError> {
711    if !valid_sha256(&frontend.engine_instrumenter_sha256) {
712        return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
713    }
714    if !valid_sha256(&frontend.engine_execution_sha256) {
715        return Err(IntegrityError::InvalidEngineDigest("execution engine"));
716    }
717    let tests = test_files(root)?;
718    let dependencies = dependency_files(root)?;
719    let configuration = configuration_files(root, project)?;
720    // Scope entries outside the instrumented set still execute in the run,
721    // and ones that carry assertions or capability imports are rewritten and
722    // cached. Everything the frontend may cache must feed the fingerprint,
723    // or an edit to such a file would be overwritten by a stale cached copy.
724    // Entries that another domain already digests stay out of the source
725    // domain so each stale reason keeps naming exactly one kind of change.
726    //
727    // Generated outputs stay out too. A theme extension's hashed bundles are
728    // rebuilt by the wrapped command and synced back into the project, with a
729    // new name every build, so digesting them marked every run stale with
730    // "instrumented source changed" the moment it finished -- while nothing
731    // instrumented had changed at all.
732    let covered_elsewhere = tests
733        .iter()
734        .chain(dependencies.iter())
735        .chain(configuration.iter())
736        .collect::<std::collections::BTreeSet<_>>();
737    let source_paths = project
738        .source_files
739        .iter()
740        .map(|path| root.join(path))
741        .chain(
742            project
743                .source_scope
744                .entries
745                .iter()
746                .filter(|entry| !entry.is_generated_output())
747                .map(|entry| root.join(&entry.file))
748                .filter(|path| !covered_elsewhere.contains(path)),
749        )
750        .collect::<Vec<_>>();
751    let source = digest_files(root, source_paths)?;
752    let tests_digest = digest_files(root, tests.iter().cloned())?;
753    let dependency_digest = digest_manifests(root, dependencies)?;
754    let configuration_digest = digest_files(
755        root,
756        configuration
757            .into_iter()
758            .chain(crate::typescript_imports::config_paths(
759                root,
760                &project.source_files,
761            )),
762    )?;
763    let frontend_instrumenter =
764        digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
765    let frontend_execution =
766        digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
767    let instrumenter = domain_hash(
768        "supercov-run-instrumenter-v1",
769        &[
770            ("language", frontend.language.as_bytes()),
771            ("version", frontend.version.as_bytes()),
772            ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
773            ("shim", frontend_instrumenter.as_bytes()),
774            // The runtime shim decides what evidence looks like, so it is part
775            // of who instrumented the run rather than of the run's setup.
776            (
777                "executionEngine",
778                frontend.engine_execution_sha256.as_bytes(),
779            ),
780            ("executionShim", frontend_execution.as_bytes()),
781        ],
782    );
783    let build_environment = frontend_map_bytes(&project.build_environment);
784    // `execution` describes the run's setup, not who instrumented it. Supercov's
785    // own source used to be folded in here as well, so upgrading Supercov made
786    // every stored run stale for a checkout that had not changed. Its identity
787    // still lives in `instrumenter`, which the build caches and run merging
788    // consult directly.
789    let execution = domain_hash(
790        "supercov-run-execution-v1",
791        &[
792            ("language", frontend.language.as_bytes()),
793            ("version", frontend.version.as_bytes()),
794            ("source", source.as_bytes()),
795            ("dependencies", dependency_digest.as_bytes()),
796            ("configuration", configuration_digest.as_bytes()),
797            ("buildEnvironment", &build_environment),
798        ],
799    );
800    let combined = domain_hash(
801        "supercov-run-combined-v1",
802        &[
803            ("language", frontend.language.as_bytes()),
804            ("version", frontend.version.as_bytes()),
805            ("source", source.as_bytes()),
806            ("tests", tests_digest.as_bytes()),
807            ("dependencies", dependency_digest.as_bytes()),
808            ("configuration", configuration_digest.as_bytes()),
809            ("instrumenter", instrumenter.as_bytes()),
810        ],
811    );
812    Ok(RunIntegrity {
813        schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
814        instrumenter_version: frontend.version.clone(),
815        git: git_integrity(root),
816        fingerprint: RunFingerprint {
817            algorithm: "sha256".into(),
818            source,
819            tests: tests_digest,
820            dependencies: dependency_digest,
821            configuration: configuration_digest,
822            instrumenter,
823            execution,
824            combined,
825            source_files: project.source_files.len(),
826            test_files: tests.len(),
827        },
828        stale: None,
829        stale_reasons: None,
830    })
831}
832
833/// Language-neutral integrity construction for frontends whose discovery does
834/// not use the JavaScript `CoverageProject` compatibility structure.
835pub fn create_explicit_run_integrity(
836    root: &Path,
837    inputs: &ExplicitIntegrityInputs,
838    frontend: &FrontendIntegrityInputs,
839) -> Result<RunIntegrity, IntegrityError> {
840    if !valid_sha256(&frontend.engine_instrumenter_sha256) {
841        return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
842    }
843    if !valid_sha256(&frontend.engine_execution_sha256) {
844        return Err(IntegrityError::InvalidEngineDigest("execution engine"));
845    }
846    let source = digest_files(root, inputs.source_files.iter().map(|path| root.join(path)))?;
847    let tests = digest_files(root, inputs.test_files.iter().map(|path| root.join(path)))?;
848    let dependencies = digest_manifests(
849        root,
850        inputs.dependency_files.iter().map(|path| root.join(path)),
851    )?;
852    let configuration = digest_files(
853        root,
854        inputs
855            .configuration_files
856            .iter()
857            .map(|path| root.join(path)),
858    )?;
859    let frontend_instrumenter =
860        digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
861    let frontend_execution =
862        digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
863    let instrumenter = domain_hash(
864        "supercov-run-instrumenter-v1",
865        &[
866            ("language", frontend.language.as_bytes()),
867            ("version", frontend.version.as_bytes()),
868            ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
869            ("shim", frontend_instrumenter.as_bytes()),
870            // The runtime shim decides what evidence looks like, so it is part
871            // of who instrumented the run rather than of the run's setup.
872            (
873                "executionEngine",
874                frontend.engine_execution_sha256.as_bytes(),
875            ),
876            ("executionShim", frontend_execution.as_bytes()),
877        ],
878    );
879    // `execution` describes the run's setup, not who instrumented it. Supercov's
880    // own source used to be folded in here as well, so upgrading Supercov made
881    // every stored run stale for a checkout that had not changed. Its identity
882    // still lives in `instrumenter`, which the build caches and run merging
883    // consult directly.
884    let execution = domain_hash(
885        "supercov-run-execution-v1",
886        &[
887            ("language", frontend.language.as_bytes()),
888            ("version", frontend.version.as_bytes()),
889            ("source", source.as_bytes()),
890            ("dependencies", dependencies.as_bytes()),
891            ("configuration", configuration.as_bytes()),
892            ("executionConfiguration", &inputs.execution_configuration),
893        ],
894    );
895    let combined = domain_hash(
896        "supercov-run-combined-v1",
897        &[
898            ("language", frontend.language.as_bytes()),
899            ("version", frontend.version.as_bytes()),
900            ("source", source.as_bytes()),
901            ("tests", tests.as_bytes()),
902            ("dependencies", dependencies.as_bytes()),
903            ("configuration", configuration.as_bytes()),
904            ("instrumenter", instrumenter.as_bytes()),
905            ("execution", execution.as_bytes()),
906        ],
907    );
908    Ok(RunIntegrity {
909        schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
910        instrumenter_version: format!("supercov-{}-{}", frontend.language, frontend.version),
911        git: git_integrity(root),
912        fingerprint: RunFingerprint {
913            // The frozen store contract names the digest primitive here. The
914            // domain-separation version belongs to the producer implementation,
915            // not this wire field.
916            algorithm: "sha256".into(),
917            source,
918            tests,
919            dependencies,
920            configuration,
921            instrumenter,
922            execution,
923            combined,
924            source_files: inputs.source_files.len(),
925            test_files: inputs.test_files.len(),
926        },
927        stale: None,
928        stale_reasons: None,
929    })
930}
931
932fn frontend_map_bytes(values: &std::collections::BTreeMap<String, String>) -> Vec<u8> {
933    let mut bytes = Vec::new();
934    for (key, value) in values {
935        bytes.extend_from_slice(&key.len().to_le_bytes());
936        bytes.extend_from_slice(key.as_bytes());
937        bytes.extend_from_slice(&value.len().to_le_bytes());
938        bytes.extend_from_slice(value.as_bytes());
939    }
940    bytes
941}
942
943#[cfg(test)]
944mod tests {
945    use std::{
946        collections::BTreeMap,
947        fs,
948        sync::atomic::{AtomicU64, Ordering},
949        time::{SystemTime, UNIX_EPOCH},
950    };
951
952    use crate::{project_discovery::discover_coverage_project, run_store::compare_run_integrity};
953
954    use super::*;
955
956    static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
957
958    fn directory(label: &str) -> PathBuf {
959        let nonce = SystemTime::now()
960            .duration_since(UNIX_EPOCH)
961            .unwrap()
962            .as_nanos();
963        let root = std::env::temp_dir().join(format!(
964            "supercov-integrity-{label}-{}-{nonce}-{}",
965            std::process::id(),
966            TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed)
967        ));
968        fs::create_dir_all(&root).unwrap();
969        root
970    }
971
972    const PACKAGE: &str = r#"{"name":"g","version":"1.0.0","dependencies":{"a":"^1.2.3"}}"#;
973    const LOCK: &str = r#"{"name":"g","version":"1.0.0","packages":{"":{"name":"g","version":"1.0.0"},"node_modules/a":{"version":"1.2.3"}}}"#;
974    const CARGO: &str =
975        "[package]\nname = \"g\"\nversion = \"1.0.0\"\n\n[dependencies]\na = \"1.2.3\"\n";
976    const PYPROJECT: &str =
977        "[project]\nname = \"g\"\nversion = \"1.0.0\"\ndependencies = [\"a==1.2.3\"]\n";
978
979    #[test]
980    fn a_release_bump_leaves_a_manifest_digest_alone() {
981        // A version number says what the project calls itself, not what it
982        // depends on, and it lives in the same file as the dependencies. Hashing
983        // it meant every release invalidated every claim in the map.
984        for (kind, before, after) in [
985            (
986                ManifestKind::PackageJson,
987                PACKAGE.to_owned(),
988                PACKAGE.replace("1.0.0", "2.0.0"),
989            ),
990            (
991                ManifestKind::PackageLock,
992                LOCK.to_owned(),
993                LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
994            ),
995            (
996                ManifestKind::CargoToml,
997                CARGO.to_owned(),
998                CARGO.replace("1.0.0", "2.0.0"),
999            ),
1000            (
1001                ManifestKind::PyprojectToml,
1002                PYPROJECT.to_owned(),
1003                PYPROJECT.replace("1.0.0", "2.0.0"),
1004            ),
1005        ] {
1006            let stable = behavioural_manifest(kind, before.as_bytes());
1007            assert!(stable.is_some());
1008            assert_eq!(stable, behavioural_manifest(kind, after.as_bytes()));
1009        }
1010    }
1011
1012    #[test]
1013    fn a_dependency_change_still_moves_a_manifest_digest() {
1014        // Dropping metadata must not drop the signal. A lockfile keeps every
1015        // dependency's version and loses only the project's own.
1016        for (kind, before, after) in [
1017            (
1018                ManifestKind::PackageJson,
1019                PACKAGE.to_owned(),
1020                PACKAGE.replace("^1.2.3", "^2.0.0"),
1021            ),
1022            (
1023                ManifestKind::PackageLock,
1024                LOCK.to_owned(),
1025                LOCK.replace(
1026                    "\"node_modules/a\":{\"version\":\"1.2.3\"}",
1027                    "\"node_modules/a\":{\"version\":\"9.9.9\"}",
1028                ),
1029            ),
1030            (
1031                ManifestKind::CargoToml,
1032                CARGO.to_owned(),
1033                CARGO.replace("a = \"1.2.3\"", "a = \"9.9.9\""),
1034            ),
1035            (
1036                ManifestKind::PyprojectToml,
1037                PYPROJECT.to_owned(),
1038                PYPROJECT.replace("a==1.2.3", "a==9.9.9"),
1039            ),
1040        ] {
1041            assert_ne!(
1042                behavioural_manifest(kind, before.as_bytes()),
1043                behavioural_manifest(kind, after.as_bytes())
1044            );
1045        }
1046    }
1047
1048    #[test]
1049    fn the_dependency_fingerprint_survives_a_release_but_not_an_upgrade() {
1050        // The whole point, at the seam the fingerprint actually uses: cutting a
1051        // release must cost nothing, and changing a dependency must still cost
1052        // a recheck.
1053        let root = directory("manifest-fingerprint");
1054        let paths = || [root.join("package.json"), root.join("package-lock.json")];
1055        write(&root, "package.json", PACKAGE);
1056        write(&root, "package-lock.json", LOCK);
1057        let before = digest_manifests(&root, paths()).unwrap();
1058
1059        write(&root, "package.json", &PACKAGE.replace("1.0.0", "2.0.0"));
1060        write(
1061            &root,
1062            "package-lock.json",
1063            &LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
1064        );
1065        assert_eq!(
1066            before,
1067            digest_manifests(&root, paths()).unwrap(),
1068            "a release must not move the dependency fingerprint"
1069        );
1070
1071        write(&root, "package.json", &PACKAGE.replace("^1.2.3", "^2.0.0"));
1072        assert_ne!(
1073            before,
1074            digest_manifests(&root, paths()).unwrap(),
1075            "an upgrade must still move it"
1076        );
1077        fs::remove_dir_all(root).unwrap();
1078    }
1079
1080    #[test]
1081    fn reformatting_a_manifest_leaves_its_digest_alone() {
1082        // Key order and whitespace are not meaning, and a formatter rewriting
1083        // either should not cost the author a re-acknowledgement.
1084        let reordered = r#"{"dependencies":{"a":"^1.2.3"},  "version":"1.0.0",
1085            "name":"g"}"#;
1086        assert_eq!(
1087            behavioural_manifest(ManifestKind::PackageJson, PACKAGE.as_bytes()),
1088            behavioural_manifest(ManifestKind::PackageJson, reordered.as_bytes())
1089        );
1090    }
1091
1092    #[test]
1093    fn an_unreadable_manifest_falls_back_to_its_bytes() {
1094        // A file we cannot parse is hashed whole, so a format we do not
1095        // understand stays conservative instead of silently hashing nothing.
1096        assert!(behavioural_manifest(ManifestKind::PackageJson, b"{ not json").is_none());
1097        assert!(behavioural_manifest(ManifestKind::CargoToml, b"[[[").is_none());
1098        let root = directory("manifest-fallback");
1099        write(&root, "package.json", "{ not json");
1100        let first = digest_manifests(&root, [root.join("package.json")]).unwrap();
1101        write(&root, "package.json", "{ still not json");
1102        assert_ne!(
1103            first,
1104            digest_manifests(&root, [root.join("package.json")]).unwrap()
1105        );
1106        fs::remove_dir_all(root).unwrap();
1107    }
1108
1109    #[test]
1110    fn linter_and_formatter_settings_are_not_execution_context() {
1111        // Neither tool changes what runs, so neither belongs in a fingerprint
1112        // that answers whether the execution context moved. Transpiler config
1113        // is a different matter and stays.
1114        for inert in [
1115            ".prettierrc",
1116            ".prettierrc.json",
1117            "prettier.config.js",
1118            ".eslintrc",
1119            ".eslintrc.json",
1120            "eslint.config.mjs",
1121        ] {
1122            assert!(!configuration_file(Path::new(inert)), "{inert}");
1123        }
1124        for real in ["tsconfig.json", ".babelrc.js", "vite.config.ts", ".npmrc"] {
1125            assert!(configuration_file(Path::new(real)), "{real}");
1126        }
1127    }
1128
1129    #[test]
1130    fn globally_tracked_names_what_the_fingerprint_already_covers() {
1131        for tracked in [
1132            "package-lock.json",
1133            "package.json",
1134            "Cargo.toml",
1135            "Gemfile.lock",
1136            "requirements-dev.txt",
1137            "supercov.gemspec",
1138            "tsconfig.json",
1139            "nested/pyproject.toml",
1140        ] {
1141            assert!(globally_tracked(tracked), "{tracked}");
1142        }
1143        for own in [
1144            "src/index.ts",
1145            "tests/helpers/gateway-process.ts",
1146            "README.md",
1147        ] {
1148            assert!(!globally_tracked(own), "{own}");
1149        }
1150    }
1151
1152    fn write(root: &Path, path: &str, contents: &str) {
1153        let path = root.join(path);
1154        fs::create_dir_all(path.parent().unwrap()).unwrap();
1155        fs::write(path, contents).unwrap();
1156    }
1157
1158    fn frontend(root: &Path) -> FrontendIntegrityInputs {
1159        FrontendIntegrityInputs {
1160            language: "javascript".into(),
1161            version: "javascript-v1".into(),
1162            root: root.to_owned(),
1163            instrumenter_files: vec![root.join("instrumenter.js")],
1164            execution_files: vec![root.join("runtime.mjs")],
1165            engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
1166            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
1167        }
1168    }
1169
1170    fn fixture() -> (PathBuf, PathBuf) {
1171        let root = directory("project");
1172        let shim = directory("shim");
1173        write(
1174            &root,
1175            "package.json",
1176            r#"{"scripts":{"build":"vite build","test":"node --test"}}"#,
1177        );
1178        write(&root, "package-lock.json", "lock");
1179        write(&root, "src/index.ts", "export const ready = true");
1180        write(&root, "tests/index.test.ts", "test('ready', () => {})");
1181        write(&root, "vite.config.ts", "export default {}");
1182        write(&root, ".cache/test262/fake.test.js", "ignored");
1183        write(
1184            &root,
1185            "supercov/.supercov-workspace-store",
1186            "Supercov instrumented workspace. Safe to delete.\n",
1187        );
1188        write(
1189            &root,
1190            "supercov/workspace/copy/tests/copied.test.ts",
1191            "ignored copied test",
1192        );
1193        write(&shim, "instrumenter.js", "instrument");
1194        write(&shim, "runtime.mjs", "runtime");
1195        (root, shim)
1196    }
1197
1198    fn integrity(root: &Path, shim: &Path, environment: &BTreeMap<String, String>) -> RunIntegrity {
1199        let project = discover_coverage_project(root, environment, &[]).unwrap();
1200        create_run_integrity(root, &project, &frontend(shim)).unwrap()
1201    }
1202
1203    #[test]
1204    fn built_assets_the_command_regenerates_do_not_move_the_source_fingerprint() {
1205        // A theme extension's Vite build lands hashed bundles in `assets/`
1206        // and the run syncs them back into the project. They are excluded
1207        // from instrumentation, so a rebuild must not read as a source change.
1208        let (root, shim) = fixture();
1209        write(
1210            &root,
1211            "package.json",
1212            r#"{"workspaces":["app_extensions/*"],"scripts":{"test":"node --test"}}"#,
1213        );
1214        write(&root, "app_extensions/upsells/package.json", "{}");
1215        write(&root, "app_extensions/upsells/frontend/embed.ts", "source");
1216        write(
1217            &root,
1218            "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1219            "bundle one",
1220        );
1221        let first = integrity(&root, &shim, &BTreeMap::new());
1222
1223        fs::remove_file(root.join("app_extensions/upsells/assets/app-embed-Be-aUw9g.js")).unwrap();
1224        write(
1225            &root,
1226            "app_extensions/upsells/assets/app-embed-CygpnWPQ.js",
1227            "bundle two",
1228        );
1229        let rebuilt = integrity(&root, &shim, &BTreeMap::new());
1230        assert_eq!(rebuilt.fingerprint.source, first.fingerprint.source);
1231        assert!(!compare_run_integrity(Some(&first), &rebuilt).stale);
1232
1233        write(
1234            &root,
1235            "app_extensions/upsells/frontend/embed.ts",
1236            "edited source",
1237        );
1238        let edited = integrity(&root, &shim, &BTreeMap::new());
1239        assert_ne!(edited.fingerprint.source, first.fingerprint.source);
1240        fs::remove_dir_all(root).unwrap();
1241        fs::remove_dir_all(shim).unwrap();
1242    }
1243
1244    #[test]
1245    fn fingerprints_every_independent_input_domain_deterministically() {
1246        let (root, shim) = fixture();
1247        let first = integrity(&root, &shim, &BTreeMap::new());
1248        let second = integrity(&root, &shim, &BTreeMap::new());
1249        assert_eq!(first, second);
1250        assert_eq!(first.fingerprint.source_files, 1);
1251        assert_eq!(first.fingerprint.test_files, 1);
1252        for digest in [
1253            &first.fingerprint.source,
1254            &first.fingerprint.tests,
1255            &first.fingerprint.dependencies,
1256            &first.fingerprint.configuration,
1257            &first.fingerprint.instrumenter,
1258            &first.fingerprint.execution,
1259            &first.fingerprint.combined,
1260        ] {
1261            assert!(valid_sha256(digest));
1262        }
1263
1264        write(&root, "src/index.ts", "export const ready = false");
1265        let source = integrity(&root, &shim, &BTreeMap::new());
1266        assert_ne!(source.fingerprint.source, first.fingerprint.source);
1267        assert_eq!(source.fingerprint.tests, first.fingerprint.tests);
1268        assert_ne!(source.fingerprint.execution, first.fingerprint.execution);
1269
1270        write(&root, "src/index.ts", "export const ready = true");
1271        write(&root, "tests/index.test.ts", "test('changed', () => {})");
1272        let tests = integrity(&root, &shim, &BTreeMap::new());
1273        assert_eq!(tests.fingerprint.source, first.fingerprint.source);
1274        assert_ne!(tests.fingerprint.tests, first.fingerprint.tests);
1275        assert_eq!(tests.fingerprint.execution, first.fingerprint.execution);
1276
1277        write(&root, "tests/index.test.ts", "test('ready', () => {})");
1278        write(&root, "package-lock.json", "changed lock");
1279        let dependencies = integrity(&root, &shim, &BTreeMap::new());
1280        assert_ne!(
1281            dependencies.fingerprint.dependencies,
1282            first.fingerprint.dependencies
1283        );
1284        assert_ne!(
1285            dependencies.fingerprint.execution,
1286            first.fingerprint.execution
1287        );
1288
1289        write(&root, "package-lock.json", "lock");
1290        write(&root, "vite.config.ts", "export default { changed: true }");
1291        let configuration = integrity(&root, &shim, &BTreeMap::new());
1292        assert_ne!(
1293            configuration.fingerprint.configuration,
1294            first.fingerprint.configuration
1295        );
1296
1297        write(&root, "vite.config.ts", "export default {}");
1298        write(&shim, "instrumenter.js", "changed instrumenter");
1299        let instrumenter = integrity(&root, &shim, &BTreeMap::new());
1300        assert_ne!(
1301            instrumenter.fingerprint.instrumenter,
1302            first.fingerprint.instrumenter
1303        );
1304        assert_ne!(
1305            instrumenter.fingerprint.combined,
1306            first.fingerprint.combined
1307        );
1308        fs::remove_dir_all(root).unwrap();
1309        fs::remove_dir_all(shim).unwrap();
1310    }
1311
1312    #[test]
1313    fn assertion_inputs_ignore_tool_worktrees_and_nested_repositories() {
1314        let (root, shim) = fixture();
1315        write(&root, "packages/ui/package.json", r#"{"name":"ui"}"#);
1316        write(
1317            &root,
1318            "packages/ui/tests/ui.test.ts",
1319            "import assert from 'node:assert/strict'; assert.equal(1, 1);",
1320        );
1321        let before = integrity(&root, &shim, &BTreeMap::new());
1322        for base in [".claude/worktrees/other", "nested-fork"] {
1323            write(
1324                &root,
1325                &format!("{base}/.git"),
1326                "gitdir: /unrelated/repository",
1327            );
1328            write(
1329                &root,
1330                &format!("{base}/tests/other.test.ts"),
1331                "assert.equal(2, 2);",
1332            );
1333            write(&root, &format!("{base}/package.json"), "{}");
1334            write(&root, &format!("{base}/tsconfig.json"), "{}");
1335        }
1336        let after = integrity(&root, &shim, &BTreeMap::new());
1337        assert_eq!(before.fingerprint, after.fingerprint);
1338        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1339        let paths = javascript_assertion_paths(&root, &project).unwrap();
1340        let inputs = crate::assertion_inputs::capture(&root, "javascript", paths).unwrap();
1341        assert!(inputs.files.contains_key("packages/ui/tests/ui.test.ts"));
1342        assert!(
1343            !inputs
1344                .files
1345                .keys()
1346                .any(|p| p.starts_with(".claude/") || p.starts_with("nested-fork/"))
1347        );
1348        fs::remove_dir_all(root).unwrap();
1349        fs::remove_dir_all(shim).unwrap();
1350    }
1351
1352    #[test]
1353    fn a_new_supercov_moves_its_own_identity_and_nothing_else() {
1354        // An upgrade must still stop a merge and bust the build caches, because
1355        // evidence from two different instrumenters is not comparable. It must
1356        // not touch the run's setup, which is what decides whether a stored run
1357        // still matches the checkout.
1358        let (root, shim) = fixture();
1359        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1360        let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1361        let mut newer = frontend(&shim);
1362        newer.engine_instrumenter_sha256 = "b".repeat(64);
1363        newer.engine_execution_sha256 = "c".repeat(64);
1364        let upgraded = create_run_integrity(&root, &project, &newer).unwrap();
1365
1366        assert_ne!(
1367            baseline.fingerprint.instrumenter, upgraded.fingerprint.instrumenter,
1368            "a merge and the build caches still have to see this"
1369        );
1370        assert_eq!(
1371            baseline.fingerprint.execution, upgraded.fingerprint.execution,
1372            "the run's setup did not change"
1373        );
1374        assert!(
1375            !compare_run_integrity(Some(&baseline), &upgraded).stale,
1376            "upgrading Supercov must not discard a recorded run"
1377        );
1378        fs::remove_dir_all(root).unwrap();
1379        fs::remove_dir_all(shim).unwrap();
1380    }
1381
1382    #[test]
1383    fn fingerprints_nested_workspace_manifests_and_execution_environment() {
1384        let (root, shim) = fixture();
1385        write(
1386            &root,
1387            "packages/ui/package.json",
1388            r#"{"dependencies":{"react":"1"}}"#,
1389        );
1390        write(&root, "packages/ui/src/index.ts", "export const ui = true");
1391        let first = integrity(&root, &shim, &BTreeMap::new());
1392        write(
1393            &root,
1394            "packages/ui/package.json",
1395            r#"{"dependencies":{"react":"2"}}"#,
1396        );
1397        let dependency = integrity(&root, &shim, &BTreeMap::new());
1398        assert_ne!(
1399            first.fingerprint.dependencies,
1400            dependency.fingerprint.dependencies
1401        );
1402
1403        let mut environment = BTreeMap::new();
1404        environment.insert("SUPERCOV_SOURCE_ROOTS".into(), "src,packages/ui/src".into());
1405        let project = discover_coverage_project(&root, &environment, &[]).unwrap();
1406        let mut project_with_build_environment = project.clone();
1407        project_with_build_environment
1408            .build_environment
1409            .insert("MODE".into(), "test".into());
1410        let changed =
1411            create_run_integrity(&root, &project_with_build_environment, &frontend(&shim)).unwrap();
1412        let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1413        assert_ne!(
1414            baseline.fingerprint.execution,
1415            changed.fingerprint.execution
1416        );
1417        assert_eq!(baseline.fingerprint.combined, changed.fingerprint.combined);
1418        assert_eq!(
1419            compare_run_integrity(Some(&baseline), &changed).reasons,
1420            ["execution environment changed"]
1421        );
1422        fs::remove_dir_all(root).unwrap();
1423        fs::remove_dir_all(shim).unwrap();
1424    }
1425
1426    #[test]
1427    fn explicit_language_integrity_uses_the_frozen_store_digest_label() {
1428        let root = directory("rust-project");
1429        write(&root, "src/lib.rs", "pub fn ready() -> bool { true }");
1430        write(
1431            &root,
1432            "Cargo.toml",
1433            "[package]\nname='fixture'\nversion='0.0.0'\n",
1434        );
1435        let inputs = ExplicitIntegrityInputs {
1436            source_files: vec!["src/lib.rs".into()],
1437            test_files: vec!["src/lib.rs".into()],
1438            dependency_files: vec!["Cargo.toml".into()],
1439            configuration_files: Vec::new(),
1440            execution_configuration: b"cargo\0test".to_vec(),
1441        };
1442        let result = create_explicit_run_integrity(
1443            &root,
1444            &inputs,
1445            &FrontendIntegrityInputs::embedded_rust(),
1446        )
1447        .unwrap();
1448        assert_eq!(result.fingerprint.algorithm, "sha256");
1449        fs::remove_dir_all(root).unwrap();
1450    }
1451
1452    #[cfg(unix)]
1453    #[test]
1454    fn rejects_linked_frontend_identity_files() {
1455        use std::os::unix::fs::symlink;
1456
1457        let (root, shim) = fixture();
1458        let outside = shim.join("outside.js");
1459        fs::write(&outside, "outside").unwrap();
1460        fs::remove_file(shim.join("instrumenter.js")).unwrap();
1461        symlink(&outside, shim.join("instrumenter.js")).unwrap();
1462        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1463        assert!(matches!(
1464            create_run_integrity(&root, &project, &frontend(&shim)),
1465            Err(IntegrityError::UnsafeFile(_))
1466        ));
1467        fs::remove_dir_all(root).unwrap();
1468        fs::remove_dir_all(shim).unwrap();
1469    }
1470}