Skip to content

Commit 0962e46

Browse files
feat(proven): transitive-import closure hashing + cycle-safety regression (#26)
## Summary The two **corrective musts** from the v0.2 maintenance roadmap. Closes #12 · Closes #13. ### #12 — transitive-import closure hashing (the real fix) `proven` invalidation hashed a file's own bytes only, so a promoted file whose **dependency** changed upstream stayed green in `proven/` while logically broken — exactly the silent-failure class arghda exists to catch. Now, when a workspace declares a source tree (`[proven] include_root` in `.arghda/config.toml`), promotion records a **closure hash** (the file + every transitive import that resolves in-tree, deterministic order), and `stale` re-checks it — distinguishing `content changed since promotion` from `a transitive import changed since promotion`. Design keeps it backward-compatible and low-blast-radius: the include root is a **workspace property**, so `transition`/`stale_proven` signatures are unchanged, existing call sites and tests are untouched, and older `hashes.json` manifests still load (`closure_sha256` is optional). ### #13 — cycle-safety regression test On inspection, `graph::transitive_imports` is **already** cycle-safe (the `reachable` visited-set prevents re-traversal) and `build` is a flat pass — so the survey's "infinite-loop risk" was overstated. Rather than invent a fix for a bug that isn't there, this pins the property with a regression test (A↔B fixture terminates), guarding against a refactor reintroducing a loop — relevant because `watch` runs over `working/`, where half-edited files can transiently form a cycle. ## Changes - `src/proven.rs` — `closure_hash()`; `ProvenRecord.closure_sha256` (optional). - `src/config.rs` — `[proven] include_root` knob + `proven_include_root()` (relative paths resolved against the workspace). - `src/workspace.rs` — record closure on promotion; richer stale reason. - `src/graph.rs` — cycle-termination regression test. - `tests/proven.rs` — end-to-end transitive-staleness test. - `STATE.a2ml` — content-hash milestone 75% → 100%. ## Verification Full CI gate green locally: `cargo fmt --check`, `cargo build --all-targets`, `cargo test` (50 unit + integration, incl. the 4 new tests), `cargo clippy --all-targets -- -D warnings`, SPDX invariant. ## Context First two items of the corrective/adaptive/perfective backlog filed as #12#25. Remaining corrective follow-ups: #14 (parser resilience), #15 (timestamp KATs). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019GiSiEfgZCte35dyykgBHs --- _Generated by [Claude Code](https://claude.ai/code/session_019GiSiEfgZCte35dyykgBHs)_
2 parents e3d0791 + d278d5c commit 0962e46

6 files changed

Lines changed: 192 additions & 10 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ milestones = [
2727
{ name = "State machine (claim/promote/reject/requeue/invalidate) + events.jsonl", completion = 100 },
2828
{ name = "`check` command (Agda typecheck + lint verdict)", completion = 100 },
2929
{ name = "Multi-root reachability (union of discovered CI roots)", completion = 100 },
30-
{ name = "Content-hash invalidation of proven (file-level; transitive imports deferred)", completion = 75 },
30+
{ name = "Content-hash invalidation of proven (file-level + transitive-import closure via [proven] include_root)", completion = 100 },
3131
{ name = "Operator config (.arghda/config.toml + --headline-pattern + --config)", completion = 100 },
3232
{ name = "RSR / standards alignment (.machine_readable, README.adoc, EXPLAINME, LICENSE, .well-known)", completion = 100 },
3333
{ name = "Groove service manifest (/.well-known/groove) for PanLL discovery", completion = 0 },
@@ -40,8 +40,8 @@ milestones = [
4040

4141
[critical-next-actions]
4242
actions = [
43-
"Template-applicability: rsr-profile.a2ml declares preset rust-cli (landed); estate policy + gate model + reference checker filed in hyperpolymath/standards#391. Follow-up: wire scripts/check-rsr-profile.sh into CI once standards#391 lands.",
44-
"Extend content-hash invalidation to cover transitive imports (needs include-root tracking)",
43+
"Template-applicability: rsr-profile.a2ml declares capabilities (landed); estate policy + gate model + reference checker landed in hyperpolymath/standards#391 + #392 (pure capability-gating). Follow-up: wire the standards rsr-profile check into CI.",
44+
"Transitive-import closure hashing landed (workspace [proven] include_root); follow-up: also expose it as a --include-root CLI flag on promote/stale.",
4545
"Serve /.well-known/groove for PanLL discovery (Groove protocol)",
4646
"Scaffold arghda-studio (AffineScript visual layer consuming the dag JSON)",
4747
]

src/config.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
use crate::lint::RuleConfig;
2222
use anyhow::{Context, Result};
2323
use serde::Deserialize;
24-
use std::path::Path;
24+
use std::path::{Path, PathBuf};
2525

2626
/// Conventional config location, relative to a source tree or workspace root.
2727
pub const CONFIG_REL_PATH: &str = ".arghda/config.toml";
@@ -33,6 +33,8 @@ pub const CONFIG_REL_PATH: &str = ".arghda/config.toml";
3333
struct ConfigFile {
3434
#[serde(default)]
3535
lint: LintTable,
36+
#[serde(default)]
37+
proven: ProvenTable,
3638
}
3739

3840
#[derive(Debug, Default, Deserialize)]
@@ -42,6 +44,13 @@ struct LintTable {
4244
headline_pattern: Option<String>,
4345
}
4446

47+
#[derive(Debug, Default, Deserialize)]
48+
#[serde(deny_unknown_fields)]
49+
struct ProvenTable {
50+
/// Source-tree include root for transitive `proven` closure hashing.
51+
include_root: Option<String>,
52+
}
53+
4554
/// Parse config TOML text into a [`RuleConfig`], overlaying built-in defaults.
4655
fn parse(text: &str) -> Result<RuleConfig> {
4756
let file: ConfigFile = toml::from_str(text).context("parsing .arghda/config.toml")?;
@@ -69,6 +78,29 @@ pub fn load_from_dir(base: &Path) -> Result<RuleConfig> {
6978
}
7079
}
7180

81+
/// The `[proven] include_root` (if any) from `<base>/.arghda/config.toml`,
82+
/// resolved relative to `base` when given as a relative path. This is the
83+
/// source tree against which a promoted file's transitive-import closure is
84+
/// hashed for staleness; `None` means own-bytes hashing only.
85+
pub fn proven_include_root(base: &Path) -> Result<Option<PathBuf>> {
86+
let candidate = base.join(CONFIG_REL_PATH);
87+
if !candidate.is_file() {
88+
return Ok(None);
89+
}
90+
let text = std::fs::read_to_string(&candidate)
91+
.with_context(|| format!("reading config {}", candidate.display()))?;
92+
let file: ConfigFile =
93+
toml::from_str(&text).with_context(|| format!("parsing config {}", candidate.display()))?;
94+
Ok(file.proven.include_root.map(|p| {
95+
let p = PathBuf::from(p);
96+
if p.is_absolute() {
97+
p
98+
} else {
99+
base.join(p)
100+
}
101+
}))
102+
}
103+
72104
#[cfg(test)]
73105
mod tests {
74106
use super::*;
@@ -122,4 +154,42 @@ mod tests {
122154
let cfg = load_from_dir(dir.path()).unwrap();
123155
assert_eq!(cfg.headline_pattern, "^[A-Z].*$");
124156
}
157+
158+
#[test]
159+
fn proven_include_root_absent_is_none() {
160+
let dir = tempfile::tempdir().unwrap();
161+
assert!(proven_include_root(dir.path()).unwrap().is_none());
162+
// A [lint]-only config also yields no include root.
163+
std::fs::create_dir_all(dir.path().join(".arghda")).unwrap();
164+
std::fs::write(dir.path().join(CONFIG_REL_PATH), "[lint]\n").unwrap();
165+
assert!(proven_include_root(dir.path()).unwrap().is_none());
166+
}
167+
168+
#[test]
169+
fn proven_include_root_resolves_absolute_and_relative() {
170+
let dir = tempfile::tempdir().unwrap();
171+
std::fs::create_dir_all(dir.path().join(".arghda")).unwrap();
172+
173+
// Relative is resolved against the workspace base.
174+
std::fs::write(
175+
dir.path().join(CONFIG_REL_PATH),
176+
"[proven]\ninclude_root = \"src\"\n",
177+
)
178+
.unwrap();
179+
assert_eq!(
180+
proven_include_root(dir.path()).unwrap(),
181+
Some(dir.path().join("src"))
182+
);
183+
184+
// Absolute is taken as-is.
185+
std::fs::write(
186+
dir.path().join(CONFIG_REL_PATH),
187+
"[proven]\ninclude_root = \"/abs/tree\"\n",
188+
)
189+
.unwrap();
190+
assert_eq!(
191+
proven_include_root(dir.path()).unwrap(),
192+
Some(std::path::PathBuf::from("/abs/tree"))
193+
);
194+
}
125195
}

src/graph.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,20 @@ mod tests {
263263
assert!(!imports.iter().any(|i| i.contains("Ignored")));
264264
}
265265

266+
#[test]
267+
fn transitive_imports_terminates_on_a_cycle() {
268+
// A <-> B mutual import is illegal in Agda, but a half-written file
269+
// under active edit in `working/` can transiently produce one. The
270+
// `reachable` visited-set must stop the walk from looping forever.
271+
let tmp = tempfile::tempdir().unwrap();
272+
let r = tmp.path();
273+
std::fs::write(r.join("A.agda"), "module A where\nopen import B\n").unwrap();
274+
std::fs::write(r.join("B.agda"), "module B where\nopen import A\n").unwrap();
275+
let reach = transitive_imports(&r.join("A.agda"), r).unwrap();
276+
assert!(reach.contains("A"));
277+
assert!(reach.contains("B"));
278+
}
279+
266280
#[test]
267281
fn discover_and_reach_over_multiple_roots() {
268282
let tmp = tempfile::tempdir().unwrap();

src/proven.rs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
//! can be sent back to `inbox` — the `proven -> inbox` invalidation from
1010
//! `docs/arghda-spec.adoc`.
1111
//!
12-
//! v1 hashes the file content only. Hashing a file *plus its transitive
13-
//! imports* (the spec's full form) needs the workspace to know the source
14-
//! tree's include root, which the flat triage layout does not yet carry;
15-
//! that is a documented follow-on.
12+
//! [`hash_file`] hashes the file's own bytes. [`closure_hash`] additionally
13+
//! folds in every transitive import that resolves inside a source-tree
14+
//! include root — the spec's full form, so a promoted file goes stale when a
15+
//! proof *under* it changes, not only when it is edited. The include root is
16+
//! a workspace property (`[proven] include_root` in `.arghda/config.toml`);
17+
//! when it is unset, only own-bytes hashing applies.
1618
1719
use crate::hash::sha256_hex;
1820
use anyhow::{Context, Result};
@@ -26,7 +28,14 @@ pub const HASHES_FILE: &str = "hashes.json";
2628
/// What was recorded when a file entered `proven`.
2729
#[derive(Clone, Debug, Serialize, Deserialize)]
2830
pub struct ProvenRecord {
31+
/// SHA-256 of the file's own bytes.
2932
pub sha256: String,
33+
/// SHA-256 of the file *and its transitive-import closure*, recorded when
34+
/// a source-tree include root was known at promotion. `None` for files
35+
/// promoted without one (own-bytes checking only); absent in older
36+
/// manifests, which still load.
37+
#[serde(default, skip_serializing_if = "Option::is_none")]
38+
pub closure_sha256: Option<String>,
3039
pub promoted_at: String,
3140
}
3241

@@ -67,3 +76,34 @@ pub fn hash_file(path: &Path) -> Result<String> {
6776
let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
6877
Ok(sha256_hex(&bytes))
6978
}
79+
80+
/// SHA-256 over `file`'s own bytes plus the bytes of every transitive import
81+
/// that resolves to a file inside `include_root`, in deterministic module
82+
/// order. This is the spec's full `proven` invalidation form: a promoted file
83+
/// is stale not only when it is edited, but when any proof it depends on is.
84+
///
85+
/// `file` may live outside `include_root` (the usual case — it has been moved
86+
/// into the triage `proven/` dir); its imports are still resolved against the
87+
/// source tree at `include_root`. Imports that do not resolve in-tree (stdlib
88+
/// / external) are skipped, exactly as the import graph omits them.
89+
pub fn closure_hash(file: &Path, include_root: &Path) -> Result<String> {
90+
let mut deps: BTreeMap<String, String> = BTreeMap::new();
91+
for module in crate::graph::transitive_imports(file, include_root)? {
92+
let path = crate::graph::module_to_path(&module, include_root);
93+
if path.is_file() {
94+
deps.insert(module, hash_file(&path)?);
95+
}
96+
}
97+
// Entry's own hash first, then sorted `module\0hash` lines: deterministic,
98+
// and sensitive to a dependency being added, removed, or edited.
99+
let mut buf = String::with_capacity(72 * (deps.len() + 1));
100+
buf.push_str(&hash_file(file)?);
101+
buf.push('\n');
102+
for (module, hash) in &deps {
103+
buf.push_str(module);
104+
buf.push('\0');
105+
buf.push_str(hash);
106+
buf.push('\n');
107+
}
108+
Ok(sha256_hex(buf.as_bytes()))
109+
}

src/workspace.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,20 @@ impl Workspace {
128128

129129
// Maintain the proven content-hash manifest: record on entry to
130130
// `proven`, drop on exit. This is what lets `stale_proven` detect a
131-
// file edited after promotion.
131+
// file edited after promotion. When the workspace declares a source
132+
// tree (`[proven] include_root`), also record the transitive-import
133+
// closure hash so an edit to a *dependency* invalidates the file too.
132134
if to == State::Proven {
133135
let mut manifest = proven::load(&self.root)?;
136+
let closure_sha256 = match crate::config::proven_include_root(&self.root)? {
137+
Some(root) => Some(proven::closure_hash(&dst, &root)?),
138+
None => None,
139+
};
134140
manifest.entries.insert(
135141
file_name.to_string(),
136142
ProvenRecord {
137143
sha256: proven::hash_file(&dst)?,
144+
closure_sha256,
138145
promoted_at: ev.ts.clone(),
139146
},
140147
);
@@ -156,6 +163,7 @@ impl Workspace {
156163
/// for `proven -> inbox` invalidation.
157164
pub fn stale_proven(&self) -> Result<Vec<StaleEntry>> {
158165
let manifest = proven::load(&self.root)?;
166+
let include_root = crate::config::proven_include_root(&self.root)?;
159167
let mut out = Vec::new();
160168
for path in self.list(State::Proven)? {
161169
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
@@ -165,7 +173,15 @@ impl Workspace {
165173
let reason = match manifest.entries.get(name) {
166174
None => Some("no recorded hash"),
167175
Some(rec) if rec.sha256 != current => Some("content changed since promotion"),
168-
Some(_) => None,
176+
// Own bytes match: if a closure hash was recorded and the
177+
// source tree is still known, a mismatch now means a
178+
// transitive import changed under the file.
179+
Some(rec) => match (&rec.closure_sha256, &include_root) {
180+
(Some(stored), Some(root)) if proven::closure_hash(&path, root)? != *stored => {
181+
Some("a transitive import changed since promotion")
182+
}
183+
_ => None,
184+
},
169185
};
170186
if let Some(reason) = reason {
171187
out.push(StaleEntry {

tests/proven.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,48 @@ fn invalidate_returns_to_inbox_and_drops_the_hash() {
6363
assert!(!manifest.entries.contains_key("Bar.agda"));
6464
}
6565

66+
#[test]
67+
fn editing_a_transitive_import_makes_a_proven_file_stale() {
68+
let tmp = tempfile::tempdir().unwrap();
69+
let ws = Workspace::init(tmp.path()).unwrap();
70+
71+
// A source tree (separate from the triage dirs) holding the dependency.
72+
let src = tmp.path().join("src");
73+
fs::create_dir_all(&src).unwrap();
74+
fs::write(src.join("Dep.agda"), "module Dep where\nx = Set\n").unwrap();
75+
76+
// Point the workspace at that source tree for closure hashing.
77+
fs::write(
78+
tmp.path().join(".arghda").join("config.toml"),
79+
format!("[proven]\ninclude_root = \"{}\"\n", src.display()),
80+
)
81+
.unwrap();
82+
83+
// A file that depends on Dep, promoted to proven.
84+
fs::write(
85+
ws.state_dir(State::Working).join("Main.agda"),
86+
"module Main where\nopen import Dep\n",
87+
)
88+
.unwrap();
89+
ws.transition("Main.agda", State::Working, State::Proven, None)
90+
.unwrap();
91+
92+
// Promotion recorded a closure hash, and nothing is stale yet.
93+
let manifest = proven::load(tmp.path()).unwrap();
94+
assert!(manifest.entries["Main.agda"].closure_sha256.is_some());
95+
assert!(ws.stale_proven().unwrap().is_empty());
96+
97+
// Edit the DEPENDENCY, not the proven file itself -> stale via closure.
98+
fs::write(src.join("Dep.agda"), "module Dep where\nx = Set\ny = Set\n").unwrap();
99+
let stale = ws.stale_proven().unwrap();
100+
assert_eq!(stale.len(), 1);
101+
assert_eq!(stale[0].file, "Main.agda");
102+
assert_eq!(
103+
stale[0].reason,
104+
"a transitive import changed since promotion"
105+
);
106+
}
107+
66108
#[test]
67109
fn proven_file_without_a_record_is_stale() {
68110
let tmp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)