Skip to main content

harn_vm/
context_manifest.rs

1//! Stat-based validity proof for an entry chunk's import-graph context.
2//!
3//! The entry-chunk cache key folds in the content of every transitively
4//! reachable user file, so deciding whether a cached chunk is still valid used
5//! to mean re-reading, re-scanning and re-hashing that whole graph on every
6//! spawn — a cold-path algorithm running on the warm path.
7//!
8//! A manifest records what the graph looked like when the key was computed, in
9//! terms cheap enough to re-check: the entry it was walked from, each file's
10//! stat identity, and the negative facts the graph also depends on. The anchor
11//! is what makes the rest mean anything — the same set of unchanged files
12//! describes a different graph under a different entry, and a cache that names
13//! artifacts by entry source hash alone will hand one entry the other's
14//! manifest.
15//!
16//! Re-checking is stats only. A different anchor, any mismatch, any file that
17//! cannot be stat'ed, and any manifest that was never written all fall back to
18//! the full walk, which recomputes the key from scratch — so a manifest can
19//! only ever save work, never decide a hit on its own.
20//!
21//! A manifest that re-checks clean is also the graph's link table. It records
22//! each file's digest plus the typed imported interface consulted by lowering,
23//! so [`GraphLinkTable`] hands module loading the complete identities it would
24//! otherwise re-read 5.7 MB of source and rebuild the graph to rederive.
25//!
26//! Stat identity is already the trust boundary inside a process:
27//! [`crate::module_source`] memoizes reads on `(path, len, mtime_ns)`. This
28//! extends that same decision across process boundaries, matching how Cargo,
29//! Zig and Bazel gate their warm paths.
30//!
31//! Stats alone are not sufficient for a file written *while* the manifest was
32//! being captured. Filesystems quantize mtime — two seconds on FAT, one second
33//! on HFS+ and older NFS, a ~15.6ms clock tick on NTFS — so two writes inside
34//! one tick record one mtime, and if the second preserves length the recorded
35//! identity is byte-identical to the first. Programmatic agent edits land in
36//! that window routinely. Each manifest therefore records when its capture
37//! began, and an entry whose mtime is not a full granularity older than that
38//! is "racily clean" in git's sense: judged by content instead of by stats
39//! ([`crate::module_source::mtime_predates_capture`]). Re-checking a racy
40//! entry also re-stamps the manifest, so the entry settles onto the stats-only
41//! path on the next spawn rather than paying a read forever.
42//!
43//! The remaining gap is the one Cargo, Zig and Bazel accept: an edit that
44//! preserves length *and* restores a settled mtime is not noticed, which takes
45//! a deliberate timestamp forgery rather than an ordinary write. That gap is
46//! pinned by a test rather than left to prose, so closing it — or widening it —
47//! has to be a deliberate edit and cannot happen by accident.
48
49use std::collections::HashMap;
50use std::path::{Path, PathBuf};
51
52use serde::{Deserialize, Serialize};
53use sha2::{Digest, Sha256};
54
55use crate::module_artifact::ModuleCompilationContext;
56use crate::module_source::{self, ModuleSource};
57
58/// One transitively reachable source file, plus the path that must stay absent
59/// for the imports that reached it to keep resolving here.
60#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
61pub struct ManifestFile {
62    /// Canonical path, matching the key the walk dedups on.
63    pub path: PathBuf,
64    pub len: u64,
65    pub mtime_ns: i128,
66    /// SHA-256 of the bytes that were folded into the context hash.
67    ///
68    /// SHA-256 because the rest of the cache already identifies source by it —
69    /// entry `source_hash`, module keys, artifact filenames — so this adds no
70    /// second digest of the same bytes, and a warm process that has keyed the
71    /// module artifact has already paid for it.
72    ///
73    /// Two things read it. A racily-clean entry is decided by content when
74    /// stats cannot decide. And a manifest that re-checked clean hands these
75    /// digests to the module loader as a [`GraphLinkTable`], paired with the
76    /// imported-interface context below to form the module artifact key.
77    pub content_hash: [u8; 32],
78    /// Imported names and kinds that participate in this file's lowering.
79    /// The validated link table must carry the same typed context as the
80    /// prepared and on-disk module keys; a content digest alone is incomplete.
81    pub compilation_context: ModuleCompilationContext,
82    /// Extensionless sibling that would shadow this file if it appeared.
83    ///
84    /// `resolve_local_import` probes `base.join(import)` *before* appending
85    /// `.harn`, so creating `dep/` next to `dep.harn` silently re-points every
86    /// `import "./dep"` at the new directory. Refactoring a module into a
87    /// directory is an ordinary thing to do, and without this the cache would
88    /// keep serving bytecode compiled against the file it replaced.
89    pub shadow: Option<PathBuf>,
90}
91
92/// An import that resolved to nothing when the key was computed.
93///
94/// The graph depends on this staying true: a file that appears later adds a
95/// real dependency without changing any recorded file's content.
96#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
97pub struct ManifestUnresolved {
98    pub anchor: PathBuf,
99    pub import: String,
100}
101
102/// A path an import resolved to that could not be read.
103///
104/// Real trees contain these: an `import "./types"` where `types/` is a
105/// directory resolves, then fails to read. The error *kind* is folded into the
106/// key, so the manifest has to reproduce it exactly rather than approximate it
107/// from a stat — which is why this re-attempts the read. There are only ever a
108/// handful of these, so re-reading them is cheaper than the walk they avoid.
109#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
110pub struct ManifestUnreadable {
111    pub path: PathBuf,
112    pub kind: String,
113}
114
115/// Everything the entry key's import-graph walk observed, in re-checkable form.
116#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
117pub struct ContextManifest {
118    /// Canonical path of the entry file this walk started from.
119    ///
120    /// Every other field is relative to it: imports resolve against the entry's
121    /// directory, so the same observations describe a different graph under a
122    /// different anchor. The entry-chunk cache names files by entry *source*
123    /// hash alone, deliberately, so two entries with identical bytes in
124    /// different directories land on one cache file and each would otherwise
125    /// find the other's manifest re-checking perfectly clean. See #5591.
126    pub entry: PathBuf,
127    pub files: Vec<ManifestFile>,
128    pub unresolved: Vec<ManifestUnresolved>,
129    pub unreadable: Vec<ManifestUnreadable>,
130    /// Raw package aliases imported by the reachable graph. Cache hits use
131    /// this projection to revalidate manifest/lock authority without parsing
132    /// or rebuilding the graph.
133    pub package_import_aliases: Vec<String>,
134    /// Digest of the package lock whose generation resolved those aliases.
135    /// `None` when the graph imports no packages.
136    pub package_lock_digest: Option<String>,
137    /// When this capture began, in [`module_source::stat_identity`]'s units and
138    /// epoch. Every recorded stat was taken after this instant, which is what
139    /// makes an older mtime provably un-reproducible by a later write.
140    ///
141    /// [`ContextManifest::begin`] is the only way to start one, so this is
142    /// never absent. A manifest decoded from an artifact whose stamp is 0 —
143    /// which no writer produces — classifies every entry as racy, costing
144    /// reads rather than trusting stats that were never timestamped.
145    pub captured_ns: i128,
146}
147
148/// What a re-check concluded about a manifest.
149#[derive(Clone, Debug)]
150pub enum ManifestCheck {
151    /// The graph moved, or could not be proven unmoved. The caller must walk.
152    Stale,
153    /// Proven unchanged from stats alone.
154    Valid,
155    /// Proven unchanged, but at least one entry sat in the racy window and had
156    /// to be read to decide. `refreshed` is the same manifest stamped with this
157    /// re-check's capture time; persisting it settles those entries onto the
158    /// stats-only path, the way git rewrites a racily-clean index entry rather
159    /// than re-reading it on every command.
160    ValidAfterRecheck { refreshed: ContextManifest },
161}
162
163impl ContextManifest {
164    /// Begin a capture anchored at `entry`, stamping the time *before*
165    /// anything is observed.
166    ///
167    /// The order matters: a stat taken before the stamp could miss a write that
168    /// landed between the two and still be called settled.
169    pub fn begin(entry: PathBuf) -> Self {
170        Self {
171            entry,
172            captured_ns: module_source::now_ns(),
173            files: Vec::new(),
174            unresolved: Vec::new(),
175            unreadable: Vec::new(),
176            package_import_aliases: Vec::new(),
177            package_lock_digest: None,
178        }
179    }
180
181    /// Whether this manifest describes the graph reachable from `entry`, and
182    /// that graph still looks exactly as it did when the manifest was written.
183    ///
184    /// Conservative in every direction: anything unreadable, ambiguous, or
185    /// changed reports `false` and costs a walk.
186    pub fn still_valid(&self, entry: &Path) -> bool {
187        !matches!(self.check(entry), ManifestCheck::Stale)
188    }
189
190    /// Package aliases whose authority must be revalidated before a cached
191    /// entry chunk can execute.
192    pub fn package_import_aliases(&self) -> &[String] {
193        &self.package_import_aliases
194    }
195
196    /// As [`Self::still_valid`], but also reports whether the answer needed a
197    /// content read, so a caller holding the artifact can re-stamp it.
198    pub fn check(&self, entry: &Path) -> ManifestCheck {
199        // The anchor first: it is a comparison, where everything below is at
200        // least a stat. The observations prove only that some set of files is
201        // unchanged, never that it is *this* entry's set (#5591).
202        if self.entry != entry {
203            return ManifestCheck::Stale;
204        }
205        // Sampled before the stats below, so the refreshed stamp is as
206        // trustworthy as one a fresh walk would produce.
207        let captured_ns = module_source::now_ns();
208        let mut rechecked = false;
209        for file in &self.files {
210            match file.check(self.captured_ns) {
211                FileCheck::Stale => return ManifestCheck::Stale,
212                FileCheck::Settled => {}
213                FileCheck::Rechecked => rechecked = true,
214            }
215        }
216        if !self
217            .unresolved
218            .iter()
219            .all(ManifestUnresolved::still_unresolved)
220            || !self
221                .unreadable
222                .iter()
223                .all(ManifestUnreadable::still_unreadable)
224        {
225            return ManifestCheck::Stale;
226        }
227        if rechecked {
228            ManifestCheck::ValidAfterRecheck {
229                refreshed: Self {
230                    captured_ns,
231                    ..self.clone()
232                },
233            }
234        } else {
235            ManifestCheck::Valid
236        }
237    }
238}
239
240/// A re-checked manifest, indexed the way module loading asks questions:
241/// canonical path to the source digest and imported-interface context that
242/// together name that module's artifact.
243///
244/// The walk that built the manifest read and digested every reachable file.
245/// Proving that manifest current proves those digests still describe the bytes
246/// on disk. The same graph walk records the imported names and kinds that can
247/// alter lowering. So a validated manifest already holds what module loading
248/// was re-reading and rebuilding the whole graph to rediscover: not dependency
249/// bodies, which independently key their artifacts, but complete identities.
250///
251/// A table is a shortcut, never an authority. Its digest names an artifact;
252/// whether one is on disk under that name is a separate question, and a module
253/// whose artifact was evicted — or which the graph never reached — falls back to
254/// being read and compiled.
255///
256/// Only [`crate::bytecode_cache::load`] can build one, at the point where its
257/// own re-check succeeded. That is what the table's existence means, and it is
258/// why there is no way to assemble one from observations nothing has validated.
259#[derive(Debug)]
260pub struct GraphLinkTable {
261    module_identity_by_path: HashMap<PathBuf, ([u8; 32], ModuleCompilationContext)>,
262}
263
264impl GraphLinkTable {
265    pub(crate) fn from_validated(manifest: &ContextManifest) -> Self {
266        Self {
267            module_identity_by_path: manifest
268                .files
269                .iter()
270                .map(|file| {
271                    (
272                        file.path.clone(),
273                        (file.content_hash, file.compilation_context.clone()),
274                    )
275                })
276                .collect(),
277        }
278    }
279
280    /// The complete cache identity recorded for `canonical`, or `None` when
281    /// this graph does not contain it.
282    pub(crate) fn module_identity(
283        &self,
284        canonical: &Path,
285    ) -> Option<([u8; 32], ModuleCompilationContext)> {
286        self.module_identity_by_path.get(canonical).cloned()
287    }
288}
289
290/// What a re-check concluded about one recorded file.
291enum FileCheck {
292    /// Stats matched and the entry was old enough for stats to be proof.
293    Settled,
294    /// Stats matched but the entry was racily clean, and its content confirmed
295    /// it.
296    Rechecked,
297    Stale,
298}
299
300impl ManifestFile {
301    /// Record `path` as observed on disk now, carrying the digest of the
302    /// `source` the walk folded into the context hash. `None` if the file
303    /// cannot be stat'ed — a file we cannot describe is one we must not claim
304    /// is unchanged.
305    ///
306    /// The digest comes from the caller's already-read bytes rather than from a
307    /// re-read here: it has to describe the version that went into the hash,
308    /// not whatever a second read would find.
309    pub fn observe(path: &Path, source: &ModuleSource) -> Option<Self> {
310        let (len, mtime_ns) = module_source::stat_identity(path)?;
311        Some(Self {
312            path: path.to_path_buf(),
313            len,
314            mtime_ns,
315            content_hash: source.sha256(),
316            compilation_context: ModuleCompilationContext::default(),
317            shadow: shadow_path(path),
318        })
319    }
320
321    fn check(&self, captured_ns: i128) -> FileCheck {
322        let Some((len, mtime_ns)) = module_source::stat_identity(&self.path) else {
323            return FileCheck::Stale;
324        };
325        if len != self.len || mtime_ns != self.mtime_ns {
326            return FileCheck::Stale;
327        }
328        if self.shadow.as_ref().is_some_and(|shadow| shadow.exists()) {
329            return FileCheck::Stale;
330        }
331        if module_source::mtime_predates_capture(mtime_ns, captured_ns) {
332            return FileCheck::Settled;
333        }
334        if self.content_matches() {
335            FileCheck::Rechecked
336        } else {
337            FileCheck::Stale
338        }
339    }
340
341    /// Whether the file still holds the bytes whose digest was recorded.
342    ///
343    /// Deliberately not [`module_source::read`]: that memo is keyed on the very
344    /// `(path, len, mtime_ns)` triple this entry just failed to trust, so a hit
345    /// would answer with the same bytes the racy window let through. Reading as
346    /// a string mirrors how the recorded digest was produced, so a file that
347    /// stopped being UTF-8 reads as changed.
348    fn content_matches(&self) -> bool {
349        let Ok(text) = std::fs::read_to_string(&self.path) else {
350            return false;
351        };
352        let mut hasher = Sha256::new();
353        hasher.update(text.as_bytes());
354        let digest: [u8; 32] = hasher.finalize().into();
355        digest == self.content_hash
356    }
357}
358
359impl ManifestUnreadable {
360    pub(crate) fn still_unreadable(&self) -> bool {
361        match module_source::read(&self.path) {
362            Ok(_) => false,
363            Err(error) => error.kind().to_string() == self.kind,
364        }
365    }
366}
367
368impl ManifestUnresolved {
369    pub(crate) fn still_unresolved(&self) -> bool {
370        harn_modules::resolve_import_path(&self.anchor, &self.import).is_none()
371    }
372}
373
374/// The extensionless path that would shadow `path`, for `*.harn` files only.
375fn shadow_path(path: &Path) -> Option<PathBuf> {
376    if path.extension()? != "harn" {
377        return None;
378    }
379    let mut shadow = path.to_path_buf();
380    shadow.set_extension("");
381    Some(shadow)
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    fn write(path: &Path, body: &str) {
389        if let Some(parent) = path.parent() {
390            std::fs::create_dir_all(parent).unwrap();
391        }
392        std::fs::write(path, body).unwrap();
393    }
394
395    /// The entry every manifest in these tests is anchored at.
396    ///
397    /// They vary what the walk observed, not which entry it walked from, so one
398    /// constant anchor keeps the anchor out of their way. What the anchor itself
399    /// decides is pinned by `an_anchor_mismatch_invalidates` and, end to end,
400    /// by `bytecode_cache_tests`.
401    fn anchor() -> PathBuf {
402        PathBuf::from("/harn/tests/entry.harn")
403    }
404
405    fn manifest_for(paths: &[PathBuf]) -> ContextManifest {
406        ContextManifest {
407            entry: anchor(),
408            files: paths
409                .iter()
410                .map(|p| {
411                    let source = ModuleSource::from_text(std::fs::read_to_string(p).unwrap());
412                    ManifestFile::observe(p, &source).expect("observe")
413                })
414                .collect(),
415            ..ContextManifest::begin(anchor())
416        }
417    }
418
419    /// Re-checks under the anchor the manifest was built at.
420    fn revalidates(manifest: &ContextManifest) -> bool {
421        manifest.still_valid(&anchor())
422    }
423
424    /// Stamp `path`'s mtime. Every timestamp this module reasons about is
425    /// controlled outright rather than waited for, so the tests neither sleep
426    /// nor depend on how finely the host filesystem happens to keep time.
427    fn set_mtime(path: &Path, when: std::time::SystemTime) {
428        std::fs::File::options()
429            .write(true)
430            .open(path)
431            .unwrap()
432            .set_times(std::fs::FileTimes::new().set_modified(when))
433            .unwrap();
434    }
435
436    fn mtime_of(path: &Path) -> std::time::SystemTime {
437        std::fs::metadata(path).unwrap().modified().unwrap()
438    }
439
440    /// A manifest over files old enough that stats alone are proof — the
441    /// ordinary case, and the one the fast path exists for.
442    fn settled_manifest_for(paths: &[PathBuf]) -> ContextManifest {
443        for path in paths {
444            // Relative to the file's own mtime, so the ageing is expressed in
445            // the filesystem's clock rather than a second reading of the host's.
446            set_mtime(path, mtime_of(path) - std::time::Duration::from_hours(1));
447        }
448        let manifest = manifest_for(paths);
449        for file in &manifest.files {
450            assert!(
451                module_source::mtime_predates_capture(file.mtime_ns, manifest.captured_ns),
452                "{} must be outside the racy window for this helper to mean anything",
453                file.path.display()
454            );
455        }
456        manifest
457    }
458
459    /// Put `manifest`'s capture in the same timestamp tick as its first entry's
460    /// mtime — what a coarse filesystem does on its own (NTFS quantizes to a
461    /// ~15.6ms clock tick, FAT to two seconds) and what a nanosecond-resolution
462    /// APFS or ext4 would essentially never produce, which is why the tests
463    /// state it rather than hoping for it.
464    fn place_inside_racy_window(manifest: &mut ContextManifest) {
465        manifest.captured_ns = manifest.files[0].mtime_ns;
466        assert!(
467            !module_source::mtime_predates_capture(
468                manifest.files[0].mtime_ns,
469                manifest.captured_ns
470            ),
471            "the entry must be racily clean for the guard to be under test"
472        );
473    }
474
475    #[test]
476    fn an_unchanged_graph_stays_valid() {
477        let tmp = tempfile::tempdir().unwrap();
478        let dep = tmp.path().join("dep.harn");
479        write(&dep, "pub fn v() -> int { return 1 }\n");
480        assert!(revalidates(&manifest_for(&[dep])));
481    }
482
483    #[test]
484    fn a_same_length_edit_invalidates() {
485        // Two writes inside one filesystem timestamp tick record one mtime, so
486        // when the second preserves byte length the recorded `(len, mtime_ns)`
487        // is identical and stats have nothing left to notice. Windows hits this
488        // routinely; the agent edits Harn exists to orchestrate are exactly the
489        // fast programmatic writes that land inside a tick. The capture time is
490        // what makes it decidable: an entry that was not already settled when
491        // the manifest was taken is judged by content.
492        let tmp = tempfile::tempdir().unwrap();
493        let dep = tmp.path().join("dep.harn");
494        write(&dep, "pub fn v() -> int { return 111 }\n");
495        let mut manifest = manifest_for(&[dep.clone()]);
496        place_inside_racy_window(&mut manifest);
497
498        let recorded = mtime_of(&dep);
499        write(&dep, "pub fn v() -> int { return 222 }\n");
500        set_mtime(&dep, recorded);
501        assert_eq!(
502            module_source::stat_identity(&dep).unwrap(),
503            (manifest.files[0].len, manifest.files[0].mtime_ns),
504            "the edit must leave a byte-identical stat identity, or this test \
505             would pass on the stats path and prove nothing"
506        );
507
508        assert!(
509            !revalidates(&manifest),
510            "an edit of identical length must still invalidate the manifest"
511        );
512    }
513
514    #[test]
515    fn a_settled_entry_is_proven_by_stats_without_reading_content() {
516        // The counterweight to the racy-window check: the overwhelming majority
517        // of entries are old enough that stats decide, and they must not start
518        // paying a read. Proven by recording a digest that cannot match — a
519        // check that consulted content would reject this manifest, and one that
520        // needed a re-stamp would report `ValidAfterRecheck`.
521        let tmp = tempfile::tempdir().unwrap();
522        let dep = tmp.path().join("dep.harn");
523        write(&dep, "pub fn v() -> int { return 1 }\n");
524        let mut manifest = settled_manifest_for(&[dep]);
525        manifest.files[0].content_hash = [0xAB; 32];
526
527        assert!(
528            matches!(manifest.check(&anchor()), ManifestCheck::Valid),
529            "a settled entry must be decided by stats alone"
530        );
531    }
532
533    #[test]
534    fn an_edit_that_restores_a_settled_mtime_is_the_documented_gap() {
535        // The stated limit of the whole scheme, made executable so it stays a
536        // decision rather than a footnote. Once an entry is settled, stats are
537        // treated as proof and content is never consulted — so an edit that
538        // preserves length and puts the old, already-settled mtime back is not
539        // noticed. Unlike the racy window this replaces, that takes deliberate
540        // timestamp forgery rather than an ordinary fast write, which is the
541        // same line Cargo, Zig and Bazel draw.
542        //
543        // If this ever fails, the identity was strengthened and this test
544        // should be deleted on purpose, not repaired.
545        let tmp = tempfile::tempdir().unwrap();
546        let dep = tmp.path().join("dep.harn");
547        write(&dep, "pub fn v() -> int { return 111 }\n");
548        let manifest = settled_manifest_for(&[dep.clone()]);
549
550        let settled = mtime_of(&dep);
551        write(&dep, "pub fn v() -> int { return 222 }\n");
552        set_mtime(&dep, settled);
553        assert_eq!(
554            module_source::stat_identity(&dep).unwrap(),
555            (manifest.files[0].len, manifest.files[0].mtime_ns),
556            "the forgery must leave a byte-identical stat identity, or this \
557             test proves nothing"
558        );
559
560        assert!(
561            revalidates(&manifest),
562            "a settled entry is decided by stats, so a forged timestamp is not \
563             noticed; if this now fails the trade-off changed and the test \
564             should be removed deliberately"
565        );
566    }
567
568    #[test]
569    fn a_racy_entry_that_still_matches_settles_instead_of_re_reading_forever() {
570        // A racily clean entry that checks out is not a miss: the graph is
571        // unchanged, and re-stamping the manifest with this check's capture
572        // time moves the entry onto the stats path for later spawns. Without
573        // that, every future spawn would re-read the same file.
574        let tmp = tempfile::tempdir().unwrap();
575        let dep = tmp.path().join("dep.harn");
576        write(&dep, "pub fn v() -> int { return 1 }\n");
577        let mut manifest = manifest_for(&[dep.clone()]);
578        place_inside_racy_window(&mut manifest);
579
580        // Rewritten with the same bytes and stamped back, so only content can
581        // tell this apart from the edit above.
582        let recorded = mtime_of(&dep);
583        write(&dep, "pub fn v() -> int { return 1 }\n");
584        set_mtime(&dep, recorded);
585
586        let ManifestCheck::ValidAfterRecheck { refreshed } = manifest.check(&anchor()) else {
587            panic!("a racy entry whose content matches must validate, and report the re-check");
588        };
589        assert!(
590            refreshed.captured_ns > manifest.captured_ns,
591            "the re-stamped manifest must carry the newer capture"
592        );
593        assert_eq!(
594            refreshed.files, manifest.files,
595            "re-stamping must not disturb the observations themselves"
596        );
597
598        // A later spawn re-checks the re-stamped manifest, and once its capture
599        // is a full granularity past the write the entry is decided by stats.
600        // Advancing the recorded capture stands in for that elapsed time rather
601        // than sleeping through it.
602        let later = ContextManifest {
603            captured_ns: refreshed.captured_ns + module_source::TIMESTAMP_GRANULARITY_NS,
604            ..refreshed
605        };
606        assert!(
607            matches!(later.check(&anchor()), ManifestCheck::Valid),
608            "an entry the racy window has moved past must return to the stats path"
609        );
610    }
611
612    #[test]
613    fn a_deleted_file_invalidates() {
614        let tmp = tempfile::tempdir().unwrap();
615        let dep = tmp.path().join("dep.harn");
616        write(&dep, "pub fn v() -> int { return 1 }\n");
617        let manifest = manifest_for(&[dep.clone()]);
618        std::fs::remove_file(&dep).unwrap();
619        assert!(!revalidates(&manifest));
620    }
621
622    #[test]
623    fn a_directory_that_would_shadow_the_module_invalidates() {
624        // Refactoring `dep.harn` into `dep/` re-points every `import "./dep"`
625        // without touching dep.harn, because the resolver probes the
626        // extensionless path first. Nothing about the recorded file changes,
627        // so only the shadow check can catch it.
628        let tmp = tempfile::tempdir().unwrap();
629        let dep = tmp.path().join("dep.harn");
630        write(&dep, "pub fn v() -> int { return 1 }\n");
631        let manifest = manifest_for(&[dep]);
632        assert!(revalidates(&manifest));
633
634        std::fs::create_dir(tmp.path().join("dep")).unwrap();
635        assert!(
636            !revalidates(&manifest),
637            "a directory shadowing the module file must invalidate the manifest"
638        );
639    }
640
641    #[test]
642    fn an_import_that_starts_resolving_invalidates() {
643        // The mirror of the file checks: no recorded file changes at all, but
644        // the graph gains a dependency it did not have.
645        let tmp = tempfile::tempdir().unwrap();
646        let entry = tmp.path().join("entry.harn");
647        write(&entry, "import \"./late\"\n");
648        let manifest = ContextManifest {
649            unresolved: vec![ManifestUnresolved {
650                anchor: entry,
651                import: "./late".to_string(),
652            }],
653            ..ContextManifest::begin(anchor())
654        };
655        assert!(revalidates(&manifest));
656
657        write(
658            &tmp.path().join("late.harn"),
659            "pub fn l() -> int { return 1 }\n",
660        );
661        assert!(
662            !revalidates(&manifest),
663            "an import that now resolves must invalidate the manifest"
664        );
665    }
666
667    #[test]
668    fn an_anchor_mismatch_invalidates() {
669        // Every observation can be immaculate and the manifest still describe
670        // the wrong graph, because which files an import reaches depends on
671        // where the walk started. Nothing else in the manifest can notice that:
672        // the recorded paths are absolute and re-check clean from anywhere.
673        let tmp = tempfile::tempdir().unwrap();
674        let dep = tmp.path().join("dep.harn");
675        write(&dep, "pub fn v() -> int { return 1 }\n");
676        let manifest = manifest_for(&[dep]);
677
678        assert!(revalidates(&manifest), "unchanged under its own anchor");
679        assert!(
680            !manifest.still_valid(Path::new("/harn/tests/elsewhere/entry.harn")),
681            "a manifest must not vouch for an entry it was not walked from"
682        );
683    }
684
685    #[test]
686    fn a_file_without_a_harn_extension_has_no_shadow() {
687        let tmp = tempfile::tempdir().unwrap();
688        let odd = tmp.path().join("dep");
689        let body = "pub fn v() -> int { return 1 }\n";
690        write(&odd, body);
691        let source = ModuleSource::from_text(body);
692        assert_eq!(ManifestFile::observe(&odd, &source).unwrap().shadow, None);
693    }
694}