From e44b0cbb3e9fbbbfb885698b54be54e0fa02d2df Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 10 Jul 2026 21:04:00 +0200 Subject: [PATCH] feat(cli): warn when the loaded project dylib is stale against the source tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing a project's src/ without cargo build silently ran the previous dylib with plausible stale numbers — the role-2 authoring-loop footgun. The loader now compares the newest mtime under src/ + Cargo.toml against the dylib's mtime right after the ArtifactMissing check and prints one stderr warning naming both timestamps (hand-rolled Hinnant civil_from_days render, no new dependency), then proceeds unchanged: warn, never refuse — a stale run is still deterministic and manifest-stamped. Every I/O failure in the scan degrades to skip-and-continue; staleness can never become a refusal. Fresh builds stay silent (inverse unit-pinned). Verified: headline e2e green, project_load 8/8, full workspace suite green, clippy -D warnings clean; independent quality review approved. closes #237 --- crates/aura-cli/src/project.rs | 129 +++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/crates/aura-cli/src/project.rs b/crates/aura-cli/src/project.rs index 4e8530c..f916eff 100644 --- a/crates/aura-cli/src/project.rs +++ b/crates/aura-cli/src/project.rs @@ -18,6 +18,7 @@ use aura_std::{std_vocabulary, std_vocabulary_types}; use sha2::{Digest, Sha256}; use std::fmt; use std::path::{Path, PathBuf}; +use std::time::SystemTime; /// Parsed `Aura.toml` — static project context only (C17): paths, nothing else. #[derive(Debug, Default, PartialEq, serde::Deserialize)] @@ -345,6 +346,100 @@ fn validate_c_tier( .map(str::to_string) } +/// Render a `SystemTime` as a human-readable UTC timestamp with a 4-digit +/// year (`YYYY-MM-DD HH:MM:SS UTC`). Hand-rolled (Howard Hinnant's +/// civil-from-days algorithm) rather than pulling in a date-formatting +/// dependency for this one warning line. +fn format_mtime(t: SystemTime) -> String { + let secs = t + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let days = secs.div_euclid(86_400); + let day_secs = secs.rem_euclid(86_400); + let (y, m, d) = civil_from_days(days); + format!( + "{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC", + day_secs / 3600, + (day_secs % 3600) / 60, + day_secs % 60 + ) +} + +/// Days-since-epoch -> (year, month, day), UTC civil calendar. See +/// . +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// The role-2 authoring loop's staleness check (#237): if `source_mtime` is +/// strictly newer than `dylib_mtime`, a forgotten `cargo build` would run the +/// previous dylib and report plausible-but-stale numbers. Returns the warning +/// line naming both timestamps, or `None` when the dylib is not stale (the +/// only case where this fires — a fresh build stays silent). +fn stale_warning( + dylib_path: &Path, + dylib_mtime: SystemTime, + source_mtime: SystemTime, +) -> Option { + if source_mtime > dylib_mtime { + Some(format!( + "warning: {} may be stale — dylib built {} but a project source \ + file was modified {} (run `cargo build` to refresh); proceeding \ + with the existing dylib", + dylib_path.display(), + format_mtime(dylib_mtime), + format_mtime(source_mtime), + )) + } else { + None + } +} + +/// The newest modification time among the project's `Cargo.toml` and every +/// file under its `src/` tree (the set that, changed, invalidates the built +/// dylib). `None` if neither exists / is readable — never fatal, staleness +/// is a warning, not a refusal. +fn newest_source_mtime(root: &Path) -> Option { + let mut newest: Option = None; + let mut consider = |t: SystemTime| { + if newest.is_none_or(|n| t > n) { + newest = Some(t); + } + }; + if let Ok(meta) = std::fs::metadata(root.join("Cargo.toml")) + && let Ok(t) = meta.modified() + { + consider(t); + } + let mut stack = vec![root.join("src")]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + match entry.file_type() { + Ok(ft) if ft.is_dir() => stack.push(path), + Ok(_) => { + if let Ok(t) = entry.metadata().and_then(|m| m.modified()) { + consider(t); + } + } + Err(_) => {} + } + } + } + newest +} + /// Locate, load (load-and-hold), verify, and charter-check the project dylib. pub fn load(root: &Path, release: bool) -> Result { let toml = parse_aura_toml(root)?; @@ -352,6 +447,12 @@ pub fn load(root: &Path, release: bool) -> Result { if !dylib_path.is_file() { return Err(ProjectError::ArtifactMissing(dylib_path)); } + if let Ok(dylib_mtime) = std::fs::metadata(&dylib_path).and_then(|m| m.modified()) + && let Some(source_mtime) = newest_source_mtime(root) + && let Some(warning) = stale_warning(&dylib_path, dylib_mtime, source_mtime) + { + eprintln!("{warning}"); + } let bytes = std::fs::read(&dylib_path) .map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?; let dylib_sha256 = format!("{:x}", Sha256::digest(&bytes)); @@ -586,4 +687,32 @@ mod tests { assert!(env.resolve("demo::Identity").is_none()); assert!(env.type_ids().contains(&"SMA")); } + + /// The role-2 authoring-loop staleness check (#237) is a pure comparison: + /// a source mtime strictly newer than the dylib's produces a warning + /// naming both timestamps in human-readable (4-digit-year) form. + #[test] + fn stale_warning_fires_and_names_both_timestamps_when_source_is_newer() { + use std::time::{Duration, UNIX_EPOCH}; + let dylib_mtime = UNIX_EPOCH + Duration::from_secs(993_859_200); // 2001-06-30 + let source_mtime = UNIX_EPOCH + Duration::from_secs(2_003_702_400); // 2033-06-30 + let path = PathBuf::from("/tmp/x.so"); + let msg = stale_warning(&path, dylib_mtime, source_mtime).expect("source is newer"); + assert!(msg.contains("2001"), "names the dylib's mtime: {msg}"); + assert!(msg.contains("2033"), "names the source's newer mtime: {msg}"); + } + + /// The inverse of the above: a fresh (or equally-timed) dylib produces no + /// warning at all — staleness is the only trigger, never routine builds. + #[test] + fn stale_warning_is_silent_when_source_is_not_newer() { + use std::time::{Duration, UNIX_EPOCH}; + let t = UNIX_EPOCH + Duration::from_secs(1_000_000_000); + let path = PathBuf::from("/tmp/x.so"); + assert!(stale_warning(&path, t, t).is_none(), "equal mtimes: not stale"); + assert!( + stale_warning(&path, t, t - Duration::from_secs(10)).is_none(), + "older source: not stale" + ); + } }