From 5b1fae282cb2a47801b442910f596511926d5a5e Mon Sep 17 00:00:00 2001 From: Michael Williams Date: Thu, 29 Oct 2026 17:04:45 -0700 Subject: [PATCH 1/4] style: establish a clean formatting and native lint baseline --- examples/distilbert/README.md | 2 +- examples/distilbert/export.py | 6 ++++-- examples/stablelm/export.py | 2 +- examples/stablelm/stablelm.exs | 4 ++-- lib/ortex/serving.ex | 2 +- native/ortex/src/lib.rs | 30 +++++++++++++++--------------- native/ortex/src/model.rs | 25 ++++++++++++------------- native/ortex/src/tensor.rs | 12 ++++++------ native/ortex/src/utils.rs | 4 ++-- scripts/install_runtime.py | 10 ++++++++-- test/shape/concat_test.exs | 2 +- 11 files changed, 53 insertions(+), 46 deletions(-) diff --git a/examples/distilbert/README.md b/examples/distilbert/README.md index 5d6385f..abb16c0 100644 --- a/examples/distilbert/README.md +++ b/examples/distilbert/README.md @@ -6,4 +6,4 @@ Run `python export.py` to create the ONNX model for distilbert/distilbert-base-u ### Labels -When exporting the model from huggingface transformers to ONNX, a `config.json` file is added to the chosen directory. This file has the id to label mappings and you can extract them directly to give a label to the input, as shwon in `distilbert_classification.exs`. +When exporting the model from huggingface transformers to ONNX, a `config.json` file is added to the chosen directory. This file has the id to label mappings and you can extract them directly to give a label to the input, as shwon in `distilbert_classification.exs`. diff --git a/examples/distilbert/export.py b/examples/distilbert/export.py index ee0496e..b4d43ad 100644 --- a/examples/distilbert/export.py +++ b/examples/distilbert/export.py @@ -7,13 +7,15 @@ """ -from transformers import DistilBertTokenizer from optimum.onnxruntime import ORTModelForSequenceClassification +from transformers import DistilBertTokenizer save_directory = "./models/distilbert-onnx/" tokenizer = DistilBertTokenizer.from_pretrained("distilbert-base-uncased-finetuned-sst-2-english") -model = ORTModelForSequenceClassification.from_pretrained("distilbert-base-uncased-finetuned-sst-2-english", export=True) +model = ORTModelForSequenceClassification.from_pretrained( + "distilbert-base-uncased-finetuned-sst-2-english", export=True +) print(model) model.save_pretrained(save_directory) diff --git a/examples/stablelm/export.py b/examples/stablelm/export.py index 1b2f8f9..ab7a16a 100644 --- a/examples/stablelm/export.py +++ b/examples/stablelm/export.py @@ -1,8 +1,8 @@ +import torch from transformers import ( AutoModelForCausalLM, AutoTokenizer, ) -import torch tokenizer = AutoTokenizer.from_pretrained("stabilityai/stablelm-tuned-alpha-3b") model = AutoModelForCausalLM.from_pretrained("stabilityai/stablelm-tuned-alpha-3b") diff --git a/examples/stablelm/stablelm.exs b/examples/stablelm/stablelm.exs index 41ca95a..1d5b08d 100644 --- a/examples/stablelm/stablelm.exs +++ b/examples/stablelm/stablelm.exs @@ -29,9 +29,9 @@ defmodule M do x = output |> Nx.backend_transfer() |> Nx.argmax(axis: 2) last = x[[.., -1]] |> Nx.new_axis(0) - IO.inspect(last[0][0] |> Nx.to_number) + IO.inspect(last[0][0] |> Nx.to_number()) - case Enum.member?([50278, 50279, 50277, 1, 0], last[0][0] |> Nx.to_number) do + case Enum.member?([50278, 50279, 50277, 1, 0], last[0][0] |> Nx.to_number()) do true -> input diff --git a/lib/ortex/serving.ex b/lib/ortex/serving.ex index 55ca37d..83e72fa 100644 --- a/lib/ortex/serving.ex +++ b/lib/ortex/serving.ex @@ -8,7 +8,7 @@ defmodule Ortex.Serving do ## Examples - ### Inline/serverless workflow + ### Inline/serverless workflow To quickly create an `Ortex.Serving` and run it diff --git a/native/ortex/src/lib.rs b/native/ortex/src/lib.rs index 0523105..07ffa91 100644 --- a/native/ortex/src/lib.rs +++ b/native/ortex/src/lib.rs @@ -30,12 +30,7 @@ fn init( } #[rustler::nif] -fn show_session( - model: ResourceArc, -) -> NifResult<( - Vec<(String, String, Option>)>, - Vec<(String, String, Option>)>, -)> { +fn show_session(model: ResourceArc) -> NifResult { Ok(model::show(model)) } @@ -43,7 +38,7 @@ fn show_session( fn run( model: ResourceArc, inputs: Vec>, -) -> NifResult, Vec, Atom, usize)>> { +) -> NifResult> { model::run(model, inputs).map_err(|e| rustler::Error::Term(Box::new(e.to_string()))) } @@ -71,7 +66,7 @@ fn to_binary<'a>( } #[rustler::nif] -pub fn slice<'a>( +pub fn slice( tensor: ResourceArc, start_indicies: Vec, lengths: Vec, @@ -85,7 +80,7 @@ pub fn slice<'a>( } #[rustler::nif] -pub fn reshape<'a>( +pub fn reshape( tensor: ResourceArc, shape: Vec, ) -> NifResult> { @@ -93,7 +88,7 @@ pub fn reshape<'a>( } #[rustler::nif] -pub fn concatenate<'a>( +pub fn concatenate( tensors: Vec>, dtype: Term, axis: i32, @@ -116,9 +111,14 @@ rustler::init!( reshape, concatenate ], - load = |env: Env, _| { - rustler::resource!(OrtexModel, env); - rustler::resource!(OrtexTensor, env); - true - } + load = load ); + +// Rustler 0.29 expands resource registration into impls inside this callback. +// Remove this compatibility allowance with the separate Rustler migration. +#[allow(non_local_definitions)] +fn load(env: Env, _info: Term) -> bool { + rustler::resource!(OrtexModel, env); + rustler::resource!(OrtexTensor, env); + true +} diff --git a/native/ortex/src/model.rs b/native/ortex/src/model.rs index f5e04b3..ccc8108 100644 --- a/native/ortex/src/model.rs +++ b/native/ortex/src/model.rs @@ -19,6 +19,10 @@ use rustler::Atom; /// Holds the model state which include onnxruntime session and environment. All /// are threadsafe so this can be called concurrently from the beam. +pub type Descriptor = (String, String, Option>); +pub type SessionDescription = (Vec, Vec); +pub type TensorOutput = (ResourceArc, Vec, Atom, usize); + pub struct OrtexModel { pub session: ort::Session, } @@ -51,13 +55,8 @@ pub fn init( /// Returns input/output information about a model. The result is a Tuple of /// `inputs` and `outputs` with elements of `(Name, Type, Dimension)` where /// `Dimension` elements of -1 are dynamic. -pub fn show( - model: ResourceArc, -) -> ( - Vec<(String, String, Option>)>, - Vec<(String, String, Option>)>, -) { - let model: &OrtexModel = &*model; +pub fn show(model: ResourceArc) -> SessionDescription { + let model: &OrtexModel = &model; let mut inputs = Vec::new(); for input in model.session.inputs.iter() { @@ -83,7 +82,7 @@ pub fn show( pub fn run( model: ResourceArc, inputs: Vec>, -) -> Result, Vec, Atom, usize)>, Error> { +) -> Result, Error> { // Grab the session and run a forward pass with it let session: &ort::Session = &model.session; @@ -94,7 +93,7 @@ pub fn run( if is_bool_input(&onnx_input.input_type) { // this assumes that the boolean input isn't huge -- we're cloning it twice; // once below, once in the try_into() - let boolified_input: &OrtexTensor = &derefed_input.clone().to_bool(); + let boolified_input: &OrtexTensor = &derefed_input.clone().into_bool(); let v: ort::SessionInputValue = boolified_input.try_into()?; ortified_inputs.push(v); } else { @@ -109,12 +108,12 @@ pub fn run( for output_descriptor in &session.outputs { let output_name: &str = &output_descriptor.name; - let val = outputs.get(output_name).expect( - &format!( + let val = outputs.get(output_name).unwrap_or_else(|| { + panic!( "Expected {} to be in the outputs, but didn't find it", output_name - )[..], - ); + ) + }); // NOTE: try_into impl here will implicitly map bool outputs to u8 outputs let ortextensor: OrtexTensor = val.try_into()?; diff --git a/native/ortex/src/tensor.rs b/native/ortex/src/tensor.rs index d4aecf1..f0efdcb 100644 --- a/native/ortex/src/tensor.rs +++ b/native/ortex/src/tensor.rs @@ -153,8 +153,8 @@ impl OrtexTensor { contents } - pub fn slice<'a>( - &'a self, + pub fn slice( + &self, start_indicies: Vec, lengths: Vec, strides: Vec, @@ -184,7 +184,7 @@ impl OrtexTensor { } } - pub fn to_bool(self) -> OrtexTensor { + pub fn into_bool(self) -> OrtexTensor { match self { OrtexTensor::u8(y) => { let bool_tensor = y.to_owned().mapv(|x| match x { @@ -205,7 +205,7 @@ impl OrtexTensor { fn slice_array<'a, T, D>( array: &'a Array, - slice_specs: &'a Vec<(isize, Option, isize)>, + slice_specs: &'a [(isize, Option, isize)], ) -> ArrayView<'a, T, D> where D: Dimension, @@ -216,7 +216,7 @@ where }) } -fn get_bytes<'a, T>(array: &'a ArrayBase) -> &'a [u8] +fn get_bytes(array: &ArrayBase) -> &[u8] where T: Data, { @@ -343,7 +343,7 @@ macro_rules! concatenate { // `typ` is the actual datatype, `ort_tensor_kind` is the OrtexTensor variant ($tensors:expr, $axis:expr, $typ:ty, $ort_tensor_kind:ident) => {{ type ArrayType<'a> = ArrayBase, Dim>; - fn filter(tensor: &OrtexTensor) -> Option { + fn filter(tensor: &OrtexTensor) -> Option> { match tensor { OrtexTensor::$ort_tensor_kind(x) => Some(x.view()), _ => None, diff --git a/native/ortex/src/utils.rs b/native/ortex/src/utils.rs index 8d96117..4242917 100644 --- a/native/ortex/src/utils.rs +++ b/native/ortex/src/utils.rs @@ -14,7 +14,7 @@ use rustler::{Atom, Env, NifResult}; use ort::{ExecutionProviderDispatch, GraphOptimizationLevel}; /// A faster (unsafe) way of creating an Array from an Erlang binary -fn initialize_from_raw_ptr(ptr: *const T, shape: &[Ix]) -> ArrayViewMut { +fn initialize_from_raw_ptr(ptr: *const T, shape: &[Ix]) -> ArrayViewMut<'_, T, IxDyn> { let array = unsafe { ArrayViewMut::from_shape_ptr(shape, ptr as *mut T) }; array } @@ -64,7 +64,7 @@ pub fn from_binary( initialize_from_raw_ptr(bin.as_ptr() as *const i64, &shape).to_owned(), ))), ("u", 8) => Ok(ResourceArc::new(OrtexTensor::u8( - initialize_from_raw_ptr(bin.as_ptr() as *const u8, &shape).to_owned(), + initialize_from_raw_ptr(bin.as_ptr(), &shape).to_owned(), ))), ("u", 16) => Ok(ResourceArc::new(OrtexTensor::u16( initialize_from_raw_ptr(bin.as_ptr() as *const u16, &shape).to_owned(), diff --git a/scripts/install_runtime.py b/scripts/install_runtime.py index b013e0d..d76a947 100644 --- a/scripts/install_runtime.py +++ b/scripts/install_runtime.py @@ -46,9 +46,15 @@ def main(): else: with tarfile.open(archive) as source: source.extractall(DESTINATION, filter="data") - pattern = {"Darwin": "libonnxruntime.*.dylib", "Linux": "libonnxruntime.so.*", "Windows": "onnxruntime.dll"}[system] + pattern = { + "Darwin": "libonnxruntime.*.dylib", + "Linux": "libonnxruntime.so.*", + "Windows": "onnxruntime.dll", + }[system] library = next((DESTINATION / name).rglob(pattern)).resolve() - RECEIPT.write_text(json.dumps({"version": version, "target": target, "library": str(library)}) + "\n") + RECEIPT.write_text( + json.dumps({"version": version, "target": target, "library": str(library)}) + "\n" + ) print(library) diff --git a/test/shape/concat_test.exs b/test/shape/concat_test.exs index 3ab9a54..b70a5f7 100644 --- a/test/shape/concat_test.exs +++ b/test/shape/concat_test.exs @@ -1,7 +1,7 @@ defmodule Ortex.TestConcat do use ExUnit.Case - # Testing each type, since there's a bunch of boilerplate that we want to + # Testing each type, since there's a bunch of boilerplate that we want to # check for errors on the Rust side %{ "s8" => {:s, 8}, From 2d17671e6f2f31d27019067c066379c1b76c76a0 Mon Sep 17 00:00:00 2001 From: Michael Williams Date: Thu, 29 Oct 2026 17:04:45 -0700 Subject: [PATCH 2/4] build: run shared language checks against staged or complete sources --- .credo.exs | 17 +++++ .formatter.exs | 5 +- Makefile | 19 ++++- docs/development.md | 14 ++++ mix.exs | 1 + mix.lock | 3 + pyproject.toml | 7 ++ scripts/check.py | 156 +++++++++++++++++++++++++++++++++++++++ scripts/hooks.py | 34 +++++++++ scripts/precommit | 4 + scripts/requirements.txt | 2 + 11 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 .credo.exs create mode 100644 pyproject.toml create mode 100644 scripts/check.py create mode 100644 scripts/hooks.py create mode 100644 scripts/precommit create mode 100644 scripts/requirements.txt diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..ae7084d --- /dev/null +++ b/.credo.exs @@ -0,0 +1,17 @@ +%{ + configs: [ + %{ + name: "default", + files: %{included: ["lib/", "test/"], excluded: [~r"/fixtures/", ~r"/package/"]}, + checks: %{ + enabled: [ + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + {Credo.Check.Readability.TrailingBlankLine, []} + ] + } + } + ] +} diff --git a/.formatter.exs b/.formatter.exs index d2cda26..2e6ff9a 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,7 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: [ + "{mix,.formatter,.credo}.exs", + "{config,lib,test,scripts,bench,examples}/**/*.{ex,exs}" + ] ] diff --git a/Makefile b/Makefile index 1cd6a92..82e066b 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ .DEFAULT_GOAL := help PYTHON ?= python3 +export PATH := $(CURDIR)/.venv/bin:$(CURDIR)/.venv/Scripts:$(PATH) export MIX_ENV := test export ORT_DYLIB_PATH ?= $(shell $(PYTHON) scripts/install_runtime.py path 2>/dev/null) -.PHONY: help setup deps doctor test docs +.PHONY: help setup deps doctor test docs hooks fmt lint check help: ## Show development commands @awk 'BEGIN {FS = ":.*## "} /^[a-zA-Z0-9_.-]+:.*## / {printf " %-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @@ -13,6 +14,8 @@ setup: ## Fetch Mix dependencies and the development runtime mix local.rebar --force $(MAKE) deps $(PYTHON) scripts/install_runtime.py install + $(PYTHON) -m venv .venv + .venv/bin/python -m pip install -r scripts/requirements.txt deps: ## Fetch the locked test and documentation dependencies mix deps.get --check-locked @@ -26,3 +29,17 @@ test: ## Run library tests and doctests docs: ## Build documentation with warnings treated as errors MIX_ENV=docs mix docs --warnings-as-errors + +hooks: ## Install the pre-commit launcher (refuses to overwrite an unrelated hook) + $(PYTHON) scripts/hooks.py + +fmt: ## Apply the language formatters + mix format + cd native/ortex && cargo fmt + ruff format . + shfmt -w scripts/precommit + +lint: ## Check formatting, static analysis, and compiler warnings + $(PYTHON) scripts/check.py --full + +check: lint test ## Run the ordinary local checks diff --git a/docs/development.md b/docs/development.md index 467a4fb..db101f6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -23,3 +23,17 @@ normal ExUnit discovery. Additional suites are introduced by later stack PRs. Documentation has its own Mix environment so building docs does not install EXLA, Torchx, or example tokenizers. Large examples own their extra dependencies. + +## Checks and hooks + +Activate `.venv/bin` alongside the mise tools after setup. `make lint` runs +format checks, Credo, Clippy, and workflow validation. `make fmt` applies +formatters. `scripts/precommit` runs fast checks against an isolated snapshot of +the Git index, so partially staged files are checked as committed. It never +changes the index or working tree. Filenames are passed as arguments, not shell +code. Full checks require prepared dependencies; hooks never install them. + +`make hooks` installs a launcher through `git rev-parse --git-path`; it refuses +to replace an unrelated hook. Git's hooks directory is shared by linked +worktrees. Agent hooks are adapters to the same checker and require the agent's +normal hook review/trust. No global permissions or model settings are changed. diff --git a/mix.exs b/mix.exs index 04a638c..32a9113 100644 --- a/mix.exs +++ b/mix.exs @@ -37,6 +37,7 @@ defmodule Ortex.MixProject do [ {:rustler, "~> 0.29.1"}, {:nx, "~> 0.6"}, + {:credo, "~> 1.7.19", only: :test, runtime: false}, {:ex_doc, "~> 0.40.4", only: :docs, runtime: false} ] end diff --git a/mix.lock b/mix.lock index fe77280..2067ae2 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,10 @@ %{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "complex": {:hex, :complex, "0.7.0", "695632ef9487517aa5d57edd1697801079d622414cb2e1a7cf538b1f9a50f205", [:mix], [], "hexpm", "0ee39c0803129f546e7f3f640da8f021c9e659402bf59da6f7f2c4848f068f8d"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, "ex_doc": {:hex, :ex_doc, "0.40.4", "66f2e42bf588594d5a8aab31cad87f2ddad09d0da1b1a2f379340ec2c2e497cb", [:mix], [{:earmark_parser, "~> 1.4.46", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "6222b9e423d76584ee34df2c82a5ed72c2d53dc153f7f483ad28b378694186cc"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "makeup": {:hex, :makeup, "1.2.3", "c3ef0ff83acf305384acba0f70bff87d8732502baa1eb3eb081d8c154fbe18f5", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "15970751b7021c2bcd7ed56a54b3def23affd3969fb7c214dffdf28e97ce6f76"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..387b33c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[tool.ruff] +target-version = "py312" +line-length = 100 +extend-exclude = ["deps", "_build", "runtime", "dist", ".venv"] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] diff --git a/scripts/check.py b/scripts/check.py new file mode 100644 index 0000000..d1b6d70 --- /dev/null +++ b/scripts/check.py @@ -0,0 +1,156 @@ +"""Run file-selected checks; staged checks use an isolated Git index snapshot.""" + +import argparse +import concurrent.futures +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def git(*arguments): + return subprocess.check_output(["git", "-C", str(ROOT), *arguments]) + + +def run_check(item, directory): + name, command = item + try: + result = subprocess.run( + command, + cwd=directory, + env={ + **os.environ, + "MIX_ENV": "test", + "PATH": os.pathsep.join( + [ + str(ROOT / ".venv/bin"), + str(ROOT / ".venv/Scripts"), + os.environ.get("PATH", ""), + ] + ), + }, + capture_output=True, + text=True, + timeout=300, + ) + return name, result.returncode, result.stdout + result.stderr + except (OSError, subprocess.TimeoutExpired) as error: + return name, 1, str(error) + + +def checks(files, directory, full): + groups = { + extension: [p for p in files if p.endswith(extension)] + for extension in (".py", ".ex", ".exs", ".rs") + } + jobs = [] + if groups[".py"]: + jobs += [ + ("python lint", ["ruff", "check", *groups[".py"]]), + ("python format", ["ruff", "format", "--check", *groups[".py"]]), + ] + elixir = groups[".ex"] + groups[".exs"] + if elixir: + jobs.append(("elixir format", ["mix", "format", "--check-formatted", *elixir])) + if groups[".rs"] or "native/ortex/Cargo.toml" in files or "rust-toolchain.toml" in files: + jobs.append( + ( + "rust format", + ["cargo", "fmt", "--manifest-path", "native/ortex/Cargo.toml", "--check"], + ) + ) + shell = [p for p in files if p.endswith(".sh") or p == "scripts/precommit"] + if shell: + jobs += [("shell lint", ["shellcheck", *shell]), ("shell format", ["shfmt", "-d", *shell])] + if any(p.startswith(".github/workflows/") for p in files): + workflows = sorted( + str(p.relative_to(directory)) for p in (directory / ".github/workflows").glob("*.yml") + ) + jobs += [ + ("workflow syntax", ["actionlint", *workflows]), + ("workflow security", ["zizmor", "--offline", "--no-progress", ".github/workflows"]), + ] + if full: + jobs += [ + ( + "elixir compile and credo", + ["mix", "do", "compile", "--warnings-as-errors", "+", "credo", "--strict"], + ), + ( + "rust clippy", + [ + "cargo", + "clippy", + "--manifest-path", + "native/ortex/Cargo.toml", + "--locked", + "--all-targets", + "--features", + "ort/load-dynamic", + "--", + "-D", + "warnings", + ], + ), + ] + return jobs + + +def check_files(files, directory, full): + files = [p for p in files if (directory / p).is_file()] + failed = False + for name in files: + path = directory / name + if ( + path.suffix not in {".py", ".ex", ".exs", ".rs", ".toml", ".json", ".md", ".yml", ".sh"} + and name != "Makefile" + ): + continue + data = path.read_bytes() + if data and ( + not data.endswith(b"\n") + or any(line.rstrip(b" \t") != line for line in data.splitlines()) + ): + print(f"FAIL whitespace: {name}") + failed = True + jobs = checks(files, directory, full) + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + futures = [pool.submit(run_check, item, directory) for item in jobs] + for future in futures: + name, status, output = future.result() + print(f"{'FAIL' if status else 'PASS'} {name}") + if output.strip(): + print(output.rstrip()) + failed |= status != 0 + return int(failed) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group() + group.add_argument("--staged", action="store_true") + group.add_argument("--files", nargs="+") + parser.add_argument("--full", action="store_true", help="also compile and run Credo/Clippy") + args = parser.parse_args() + if args.staged and args.full: + parser.error("staged checks are fast static checks; use make lint for full checks") + if args.staged: + names = git("diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z") + else: + names = git("ls-files", "--cached", "--others", "--exclude-standard", "-z") + files = args.files or list(dict.fromkeys(os.fsdecode(p) for p in names.split(b"\0") if p)) + for name in files: + if Path(name).is_absolute() or ".." in Path(name).parts: + parser.error(f"expected a repository-relative path: {name}") + if args.staged: + with tempfile.TemporaryDirectory(prefix="ortex-index-") as snapshot: + git("checkout-index", "--all", f"--prefix={snapshot}{os.sep}") + return check_files(files, Path(snapshot), False) + return check_files(files, ROOT, args.full) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/hooks.py b/scripts/hooks.py new file mode 100644 index 0000000..733092e --- /dev/null +++ b/scripts/hooks.py @@ -0,0 +1,34 @@ +"""Install a worktree-aware hook without changing core.hooksPath.""" + +import argparse +import os +import shlex +import subprocess +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--force", action="store_true", help="replace a different existing hook") + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + hook = Path( + subprocess.check_output( + ["git", "rev-parse", "--git-path", "hooks/pre-commit"], cwd=root, text=True + ).strip() + ) + if not hook.is_absolute(): + hook = root / hook + content = '#!/bin/sh\nexec sh "$(git rev-parse --show-toplevel)/scripts/precommit" "$@"\n' + if hook.exists() and hook.read_text() != content and not args.force: + raise SystemExit( + f"Existing hook left intact: {shlex.quote(str(hook))}; inspect it before using --force" + ) + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text(content) + hook.chmod(hook.stat().st_mode | 0o111) + print(f"Installed {os.fspath(hook)}; linked worktrees share this hook") + + +if __name__ == "__main__": + main() diff --git a/scripts/precommit b/scripts/precommit new file mode 100644 index 0000000..59f7738 --- /dev/null +++ b/scripts/precommit @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +cd "$(git rev-parse --show-toplevel)" +exec python3 scripts/check.py --staged diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..77c7738 --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,2 @@ +ruff==0.17.3 +zizmor==1.31.1 From b171a5e62cbede7ca5c11f325bfe79f0fc9098f4 Mon Sep 17 00:00:00 2001 From: Michael Williams Date: Thu, 29 Oct 2026 17:04:45 -0700 Subject: [PATCH 3/4] build: connect agent edit hooks to the shared checker --- .claude/settings.json | 9 ++++++++- .codex/hooks.json | 8 ++++++++ scripts/agent_check.py | 43 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 .codex/hooks.json create mode 100644 scripts/agent_check.py diff --git a/.claude/settings.json b/.claude/settings.json index 0967ef4..ece65bb 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1 +1,8 @@ -{} +{ + "hooks": { + "PostToolUse": [{ + "matcher": "Edit|Write", + "hooks": [{"type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/scripts/agent_check.py\"", "timeout": 45}] + }] + } +} diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..e914a64 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,8 @@ +{ + "hooks": { + "PostToolUse": [{ + "matcher": "apply_patch|Edit|Write", + "hooks": [{"type": "command", "command": "python3 \"$(git rev-parse --show-toplevel)/scripts/agent_check.py\"", "timeout": 45}] + }] + } +} diff --git a/scripts/agent_check.py b/scripts/agent_check.py new file mode 100644 index 0000000..df81b3a --- /dev/null +++ b/scripts/agent_check.py @@ -0,0 +1,43 @@ +"""Translate supported file-edit hook payloads into the shared fast checker.""" + +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + payload = json.load(sys.stdin) + arguments = payload.get("tool_input", {}) + paths = [] + if isinstance(arguments, dict): + if arguments.get("file_path"): + paths.append(arguments["file_path"]) + patch = arguments.get("input", arguments.get("patch", "")) + else: + patch = arguments + if isinstance(patch, str): + for line in patch.splitlines(): + if line.startswith(("*** Add File: ", "*** Update File: ")): + paths.append(line.split(": ", 1)[1]) + names = [] + for name in paths: + path = Path(name) + path = path if path.is_absolute() else ROOT / path + try: + relative = path.resolve().relative_to(ROOT) + except ValueError: + continue + if path.is_file(): + names.append(str(relative)) + if not names: + return 0 + return subprocess.call( + [sys.executable, str(ROOT / "scripts/check.py"), "--files", *names], cwd=ROOT + ) + + +if __name__ == "__main__": + sys.exit(main()) From b5b72c84f5b7f79bc6fad1bb91039fc78fc444a7 Mon Sep 17 00:00:00 2001 From: Michael Williams Date: Thu, 29 Oct 2026 17:43:54 -0700 Subject: [PATCH 4/4] fix: preserve safe pre-commit execution across linked worktrees --- scripts/check.py | 11 +++++++---- scripts/hooks.py | 2 +- scripts/precommit | 0 3 files changed, 8 insertions(+), 5 deletions(-) mode change 100644 => 100755 scripts/precommit diff --git a/scripts/check.py b/scripts/check.py index d1b6d70..c7202c0 100644 --- a/scripts/check.py +++ b/scripts/check.py @@ -49,12 +49,12 @@ def checks(files, directory, full): jobs = [] if groups[".py"]: jobs += [ - ("python lint", ["ruff", "check", *groups[".py"]]), - ("python format", ["ruff", "format", "--check", *groups[".py"]]), + ("python lint", ["ruff", "check", "--", *groups[".py"]]), + ("python format", ["ruff", "format", "--check", "--", *groups[".py"]]), ] elixir = groups[".ex"] + groups[".exs"] if elixir: - jobs.append(("elixir format", ["mix", "format", "--check-formatted", *elixir])) + jobs.append(("elixir format", ["mix", "format", "--check-formatted", "--", *elixir])) if groups[".rs"] or "native/ortex/Cargo.toml" in files or "rust-toolchain.toml" in files: jobs.append( ( @@ -64,7 +64,10 @@ def checks(files, directory, full): ) shell = [p for p in files if p.endswith(".sh") or p == "scripts/precommit"] if shell: - jobs += [("shell lint", ["shellcheck", *shell]), ("shell format", ["shfmt", "-d", *shell])] + jobs += [ + ("shell lint", ["shellcheck", "--", *shell]), + ("shell format", ["shfmt", "-d", "--", *shell]), + ] if any(p.startswith(".github/workflows/") for p in files): workflows = sorted( str(p.relative_to(directory)) for p in (directory / ".github/workflows").glob("*.yml") diff --git a/scripts/hooks.py b/scripts/hooks.py index 733092e..4013e4f 100644 --- a/scripts/hooks.py +++ b/scripts/hooks.py @@ -19,7 +19,7 @@ def main(): ) if not hook.is_absolute(): hook = root / hook - content = '#!/bin/sh\nexec sh "$(git rev-parse --show-toplevel)/scripts/precommit" "$@"\n' + content = '#!/bin/sh\nroot=$(git rev-parse --show-toplevel) || exit 1\ntest -f "$root/scripts/precommit" || exit 0\nexec sh "$root/scripts/precommit" "$@"\n' if hook.exists() and hook.read_text() != content and not args.force: raise SystemExit( f"Existing hook left intact: {shlex.quote(str(hook))}; inspect it before using --force" diff --git a/scripts/precommit b/scripts/precommit old mode 100644 new mode 100755