diff --git a/.github/workflows/abi-ffi-gate.yml b/.github/workflows/abi-ffi-gate.yml index 269464d..f647ba6 100644 --- a/.github/workflows/abi-ffi-gate.yml +++ b/.github/workflows/abi-ffi-gate.yml @@ -21,8 +21,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Install Julia 1.11.5 + run: | + 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 + tar -xf /tmp/julia.tar.gz -C /tmp + echo "/tmp/julia-1.11.5/bin" >> "$GITHUB_PATH" - name: Run ABI-FFI gate - run: python3 scripts/abi-ffi-gate.py + run: | + julia --version # confirms the pinned 1.11.5 is on PATH, not the runner default + julia scripts/abi-ffi-gate.jl zig-build: name: Zig FFI builds + tests (Zig 0.14.0) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index c60e60a..b1b86c6 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -14,4 +14,4 @@ permissions: jobs: rust-ci: - uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@d135b05bfc647d0c0fbfedc7e80f37ea50f49236 + uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@8dc2bf039d1ff0372d650895c46bea7fbaec68ff diff --git a/benches/typedqliser_bench.rs b/benches/typedqliser_bench.rs index 77f8f05..b84f4b0 100644 --- a/benches/typedqliser_bench.rs +++ b/benches/typedqliser_bench.rs @@ -8,7 +8,7 @@ //! - `schema_check` and `type_check` overhead per query size. //! - Plugin creation cost. -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; use typedqliser::plugins::{ColumnDef, Schema, TableDef, get_plugin}; // --------------------------------------------------------------------------- @@ -19,8 +19,7 @@ use typedqliser::plugins::{ColumnDef, Schema, TableDef, get_plugin}; const SMALL_QUERY: &str = "SELECT id FROM accounts WHERE id = 1"; /// Medium query (~180 bytes) — a two-table JOIN with a WHERE clause. -const MEDIUM_QUERY: &str = - "SELECT accounts.id, accounts.username, transactions.amount \ +const MEDIUM_QUERY: &str = "SELECT accounts.id, accounts.username, transactions.amount \ FROM accounts \ INNER JOIN transactions ON accounts.id = transactions.account_id \ WHERE accounts.id > 10 AND transactions.amount > 0 \ @@ -28,8 +27,7 @@ const MEDIUM_QUERY: &str = LIMIT 50"; /// Large query (~400 bytes) — a CTE + subquery + GROUP BY. -const LARGE_QUERY: &str = - "WITH high_value AS ( \ +const LARGE_QUERY: &str = "WITH high_value AS ( \ SELECT account_id, SUM(amount) AS total \ FROM transactions \ WHERE amount > 100 \ diff --git a/scripts/abi-ffi-gate.jl b/scripts/abi-ffi-gate.jl new file mode 100644 index 0000000..540ce1a --- /dev/null +++ b/scripts/abi-ffi-gate.jl @@ -0,0 +1,116 @@ +#!/usr/bin/env julia +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# abi-ffi-gate.jl — fail (exit 1) if the Zig FFI does not conform to the Idris2 +# ABI. The Idris2 ABI is the source of truth. Checks, with no compile toolchain +# needed (pure base-Julia text analysis): +# +# 1. the Zig FFI carries no unrendered `{{...}}` template tokens; +# 2. every `%foreign "C:"` symbol declared anywhere in the ABI .idr +# sources is exported by the Zig FFI (`export fn `); +# 3. the Zig `Result = enum(c_int)` and the Idris `resultToInt` agree on BOTH +# names and integer values (the `Error`/`err` spelling is treated as one). +# +# Usage: julia scripts/abi-ffi-gate.jl [repo_root] (defaults to cwd) +# +# Julia port of the former scripts/abi-ffi-gate.py (Python is banned estate-wide, +# RSR-H4); behaviour is identical. + +"camelCase / PascalCase → snake_case (insert `_` before each non-initial capital)." +camel_to_snake(s) = lowercase(replace(s, r"(? "_")) + +"Canonical result-code key: lowercased, with `err`/`error` unified to `error`." +function canon_rc(name) + n = lowercase(name) + (n == "err" || n == "error") ? "error" : n +end + +"Return {variant => value} for the C-ABI `Result` enum (the `enum(c_int)` block whose `ok = 0`), or empty." +function find_result_enum(zig::AbstractString) + best = Dict{String,Int}() + for m in eachmatch(r"enum\s*\(\s*c_int\s*\)\s*\{(.*?)\}"s, zig) + body = m.captures[1] + variants = Dict{String,Int}() + for vm in eachmatch(r"@?\"?([A-Za-z_][A-Za-z0-9_]*)\"?\s*=\s*(\d+)", body) + variants[canon_rc(vm.captures[1])] = parse(Int, vm.captures[2]) + end + # The Result enum is the one starting at ok = 0. + if get(variants, "ok", nothing) == 0 && length(variants) > length(best) + best = variants + end + end + return best +end + +"Collect every `*.idr` under `abi_dir`, skipping any `build/` output directory." +function idr_sources(abi_dir::AbstractString) + files = String[] + isdir(abi_dir) || return files + for (root, _dirs, fs) in walkdir(abi_dir) + occursin("/build/", root * "/") && continue + for f in fs + endswith(f, ".idr") && push!(files, joinpath(root, f)) + end + end + return files +end + +function main(root::AbstractString)::Int + name = basename(rstrip(abspath(root), '/')) + abi_dir = joinpath(root, "src/interface/abi") + zig_path = joinpath(root, "src/interface/ffi/src/main.zig") + errs = String[] + + idr_files = idr_sources(abi_dir) + if isempty(idr_files) + println("ABI-FFI GATE: SKIP ($name) — no Idris2 ABI .idr files under $abi_dir") + return 0 + end + if !isfile(zig_path) + println("ABI-FFI GATE: FAIL ($name) — no Zig FFI at $zig_path") + return 1 + end + + idr = join((read(p, String) for p in idr_files), "\n") + zig = read(zig_path, String) + + # 1. unrendered template tokens + toks = sort(unique(String(m.match) for m in eachmatch(r"\{\{[A-Za-z0-9_]+\}\}", zig))) + isempty(toks) || push!(errs, "Zig FFI has unrendered template tokens: $(toks)") + + # 2. foreign C symbols must be exported + csyms = sort(unique(String(m.captures[1]) for m in eachmatch(r"C:([A-Za-z0-9_]+)", idr))) + exports = Set(String(m.captures[1]) for m in eachmatch(r"export fn ([A-Za-z0-9_]+)", zig)) + missing_syms = [s for s in csyms if !(s in exports)] + isempty(missing_syms) || + push!(errs, "$(length(missing_syms)) ABI function(s) not exported by the Zig FFI: $(missing_syms)") + + # 3. result-code map (names + values) must agree + idr_rc = Dict{String,Int}() + for m in eachmatch(r"resultToInt\s+([A-Za-z0-9]+)\s*=\s*(\d+)", idr) + idr_rc[canon_rc(camel_to_snake(m.captures[1]))] = parse(Int, m.captures[2]) + end + zig_rc = find_result_enum(zig) + if !isempty(idr_rc) && isempty(zig_rc) + push!(errs, "no Zig `enum(c_int)` Result block (with `ok = 0`) found to compare result codes") + elseif !isempty(idr_rc) && !isempty(zig_rc) && idr_rc != zig_rc + push!(errs, "Result-code map differs (name or value):\n" * + " Idris resultToInt: $(sort(collect(idr_rc)))\n" * + " Zig Result enum: $(sort(collect(zig_rc)))") + end + + if !isempty(errs) + println("ABI-FFI GATE: FAIL ($name)") + for e in errs + println(" - " * e) + end + return 1 + end + println("ABI-FFI GATE: OK ($name) — $(length(csyms)) ABI functions exported, " * + "$(length(idr_rc)) result codes match") + return 0 +end + +root = length(ARGS) >= 1 ? ARGS[1] : "." +exit(main(root)) diff --git a/scripts/abi-ffi-gate.py b/scripts/abi-ffi-gate.py deleted file mode 100755 index 9ee96db..0000000 --- a/scripts/abi-ffi-gate.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: MPL-2.0 -# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -# -# abi-ffi-gate.py — fail (exit 1) if the Zig FFI does not conform to the Idris2 -# ABI. The Idris2 ABI is the source of truth. Checks, with no toolchain needed: -# -# 1. the Zig FFI carries no unrendered `{{...}}` template tokens; -# 2. every `%foreign "C:"` symbol declared anywhere in the ABI .idr -# sources is exported by the Zig FFI (`export fn `); -# 3. the Zig `Result = enum(c_int)` and the Idris `resultToInt` agree on BOTH -# names and integer values (the `Error`/`err` spelling is treated as one). -# -# Usage: python3 scripts/abi-ffi-gate.py [repo_root] (defaults to cwd) - -import os -import re -import sys -import glob - - -def camel_to_snake(s): - return re.sub(r"(? len(best): - best = variants - return best - - -def main(): - root = sys.argv[1] if len(sys.argv) > 1 else "." - name = os.path.basename(os.path.abspath(root)) - abi_dir = os.path.join(root, "src/interface/abi") - zig_path = os.path.join(root, "src/interface/ffi/src/main.zig") - errs = [] - - idr_files = [ - p for p in glob.glob(os.path.join(abi_dir, "**", "*.idr"), recursive=True) - if os.sep + "build" + os.sep not in p - ] - if not idr_files: - print(f"ABI-FFI GATE: SKIP ({name}) — no Idris2 ABI .idr files under {abi_dir}") - return 0 - if not os.path.exists(zig_path): - print(f"ABI-FFI GATE: FAIL ({name}) — no Zig FFI at {zig_path}") - return 1 - - idr = "\n".join(open(p, encoding="utf-8").read() for p in idr_files) - zig = open(zig_path, encoding="utf-8").read() - - # 1. unrendered template tokens - toks = sorted(set(re.findall(r"\{\{[A-Za-z0-9_]+\}\}", zig))) - if toks: - errs.append(f"Zig FFI has unrendered template tokens: {toks}") - - # 2. foreign C symbols must be exported - csyms = sorted(set(re.findall(r"C:([A-Za-z0-9_]+)", idr))) - exports = set(re.findall(r"export fn ([A-Za-z0-9_]+)", zig)) - missing = [s for s in csyms if s not in exports] - if missing: - errs.append(f"{len(missing)} ABI function(s) not exported by the Zig FFI: {missing}") - - # 3. result-code map (names + values) must agree - idr_rc = {} - for m in re.finditer(r"resultToInt\s+([A-Za-z0-9]+)\s*=\s*(\d+)", idr): - idr_rc[canon_rc(camel_to_snake(m.group(1)))] = int(m.group(2)) - zig_rc = find_result_enum(zig) - if idr_rc and not zig_rc: - errs.append("no Zig `enum(c_int)` Result block (with `ok = 0`) found to compare result codes") - elif idr_rc and zig_rc and idr_rc != zig_rc: - errs.append( - "Result-code map differs (name or value):\n" - f" Idris resultToInt: {dict(sorted(idr_rc.items()))}\n" - f" Zig Result enum: {dict(sorted(zig_rc.items()))}" - ) - - if errs: - print(f"ABI-FFI GATE: FAIL ({name})") - for e in errs: - print(" - " + e) - return 1 - print(f"ABI-FFI GATE: OK ({name}) — {len(csyms)} ABI functions exported, " - f"{len(idr_rc)} result codes match") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/plugins/sql.rs b/src/plugins/sql.rs index ac4770a..c44aea0 100644 --- a/src/plugins/sql.rs +++ b/src/plugins/sql.rs @@ -205,18 +205,17 @@ impl SqlPlugin { | BinaryOperator::Lt | BinaryOperator::LtEq | BinaryOperator::Gt - | BinaryOperator::GtEq => { + | BinaryOperator::GtEq if lt_cat != rt_cat && lt_cat != TypeCategory::Unknown - && rt_cat != TypeCategory::Unknown - { - issues.push(TypeIssue { - message: format!( - "Comparing incompatible types: {} ({:?}) vs {} ({:?})", - lt, lt_cat, rt, rt_cat - ), - }); - } + && rt_cat != TypeCategory::Unknown => + { + issues.push(TypeIssue { + message: format!( + "Comparing incompatible types: {} ({:?}) vs {} ({:?})", + lt, lt_cat, rt, rt_cat + ), + }); } _ => {} } diff --git a/tests/e2e_test.rs b/tests/e2e_test.rs index d74c938..1f0a465 100644 --- a/tests/e2e_test.rs +++ b/tests/e2e_test.rs @@ -137,10 +137,7 @@ fn e2e_unknown_table_fails_at_l2() { assert!(plugin.parse_check(query).is_ok(), "L1 must pass"); let l2 = plugin.schema_check(query, &s).unwrap(); - assert!( - !l2.is_empty(), - "E2E: unknown table must produce L2 issues" - ); + assert!(!l2.is_empty(), "E2E: unknown table must produce L2 issues"); assert!( l2.iter().any(|i| i.message.contains("ghost_table")), "L2 issue must mention the missing table name" @@ -307,7 +304,10 @@ fn e2e_cte_parses_at_l1() { let plugin = get_plugin("sql").unwrap(); let query = "WITH top_accounts AS (SELECT id FROM accounts WHERE balance > 100) \ SELECT id FROM top_accounts"; - assert!(plugin.parse_check(query).is_ok(), "E2E: CTE must parse at L1"); + assert!( + plugin.parse_check(query).is_ok(), + "E2E: CTE must parse at L1" + ); } // --------------------------------------------------------------------------- diff --git a/tests/property_test.rs b/tests/property_test.rs index 7e5ca07..e8ef59c 100644 --- a/tests/property_test.rs +++ b/tests/property_test.rs @@ -22,7 +22,10 @@ const CORPUS: &[(&str, &str)] = &[ // 0 — simple valid select ("simple_select", "SELECT 1"), // 1 — select from known table (requires schema) - ("select_known", "SELECT id, username FROM accounts WHERE id = 1"), + ( + "select_known", + "SELECT id, username FROM accounts WHERE id = 1", + ), // 2 — invalid syntax ("invalid_syntax", "NOT SQL AT ALL @@@ !!!"), // 3 — type mismatch: integer vs string @@ -45,7 +48,10 @@ const CORPUS: &[(&str, &str)] = &[ "SELECT id FROM accounts WHERE id IN (SELECT account_id FROM transactions)", ), // 9 — aggregate - ("aggregate", "SELECT account_id, COUNT(*) FROM transactions GROUP BY account_id"), + ( + "aggregate", + "SELECT account_id, COUNT(*) FROM transactions GROUP BY account_id", + ), ]; // --------------------------------------------------------------------------- @@ -333,7 +339,7 @@ fn property_no_panic_on_full_corpus() { // We cannot move plugin/s into catch_unwind easily, so we just // verify the calls succeed outside of catch_unwind — if they // panic, the test fails. - drop(label); + let _ = label; }); let _ = plugin.schema_check(query, &s); let _ = plugin.type_check(query, &s);