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