feat(cli): warn when the loaded project dylib is stale against the source tree

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
This commit is contained in:
2026-07-10 21:04:00 +02:00
parent 143c87c0ae
commit e44b0cbb3e
+129
View File
@@ -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
/// <https://howardhinnant.github.io/date_algorithms.html#civil_from_days>.
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<String> {
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<SystemTime> {
let mut newest: Option<SystemTime> = 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<ProjectEnv, ProjectError> {
let toml = parse_aura_toml(root)?;
@@ -352,6 +447,12 @@ pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> {
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"
);
}
}