Skip to main content

devscout_rs/semantic/
freshness.rs

1// Whole-artifact freshness: whether the checkout has moved since the
2// admitted compiler-facts artifact was captured. The normal analysis path
3// never spawns a compiler, so this cannot recompute a live compilation
4// fingerprint -- it can only compare what the artifact itself recorded
5// (`sourceSnapshot.headSha`/`.dirty`) against what the checkout can tell
6// about itself without one: the current git head, and whether the tree is
7// dirty right now. A mismatch on either leg invalidates every fact the
8// artifact carries, even though the file consuming a given reference may be
9// byte-identical to what it was when the artifact was captured -- the
10// compilation context changed, which is exactly what freshness here means to
11// catch. This is coarser than a per-dependency check (any tracked file
12// changing anywhere invalidates the whole artifact, not only the affected
13// compilation), which is the safe direction: it can only under-apply the
14// layer, never emit a stale confirmed edge.
15//
16// The head/dirty pair alone is blind to context that changes without moving
17// this repository's own git state at all: an MSBuild import
18// (`Directory.Build.props`, `Directory.Packages.props`, a `.targets` file)
19// living outside the mapped scope, or a package/reference asset resolved
20// from outside the working tree (the global NuGet cache, an updated SDK's
21// own defaults). Per-compilation `context.envelope.compilations[].imports[]`
22// is exactly the producer's own record of which external files it consulted
23// and their content hash at capture time -- `check_imports` re-hashes each
24// one directly off disk, offline, and compares, so a changed import
25// invalidates the artifact even when the consuming file and repository HEAD
26// are both untouched. A changed PACKAGE reference and a changed COMPILER
27// OPTION are typically the SAME trigger from this checkout's point of view
28// (both usually live in a `.props`/`.targets` import), so one mechanism
29// serves both named triggers, with the reason naming which import moved.
30
31use std::path::Path;
32
33use sha1::{Digest, Sha1};
34
35use crate::manifest;
36
37/// Whether the admitted artifact is still trustworthy at `map` time.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Freshness {
40    /// The checkout is exactly as it was when the artifact was captured.
41    Fresh,
42    /// Something has moved; every fact in the artifact falls through to the
43    /// syntax ladder.
44    Stale {
45        /// The machine-readable trigger, folded into `Uncertainty::Stale`.
46        /// Owned rather than `&'static str` because the import triggers
47        /// (`check_imports`) name the specific import that moved, which is
48        /// only known at evaluation time.
49        reason: String,
50    },
51}
52
53impl Freshness {
54    /// Whether this is [`Freshness::Fresh`].
55    pub fn is_fresh(&self) -> bool {
56        matches!(self, Freshness::Fresh)
57    }
58}
59
60/// One `context.envelope.compilations[].imports[]` entry: an external file
61/// the producer consulted, and its content hash (lower-case hex SHA-1, the
62/// producer's own encoding) at capture time. Collected across every
63/// compilation the artifact embeds -- freshness here is whole-artifact, the
64/// same coarseness the head/dirty checks above already apply.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct ImportRef {
67    /// The producer's own identity string, e.g.
68    /// `"external-file:Directory.Build.props"`.
69    pub identity: String,
70    /// The producer's own SHA-1 hex digest of that file's content at
71    /// capture time.
72    pub hash: String,
73}
74
75/// `identity`'s own external-file marker -- see `ImportRef`'s own doc
76/// comment. Anything else is a producer identity shape this checkout has no
77/// path to re-hash (not a local file at all) and is skipped, never treated
78/// as a mismatch.
79const EXTERNAL_FILE_PREFIX: &str = "external-file:";
80
81/// Re-hashes every `ImportRef` directly off `root`'s current disk state and
82/// compares it against the producer's own recorded hash. `None` when every
83/// import still matches (or there is nothing to check); `Some(reason)` on
84/// the first mismatch -- a changed import invalidates the whole artifact,
85/// the same coarse, safe-direction rule the head/dirty checks apply. The
86/// reason names the one import that moved (its own `identity` string, e.g.
87/// `"import-file-changed:external-file:Directory.Build.props"`), so a reader
88/// of `Uncertainty::Stale`'s reason can tell WHICH import triggered it
89/// without re-deriving the whole artifact's import list.
90fn check_imports(root: &Path, imports: &[ImportRef]) -> Option<String> {
91    for import in imports {
92        let Some(rel) = import.identity.strip_prefix(EXTERNAL_FILE_PREFIX) else {
93            continue;
94        };
95        let Ok(bytes) = std::fs::read(root.join(rel)) else {
96            return Some(format!("import-file-missing:{}", import.identity));
97        };
98        let digest = Sha1::digest(&bytes);
99        let current_hash: String = digest.iter().map(|b| format!("{b:02x}")).collect();
100        if current_hash != import.hash {
101            return Some(format!("import-file-changed:{}", import.identity));
102        }
103    }
104    None
105}
106
107/// Evaluates freshness for one admitted artifact against `root`'s current
108/// checkout state. `admitted_head_sha` and `admitted_dirty` come straight off
109/// the artifact's own `sourceSnapshot` object (see `layer::parse_raw`);
110/// `admitted_dirty` defaults to `true` when the artifact does not say,
111/// because an unstated dirty flag cannot be trusted to mean clean. `imports`
112/// is every compilation's own `ImportRef` list, flattened -- see this
113/// module's own header comment.
114pub fn evaluate(
115    root: &Path,
116    admitted_head_sha: Option<&str>,
117    admitted_dirty: bool,
118    imports: &[ImportRef],
119) -> Freshness {
120    if admitted_dirty {
121        return Freshness::Stale {
122            reason: "source-snapshot-dirty-at-admission".to_string(),
123        };
124    }
125    let Some(admitted_head) = admitted_head_sha else {
126        return Freshness::Stale {
127            reason: "source-snapshot-missing".to_string(),
128        };
129    };
130    if manifest::is_working_tree_dirty(root, &[".".to_string()]) {
131        return Freshness::Stale {
132            reason: "working-tree-dirty".to_string(),
133        };
134    }
135    match manifest::git_head(root) {
136        Some(current) if current == admitted_head => {}
137        Some(_) => {
138            return Freshness::Stale {
139                reason: "source-head-mismatch".to_string(),
140            }
141        }
142        None => {
143            return Freshness::Stale {
144                reason: "source-head-unavailable".to_string(),
145            }
146        }
147    }
148    if let Some(reason) = check_imports(root, imports) {
149        return Freshness::Stale { reason };
150    }
151    Freshness::Fresh
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use std::process::Command;
158    use std::sync::atomic::{AtomicU64, Ordering};
159
160    static COUNTER: AtomicU64 = AtomicU64::new(0);
161
162    fn scratch_repo(label: &str) -> std::path::PathBuf {
163        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
164        let dir = std::env::temp_dir().join(format!(
165            "scout-semantic-freshness-{label}-{}-{n}",
166            std::process::id()
167        ));
168        std::fs::create_dir_all(&dir).unwrap();
169        let run = |args: &[&str]| {
170            let status = Command::new("git")
171                .args(args)
172                .current_dir(&dir)
173                .status()
174                .expect("git available for the test");
175            assert!(status.success(), "git {args:?} failed");
176        };
177        run(&["init", "-q"]);
178        run(&["config", "user.email", "test@example.com"]);
179        run(&["config", "user.name", "test"]);
180        std::fs::write(dir.join("a.txt"), "a").unwrap();
181        run(&["add", "."]);
182        run(&["commit", "-q", "-m", "init"]);
183        dir
184    }
185
186    fn head_of(dir: &Path) -> String {
187        manifest::git_head(dir).expect("head available")
188    }
189
190    #[test]
191    fn dirty_at_admission_is_stale_regardless_of_the_current_tree() {
192        let dir = scratch_repo("dirty-admission");
193        let head = head_of(&dir);
194        assert_eq!(
195            evaluate(&dir, Some(&head), true, &[]),
196            Freshness::Stale {
197                reason: "source-snapshot-dirty-at-admission".to_string()
198            }
199        );
200    }
201
202    #[test]
203    fn a_missing_head_sha_is_stale() {
204        let dir = scratch_repo("missing-head");
205        assert_eq!(
206            evaluate(&dir, None, false, &[]),
207            Freshness::Stale {
208                reason: "source-snapshot-missing".to_string()
209            }
210        );
211    }
212
213    #[test]
214    fn a_matching_clean_head_is_fresh() {
215        let dir = scratch_repo("matching-head");
216        let head = head_of(&dir);
217        assert_eq!(evaluate(&dir, Some(&head), false, &[]), Freshness::Fresh);
218    }
219
220    #[test]
221    fn a_moved_head_is_stale_even_with_the_consuming_file_untouched() {
222        let dir = scratch_repo("moved-head");
223        let admitted_head = head_of(&dir);
224        std::fs::write(dir.join("b.txt"), "b").unwrap();
225        Command::new("git")
226            .args(["add", "."])
227            .current_dir(&dir)
228            .status()
229            .unwrap();
230        Command::new("git")
231            .args(["commit", "-q", "-m", "second"])
232            .current_dir(&dir)
233            .status()
234            .unwrap();
235        assert_eq!(
236            evaluate(&dir, Some(&admitted_head), false, &[]),
237            Freshness::Stale {
238                reason: "source-head-mismatch".to_string()
239            }
240        );
241    }
242
243    #[test]
244    fn an_uncommitted_edit_now_is_stale_even_at_the_same_admitted_head() {
245        let dir = scratch_repo("dirty-now");
246        let head = head_of(&dir);
247        std::fs::write(dir.join("a.txt"), "changed").unwrap();
248        assert_eq!(
249            evaluate(&dir, Some(&head), false, &[]),
250            Freshness::Stale {
251                reason: "working-tree-dirty".to_string()
252            }
253        );
254    }
255
256    // Import triggers (the package/reference-asset and compiler-option
257    // controls): a package/reference asset or compiler option most often
258    // lives in an MSBuild import (`Directory.Build.props`,
259    // `Directory.Packages.props`, a `.targets` file). When that import sits
260    // OUTSIDE the mapped repository (a common upward-search layout, and the
261    // case this checkout's own git dirty check is structurally blind to --
262    // `git status` run from `dir` never sees a path outside it at all), the
263    // consuming file and repository HEAD both stay untouched while the
264    // import itself changes. Each test below writes the import one level
265    // ABOVE the scratch repo, deliberately outside its git tree, so the
266    // existing head/dirty checks cannot be the ones catching it -- only
267    // `check_imports` can.
268
269    fn sha1_hex(bytes: &[u8]) -> String {
270        Sha1::digest(bytes)
271            .iter()
272            .map(|b| format!("{b:02x}"))
273            .collect()
274    }
275
276    #[test]
277    fn an_import_matching_its_recorded_hash_is_fresh() {
278        let dir = scratch_repo("import-fresh");
279        let head = head_of(&dir);
280        let import_path = dir.parent().unwrap().join(format!(
281            "Directory.Build.props-{}",
282            dir.file_name().unwrap().to_str().unwrap()
283        ));
284        std::fs::write(&import_path, b"<Project />").unwrap();
285        let imports = [ImportRef {
286            identity: format!(
287                "external-file:../{}",
288                import_path.file_name().unwrap().to_str().unwrap()
289            ),
290            hash: sha1_hex(b"<Project />"),
291        }];
292        assert_eq!(
293            evaluate(&dir, Some(&head), false, &imports),
294            Freshness::Fresh
295        );
296        std::fs::remove_file(&import_path).ok();
297    }
298
299    #[test]
300    fn a_changed_import_is_stale_even_with_head_unchanged_and_the_tree_clean() {
301        let dir = scratch_repo("import-changed");
302        let head = head_of(&dir);
303        let import_path = dir.parent().unwrap().join(format!(
304            "Directory.Build.props-{}",
305            dir.file_name().unwrap().to_str().unwrap()
306        ));
307        std::fs::write(
308            &import_path,
309            b"<Project><Nullable>enable</Nullable></Project>",
310        )
311        .unwrap();
312        // Recorded hash is of the OLD content -- this checkout's own git
313        // state (this scratch repo's head and working tree) never moved.
314        let identity = format!(
315            "external-file:../{}",
316            import_path.file_name().unwrap().to_str().unwrap()
317        );
318        let imports = [ImportRef {
319            identity: identity.clone(),
320            hash: sha1_hex(b"<Project />"),
321        }];
322        assert_eq!(
323            evaluate(&dir, Some(&head), false, &imports),
324            Freshness::Stale {
325                reason: format!("import-file-changed:{identity}"),
326            },
327            "the reason must name WHICH import moved, not just that one did"
328        );
329        std::fs::remove_file(&import_path).ok();
330    }
331
332    #[test]
333    fn a_missing_import_is_stale() {
334        let dir = scratch_repo("import-missing");
335        let head = head_of(&dir);
336        let identity = "external-file:../does-not-exist.props".to_string();
337        let imports = [ImportRef {
338            identity: identity.clone(),
339            hash: sha1_hex(b"anything"),
340        }];
341        assert_eq!(
342            evaluate(&dir, Some(&head), false, &imports),
343            Freshness::Stale {
344                reason: format!("import-file-missing:{identity}"),
345            },
346            "the reason must name WHICH import is missing, not just that one is"
347        );
348    }
349
350    #[test]
351    fn an_import_identity_with_no_external_file_marker_is_never_checked() {
352        let dir = scratch_repo("import-unmarked");
353        let head = head_of(&dir);
354        let imports = [ImportRef {
355            identity: "metadata:System.Private.CoreLib|mvid:00000000".to_string(),
356            hash: "not-a-real-hash".to_string(),
357        }];
358        assert_eq!(
359            evaluate(&dir, Some(&head), false, &imports),
360            Freshness::Fresh
361        );
362    }
363}