Skip to content

Commit 315464b

Browse files
ArghDA M4: Idris2 backend + --backend selector — genuinely multi-backend (#34)
## What & why The **second backend**, on the M1 trait — arghda now drives two provers, chosen at the CLI with `--backend agda|idris2`. Idris2 is a core estate language (it owns ABIs — the Idris2-ABI / Zig-FFI pattern), so it's first-class, not merely a prover. ## Changes - **`src/prover/idris2.rs`** — `Idris2` impl: `idris2 --check --source-dir <root> <file>` (the `-i` include-root analog, **ground-truthed against Idris2 0.7.0**), exit-code-only verdict (0→Proven, ran-nonzero→Error, absent→Unavailable). Dotted module names reuse `graph::module_name_of`; `.idr` `module_to_path`; an Idris2 import parser (`import [public] Mod [as Alias]`, top-level — no `open`); `Main.idr` root discovery. - **`src/lint/idris2.rs`** — `Idris2EscapeHatch` flags `believe_me`/`assert_total`/`assert_smaller`/`idris_crash` + `%default partial`. Deliberately named `escape-hatch` so it **reuses the reasoning-graph's amber cap** — a `believe_me` is treated identically across Agda and Idris2. - **`src/main.rs`** — new `--backend agda|idris2` on scan/check/dag/reason via `backend_for()`; unknown backend errors cleanly. (Wire first — the backend is *reachable*, not just present.) CLI `about` now reflects multi-backend. ## Verified (ran, not inferred) - `cargo test` → **93 passing, 0 failed** (70 lib + 23 integration; +9 Idris2 unit: backend identity, `.idr` path mapping, import parser incl. public/alias, `check_file` honesty, escape-hatch rule; +3 Idris2 integration). The graph/dag/reason tests are **hermetic** — text parsing only, no binary needed, so CI is unaffected by whether `idris2` is installed. - `cargo clippy -D warnings` clean · `cargo fmt --check` clean · `check-spdx.sh` OK. - **Dogfooded with real idris2 0.7.0 on PATH:** - `arghda check --backend idris2 …/Main.idr` → real typecheck → `proven-eligible` - `arghda reason --backend idris2 --check` → live typechecks each node → Main cone `proven`+`wired`, `Orphan` `proven`-but-`wired=false` (the honest wired-vs-sound distinction working) - `--backend lean4` → `Error: unknown backend lean4 (known: agda, idris2)` ## Honest scope M4 = ~90%. The documented follow-on 10%: `.ipkg`-declared roots (currently `Main.idr` discovery) and totality-hole (`?name`) + per-def `partial` lint. `build/` idris2 output is gitignored. ## Next M5 SMT solvers (Z3/CVC5) — the first **Solver**-kind backend, exercising the other half of the taxonomy · M2 Cubical-Agda. --- _Generated by [Claude Code](https://claude.ai/code/session_012MpYSh6Wy8YMBH2E3qVyT7)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 417b011 commit 315464b

13 files changed

Lines changed: 571 additions & 29 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,8 @@ result-*
2121

2222
# Agda compiled interface files (generated by typecheck runs)
2323
*.agdai
24+
25+
# Idris2 compiled output (generated by `idris2 --check` runs)
26+
build/
27+
*.ttc
28+
*.ttm

.machine_readable/6a2/STATE.a2ml

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ milestones = [
4444
{ name = "M1: Backend trait (Assistant|Solver kinds; Outcome/Verdict); agda.rs -> prover/agda.rs; graph/dag take &dyn Backend (pure refactor, 50 tests stay green)", completion = 100 },
4545
{ name = "M2: Cubical-Agda variant (Agda::cubical(), --cubical --safe island)", completion = 0 },
4646
{ name = "M3: Flying-Logic reasoning graph (src/reason: And|Or juncts, demote-only cycle-safe Verdict propagation, additive studio JSON)", completion = 100 },
47-
{ name = "M4: Idris2 adapter (core/ABI language: .ipkg/--check/totality; escape-hatches)", completion = 0 },
47+
{ name = "M4: Idris2 adapter (core/ABI language: .ipkg/--check/totality; escape-hatches)", completion = 90 },
4848
{ name = "M5: SMT solver backends (Z3, CVC5): SMT-LIB2 -> sat/unsat/unknown -> Verdict", completion = 0 },
4949
{ name = "M6: Lean4 adapter (lake build + #print axioms audit)", completion = 0 },
5050
{ name = "M7: Echidna dispatch seam (optional route to orchestrator :8090, same Outcome)", completion = 0 },
@@ -60,6 +60,22 @@ milestones = [
6060
# Remaining M0: `arghda doctor` Rust subcommand (interim: `just doctor` shells
6161
# the script's --verify-only table).
6262
#
63+
# M4 landed 2026-07-01 (90%): the Idris2 backend — arghda is now genuinely
64+
# multi-backend. src/prover/idris2.rs: `idris2 --check --source-dir <root>
65+
# <file>` (ground-truthed vs Idris2 0.7.0), exit-code-only verdict; dotted
66+
# module names reuse graph::module_name_of; .idr module_to_path; Idris2 import
67+
# parser (`import [public] Mod [as Alias]`, top-level — no `open`); Main.idr
68+
# root discovery. src/lint/idris2.rs: Idris2EscapeHatch (believe_me/assert_total
69+
# /assert_smaller/idris_crash + `%default partial`), named "escape-hatch" so it
70+
# reuses the reason-graph amber cap. WIRED via a new `--backend agda|idris2` CLI
71+
# selector on scan/check/dag/reason (backend_for()); unknown backend errors
72+
# cleanly. 93 tests (70 lib + 23 integration; +9 Idris2 unit +3 Idris2
73+
# integration; the graph/dag/reason tests are hermetic — text parsing, no binary
74+
# needed). Dogfooded with REAL idris2 on PATH: `check --backend idris2` →
75+
# proven-eligible; `reason --backend idris2 --check` → live typechecks, Main cone
76+
# proven+wired, Orphan proven-but-unwired. Follow-ons (the missing 10%):
77+
# .ipkg-declared roots, totality-hole (`?name`) + per-def `partial` lint.
78+
#
6379
# M3 landed 2026-07-01: the Flying-Logic reasoning graph (src/reason/) — the
6480
# headline capability. Wraps DagDocument verbatim (ReasonDocument.dag) + adds a
6581
# per-node Verdict, And|Or edge juncts, and a memoised, cycle-safe, DEMOTE-ONLY
@@ -104,10 +120,10 @@ milestones = [
104120

105121
[critical-next-actions]
106122
actions = [
107-
"M1 (Backend trait) + M3 (reasoning graph) DONE. Next, all unblocked by the stable trait — pick per priority:",
108-
"Flying-Logic epic M4: Idris2 adapter (core/ABI language: .ipkg/--check/totality; believe_me/assert_total/%partial escape-hatches) — Idris2 v0.7.0 is provisioned.",
109-
"Flying-Logic epic M5: SMT solver backends (Z3, CVC5) — the Solver kind; SMT-LIB2 -> sat(Refuted)/unsat(Proven)/unknown(Unknown). Both binaries are provisioned.",
123+
"M1 (Backend trait) + M3 (reasoning graph) + M4 (Idris2) DONE. Next, all unblocked by the stable trait + --backend selector:",
124+
"Flying-Logic epic M5: SMT solver backends (Z3, CVC5) — the FIRST Solver-kind backend; SMT-LIB2 -> unsat(Proven)/sat(Refuted)/unknown(Unknown). Both binaries provisioned. Exercises the Solver half of the taxonomy (no imports; isolated nodes).",
110125
"Flying-Logic epic M2: Cubical-Agda variant (Agda::cubical(), --cubical --safe island; cross-flag import edges forbidden).",
126+
"M4 follow-on (the missing 10%): .ipkg-declared roots; totality-hole (?name) + per-def `partial` lint.",
111127
"M3 follow-on: feed real verdicts into `reason` from a workspace `proven/` state + wire staleness from the content-hash closure (proven.rs) so `arghda reason <workspace>` lights the cone without --check.",
112128
"Finish M0: add the `arghda doctor` Rust subcommand and repoint `just doctor` at it (interim shells provision-provers.sh --verify-only).",
113129
"Serve /.well-known/groove for PanLL discovery (Groove protocol) — folds into M11.",
@@ -119,10 +135,11 @@ last-result = "pass" # unknown | pass | warn | fail
119135
open-warnings = 0
120136
open-failures = 0
121137
# `just check` (fmt-check + clippy -D warnings + build + test + SPDX) green
122-
# post-M3: 61 lib + 20 integration = 81 tests pass. Dogfooded via real CLI
123-
# runs: `dag` JSON byte-compatible; `check` + `reason --check` ran real agda
124-
# typechecks (proven-eligible / all-Proven+wired cone). Dogfooded historically
125-
# against echo-types + agda-unused.
138+
# post-M4: 70 lib + 23 integration = 93 tests pass. Dogfooded via real CLI
139+
# runs on BOTH backends: agda (`check`/`reason --check`) and idris2 (`check
140+
# --backend idris2` → proven-eligible; `reason --backend idris2 --check` →
141+
# live-typechecked cone). Dogfooded historically against echo-types +
142+
# agda-unused.
126143

127144
[ecosystem]
128145
part-of = ["arghda"]

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,6 @@ pub use diagnostic::{Diagnostic, LintReport, Severity};
3030
pub use event::{Event, EventKind};
3131
pub use graph::{build as build_graph, ImportGraph};
3232
pub use lint::{default_rules, rules_with_config, run_lints, LintRule, RuleConfig};
33-
pub use prover::{default_backend, Agda, Backend, BackendKind, Outcome, Verdict};
33+
pub use prover::{default_backend, Agda, Backend, BackendKind, Idris2, Outcome, Verdict};
3434
pub use reason::{build as build_reason, Junct, ReasonDocument, ReasonEdge, ReasonNode};
3535
pub use workspace::{StaleEntry, State, Workspace};

src/lint/idris2.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
4+
//! Idris2 `escape-hatch` (warn) — surface soundness escape hatches.
5+
//!
6+
//! The Idris2 analogue of the Agda [`super::escape_hatch::EscapeHatch`]. It
7+
//! shares the rule *name* `escape-hatch` deliberately: the reasoning graph
8+
//! (`crate::reason`) caps any `escape-hatch` node at amber, so a `believe_me`
9+
//! in Idris2 and a `believe_me` in Agda get the same honest treatment.
10+
//!
11+
//! Flags the Idris2 trust/escape primitives `believe_me`, `assert_total`,
12+
//! `assert_smaller`, `idris_crash`, and the `%default partial` directive.
13+
//! Idris2 `--check` exits 0 even when these are present, so without this rule
14+
//! they would silently ride along in an otherwise-green verdict — exactly the
15+
//! silent-failure class arghda exists to surface. Totality holes (`?name`)
16+
//! and per-def `partial` modifiers are a documented follow-on.
17+
18+
use super::{LintContext, LintRule};
19+
use crate::diagnostic::{Diagnostic, LintReport, Severity};
20+
use anyhow::{Context, Result};
21+
use std::fs;
22+
use std::path::Path;
23+
24+
pub struct Idris2EscapeHatch;
25+
26+
const ESCAPE_TOKENS: &[&str] = &[
27+
"believe_me",
28+
"assert_total",
29+
"assert_smaller",
30+
"idris_crash",
31+
];
32+
33+
impl LintRule for Idris2EscapeHatch {
34+
fn name(&self) -> &'static str {
35+
// Shared with the Agda rule on purpose — the reasoning graph's
36+
// lint→verdict cap keys on this name.
37+
"escape-hatch"
38+
}
39+
40+
fn run(&self, file: &Path, _ctx: &LintContext<'_>, report: &mut LintReport) -> Result<()> {
41+
let contents =
42+
fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
43+
for (i, line) in contents.lines().enumerate() {
44+
let trimmed = line.trim_start();
45+
if trimmed.starts_with("--") {
46+
continue; // whole-line comment
47+
}
48+
// The `%default partial` directive turns off totality checking.
49+
if trimmed.starts_with("%default") && trimmed.contains("partial") {
50+
report.push(warn(
51+
self.name(),
52+
file,
53+
i + 1,
54+
"totality escape directive `%default partial`".to_string(),
55+
));
56+
}
57+
// Trust/escape primitives, ignoring any trailing line comment.
58+
let code = line.split(" --").next().unwrap_or(line);
59+
for tok in ESCAPE_TOKENS {
60+
if has_token(code, tok) {
61+
report.push(warn(
62+
self.name(),
63+
file,
64+
i + 1,
65+
format!("escape primitive `{tok}`"),
66+
));
67+
}
68+
}
69+
}
70+
Ok(())
71+
}
72+
}
73+
74+
fn warn(rule: &str, file: &Path, line: usize, message: String) -> Diagnostic {
75+
Diagnostic {
76+
rule: rule.to_string(),
77+
severity: Severity::Warn,
78+
file: file.to_path_buf(),
79+
message,
80+
line: Some(line),
81+
}
82+
}
83+
84+
/// `tok` appears in `s` as a delimited token (so `assert_totally` or
85+
/// `believe_me_helper` do not match).
86+
fn has_token(s: &str, tok: &str) -> bool {
87+
s.split(|c: char| c.is_whitespace() || "(){};,".contains(c))
88+
.any(|w| w == tok)
89+
}
90+
91+
#[cfg(test)]
92+
mod tests {
93+
use super::*;
94+
use crate::lint::run_lints;
95+
use std::path::PathBuf;
96+
97+
fn lint_str(body: &str) -> LintReport {
98+
let tmp = tempfile::NamedTempFile::new().unwrap();
99+
std::fs::write(tmp.path(), body).unwrap();
100+
let roots: [PathBuf; 0] = [];
101+
let ctx = LintContext {
102+
include_root: tmp.path().parent().unwrap(),
103+
entry_modules: &roots,
104+
};
105+
let rules: Vec<Box<dyn LintRule>> = vec![Box::new(Idris2EscapeHatch)];
106+
run_lints(tmp.path(), &ctx, &rules).unwrap()
107+
}
108+
109+
#[test]
110+
fn believe_me_is_warned_under_the_escape_hatch_name() {
111+
let r = lint_str("module M\n\nx : Nat\nx = believe_me 0\n");
112+
assert_eq!(r.warns().count(), 1);
113+
assert_eq!(r.diagnostics[0].rule, "escape-hatch");
114+
assert!(r.diagnostics[0].message.contains("believe_me"));
115+
}
116+
117+
#[test]
118+
fn assert_total_and_default_partial_are_warned() {
119+
let r = lint_str("%default partial\n\nf : Nat -> Nat\nf x = assert_total (f x)\n");
120+
assert_eq!(r.warns().count(), 2);
121+
}
122+
123+
#[test]
124+
fn escape_token_in_comment_is_ignored() {
125+
let r = lint_str("module M\n\nx : Nat\nx = 0 -- avoid believe_me here\n");
126+
assert!(r.diagnostics.is_empty());
127+
}
128+
129+
#[test]
130+
fn longer_identifier_does_not_match() {
131+
let r = lint_str("module M\n\nbelieve_me_helper : Nat\nbelieve_me_helper = 0\n");
132+
assert!(r.diagnostics.is_empty());
133+
}
134+
135+
#[test]
136+
fn clean_idris_file_is_silent() {
137+
let r = lint_str("module M\n\ngreeting : String\ngreeting = \"hi\"\n");
138+
assert!(r.diagnostics.is_empty());
139+
}
140+
}

src/lint/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use anyhow::Result;
66
use std::path::{Path, PathBuf};
77

88
pub mod escape_hatch;
9+
pub mod idris2;
910
pub mod orphan_module;
1011
pub mod postulate;
1112
pub mod safe_pragma;

0 commit comments

Comments
 (0)