Skip to content

Commit 14d34e9

Browse files
ci: make CI genuinely green — rust-ci toolchain pin + canonical Julia ABI-FFI gate + fmt/clippy (#42)
## 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. - **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 last Python file from CI) - [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 b3d0283 commit 14d34e9

7 files changed

Lines changed: 166 additions & 117 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

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/abi/mod.rs

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -563,10 +563,22 @@ mod tests {
563563

564564
#[test]
565565
fn test_signal_type_parsing() {
566-
assert_eq!("bool".parse::<SignalType>().expect("TODO: handle error"), SignalType::Bool);
567-
assert_eq!("int".parse::<SignalType>().expect("TODO: handle error"), SignalType::Int);
568-
assert_eq!("float".parse::<SignalType>().expect("TODO: handle error"), SignalType::Float);
569-
assert_eq!("real".parse::<SignalType>().expect("TODO: handle error"), SignalType::Real);
566+
assert_eq!(
567+
"bool".parse::<SignalType>().expect("TODO: handle error"),
568+
SignalType::Bool
569+
);
570+
assert_eq!(
571+
"int".parse::<SignalType>().expect("TODO: handle error"),
572+
SignalType::Int
573+
);
574+
assert_eq!(
575+
"float".parse::<SignalType>().expect("TODO: handle error"),
576+
SignalType::Float
577+
);
578+
assert_eq!(
579+
"real".parse::<SignalType>().expect("TODO: handle error"),
580+
SignalType::Real
581+
);
570582
assert!("unknown".parse::<SignalType>().is_err());
571583
}
572584

@@ -598,27 +610,37 @@ mod tests {
598610
#[test]
599611
fn test_safety_standard_parsing() {
600612
assert_eq!(
601-
"DO-178C".parse::<SafetyStandard>().expect("TODO: handle error"),
613+
"DO-178C"
614+
.parse::<SafetyStandard>()
615+
.expect("TODO: handle error"),
602616
SafetyStandard::Do178c
603617
);
604618
assert_eq!(
605-
"IEC-61508".parse::<SafetyStandard>().expect("TODO: handle error"),
619+
"IEC-61508"
620+
.parse::<SafetyStandard>()
621+
.expect("TODO: handle error"),
606622
SafetyStandard::Iec61508
607623
);
608624
assert_eq!(
609-
"ISO-26262".parse::<SafetyStandard>().expect("TODO: handle error"),
625+
"ISO-26262"
626+
.parse::<SafetyStandard>()
627+
.expect("TODO: handle error"),
610628
SafetyStandard::Iso26262
611629
);
612630
}
613631

614632
#[test]
615633
fn test_embedded_target_parsing() {
616634
assert_eq!(
617-
"arm-cortex-m".parse::<EmbeddedTarget>().expect("TODO: handle error"),
635+
"arm-cortex-m"
636+
.parse::<EmbeddedTarget>()
637+
.expect("TODO: handle error"),
618638
EmbeddedTarget::ArmCortexM
619639
);
620640
assert_eq!(
621-
"riscv".parse::<EmbeddedTarget>().expect("TODO: handle error"),
641+
"riscv"
642+
.parse::<EmbeddedTarget>()
643+
.expect("TODO: handle error"),
622644
EmbeddedTarget::RiscV
623645
);
624646
assert_eq!(

src/manifest/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,14 @@ pub fn validate(manifest: &Manifest) -> Result<()> {
260260
if node.name.is_empty() {
261261
anyhow::bail!("{}: name must not be empty", ctx);
262262
}
263-
if !node.name.chars().next().expect("TODO: handle error").is_ascii_alphabetic() && !node.name.starts_with('_') {
263+
if !node
264+
.name
265+
.chars()
266+
.next()
267+
.expect("TODO: handle error")
268+
.is_ascii_alphabetic()
269+
&& !node.name.starts_with('_')
270+
{
264271
anyhow::bail!("{}: name must start with a letter or underscore", ctx);
265272
}
266273

tests/integration_test.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use lustreiser::abi::{
1111
Clock, EmbeddedTarget, LustreNode, SafetyStandard, Signal, SignalType, TemporalOperator, Wcet,
1212
};
1313
use lustreiser::codegen;
14-
use lustreiser::manifest::{self, Manifest};
14+
use lustreiser::manifest::{self};
1515
use std::fs;
1616
use tempfile::TempDir;
1717

@@ -305,7 +305,7 @@ fn test_abi_types_round_trip() {
305305
// Embedded targets.
306306
let arm: EmbeddedTarget = "arm-cortex-m".parse().unwrap();
307307
assert_eq!(arm.target_triple(), "arm-none-eabi");
308-
assert!(arm.compiler_flags().len() > 0);
308+
assert!(!arm.compiler_flags().is_empty());
309309

310310
let riscv: EmbeddedTarget = "riscv".parse().unwrap();
311311
assert_eq!(riscv.target_triple(), "riscv32-unknown-elf");

0 commit comments

Comments
 (0)