Skip to content

Commit 1e5969a

Browse files
committed
feat(graph): multi-root reachability for orphan detection
The orphan rule previously computed reachability from a single entry module, so on a corpus with several CI roots it over-reported: every module reachable only from a non-`All.agda` root looked orphaned. The echo-types dogfood made this concrete — 38 orphan hits, most of them false. Changes - `graph::discover_roots` auto-discovers CI roots (`All.agda`/`Smoke.agda` at any depth); `graph::reachable_from_roots` unions reachability across them. - `LintContext.entry_module: &Path` -> `entry_modules: &[PathBuf]`; the orphan rule flags a module only if it is unreachable from *every* root and is not itself a root. - `scan` and `dag` share `resolve_roots_and_rules`: explicit `--entry` (now repeatable) wins, else auto-discover; if no roots are found the orphan rule is dropped (with a note) rather than flagging everything. - `dag` document now carries `entry_modules` (the roots it used). Verification - cargo fmt/clippy(-D warnings)/test clean; 21 tests (added a graph unit test proving union reachability: Used via All + Other via Smoke are reachable, Orphan via neither is not). - Re-dogfooded on echo-types (193 modules, 5 discovered roots): orphan reports drop 38 -> 17 genuine orphans (the experimental/echo-additive tree incl. VarianceGate.agda, plus standalone scratch files); the `dag` clean count rises 155 -> 176. postulate/pragma hits unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GiSiEfgZCte35dyykgBHs
1 parent 0974837 commit 1e5969a

9 files changed

Lines changed: 162 additions & 76 deletions

File tree

README.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ record.
1414
- Filesystem watcher (`notify`-based)
1515
- Linter rules:
1616
- `missing-safe-pragma` — file lacks `{-# OPTIONS --safe --without-K #-}`
17-
- `orphan-module``.agda` file not imported from `All.agda`
17+
- `orphan-module``.agda` file not reachable from any CI root (roots
18+
are auto-discovered as `All.agda`/`Smoke.agda`, or passed via `--entry`;
19+
reachability is the *union*, so a module verified from any root is not
20+
an orphan)
1821
- `unjustified-postulate``postulate` without an adjacent `-- JUSTIFY:` comment
1922
- Workspace state machine — transitions are file moves, each logged to
2023
`.arghda/events.jsonl` (`claim`, `promote`, `reject`, `requeue`,
@@ -28,9 +31,14 @@ record.
2831
`reject`, `requeue`, `invalidate`, `events`, `watch`
2932

3033
Dogfooded against the echo-types corpus (193 modules): `dag` emits the
31-
903-edge import graph; `scan` flags the known real orphan
32-
(`experimental/echo-additive/VarianceGate.agda`) and the files deliberately
33-
outside the `--safe --without-K` kernel cone.
34+
903-edge import graph; multi-root discovery (5 roots: `All.agda`,
35+
`Smoke.agda`, `Ordinal/Buchholz/Smoke.agda`, `characteristic/All.agda`,
36+
`examples/All.agda`) narrows orphan reports from 38 to the 17 genuine
37+
orphans — the `experimental/echo-additive/` tree (including
38+
`VarianceGate.agda`, the orphan the 2026-06-16 trust audit found by hand)
39+
plus standalone scratch files. `scan` also flags the files deliberately
40+
outside the `--safe --without-K` kernel cone (`Fidelity.agda`, the cubical
41+
island, the postulated shadow).
3442

3543
Not yet: the remaining lint rules (`missing-without-k`, `unpinned-headline`,
3644
`unused-import`, `tab-mix`), content-hash invalidation of `proven`, the

src/dag.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,24 +46,25 @@ pub struct Blocked {
4646
pub struct DagDocument {
4747
pub version: &'static str,
4848
pub include_root: PathBuf,
49-
pub entry_module: PathBuf,
49+
pub entry_modules: Vec<PathBuf>,
5050
pub generated_at: String,
5151
pub nodes: Vec<DagNode>,
5252
pub edges: Vec<Edge>,
5353
pub blocked: Vec<Blocked>,
5454
}
5555

5656
/// Build the DAG document for the source tree at `include_root`, using
57-
/// `entry` for the orphan-reachability rule and `rules` as the lint pack.
57+
/// `entry_modules` (the union of CI roots) for the orphan-reachability rule
58+
/// and `rules` as the lint pack.
5859
pub fn build(
5960
include_root: &Path,
60-
entry: &Path,
61+
entry_modules: &[PathBuf],
6162
rules: &[Box<dyn LintRule>],
6263
) -> Result<DagDocument> {
6364
let graph = graph::build(include_root)?;
6465
let ctx = LintContext {
6566
include_root,
66-
entry_module: entry,
67+
entry_modules,
6768
};
6869

6970
let mut nodes = Vec::with_capacity(graph.nodes.len());
@@ -107,7 +108,7 @@ pub fn build(
107108
Ok(DagDocument {
108109
version: "0.1",
109110
include_root: include_root.to_path_buf(),
110-
entry_module: entry.to_path_buf(),
111+
entry_modules: entry_modules.to_vec(),
111112
generated_at: now_rfc3339(),
112113
nodes,
113114
edges: graph.edges,

src/graph.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,44 @@ pub fn transitive_imports(entry: &Path, include_root: &Path) -> Result<HashSet<S
113113
Ok(reachable)
114114
}
115115

116+
/// Union of the modules reachable from each root in `roots`. A module
117+
/// verified from *any* CI entry point counts as reachable.
118+
pub fn reachable_from_roots(roots: &[PathBuf], include_root: &Path) -> Result<HashSet<String>> {
119+
let mut all = HashSet::new();
120+
for root in roots {
121+
for m in transitive_imports(root, include_root)? {
122+
all.insert(m);
123+
}
124+
}
125+
Ok(all)
126+
}
127+
128+
/// Discover conventional CI root modules under `include_root`: every file
129+
/// named `All.agda` or `Smoke.agda`, at any depth. These are the entry
130+
/// points an estate `--safe --without-K` workspace registers its verified
131+
/// suite from (e.g. echo-types has `All.agda`, `Smoke.agda`,
132+
/// `characteristic/All.agda`, `examples/All.agda`, `tutorial/All.agda`).
133+
pub fn discover_roots(include_root: &Path) -> Vec<PathBuf> {
134+
let mut roots = Vec::new();
135+
for entry in WalkDir::new(include_root)
136+
.into_iter()
137+
.filter_map(|e| e.ok())
138+
{
139+
let path = entry.path();
140+
if path.extension().and_then(|s| s.to_str()) != Some("agda") {
141+
continue;
142+
}
143+
if matches!(
144+
path.file_name().and_then(|s| s.to_str()),
145+
Some("All.agda") | Some("Smoke.agda")
146+
) {
147+
roots.push(path.to_path_buf());
148+
}
149+
}
150+
roots.sort();
151+
roots
152+
}
153+
116154
/// A node in the import graph: a `.agda` source file and its module name.
117155
#[derive(Clone, Debug, Serialize)]
118156
pub struct GraphNode {
@@ -221,4 +259,35 @@ mod tests {
221259
assert!(imports.contains(&"Baz".to_string()));
222260
assert!(!imports.iter().any(|i| i.contains("Ignored")));
223261
}
262+
263+
#[test]
264+
fn discover_and_reach_over_multiple_roots() {
265+
let tmp = tempfile::tempdir().unwrap();
266+
let r = tmp.path();
267+
std::fs::write(r.join("All.agda"), "module All where\nopen import Used\n").unwrap();
268+
std::fs::write(
269+
r.join("Smoke.agda"),
270+
"module Smoke where\nopen import Other\n",
271+
)
272+
.unwrap();
273+
std::fs::write(r.join("Used.agda"), "module Used where\n").unwrap();
274+
std::fs::write(r.join("Other.agda"), "module Other where\n").unwrap();
275+
std::fs::write(r.join("Orphan.agda"), "module Orphan where\n").unwrap();
276+
277+
let roots = discover_roots(r);
278+
let names: Vec<String> = roots
279+
.iter()
280+
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
281+
.collect();
282+
assert_eq!(roots.len(), 2, "discovers All.agda + Smoke.agda: {names:?}");
283+
assert!(names.contains(&"All.agda".to_string()));
284+
assert!(names.contains(&"Smoke.agda".to_string()));
285+
286+
// Reachability is the UNION: Used (via All) and Other (via Smoke)
287+
// are both reachable; Orphan (via neither) is not.
288+
let reachable = reachable_from_roots(&roots, r).unwrap();
289+
assert!(reachable.contains("Used"));
290+
assert!(reachable.contains("Other"));
291+
assert!(!reachable.contains("Orphan"));
292+
}
224293
}

src/lint/mod.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::diagnostic::LintReport;
22
use anyhow::Result;
3-
use std::path::Path;
3+
use std::path::{Path, PathBuf};
44

55
pub mod orphan_module;
66
pub mod postulate;
@@ -12,8 +12,10 @@ pub struct LintContext<'a> {
1212
/// Agda include root; `.agda` files' module names are computed
1313
/// relative to this path.
1414
pub include_root: &'a Path,
15-
/// Path to the `All.agda` (or equivalent) entry module.
16-
pub entry_module: &'a Path,
15+
/// The root modules (e.g. `All.agda`, `Smoke.agda`). Reachability is
16+
/// computed from the *union* of these, so a module verified from any
17+
/// CI entry point is not an orphan.
18+
pub entry_modules: &'a [PathBuf],
1719
}
1820

1921
pub trait LintRule: Send + Sync {

src/lint/orphan_module.rs

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use super::{LintContext, LintRule};
22
use crate::diagnostic::{Diagnostic, LintReport, Severity};
3-
use crate::graph::{module_name_of, transitive_imports};
3+
use crate::graph::{module_name_of, reachable_from_roots};
44
use anyhow::{Context, Result};
55
use std::path::Path;
66

@@ -16,28 +16,27 @@ impl LintRule for OrphanModule {
1616
return Ok(()); // file sits outside include_root; nothing to say
1717
};
1818

19-
// The entry module itself is never an orphan of itself.
20-
if Some(module.as_str()) == module_name_of(ctx.entry_module, ctx.include_root).as_deref() {
19+
// A root module is never an orphan of itself.
20+
let is_root = ctx
21+
.entry_modules
22+
.iter()
23+
.any(|root| module_name_of(root, ctx.include_root).as_deref() == Some(module.as_str()));
24+
if is_root {
2125
return Ok(());
2226
}
2327

24-
let reachable =
25-
transitive_imports(ctx.entry_module, ctx.include_root).with_context(|| {
26-
format!(
27-
"computing transitive imports from {}",
28-
ctx.entry_module.display()
29-
)
30-
})?;
28+
let reachable = reachable_from_roots(ctx.entry_modules, ctx.include_root)
29+
.context("computing reachability from root modules")?;
3130

3231
if !reachable.contains(&module) {
3332
report.push(Diagnostic {
3433
rule: self.name().to_string(),
3534
severity: Severity::HardBlock,
3635
file: file.to_path_buf(),
3736
message: format!(
38-
"module `{}` is not reachable via imports from `{}`",
37+
"module `{}` is not reachable via imports from any of the {} root module(s)",
3938
module,
40-
ctx.entry_module.display()
39+
ctx.entry_modules.len()
4140
),
4241
line: None,
4342
});

src/lint/postulate.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,10 @@ mod tests {
7373
fn lint_str(body: &str) -> LintReport {
7474
let tmp = tempfile::NamedTempFile::new().unwrap();
7575
std::fs::write(tmp.path(), body).unwrap();
76+
let roots = [tmp.path().to_path_buf()];
7677
let ctx = LintContext {
7778
include_root: tmp.path().parent().unwrap(),
78-
entry_module: tmp.path(),
79+
entry_modules: &roots,
7980
};
8081
let rules: Vec<Box<dyn LintRule>> = vec![Box::new(UnjustifiedPostulate)];
8182
run_lints(tmp.path(), &ctx, &rules).unwrap()

0 commit comments

Comments
 (0)