Skip to content

Commit ace54f3

Browse files
feat: finish the v0.1 engine — dag, check, state machine, event stream (#2)
## Phase 1 — finish the v0.1 engine Implements the spec's first-sprint items 4/5/6 (`docs/arghda-spec.adoc`) plus a soundness lint. The engine now produces the JSON contract the planned AffineScript visual layer (`arghda-studio`, see `docs/arghda-vision.adoc`) will consume. **Verified with the real Agda toolchain (2.6.3) and dogfooded against the echo-types corpus**, not eyeballed. ### New capabilities - **`graph` module** — the Agda import graph is now first-class (nodes + typed `imports` edges, deterministic output). Lifted out of the `orphan-module` rule, which had computed the edges and *thrown them away*; the rule now reuses it. - **`dag` command** — emits the dependency-DAG JSON: nodes with lint-derived status (`clean`/`warn`/`blocked`), import edges, and a `blocked` list (self-blocked by rule + prerequisite-blocked by an unclean import). - **Workspace state machine** — `transition`/`state_of` move files between the four triage states, validated against the spec's transition table, each move appended to `.arghda/events.jsonl`. CLI: `claim`, `promote`, `reject`, `requeue`, `invalidate`, `events`. - **`check` command** — runs Agda on a file and combines the typecheck verdict with the lint report (`proven-eligible` / `rejected` / `agda-unavailable`); degrades gracefully when `agda` is absent. - **`event`** (append-only JSONL log + reader), **`agda`** (typecheck invocation, graceful `NotFound`), **`timestamp`** (dependency-free RFC 3339 — keeps the build hermetic against the lockfile). - **New lint rule `unjustified-postulate`** (hard-block) — a `postulate` opener with no adjacent `-- JUSTIFY:` comment. ### Verification (the "do it right, not looks-fine" part) - `cargo fmt --check` / `clippy -D warnings` / `build` / `test` all clean — **20 tests** (11 unit + 9 integration: `dag`, `transitions`, `smoke`). - **`check` against real Agda 2.6.3:** clean file → `proven-eligible`; a file that *typechecks but lacks `--safe`* → `rejected` by lint (the silent-failure class the spec targets); a type error → `rejected` (agda exit 42). JSON output verified. - **`dag`/`scan` dogfooded on echo-types (193 modules):** 903 import edges; `scan` independently rediscovers the orphan the 2026-06-16 trust audit found *by hand* (`experimental/echo-additive/VarianceGate.agda`) and the three files deliberately outside the `--safe --without-K` kernel cone (`Fidelity.agda`, the cubical island, the postulated shadow). ### Deferred to the next slice Remaining lint rules (`missing-without-k`, `unpinned-headline`, `unused-import`, `tab-mix`), content-hash invalidation of `proven`, multi-root reachability (echo-types is reachable from a *union* of CI roots, so single-entry orphan detection over-reports), the Groove service manifest, and the `.machine_readable/` RSR retrofit. Draft for review. 🤖 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 6deb96a + 1e5969a commit ace54f3

15 files changed

Lines changed: 1282 additions & 183 deletions

File tree

README.md

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,37 @@ record.
1212
- `Workspace` struct with four-state dir layout (`inbox`, `working`,
1313
`proven`, `rejected`)
1414
- Filesystem watcher (`notify`-based)
15-
- Two linter rules:
15+
- Linter rules:
1616
- `missing-safe-pragma` — file lacks `{-# OPTIONS --safe --without-K #-}`
17-
- `orphan-module``.agda` file not imported from `All.agda`
18-
- CLI (`arghda`) with subcommands: `init`, `scan`, `watch`
19-
20-
Not yet: `promote`, `reject`, `dag` (v0.1.x).
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)
21+
- `unjustified-postulate``postulate` without an adjacent `-- JUSTIFY:` comment
22+
- Workspace state machine — transitions are file moves, each logged to
23+
`.arghda/events.jsonl` (`claim`, `promote`, `reject`, `requeue`,
24+
`invalidate`)
25+
- `dag` — emits the dependency-DAG JSON (nodes + import edges + blocked
26+
list) for a source tree: the contract a visual layer consumes
27+
- `check` — runs Agda on a file and combines the typecheck verdict with the
28+
lint report (degrades gracefully when `agda` is absent)
29+
- First-class import graph (the `graph` module, lifted out of the orphan rule)
30+
- CLI (`arghda`): `init`, `scan`, `check`, `dag`, `claim`, `promote`,
31+
`reject`, `requeue`, `invalidate`, `events`, `watch`
32+
33+
Dogfooded against the echo-types corpus (193 modules): `dag` emits the
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).
42+
43+
Not yet: the remaining lint rules (`missing-without-k`, `unpinned-headline`,
44+
`unused-import`, `tab-mix`), content-hash invalidation of `proven`, the
45+
Groove service manifest, and the `.machine_readable/` RSR retrofit.
2146

2247
## Build
2348

src/agda.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//! Shelling out to `agda` to typecheck a file.
2+
//!
3+
//! ArghDA never proves anything itself — it asks Agda. This module runs
4+
//! the typechecker and captures the verdict. If `agda` is not on `PATH`
5+
//! it degrades gracefully (`available: false`) rather than erroring, so
6+
//! the rest of the engine still works in an Agda-less environment (CI
7+
//! linting, DAG extraction, triage moves).
8+
9+
use anyhow::Result;
10+
use serde::Serialize;
11+
use std::path::Path;
12+
use std::process::Command;
13+
14+
const TAIL_LINES: usize = 40;
15+
16+
/// Result of a typecheck attempt.
17+
#[derive(Clone, Debug, Serialize)]
18+
pub struct AgdaOutcome {
19+
/// Whether the `agda` binary was found and executed.
20+
pub available: bool,
21+
/// Process exit code, if the process ran.
22+
pub exit_code: Option<i32>,
23+
/// `true` iff agda exited 0.
24+
pub ok: bool,
25+
/// Last few lines of combined stdout+stderr (for surfacing errors).
26+
pub output_tail: String,
27+
}
28+
29+
impl AgdaOutcome {
30+
fn unavailable() -> Self {
31+
Self {
32+
available: false,
33+
exit_code: None,
34+
ok: false,
35+
output_tail: String::new(),
36+
}
37+
}
38+
}
39+
40+
/// Typecheck `file` with `include_root` on the search path
41+
/// (`agda -i <include_root> <file>`).
42+
pub fn check_file(file: &Path, include_root: &Path) -> Result<AgdaOutcome> {
43+
let output = Command::new("agda")
44+
.arg("-i")
45+
.arg(include_root)
46+
.arg(file)
47+
.output();
48+
49+
match output {
50+
Ok(out) => {
51+
let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
52+
combined.push_str(&String::from_utf8_lossy(&out.stderr));
53+
Ok(AgdaOutcome {
54+
available: true,
55+
exit_code: out.status.code(),
56+
ok: out.status.success(),
57+
output_tail: tail(&combined, TAIL_LINES),
58+
})
59+
}
60+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AgdaOutcome::unavailable()),
61+
Err(e) => Err(e.into()),
62+
}
63+
}
64+
65+
fn tail(s: &str, n: usize) -> String {
66+
let lines: Vec<&str> = s.lines().collect();
67+
let start = lines.len().saturating_sub(n);
68+
lines[start..].join("\n")
69+
}
70+
71+
#[cfg(test)]
72+
mod tests {
73+
use super::*;
74+
75+
#[test]
76+
fn tail_keeps_last_n_lines() {
77+
assert_eq!(tail("a\nb\nc\nd", 2), "c\nd");
78+
assert_eq!(tail("only", 5), "only");
79+
assert_eq!(tail("", 3), "");
80+
}
81+
}

src/dag.rs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
//! The DAG document — the JSON contract a visual layer consumes.
2+
//!
3+
//! Builds on the import graph (`crate::graph`) plus a lint pass over every
4+
//! node. The schema follows `docs/arghda-spec.adoc`; `status` is honest
5+
//! about what the engine knows without a typecheck: it is lint-derived
6+
//! (`clean` / `warn` / `blocked`), not a claim of `proven`. Triage status
7+
//! (proven/rejected) attaches when files flow through a workspace and are
8+
//! checked with Agda.
9+
10+
use crate::diagnostic::Severity;
11+
use crate::graph::{self, Edge};
12+
use crate::lint::{run_lints, LintContext, LintRule};
13+
use crate::timestamp::now_rfc3339;
14+
use anyhow::Result;
15+
use serde::Serialize;
16+
use std::collections::{BTreeMap, BTreeSet};
17+
use std::path::{Path, PathBuf};
18+
19+
/// Per-node lint summary: the rule names that fired, by severity.
20+
#[derive(Clone, Debug, Default, Serialize)]
21+
pub struct LintSummary {
22+
pub hard_block: Vec<String>,
23+
pub warn: Vec<String>,
24+
}
25+
26+
/// A DAG node: an in-tree module with its lint-derived status.
27+
#[derive(Clone, Debug, Serialize)]
28+
pub struct DagNode {
29+
pub id: String,
30+
pub file: PathBuf,
31+
/// `clean` | `warn` | `blocked` (lint-derived; not a proof claim).
32+
pub status: &'static str,
33+
pub lint: LintSummary,
34+
}
35+
36+
/// A module that cannot advance, and why.
37+
#[derive(Clone, Debug, Serialize)]
38+
pub struct Blocked {
39+
pub node: String,
40+
pub blocked_by: Vec<String>,
41+
pub reason: String,
42+
}
43+
44+
/// The full emitted document.
45+
#[derive(Clone, Debug, Serialize)]
46+
pub struct DagDocument {
47+
pub version: &'static str,
48+
pub include_root: PathBuf,
49+
pub entry_modules: Vec<PathBuf>,
50+
pub generated_at: String,
51+
pub nodes: Vec<DagNode>,
52+
pub edges: Vec<Edge>,
53+
pub blocked: Vec<Blocked>,
54+
}
55+
56+
/// Build the DAG document for the source tree at `include_root`, using
57+
/// `entry_modules` (the union of CI roots) for the orphan-reachability rule
58+
/// and `rules` as the lint pack.
59+
pub fn build(
60+
include_root: &Path,
61+
entry_modules: &[PathBuf],
62+
rules: &[Box<dyn LintRule>],
63+
) -> Result<DagDocument> {
64+
let graph = graph::build(include_root)?;
65+
let ctx = LintContext {
66+
include_root,
67+
entry_modules,
68+
};
69+
70+
let mut nodes = Vec::with_capacity(graph.nodes.len());
71+
let mut self_blocked: BTreeMap<String, Vec<String>> = BTreeMap::new();
72+
73+
for gn in &graph.nodes {
74+
let abs = include_root.join(&gn.file);
75+
let report = run_lints(&abs, &ctx, rules)?;
76+
77+
let mut summary = LintSummary::default();
78+
for d in &report.diagnostics {
79+
match d.severity {
80+
Severity::HardBlock => summary.hard_block.push(d.rule.clone()),
81+
Severity::Warn => summary.warn.push(d.rule.clone()),
82+
}
83+
}
84+
summary.hard_block.sort();
85+
summary.hard_block.dedup();
86+
summary.warn.sort();
87+
summary.warn.dedup();
88+
89+
let status = if !summary.hard_block.is_empty() {
90+
self_blocked.insert(gn.id.clone(), summary.hard_block.clone());
91+
"blocked"
92+
} else if !summary.warn.is_empty() {
93+
"warn"
94+
} else {
95+
"clean"
96+
};
97+
98+
nodes.push(DagNode {
99+
id: gn.id.clone(),
100+
file: gn.file.clone(),
101+
status,
102+
lint: summary,
103+
});
104+
}
105+
106+
let blocked = compute_blocked(&self_blocked, &graph.edges);
107+
108+
Ok(DagDocument {
109+
version: "0.1",
110+
include_root: include_root.to_path_buf(),
111+
entry_modules: entry_modules.to_vec(),
112+
generated_at: now_rfc3339(),
113+
nodes,
114+
edges: graph.edges,
115+
blocked,
116+
})
117+
}
118+
119+
/// A node is blocked if it hard-blocks on its own (`reason` = the rule
120+
/// names) or if any module it imports is itself hard-blocked
121+
/// (`blocked_by` = those prerequisites). Both are surfaced so the visual
122+
/// layer can colour a node red for its own fault *and* show the upstream
123+
/// wall it is waiting on.
124+
fn compute_blocked(self_blocked: &BTreeMap<String, Vec<String>>, edges: &[Edge]) -> Vec<Blocked> {
125+
let mut out = Vec::new();
126+
127+
for (node, rules) in self_blocked {
128+
out.push(Blocked {
129+
node: node.clone(),
130+
blocked_by: Vec::new(),
131+
reason: rules.join(", "),
132+
});
133+
}
134+
135+
// Group each node's hard-blocked direct prerequisites.
136+
let mut deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
137+
for e in edges {
138+
if self_blocked.contains_key(&e.to) {
139+
deps.entry(e.from.clone()).or_default().insert(e.to.clone());
140+
}
141+
}
142+
for (node, prereqs) in deps {
143+
out.push(Blocked {
144+
node,
145+
blocked_by: prereqs.into_iter().collect(),
146+
reason: "prerequisite not clean".to_string(),
147+
});
148+
}
149+
150+
out.sort_by(|a, b| (&a.node, &a.reason).cmp(&(&b.node, &b.reason)));
151+
out
152+
}

src/event.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//! The triage event stream.
2+
//!
3+
//! Every state transition appends one JSON object (one line) to
4+
//! `<workspace>/.arghda/events.jsonl`. The log is append-only and is the
5+
//! audit trail a downstream visual layer replays to reconstruct history.
6+
7+
use crate::timestamp::now_rfc3339;
8+
use crate::workspace::State;
9+
use anyhow::{Context, Result};
10+
use serde::{Deserialize, Serialize};
11+
use std::fs::{self, OpenOptions};
12+
use std::io::Write;
13+
use std::path::{Path, PathBuf};
14+
15+
/// What happened. Mirrors the transition table in `arghda-spec.adoc`.
16+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
17+
#[serde(rename_all = "kebab-case")]
18+
pub enum EventKind {
19+
Claim,
20+
Promote,
21+
Reject,
22+
Requeue,
23+
Invalidate,
24+
}
25+
26+
/// A single transition record.
27+
#[derive(Clone, Debug, Serialize, Deserialize)]
28+
pub struct Event {
29+
pub ts: String,
30+
pub kind: EventKind,
31+
pub file: String,
32+
#[serde(skip_serializing_if = "Option::is_none")]
33+
pub from: Option<State>,
34+
#[serde(skip_serializing_if = "Option::is_none")]
35+
pub to: Option<State>,
36+
#[serde(skip_serializing_if = "Option::is_none")]
37+
pub note: Option<String>,
38+
}
39+
40+
impl Event {
41+
pub fn new(kind: EventKind, file: impl Into<String>) -> Self {
42+
Self {
43+
ts: now_rfc3339(),
44+
kind,
45+
file: file.into(),
46+
from: None,
47+
to: None,
48+
note: None,
49+
}
50+
}
51+
52+
pub fn with_from(mut self, from: State) -> Self {
53+
self.from = Some(from);
54+
self
55+
}
56+
57+
pub fn with_to(mut self, to: State) -> Self {
58+
self.to = Some(to);
59+
self
60+
}
61+
62+
pub fn with_note(mut self, note: Option<String>) -> Self {
63+
self.note = note;
64+
self
65+
}
66+
}
67+
68+
/// File name of the rolling event log, under `<workspace>/.arghda/`.
69+
pub const EVENTS_FILE: &str = "events.jsonl";
70+
71+
fn events_path(ws_root: &Path) -> PathBuf {
72+
ws_root.join(".arghda").join(EVENTS_FILE)
73+
}
74+
75+
/// Append one event as a JSON line. Creates `.arghda/` if absent.
76+
pub fn append(ws_root: impl AsRef<Path>, ev: &Event) -> Result<()> {
77+
let ws_root = ws_root.as_ref();
78+
let meta = ws_root.join(".arghda");
79+
fs::create_dir_all(&meta).with_context(|| format!("creating {}", meta.display()))?;
80+
let path = events_path(ws_root);
81+
let mut line = serde_json::to_string(ev).context("serialising event")?;
82+
line.push('\n');
83+
let mut f = OpenOptions::new()
84+
.create(true)
85+
.append(true)
86+
.open(&path)
87+
.with_context(|| format!("opening {}", path.display()))?;
88+
f.write_all(line.as_bytes())
89+
.with_context(|| format!("appending to {}", path.display()))?;
90+
Ok(())
91+
}
92+
93+
/// Read the whole event log in order. Empty if the log does not exist yet.
94+
pub fn read_all(ws_root: impl AsRef<Path>) -> Result<Vec<Event>> {
95+
let path = events_path(ws_root.as_ref());
96+
if !path.is_file() {
97+
return Ok(Vec::new());
98+
}
99+
let contents =
100+
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
101+
let mut out = Vec::new();
102+
for (i, line) in contents.lines().enumerate() {
103+
if line.trim().is_empty() {
104+
continue;
105+
}
106+
let ev: Event = serde_json::from_str(line)
107+
.with_context(|| format!("parsing {} line {}", path.display(), i + 1))?;
108+
out.push(ev);
109+
}
110+
Ok(out)
111+
}

0 commit comments

Comments
 (0)