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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user