Skip to content

Commit f4551d4

Browse files
ci: make CI genuinely green — rust-ci toolchain pin + canonical Julia ABI-FFI gate + fmt/clippy (#46)
## Summary Make CI genuinely green. The shared rust-ci pin on `main` predates standards#439, so the SHA-pinned `dtolnay/rust-toolchain` step errors out before the job runs. This bumps the pin so rust-ci actually runs, replaces the estate-banned Python ABI-FFI gate with the canonical Julia gate (matches `verisimiser`), and brings the Rust sources to fmt + clippy(`-D warnings`) clean. ## Changes - **rust-ci:** bump `rust-ci-reusable.yml` pin `d135b05` → `8dc2bf0` (current `standards` HEAD; includes #439 toolchain fix + #441/#442). - **ABI-FFI gate:** remove `scripts/abi-ffi-gate.py` (Python banned estate-wide); add `scripts/abi-ffi-gate.jl` (behaviour-identical Julia port); workflow installs Julia 1.11.5. Matches `verisimiser`'s canonical gate. - **demo input:** the bundled `examples/data-pipeline/pipeline.py` is the Python pipeline julianiser *wraps* into Julia — kept, with a `hypatia:ignore` pragma marking it a demo input fixture (not estate code). - **Rust hygiene:** `cargo fmt` + `clippy --fix` where the repo had pre-existing drift, so `cargo fmt --all -- --check` and `cargo clippy --locked --all-targets -- -D warnings` pass. ## RSR Quality Checklist ### Required - [x] Tests pass (`cargo test --locked --all-targets`) - [x] Code is formatted (`cargo fmt --all -- --check`) - [x] Linter is clean (`cargo clippy --locked --all-targets -- -D warnings`) - [x] No banned language patterns (removes the Python gate; demo input pragma'd) - [x] SPDX license headers present on new/modified files - [x] No secrets, credentials, or `.env` files included ### As Applicable - [x] ABI/FFI changes validated (gate logic preserved; conformance OK) ## Testing Verified locally with the toolchain CI uses (cargo 1.94.1, rustfmt 1.8.0): `cargo fmt --check`, `clippy -D warnings`, `cargo check --locked`, `cargo test --locked` all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01A6PSzJWpRxtzGDjUCEh7Mx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4a5ac2a commit f4551d4

8 files changed

Lines changed: 148 additions & 115 deletions

File tree

.github/workflows/abi-ffi-gate.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,15 @@ jobs:
2121
runs-on: ubuntu-latest
2222
steps:
2323
- uses: actions/checkout@v4
24+
- name: Install Julia 1.11.5
25+
run: |
26+
curl -fsSL https://julialang-s3.julialang.org/bin/linux/x64/1.11/julia-1.11.5-linux-x86_64.tar.gz -o /tmp/julia.tar.gz
27+
tar -xf /tmp/julia.tar.gz -C /tmp
28+
echo "/tmp/julia-1.11.5/bin" >> "$GITHUB_PATH"
2429
- name: Run ABI-FFI gate
25-
run: python3 scripts/abi-ffi-gate.py
30+
run: |
31+
julia --version # confirms the pinned 1.11.5 is on PATH, not the runner default
32+
julia scripts/abi-ffi-gate.jl
2633
2734
zig-build:
2835
name: Zig FFI builds + tests (Zig 0.14.0)

.github/workflows/rust-ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ permissions:
1414

1515
jobs:
1616
rust-ci:
17-
uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@d135b05bfc647d0c0fbfedc7e80f37ea50f49236
17+
uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@8dc2bf039d1ff0372d650895c46bea7fbaec68ff

examples/data-pipeline/pipeline.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# hypatia:ignore cicd_rules/banned_language_file -- demo INPUT: the Python pipeline julianiser wraps into Julia (fixture, not estate code)
12
# SPDX-License-Identifier: MPL-2.0
23
# Example Python data pipeline for julianiser translation demo.
34
#

scripts/abi-ffi-gate.jl

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env julia
2+
# SPDX-License-Identifier: MPL-2.0
3+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
4+
#
5+
# abi-ffi-gate.jl — fail (exit 1) if the Zig FFI does not conform to the Idris2
6+
# ABI. The Idris2 ABI is the source of truth. Checks, with no compile toolchain
7+
# needed (pure base-Julia text analysis):
8+
#
9+
# 1. the Zig FFI carries no unrendered `{{...}}` template tokens;
10+
# 2. every `%foreign "C:<name>"` symbol declared anywhere in the ABI .idr
11+
# sources is exported by the Zig FFI (`export fn <name>`);
12+
# 3. the Zig `Result = enum(c_int)` and the Idris `resultToInt` agree on BOTH
13+
# names and integer values (the `Error`/`err` spelling is treated as one).
14+
#
15+
# Usage: julia scripts/abi-ffi-gate.jl [repo_root] (defaults to cwd)
16+
#
17+
# Julia port of the former scripts/abi-ffi-gate.py (Python is banned estate-wide,
18+
# RSR-H4); behaviour is identical.
19+
20+
"camelCase / PascalCase → snake_case (insert `_` before each non-initial capital)."
21+
camel_to_snake(s) = lowercase(replace(s, r"(?<!^)(?=[A-Z])" => "_"))
22+
23+
"Canonical result-code key: lowercased, with `err`/`error` unified to `error`."
24+
function canon_rc(name)
25+
n = lowercase(name)
26+
(n == "err" || n == "error") ? "error" : n
27+
end
28+
29+
"Return {variant => value} for the C-ABI `Result` enum (the `enum(c_int)` block whose `ok = 0`), or empty."
30+
function find_result_enum(zig::AbstractString)
31+
best = Dict{String,Int}()
32+
for m in eachmatch(r"enum\s*\(\s*c_int\s*\)\s*\{(.*?)\}"s, zig)
33+
body = m.captures[1]
34+
variants = Dict{String,Int}()
35+
for vm in eachmatch(r"@?\"?([A-Za-z_][A-Za-z0-9_]*)\"?\s*=\s*(\d+)", body)
36+
variants[canon_rc(vm.captures[1])] = parse(Int, vm.captures[2])
37+
end
38+
# The Result enum is the one starting at ok = 0.
39+
if get(variants, "ok", nothing) == 0 && length(variants) > length(best)
40+
best = variants
41+
end
42+
end
43+
return best
44+
end
45+
46+
"Collect every `*.idr` under `abi_dir`, skipping any `build/` output directory."
47+
function idr_sources(abi_dir::AbstractString)
48+
files = String[]
49+
isdir(abi_dir) || return files
50+
for (root, _dirs, fs) in walkdir(abi_dir)
51+
occursin("/build/", root * "/") && continue
52+
for f in fs
53+
endswith(f, ".idr") && push!(files, joinpath(root, f))
54+
end
55+
end
56+
return files
57+
end
58+
59+
function main(root::AbstractString)::Int
60+
name = basename(rstrip(abspath(root), '/'))
61+
abi_dir = joinpath(root, "src/interface/abi")
62+
zig_path = joinpath(root, "src/interface/ffi/src/main.zig")
63+
errs = String[]
64+
65+
idr_files = idr_sources(abi_dir)
66+
if isempty(idr_files)
67+
println("ABI-FFI GATE: SKIP ($name) — no Idris2 ABI .idr files under $abi_dir")
68+
return 0
69+
end
70+
if !isfile(zig_path)
71+
println("ABI-FFI GATE: FAIL ($name) — no Zig FFI at $zig_path")
72+
return 1
73+
end
74+
75+
idr = join((read(p, String) for p in idr_files), "\n")
76+
zig = read(zig_path, String)
77+
78+
# 1. unrendered template tokens
79+
toks = sort(unique(String(m.match) for m in eachmatch(r"\{\{[A-Za-z0-9_]+\}\}", zig)))
80+
isempty(toks) || push!(errs, "Zig FFI has unrendered template tokens: $(toks)")
81+
82+
# 2. foreign C symbols must be exported
83+
csyms = sort(unique(String(m.captures[1]) for m in eachmatch(r"C:([A-Za-z0-9_]+)", idr)))
84+
exports = Set(String(m.captures[1]) for m in eachmatch(r"export fn ([A-Za-z0-9_]+)", zig))
85+
missing_syms = [s for s in csyms if !(s in exports)]
86+
isempty(missing_syms) ||
87+
push!(errs, "$(length(missing_syms)) ABI function(s) not exported by the Zig FFI: $(missing_syms)")
88+
89+
# 3. result-code map (names + values) must agree
90+
idr_rc = Dict{String,Int}()
91+
for m in eachmatch(r"resultToInt\s+([A-Za-z0-9]+)\s*=\s*(\d+)", idr)
92+
idr_rc[canon_rc(camel_to_snake(m.captures[1]))] = parse(Int, m.captures[2])
93+
end
94+
zig_rc = find_result_enum(zig)
95+
if !isempty(idr_rc) && isempty(zig_rc)
96+
push!(errs, "no Zig `enum(c_int)` Result block (with `ok = 0`) found to compare result codes")
97+
elseif !isempty(idr_rc) && !isempty(zig_rc) && idr_rc != zig_rc
98+
push!(errs, "Result-code map differs (name or value):\n" *
99+
" Idris resultToInt: $(sort(collect(idr_rc)))\n" *
100+
" Zig Result enum: $(sort(collect(zig_rc)))")
101+
end
102+
103+
if !isempty(errs)
104+
println("ABI-FFI GATE: FAIL ($name)")
105+
for e in errs
106+
println(" - " * e)
107+
end
108+
return 1
109+
end
110+
println("ABI-FFI GATE: OK ($name) — $(length(csyms)) ABI functions exported, " *
111+
"$(length(idr_rc)) result codes match")
112+
return 0
113+
end
114+
115+
root = length(ARGS) >= 1 ? ARGS[1] : "."
116+
exit(main(root))

scripts/abi-ffi-gate.py

Lines changed: 0 additions & 103 deletions
This file was deleted.

src/codegen/benchmark.rs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ fn generate_julia_benchmark(manifest: &Manifest, units: &[TranslationUnit]) -> S
8383
writeln!(out, "# Auto-generated by julianiser — benchmark script").expect("TODO: handle error");
8484
writeln!(out, "# Project: {}", manifest.project.name).expect("TODO: handle error");
8585
writeln!(out, "#").expect("TODO: handle error");
86-
writeln!(out, "# Run with: julia --project=. benchmarks/benchmark.jl").expect("TODO: handle error");
86+
writeln!(out, "# Run with: julia --project=. benchmarks/benchmark.jl")
87+
.expect("TODO: handle error");
8788
writeln!(out).expect("TODO: handle error");
8889

8990
writeln!(out, "using BenchmarkTools").expect("TODO: handle error");
@@ -112,7 +113,8 @@ fn generate_julia_benchmark(manifest: &Manifest, units: &[TranslationUnit]) -> S
112113
writeln!(out, "println(\"=\"^60)").expect("TODO: handle error");
113114
writeln!(out).expect("TODO: handle error");
114115

115-
writeln!(out, "# Warm up Julia's JIT compiler before benchmarking.").expect("TODO: handle error");
116+
writeln!(out, "# Warm up Julia's JIT compiler before benchmarking.")
117+
.expect("TODO: handle error");
116118
writeln!(out, "println(\"Warming up JIT...\")").expect("TODO: handle error");
117119
for unit in units {
118120
writeln!(out, "try").expect("TODO: handle error");
@@ -147,7 +149,8 @@ fn generate_julia_benchmark(manifest: &Manifest, units: &[TranslationUnit]) -> S
147149
unit.module_name
148150
)
149151
.expect("TODO: handle error");
150-
writeln!(out, "display(bench_{})", to_snake(&unit.module_name)).expect("TODO: handle error");
152+
writeln!(out, "display(bench_{})", to_snake(&unit.module_name))
153+
.expect("TODO: handle error");
151154
writeln!(out, "println()").expect("TODO: handle error");
152155
writeln!(out).expect("TODO: handle error");
153156
}
@@ -238,8 +241,10 @@ fn generate_benchmark_runner(manifest: &Manifest, units: &[TranslationUnit]) ->
238241

239242
match unit.language {
240243
SourceLanguage::Python => {
241-
writeln!(out, "if command -v python3 &> /dev/null; then").expect("TODO: handle error");
242-
writeln!(out, " echo \"Timing: python3 {}\"", unit.source_path).expect("TODO: handle error");
244+
writeln!(out, "if command -v python3 &> /dev/null; then")
245+
.expect("TODO: handle error");
246+
writeln!(out, " echo \"Timing: python3 {}\"", unit.source_path)
247+
.expect("TODO: handle error");
243248
writeln!(
244249
out,
245250
" time python3 \"$PROJECT_DIR/../{}\" 2>&1 || echo \" (Python script failed or not found)\"",
@@ -255,8 +260,10 @@ fn generate_benchmark_runner(manifest: &Manifest, units: &[TranslationUnit]) ->
255260
writeln!(out, "fi").expect("TODO: handle error");
256261
}
257262
SourceLanguage::R => {
258-
writeln!(out, "if command -v Rscript &> /dev/null; then").expect("TODO: handle error");
259-
writeln!(out, " echo \"Timing: Rscript {}\"", unit.source_path).expect("TODO: handle error");
263+
writeln!(out, "if command -v Rscript &> /dev/null; then")
264+
.expect("TODO: handle error");
265+
writeln!(out, " echo \"Timing: Rscript {}\"", unit.source_path)
266+
.expect("TODO: handle error");
260267
writeln!(
261268
out,
262269
" time Rscript \"$PROJECT_DIR/../{}\" 2>&1 || echo \" (R script failed or not found)\"",

src/codegen/julia_gen.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,8 @@ pub fn generate_julia_module(unit: &TranslationUnit, manifest_mappings: &[Mappin
227227
writeln!(output, "function run_pipeline()").expect("TODO: handle error");
228228

229229
if unit.detected_calls.is_empty() {
230-
writeln!(output, " # No translatable library calls detected.").expect("TODO: handle error");
230+
writeln!(output, " # No translatable library calls detected.")
231+
.expect("TODO: handle error");
231232
writeln!(output, " @info \"No operations to run.\"").expect("TODO: handle error");
232233
} else {
233234
for call in &unit.detected_calls {
@@ -364,7 +365,8 @@ pub fn generate_project_toml(manifest: &Manifest, units: &[TranslationUnit]) ->
364365
if let Some(uuid) = uuids.get(pkg.as_str()) {
365366
writeln!(output, "{} = \"{}\"", pkg, uuid).expect("TODO: handle error");
366367
} else {
367-
writeln!(output, "# {} = \"<uuid>\" # TODO: look up UUID", pkg).expect("TODO: handle error");
368+
writeln!(output, "# {} = \"<uuid>\" # TODO: look up UUID", pkg)
369+
.expect("TODO: handle error");
368370
}
369371
}
370372

src/codegen/parser.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,10 @@ pub fn parse_python_source(source_path: &str, content: &str) -> Result<Translati
148148

149149
// Detect `import X as Y` or `import X`
150150
if trimmed.starts_with("import ") && !trimmed.starts_with("import(") {
151-
let rest = trimmed.strip_prefix("import ").expect("TODO: handle error").trim();
151+
let rest = trimmed
152+
.strip_prefix("import ")
153+
.expect("TODO: handle error")
154+
.trim();
152155
if let Some((module, alias)) = rest.split_once(" as ") {
153156
let module = module.trim();
154157
let alias = alias.trim();

0 commit comments

Comments
 (0)