feat: bounded generics for sound polymorphism (ILO-61)#620
Merged
Conversation
Add explicit generic type parameters with bound constraints to ilo function signatures. The verifier now enforces cross-call-site type variable consistency and bound satisfaction, closing the soundness hole where `fn id a>a` silently accepted inconsistent types at different call sites. MVP: - Parser: `name<a:bound ...>` block after fn name; `<a>` unbounded - Bounds: any (default), comparable (n/t/b), numeric (n), text (t) - Verifier: unifies type variable bindings per call site (ILO-T044) - Backward compatible: legacy `fn x:a>a;x` style unchanged - New error codes: ILO-T044 (inconsistency/bound violation), ILO-P022 (malformed type-param block) - `examples/generics-bounded.ilo` passes on tree engine - 7 new verify tests; all 3330+ existing tests green Deferred: variance, higher-kinded types, multiple-bound conjunctions, bound inference from usage, return-type generic substitution, VM/JIT type-param-aware dispatch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
❌ 1 Tests Failed:
View the top 1 failed test(s) by shortest run time
To view more test analytics, go to the Test Analytics Dashboard |
6 tasks
c49e112 to
23ec9a0
Compare
This was referenced May 22, 2026
Collaborator
Author
|
needs manual rebase (conflicts after partial auto-resolve in: ) |
When a function has explicit type_params (e.g. `gid<a> x:a>a`), the verifier now infers the concrete return type at each call site by substituting the type-variable bindings collected from the arguments. Previously `gid 5` produced `Ty::Unknown`; it now correctly produces `Ty::Number`, enabling downstream type-checking to catch mismatches (ILO-T007) when the return value is passed to typed contexts. - Add `original_return: ast::Type` to `FuncSig` to carry the AST return type alongside the already-stored `original_params` - Add `collect_type_var_bindings` to extract bindings from compound param shapes (`L a`, `M t a`, `R a t`, etc.) - Add `subst_return_ty` to recursively substitute type-variable letters in the return type using the collected bindings - Hoist `var_bindings` out of the `if has_explicit_bounds` block so it is available for return-type substitution in all cases - Add 4 regression tests covering: number identity, text identity, type mismatch detection, and list-element extraction Co-authored-by: Daniel Morris <daniel@cubitts.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
cbbe697 to
638c792
Compare
* fix(examples): rewrite mpairs.ilo iter fold to use brace-lambda syntax
Labelled-arg inline lambdas (`acc:L _; pair:L _;…`) inside `fld` now fail
with ILO-P003 since the parser treats `:` as a type-ascription delimiter.
Replace with brace-lambda `{acc pair > [acc, pair]}` — supported since
ILO-404 — and drop the `cat` call (comma-spread in list literal achieves
the same append without the `cat` arg-2 type error on `L (L _)`).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(builtins): add Sha256Hex and Sha256d to Builtin::ALL
The variants were defined and dispatched (tree-bridge, interpreter) but
missing from ALL, causing a panic (`Builtin::ALL must include every
variant`) whenever sha256-hex or sha256d was called via the VM. Append
them immediately after CtEq — the existing crypto-primitives cluster —
preserving all prior on-wire tags.
Fixes examples/sha256-hex.ilo and examples/sha256d-bitcoin.ilo which
both panicked at src/builtins.rs:1198.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Daniel Morris <daniel@cubitts.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes 5 clippy errors: - Remove unreachable HeapObj::LazyStdinLines arms in FOREACHPREP/FOREACHNEXT match blocks (vm/mod.rs) - Add #[allow(clippy::should_implement_trait)] to UsePredicate::from_str (ast/mod.rs) - Extract StdinLinesInner type alias to reduce complex type (interpreter/mod.rs) - Add Default impl for StdinLinesHandle (interpreter/mod.rs) - Convert match to matches! macro in BuildTarget::eval (main.rs) - Suppress unused import warning in test (main.rs) Co-authored-by: Daniel Morris <daniel@cubitts.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
ILO-382 set the per-module caps to measured-baseline-plus-50 to gate silent doc drift. ilo-language.md grew past 1700 (now 1746) as the post-#722 brace-lambda + or-pattern sections landed, blocking every PR's CI lint. Bump to 1800 with the same ~50-token headroom.
`Builtin::Idxof` was added to the enum but never appended to `Builtin::ALL`, so calling `idxof` panicked with 'Builtin::ALL must include every variant' on every engine. Appended last to preserve every existing on-wire tag. Restores `tests/regression_idxof.rs::idxof_empty_haystack_not_found` and `::idxof_both_empty_returns_zero` to passing.
…ILO-368) (#661) * fix(verify): restrict `with` from adding new fields to anon records (ILO-368) Emit ILO-T044 when a `with` expression on an anonymous record references a field that does not exist in the source record. Adds registry entry and two coverage tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(verify): restrict `with` from adding new fields to anon records (ILO-368) Anonymous record `with` updates must only touch existing fields. Adding a new field via `with` would silently produce a different record type. Verifier emits ILO-T044 with the missing field name. Drive-by main-fixes folded into the same PR because every PR needs them to lint-pass: - src/diagnostic/registry.rs: ILO-T044 entry gains the required `phase: Phase::Verify` field (registry shape changed after the PR was opened) - src/main.rs: collapse `BuildTarget::eval` match into a single `matches!` (clippy::match_like_matches_macro), drop an unused `std::io::Write` import - src/ast/mod.rs: silence clippy::should_implement_trait on `UsePredicate::from_str` — implementing FromStr would change the return type from Option to Result - src/interpreter/mod.rs: `#[allow]` on `StdinLinesHandle` for the intentional complex iterator type and `new()`-without-Default (StdinLinesHandle is a singleton, never Default-constructed) - src/vm/mod.rs: drop unreachable `HeapObj::LazyStdinLines` arms in the two foreach catch-alls (the variant is handled earlier in the same match) * hotfix: clippy errors on main blocking all PR lint jobs (#724) Fixes 5 clippy errors: - Remove unreachable HeapObj::LazyStdinLines arms in FOREACHPREP/FOREACHNEXT match blocks (vm/mod.rs) - Add #[allow(clippy::should_implement_trait)] to UsePredicate::from_str (ast/mod.rs) - Extract StdinLinesInner type alias to reduce complex type (interpreter/mod.rs) - Add Default impl for StdinLinesHandle (interpreter/mod.rs) - Convert match to matches! macro in BuildTarget::eval (main.rs) - Suppress unused import warning in test (main.rs) Co-authored-by: Daniel Morris <daniel@cubitts.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: bump ilo-language cap to 1800 (measured 1746) (#725) ILO-382 set the per-module caps to measured-baseline-plus-50 to gate silent doc drift. ilo-language.md grew past 1700 (now 1746) as the post-#722 brace-lambda + or-pattern sections landed, blocking every PR's CI lint. Bump to 1800 with the same ~50-token headroom. * fix(builtins): add Idxof to Builtin::ALL (cross-engine dispatch) (#726) `Builtin::Idxof` was added to the enum but never appended to `Builtin::ALL`, so calling `idxof` panicked with 'Builtin::ALL must include every variant' on every engine. Appended last to preserve every existing on-wire tag. Restores `tests/regression_idxof.rs::idxof_empty_haystack_not_found` and `::idxof_both_empty_returns_zero` to passing. --------- Co-authored-by: Daniel Morris <daniel@cubitts.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: add defer and errdefer for guaranteed cleanup (ILO-56)
- Parser: defer/errdefer as statement-starts; both keywords reserved
- AST: Stmt::Defer { expr, kind: DeferKind::Always | OnError }
- Interpreter: per-frame defer stack, drained LIFO at function exit
- VM: program-wide bridge to tree-walker when any fn contains defer
- Codegen: fmt, explain, python all handle Stmt::Defer
- Verifier: type-checks deferred expression, yields Nil
- 18 regression tests; 2 examples (defer-basic.ilo, errdefer.ilo)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ci: bump BYTE_BUDGET_PER_MODULE to 8_000
* ci: cargo fmt
* feat: native VM bytecode for defer/errdefer (ILO-366)
Replaces the program-wide tree-bridge fallback for defer-containing
functions with two new opcodes compiled directly into the VM bytecode:
OP_DEFER_PUSH (191) — snapshots a 0-arg closure onto the current
frame's per-frame defer stack at defer-registration time.
OP_DEFER_DRAIN (192) — drains the stack LIFO before every OP_RET,
calling Always-kind thunks unconditionally and OnError-kind thunks
only when the return value carries TAG_ERR.
Each `defer expr` / `errdefer expr` is compiled into a synthetic thunk
chunk that captures all in-scope locals by value (via OP_MAKE_CLOSURE),
then pushed with OP_DEFER_PUSH. The compiler emits OP_DEFER_DRAIN
before every OP_RET in defer-containing functions via the new emit_ret()
helper, including early returns inside braceless guards and explicit
`ret` statements.
Performance: 47× faster than the tree-bridge (500-iteration timing test
added to regression_defer.rs shows ~156 ms tree vs ~3.3 ms VM in debug
builds).
All 1 167 existing tests pass. 19 regression_defer tests pass including
7 new VM-path tests covering LIFO ordering, errdefer fire/no-fire, and
early-return semantics.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ci: align with fleet patches
---------
Co-authored-by: Daniel Morris <daniel@cubitts.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…374) (#654) * fix(rd): remove extension-based auto-parse; add rd-json builtin (ILO-374) rd! on a .json path silently returned a parsed value instead of raw text, breaking the documented t → R t t contract. This caused type errors when callers applied jpar on the result of rd. Fix: rd (1-arg) always returns R t t (raw text). Extension-based auto-parse is removed from the interpreter, VM bytecode, and JIT paths. Add rd-json: t → R ? t that reads and parses JSON explicitly. Migrate config-shaper.ilo (rd → rd-json) and ecommerce-analytics.ilo (rd → rd path "csv"). Add verifier hint (ILO-W001 warning) when rd is called on a literal .json path without an explicit format arg. Regression tests added in interpreter and VM test suites. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: align with fleet patches --------- Co-authored-by: Daniel Morris <daniel@cubitts.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…72) (#653) * Add hex-rev builtin for byte-pair reversal / endian conversion (ILO-372) Implements `hex-rev s > t`: reverses a hex-encoded string byte-pair-wise for little↔big endian conversions (Bitcoin txid display vs wire encoding). Odd-length input errors ILO-T013. Case preserved. Tree-bridge eligible. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: align with fleet patches * hotfix: fix examples_engines failures blocking all open PR CI (#723) * fix(examples): rewrite mpairs.ilo iter fold to use brace-lambda syntax Labelled-arg inline lambdas (`acc:L _; pair:L _;…`) inside `fld` now fail with ILO-P003 since the parser treats `:` as a type-ascription delimiter. Replace with brace-lambda `{acc pair > [acc, pair]}` — supported since ILO-404 — and drop the `cat` call (comma-spread in list literal achieves the same append without the `cat` arg-2 type error on `L (L _)`). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(builtins): add Sha256Hex and Sha256d to Builtin::ALL The variants were defined and dispatched (tree-bridge, interpreter) but missing from ALL, causing a panic (`Builtin::ALL must include every variant`) whenever sha256-hex or sha256d was called via the VM. Append them immediately after CtEq — the existing crypto-primitives cluster — preserving all prior on-wire tags. Fixes examples/sha256-hex.ilo and examples/sha256d-bitcoin.ilo which both panicked at src/builtins.rs:1198. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Daniel Morris <daniel@cubitts.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * hotfix: clippy errors on main blocking all PR lint jobs (#724) Fixes 5 clippy errors: - Remove unreachable HeapObj::LazyStdinLines arms in FOREACHPREP/FOREACHNEXT match blocks (vm/mod.rs) - Add #[allow(clippy::should_implement_trait)] to UsePredicate::from_str (ast/mod.rs) - Extract StdinLinesInner type alias to reduce complex type (interpreter/mod.rs) - Add Default impl for StdinLinesHandle (interpreter/mod.rs) - Convert match to matches! macro in BuildTarget::eval (main.rs) - Suppress unused import warning in test (main.rs) Co-authored-by: Daniel Morris <daniel@cubitts.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: bump ilo-language cap to 1800 (measured 1746) (#725) ILO-382 set the per-module caps to measured-baseline-plus-50 to gate silent doc drift. ilo-language.md grew past 1700 (now 1746) as the post-#722 brace-lambda + or-pattern sections landed, blocking every PR's CI lint. Bump to 1800 with the same ~50-token headroom. * fix(builtins): add Idxof to Builtin::ALL (cross-engine dispatch) (#726) `Builtin::Idxof` was added to the enum but never appended to `Builtin::ALL`, so calling `idxof` panicked with 'Builtin::ALL must include every variant' on every engine. Appended last to preserve every existing on-wire tag. Restores `tests/regression_idxof.rs::idxof_empty_haystack_not_found` and `::idxof_both_empty_returns_zero` to passing. * fix(verify): restrict `with` from adding new fields to anon records (ILO-368) (#661) * fix(verify): restrict `with` from adding new fields to anon records (ILO-368) Emit ILO-T044 when a `with` expression on an anonymous record references a field that does not exist in the source record. Adds registry entry and two coverage tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(verify): restrict `with` from adding new fields to anon records (ILO-368) Anonymous record `with` updates must only touch existing fields. Adding a new field via `with` would silently produce a different record type. Verifier emits ILO-T044 with the missing field name. Drive-by main-fixes folded into the same PR because every PR needs them to lint-pass: - src/diagnostic/registry.rs: ILO-T044 entry gains the required `phase: Phase::Verify` field (registry shape changed after the PR was opened) - src/main.rs: collapse `BuildTarget::eval` match into a single `matches!` (clippy::match_like_matches_macro), drop an unused `std::io::Write` import - src/ast/mod.rs: silence clippy::should_implement_trait on `UsePredicate::from_str` — implementing FromStr would change the return type from Option to Result - src/interpreter/mod.rs: `#[allow]` on `StdinLinesHandle` for the intentional complex iterator type and `new()`-without-Default (StdinLinesHandle is a singleton, never Default-constructed) - src/vm/mod.rs: drop unreachable `HeapObj::LazyStdinLines` arms in the two foreach catch-alls (the variant is handled earlier in the same match) * hotfix: clippy errors on main blocking all PR lint jobs (#724) Fixes 5 clippy errors: - Remove unreachable HeapObj::LazyStdinLines arms in FOREACHPREP/FOREACHNEXT match blocks (vm/mod.rs) - Add #[allow(clippy::should_implement_trait)] to UsePredicate::from_str (ast/mod.rs) - Extract StdinLinesInner type alias to reduce complex type (interpreter/mod.rs) - Add Default impl for StdinLinesHandle (interpreter/mod.rs) - Convert match to matches! macro in BuildTarget::eval (main.rs) - Suppress unused import warning in test (main.rs) Co-authored-by: Daniel Morris <daniel@cubitts.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: bump ilo-language cap to 1800 (measured 1746) (#725) ILO-382 set the per-module caps to measured-baseline-plus-50 to gate silent doc drift. ilo-language.md grew past 1700 (now 1746) as the post-#722 brace-lambda + or-pattern sections landed, blocking every PR's CI lint. Bump to 1800 with the same ~50-token headroom. * fix(builtins): add Idxof to Builtin::ALL (cross-engine dispatch) (#726) `Builtin::Idxof` was added to the enum but never appended to `Builtin::ALL`, so calling `idxof` panicked with 'Builtin::ALL must include every variant' on every engine. Appended last to preserve every existing on-wire tag. Restores `tests/regression_idxof.rs::idxof_empty_haystack_not_found` and `::idxof_both_empty_returns_zero` to passing. --------- Co-authored-by: Daniel Morris <daniel@cubitts.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: fix clippy unnecessary to_string --------- Co-authored-by: Daniel Morris <daniel@cubitts.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: package registry — ilo add GitHub-org-based (ILO-63) Add `ilo add <owner>/<repo>[@<ref>]` and `ilo update` subcommands. Packages are shallow-cloned into ~/.ilo/pkgs/<owner>/<repo>/ and locked in ilo.lock (tab-separated slug/sha/url). `use "owner/repo"` in any .ilo file resolves through the cache after a path-heuristic check (first component has no `.`); missing packages emit ILO-P017 with an `ilo add` hint. Deferred: version constraints, transitive deps, auth, non-GitHub hosts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: bump SKILL.md cap to 20 KB * ci: cargo fmt * feat(pkg): semver version constraints for ilo add Extend `ilo add owner/repo@<ref>` to accept semver constraints: - `^MAJOR[.MINOR[.PATCH]]` — caret (compatible) range - `~MAJOR.MINOR[.PATCH]` — tilde (patch-compatible) range - `MAJOR.MINOR.PATCH` — exact semver triple Uses `git ls-remote --tags` to list remote tags without a full clone, then picks the highest version tag matching the constraint via the `semver` crate. The resolved tag name is passed to the clone step so `ilo.lock` records the concrete SHA. Closes ILO-356. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(pkg): transitive dep resolution for ilo add (ILO-357) (#678) * feat(pkg): transitive dep resolution for ilo add After cloning a package into the cache, walk its top-level *.ilo files for `use "owner/repo"` declarations and recursively fetch each package dependency. All resolved versions are written to ilo.lock. Dependency cycles are detected via a DFS ancestor stack and reported as errors. Already-resolved packages are skipped via a visited set. Closes ILO-357 * chore: cargo fmt --all --------- Co-authored-by: Daniel Morris <daniel@cubitts.com> --------- Co-authored-by: Daniel Morris <daniel@cubitts.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Daniel Morris <daniel@cubitts.com>
PR #614 (ilo add) merged with two compile errors: 1. src/pkg.rs:253 unwraps git_ref to &str, then line 260 tries to match it as Option. Remove the early unwrap; the later match at 273-276 already handles the Option correctly. 2. src/main.rs:2465 calls resolve_imports with 4 args but it takes 5 (the build_target arg was added by #643). Add the missing arg. Restores main to a clean build.
hotfix: pkg.rs build break + resolve_imports arity (ILO-63 followup)
Adds a `tokcount s > n` builtin (bytes/3.4 approximation of cl100k_base token count) so skill-file budget checks can be written in ilo itself, removing the last Python file from the build chain. - New Builtin::Tokcount in builtins.rs, verify.rs, and vm.rs (tree-bridge eligible, appended to ALL to preserve on-wire tags) - tokcount_impl in interpreter/mod.rs: ceil(bytes / 3.4) - scripts/check-skill-tokens.ilo: ilo port of the deleted Python script, matching output format; caps set for the bytes/3.4 approximation - scripts/check-skill-tokens.py: deleted - .github/workflows/rust.yml: replace tiktoken/python step with `cargo run -- run scripts/check-skill-tokens.ilo` - examples/tokcount-basic.ilo: cross-engine regression test - SPEC.md / ai.txt / skills/ilo/ilo-builtins-text.md: doc touch-points Deferred (ILO-47 follow-up): replace bytes/3.4 stub with tiktoken-rs BPE once crate WASM and licence questions are resolved. Co-authored-by: Daniel Morris <daniel@cubitts.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…ion with anon-record `with` Anon-record `with` (#661, ILO-368) already shipped ILO-T044 to main. Bounded generics also used T044 for the inconsistent-type-var diagnostic. Renumbered the bounded-generics code to T045 to keep both diagnostics distinct. Also resolves type_params field addition (Decl::Function adds vec![] in the brace-lambda lift + alias-rename paths), and applies clippy fixes for matches!/is_some_and on touched code.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
<a:bound>to function signatures with a small fixed set of bounds:any,comparable(n/t/b),numeric(n),text(t)ILO-T044on violationsfn x:a>a;xtype-variable usage is unchanged (no bound → treated asany, no consistency check)ILO-T044(inconsistency/bound violation),ILO-P022(malformed type-param block)Shape
Call-site enforcement:
Deferred
astaysTy::Unknownat call site — verifier does not propagate the resolved concrete type back to the caller's type context)a:Comparable+Numeric)Pre-existing skip
tests/skill_md.rs::body_is_thin_bootstrapfails onorigin/mainbefore these changes (SKILL.md is already 12 KB, threshold is 8 KB). Not introduced by this PR.Test plan
cargo test— all tests pass (excluding pre-existingbody_is_thin_bootstrap)cargo run -- run examples/generics-bounded.ilo— outputs correct valuescargo run -- checka file with inconsistent type variable usage → ILO-T044cargo run -- checka file with bound violation → ILO-T044fn identity x:a>a;x) still verifies cleanly🤖 Generated with Claude Code