Skip to content

Commit 4f4a9f5

Browse files
ArghDA M4 follow-on: Idris2 .ipkg-declared roots (#41)
First of the refinement follow-ons. Also the **live confirmation** that the code-scanning fix (`codeql.yml`, merged in #40) works — this PR should pass the Code Scanning gate and be auto-mergeable without a manual bootstrap. ## What Idris2 `discover_roots` now unions two conventions: 1. **`.ipkg` `main = <Module>`** — the package's executable entry, resolved to its `.idr` file (honouring an optional quoted `sourcedir`; `--` comments stripped; first existing candidate base wins). The entry needn't be literally `Main.idr` — e.g. `main = App` finds `App.idr`. 2. **Any `Main.idr`** — the bare-tree fallback (unchanged). `ipkg_main_roots()` parses the `.ipkg` `key = value` fields (only `main` and `sourcedir`/`source-dir` consulted) and resolves each `main` module against candidate source roots. ## Verified (ran, not inferred) - `cargo test` → **127 passing, 0 failed** (+1 integration: `main = App` discovered + its cone wired; +2 unit: sourcedir/comment resolution; no-resolvable-main → empty). - clippy -D warnings, fmt, SPDX clean. - **Dogfooded:** `reason --backend idris2` over an `.ipkg` fixture → `crt_roots: ['App']`, App + Helper wired. ## Status M4 now ~95% (remaining: totality-hole `?name` + per-def `partial` lint). Next follow-ons: M6 (lake-env + `#print axioms` audit), M3 (workspace-fed verdicts + staleness). --- _Generated by [Claude Code](https://claude.ai/code/session_012MpYSh6Wy8YMBH2E3qVyT7)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e4fba04 commit 4f4a9f5

6 files changed

Lines changed: 176 additions & 11 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 28 additions & 3 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 = 100 },
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 = 90 },
47+
{ name = "M4: Idris2 adapter (core/ABI language: .ipkg/--check/totality; escape-hatches)", completion = 95 },
4848
{ name = "M5: SMT solver backends (Z3, CVC5): SMT-LIB2 -> sat/unsat/unknown -> Verdict", completion = 100 },
4949
{ name = "M6: Lean4 adapter (lake build + #print axioms audit)", completion = 85 },
5050
{ name = "M7: Echidna dispatch seam (optional route to orchestrator :8090, same Outcome)", completion = 70 },
@@ -60,6 +60,30 @@ milestones = [
6060
# Remaining M0: `arghda doctor` Rust subcommand (interim: `just doctor` shells
6161
# the script's --verify-only table).
6262
#
63+
# Follow-on round (refinement, 2026-07-01, after the engine spine):
64+
#
65+
# CI FIX (arghda-core#40 was auto-merge-blocked on "Waiting for Code Scanning
66+
# results. Code Scanning may not be configured for the target branch"): arghda-
67+
# core was EXTRACTED from echo-types, not instantiated from rsr-template-repo,
68+
# so it never inherited the template's codeql.yml — yet main's branch protection
69+
# requires code-scanning. Earlier PRs merged only because the owner merged
70+
# manually; auto-merge enforces strictly. FIX (at source): restored the
71+
# canonical estate .github/workflows/codeql.yml verbatim (language: actions,
72+
# build-mode none, SHA-pinned; same file rsr-template-repo + cicd-squabbler run
73+
# green). Triggers on pull_request (PR gets a run) + push:[main] (establishes the
74+
# baseline). Landed via #40; the advanced-security bot confirmed "Code Scanning
75+
# has recently been set up". Permanent — future PRs satisfy the gate
76+
# automatically. NB the template is fine; only extraction-origin repos drift.
77+
#
78+
# M4 FOLLOW-ON landed 2026-07-01: Idris2 `.ipkg`-declared roots. discover_roots
79+
# now unions (a) `.ipkg` `main = <Module>` resolved to its .idr (honouring an
80+
# optional quoted `sourcedir`; `--` comments stripped; first existing candidate
81+
# base wins) with (b) the Main.idr fallback. So an executable entry named other
82+
# than Main.idr (e.g. `main = App`) is found. 127 tests (+1 integration ipkg-main
83+
# +2 unit ipkg-parser incl. sourcedir + no-resolvable-main). Dogfooded: `reason
84+
# --backend idris2` over an ipkg fixture → crt_roots ['App'], App+Helper wired.
85+
# M4 now 95% (remaining: totality-hole `?name` + per-def `partial` lint).
86+
#
6387
# M7 landed 2026-07-01 (70%): the Echidna dispatch seam — completes the
6488
# engine-spine deliverable (M0-M7 + M11). src/dispatch.rs: `Dispatch { Local,
6589
# Echidna { base_url } }` + `run(backend, file, include_root) -> Outcome`
@@ -219,7 +243,7 @@ actions = [
219243
"ENGINE-SPINE DELIVERABLE COMPLETE: M0-M7 + M11 all landed. v0.1 spec closed. Six backends (agda, agda-cubical, idris2, lean4 Assistant; z3, cvc5 Solver) + reasoning graph + doctor + groove + dispatch seam. Remaining work is refinement + the heavy tail:",
220244
"M7 follow-on: the real Echidna orchestrator HTTP client (feature-gated), once Echidna's API is confirmed — only Dispatch::run changes.",
221245
"M6 follow-on: lake-env/LEAN_PATH multi-file resolution; per-decl #print axioms audit to promote Unknown→Proven.",
222-
"M4 follow-on: .ipkg-declared roots; totality-hole (?name) + per-def `partial` lint.",
246+
"M4 follow-on: .ipkg-declared roots DONE; remaining = totality-hole (?name) + per-def `partial` lint.",
223247
"M3 follow-on: feed real verdicts into `reason` from a workspace `proven/` state + wire staleness from the content-hash closure (proven.rs).",
224248
"M8-M10 heavy tail (Coq/Rocq, Isabelle, Mizar) — gated on --heavy provisioning (Coq via opam ~1hr; Isabelle ~4GB; Mizar niche).",
225249
]
@@ -230,7 +254,8 @@ last-result = "pass" # unknown | pass | warn | fail
230254
open-warnings = 0
231255
open-failures = 0
232256
# `just check` (fmt-check + clippy -D warnings + build + test + SPDX) green
233-
# post-M7: 91 lib + 33 integration = 124 tests pass. doctor/groove/dispatch
257+
# post-M4-followon: 93 lib + 34 integration = 127 tests pass. codeql.yml added
258+
# (code-scanning gate fix). doctor/groove/dispatch
234259
# dogfooded: 6/6 backends runnable; groove writes /.well-known/groove; --dispatch
235260
# local real, echidna honest-stub. All six backends dogfooded via real CLI: agda
236261
# + agda-cubical + idris2 + lean4 (Assistant), z3 + cvc5 (Solver). Dogfooded

src/prover/idris2.rs

Lines changed: 90 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,23 +126,26 @@ impl Backend for Idris2 {
126126
}
127127

128128
fn discover_roots(&self, include_root: &Path) -> Vec<PathBuf> {
129-
// Idris2 has no `All.agda`-style convention; the executable entry is
130-
// `Main.idr`. (`.ipkg`-declared `main`/`modules` roots are a
131-
// documented follow-on.)
129+
// Two conventions, unioned:
130+
// 1. `.ipkg`-declared `main = <Module>` — the package's executable
131+
// entry, resolved to its `.idr` file (needn't be `Main.idr`).
132+
// 2. Any `Main.idr` — the bare-tree fallback.
132133
let mut roots = Vec::new();
133134
for entry in WalkDir::new(include_root)
134135
.into_iter()
135136
.filter_map(|e| e.ok())
136137
{
137138
let path = entry.path();
138-
if path.extension().and_then(|s| s.to_str()) != Some("idr") {
139-
continue;
140-
}
141-
if path.file_name().and_then(|s| s.to_str()) == Some("Main.idr") {
142-
roots.push(path.to_path_buf());
139+
match path.extension().and_then(|s| s.to_str()) {
140+
Some("ipkg") => roots.extend(ipkg_main_roots(path, include_root)),
141+
Some("idr") if path.file_name().and_then(|s| s.to_str()) == Some("Main.idr") => {
142+
roots.push(path.to_path_buf());
143+
}
144+
_ => {}
143145
}
144146
}
145147
roots.sort();
148+
roots.dedup();
146149
roots
147150
}
148151

@@ -161,6 +164,56 @@ fn tail(s: &str, n: usize) -> String {
161164
lines[start..].join("\n")
162165
}
163166

167+
/// Resolve an Idris2 `.ipkg`'s `main = <Module>` declaration(s) to the
168+
/// module's `.idr` file, honouring an optional `sourcedir`. Returns the
169+
/// resolved files that exist (first matching candidate base per `main`).
170+
/// Non-`main` fields are ignored; `--` comments are stripped.
171+
fn ipkg_main_roots(ipkg: &Path, include_root: &Path) -> Vec<PathBuf> {
172+
let Ok(contents) = fs::read_to_string(ipkg) else {
173+
return Vec::new();
174+
};
175+
let mut mains: Vec<String> = Vec::new();
176+
let mut sourcedir: Option<String> = None;
177+
for line in contents.lines() {
178+
let code = line.split("--").next().unwrap_or(line);
179+
if let Some((key, value)) = code.split_once('=') {
180+
match key.trim() {
181+
"main" => mains.push(value.trim().to_string()),
182+
"sourcedir" | "source-dir" => {
183+
sourcedir = Some(value.trim().trim_matches('"').trim().to_string());
184+
}
185+
_ => {}
186+
}
187+
}
188+
}
189+
190+
let ipkg_dir = ipkg.parent().unwrap_or(include_root);
191+
// Candidate source roots to resolve the main module against, in order.
192+
let mut bases: Vec<PathBuf> = Vec::new();
193+
if let Some(sd) = &sourcedir {
194+
bases.push(ipkg_dir.join(sd));
195+
bases.push(include_root.join(sd));
196+
}
197+
bases.push(ipkg_dir.to_path_buf());
198+
bases.push(include_root.to_path_buf());
199+
200+
let mut out = Vec::new();
201+
for m in &mains {
202+
for base in &bases {
203+
let mut p = base.clone();
204+
for part in m.split('.') {
205+
p.push(part);
206+
}
207+
p.set_extension("idr");
208+
if p.is_file() {
209+
out.push(p);
210+
break; // first existing candidate wins
211+
}
212+
}
213+
}
214+
out
215+
}
216+
164217
#[cfg(test)]
165218
mod tests {
166219
use super::*;
@@ -217,4 +270,33 @@ mod tests {
217270
assert!(out.exit_code.is_none());
218271
}
219272
}
273+
274+
#[test]
275+
fn ipkg_main_resolves_via_sourcedir_and_strips_comments() {
276+
// main declared with a dotted module under a quoted sourcedir, plus a
277+
// `--` comment and a distractor field — only the real main resolves.
278+
let tmp = tempfile::tempdir().unwrap();
279+
let root = tmp.path();
280+
std::fs::create_dir_all(root.join("src/Cli")).unwrap();
281+
std::fs::write(root.join("src/Cli/App.idr"), "module Cli.App\n").unwrap();
282+
std::fs::write(
283+
root.join("thing.ipkg"),
284+
"package thing\n\
285+
-- main = NotThis (this line is a comment)\n\
286+
sourcedir = \"src\"\n\
287+
main = Cli.App\n\
288+
executable = thing\n",
289+
)
290+
.unwrap();
291+
let roots = ipkg_main_roots(&root.join("thing.ipkg"), root);
292+
assert_eq!(roots, vec![root.join("src/Cli/App.idr")]);
293+
}
294+
295+
#[test]
296+
fn ipkg_with_no_resolvable_main_yields_nothing() {
297+
let tmp = tempfile::tempdir().unwrap();
298+
let root = tmp.path();
299+
std::fs::write(root.join("x.ipkg"), "package x\nmain = Nope\n").unwrap();
300+
assert!(ipkg_main_roots(&root.join("x.ipkg"), root).is_empty());
301+
}
220302
}

tests/fixtures/idris2-pkg/App.idr

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module App
2+
3+
import Helper
4+
5+
main : IO ()
6+
main = putStrLn message
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module Helper
2+
3+
export
4+
message : String
5+
message = "hi"

tests/fixtures/idris2-pkg/foo.ipkg

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package foo
2+
-- the executable entry module is App, not Main
3+
main = App
4+
executable = foo
5+
depends = base

tests/idris2.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ fn idris2_fixture() -> PathBuf {
2020
p
2121
}
2222

23+
fn idris2_pkg_fixture() -> PathBuf {
24+
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
25+
p.push("tests");
26+
p.push("fixtures");
27+
p.push("idris2-pkg");
28+
p
29+
}
30+
2331
/// The graph path needs no lint rules; a typed empty pack.
2432
fn no_rules() -> Vec<Box<dyn LintRule>> {
2533
Vec::new()
@@ -89,3 +97,37 @@ fn idris2_reason_graph_wires_the_main_cone() {
8997
assert!(wired("Data.Helper"));
9098
assert!(!wired("Orphan"), "Orphan must be unwired");
9199
}
100+
101+
#[test]
102+
fn idris2_ipkg_main_is_discovered_as_a_root() {
103+
// The `.ipkg` follow-on: `main = App` (NOT Main.idr) is resolved to
104+
// App.idr as the CI root, and the App cone wires from it.
105+
let root = idris2_pkg_fixture();
106+
let roots = Idris2.discover_roots(&root);
107+
let names: Vec<String> = roots
108+
.iter()
109+
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
110+
.collect();
111+
assert_eq!(
112+
names,
113+
vec!["App.idr".to_string()],
114+
"ipkg `main = App` must be discovered (there is no Main.idr)"
115+
);
116+
117+
let dag = build_dag(
118+
&root,
119+
&roots,
120+
&no_rules(),
121+
DEFAULT_HEADLINE_PATTERN,
122+
&Idris2,
123+
)
124+
.unwrap();
125+
let doc = build_reason(dag, &Idris2, &BTreeMap::new(), &BTreeSet::new());
126+
assert_eq!(doc.crt_roots, vec!["App".to_string()]);
127+
let wired = |id: &str| doc.nodes.iter().find(|n| n.id == id).unwrap().wired;
128+
assert!(wired("App"));
129+
assert!(
130+
wired("Helper"),
131+
"Helper is imported by the ipkg-declared main"
132+
);
133+
}

0 commit comments

Comments
 (0)