View Source Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog and this project adheres to Semantic Versioning.
[0.18.0] - 2026-05-28
Raises the Elixir version floor to 1.16 and aligns optional dependencies with their current stable series. No changes to Bond's public API or runtime behaviour.
Changed (breaking)
Minimum Elixir version is now
~> 1.16(was~> 1.14). Elixir 1.14 and 1.15 are no longer supported. The floor was raised to resolve formatter drift (the(-> t)zero-arity typespec syntax introduced in 1.15.0) and to allow CI to run format-checking against the newest Elixir without a version pin.Bond.PropertyTestrequiresstream_data ~> 1.0(was~> 0.6). The optional property-based testing integration now targets the stable 1.x release series. Users ofBond.PropertyTestmust upgrade their ownstream_datadependency to~> 1.0(current release: 1.3.0).
Internal
- CI matrix now spans Elixir 1.16–1.19 / OTP 26–27; the 1.14 row is removed.
lintjob runs on Elixir 1.19.5 (was pinned to 1.14.5 due to formatter drift; pin is no longer needed).- New
dialyzerCI job runsmix dialyzeragainst Bond's own library code on Elixir 1.19.5 / OTP 27.2, establishing a clean Dialyzer baseline for the library itself (previously only the downstream consumer was Dialyzer-checked). - GitHub Actions updated to Node.js 24 runtime (
checkout@v6,cache@v5). credoupdated 1.7.3 → 1.7.18;stream_dataupdated 0.6.0 → 1.3.0.
[0.17.5] - 2026-05-28
A patch release eliminating downstream mix dialyzer warnings emitted by
Bond-generated code when the user's assertion duplicates a typespec-implied
guard. Surfaced by a dogfood round in a real consumer (Photon) where every
use Bond module produced one pattern_match or pattern_match_cov
warning, forcing the consumer to suppress with @dialyzer :no_match per
module.
Fixed
Lifted assertion defps no longer emit
if/elseinline. The truthiness check and throw-on-failure moved into two newBond.Runtime.Evalfunctions —check_assertion/3(used by@pre,@post, and@invariant) andcheck_value/3(used byBond.check/1). Each is defined as a multi-clause function matchingfalse/nil/_, which prevents Dialyzer's caller-flow narrowing from killing the falsy clause when the user's expression is staticallytrue(e.g.@pre is_binary(x)on a function@spec-narrowed tobinary()).Lifted-defp arguments are routed through a type-laundering helper.
Bond.Predicates.__opaque__/1(and__truthy__/1for the~>operator) use:persistent_term.get/2to defeat Dialyzer's parameter-type propagation from the wrapper into the lifted defp. Without this, an assertion expression containingand/or/case(which expand to their own internalcase) would still narrow under the wrapper's@spec, producingpattern_matchwarnings inside the user's expression itself — e.g.@post is_binary(result) and result != ""on a function returningbinary()had a deadfalse ->clause in theand/2expansion.Bond.Predicates.<~/2discriminator is laundered. The pattern-match operator'scase expr do pattern -> true; _unmatched -> false endpreviously producedpattern_match_covwarnings when the user's pattern exhausted the@spec-narrowed type ofexpr(e.g.{:ok, _} <~ resulton a function returning{:ok, integer()}). Routingexprthrough__opaque__/1keeps the_unmatchedclause reachable.Bond.Predicates.~>/2antecedent is laundered. The implication operator'sif antecedent do !!consequent else true endpreviously produced apattern_matchwarning on theelse: truebranch when the antecedent was staticallytrue(e.g.is_binary(x) ~> ...on a binary argument). Routing the antecedent through__truthy__/1keeps theelsebranch reachable.
Internal
Five new fixtures in
integration/consumer/lib/contract_consumer.ex(TypedGuardandTypedInvariant) exercise every shape that previously warned: tautological@pre/@post/@invariant,~>antecedent tautology,<~exhaustive pattern. The existing downstream-Dialyzer CI job (added in 0.17.4) is now also a regression guard for this fix.Generated-AST unit tests updated in
test/bond/compiler/{annotated_function,assertion,invariants}_test.exsto assert that wrapper → lifted-defp call sites route every argument throughBond.Predicates.__opaque__/1and that the lifted defps delegate the if/throw toBond.Runtime.Eval.check_assertion/3/check_value/3.
Requirements
- Unchanged. Elixir
~> 1.14.
[0.17.4] - 2026-05-28
A patch release fixing two Elixir 1.18+ compatibility issues, surfaced by a new downstream-consumer integration test that compiles and Dialyzer-checks the code Bond generates into a using module. Also expands the CI matrix to five Elixir versions and eliminates a family of parallel-compile races exposed by Elixir 1.19's more aggressive parallel compiler.
Fixed
Invariant post-check no longer emits a struct
caseinto user modules. Elixir 1.18+'s type checker (under--warnings-as-errors) rejects acase var!(result)with struct clauses when the function's return type can't be a struct — for examplesize/1returning an integer — producing "the following clause will never match" warnings that fail downstream builds. The shape match moved intoBond.Runtime.Eval.check_struct_invariant/3, whoseresultis typedterm(), eliminating the false positive. Runtime behaviour is unchanged.Bond.Runtime.Eval.should_evaluate?/3type-spec widened. Thechain_defaultsmap's value type previously excluded:purge, but a function with no@pre/@postof its own (e.g. an invariant-onlysize/1) legitimately contributes:purgefor those kinds. Dialyzer flagged the mismatch and cascaded into six spurious downstream findings for users runningmix dialyzeron their projects.
Internal
Downstream-consumer integration test added (
integration/consumer). A standalone Mix project thatuses Bond is now compiled and Dialyzer-checked as part of CI. This is the test that surfaced the two fixes above.CI matrix expanded from 3 to 5 cells. Covers every Elixir minor from 1.14 to the current stable (1.19), each paired with the highest OTP it supports: 1.14.5/OTP 25.3, 1.16.3/OTP 26.2, 1.17.3/OTP 27.2, 1.18.3/OTP 27.2, 1.19.5/OTP 27.2. Confirms the
~> 1.14floor is accurate across the full matrix.Parallel-compile races eliminated. Elixir 1.19's more aggressive parallel compiler exposed race conditions where Bond's internal BEAM files could be read before they were fully written to disk. Fixed by:
- Extracting
Bond.Compiler.CompileStateFSM.Serverfromcompile_state_fsm.exinto its own file. - Extracting
Bond.Compiler.AnnotatedFunction.Clausefromannotated_function.exinto its own file. - Changing
aliastorequireinbond.exandcompiler.exto establish a complete compile-dep chain that Mix's parallel scheduler enforces.
- Extracting
Requirements
- Unchanged. Elixir
~> 1.14.
[0.17.3] - 2026-06-03
A small additive release: _name and name are now treated as semantically
equivalent in the consistent-naming check. Surfaced by a Photon dogfood
round where the fallback-clause idiom (def f(_a, _b, c) paired with a
contracted def f(a, b, c)) tripped the agreement rule unnecessarily.
Changed
Underscore-prefixed top-level names normalize against their unprefixed counterparts in
Bond.Compiler.Clauses.canonical_names/2._aandaagree at the same position; the canonical at that position is the non-underscored form. This matches Elixir's leading-underscore convention ("bound but intentionally unused" — the same parameter, just marked as not-used in the body).# 0.17.2 — `CompileError`: position 0 disagrees (`:_a` vs `:a`) # 0.17.3 — compiles cleanly @pre is_atom(a) def f(a, b, c) when is_atom(a), do: {:ok, a, b, c} def f(_a, _b, c), do: {:fallback, c}Wildcards (bare
_, returned asnilfromtop_level_name/1) are unaffected — they continue to adopt sibling clauses' names rather than agreeing with_adirectly.Bond.Compiler.Clauses.referenced_param_names/2treats both spellings symmetrically: a contract referencingamatches a clause binding_a, and a contract referencing_amatches a clause bindinga. The intersection check is name-equivalent under the leading-underscore normalization.
Internal
8 new unit tests in
Bond.Compiler.ClausesTestfor the normalization rules (agreement, all-underscored agreement, truly-different names still disagreeing, wildcard-vs-named-underscore distinction, both directions ofreferenced_param_namesmatching).1 new behavioural test in
Bond.MultiClauseDispatchTestdriving the Photon-style fallback-clause idiom end-to-end.FAQ entry "How are multi-clause functions handled?" gains a paragraph on the
_name/nameequivalence.
Requirements
- Unchanged. Elixir
~> 1.14.
[0.17.2] - 2026-06-02
A purely additive release narrowing the 0.17.0 consistent-naming rule from "all positions must agree" to "positions referenced by some contract must agree." Surfaced by a second round of Photon dogfooding where a trivial result-only contract on a four-clause shape-dispatching function would otherwise have required renaming every parameter across all clauses.
Changed
Multi-clause consistent-naming check is now per-position based on contract references. A contract that doesn't reference any parameter name imposes no naming constraint:
# 0.17.0 — `CompileError`: position 1 disagrees on :game vs :league # 0.17.2 — compiles cleanly @post is_boolean(result) def can_access?(conn, %Game{} = game, %GameFilm{} = film), do: ... def can_access?(conn, league, conference) when is_binary(league), do: ...Bond walks every
@pre/@post/@invariantexpression's AST and collects bare-variable references, intersecting with the union of top-level parameter names across all clauses. Synthetic bindings (result,old(...)helpers, the invariantsubject) drop out of the intersection automatically. Agreement is required only at positions whose canonical name appears in the resulting set.Adding a contract that does reference a disagreeing position re-engages the rule at that position — the
CompileErrorfires exactly when (and where) a contract needs the consistency. Trivial contracts attach freely; shape-dependent contracts still need~>for cross-clause uniformity.
Added
Bond.Compiler.Clauses.referenced_param_names/2— new internal helper. Walks an assertion list's expression ASTs collecting bare-variable names, intersected with the union of top-level parameter names across all clauses.Bond.Compiler.Clauses.assert_clauses_agree!/4— gains a fourth argument for the set of names requiring agreement. The 3-arg form remains (defaulting to:all) for strict-mode callers and existing tests.
Fixed
Bond.Compiler.Clauses.rewrite_clause_params/3now correctly rebinds the canonical name when a clause's top-level name differs from it. This case couldn't arise under 0.17.0's strict rule but does under the 0.17.2 relaxation (at unreferenced positions where the canonical is a generatedbond_arg_<idx>regardless of what the user named the parameter). Without the rebind, the wrapper would reference an unbound name at the super-call site.
Internal
13 new unit tests in
Bond.Compiler.ClausesTestcovering the AST walker (bare/remote/operator/old/subjectreferences, the cross-clause candidate-name union, and the documented closure- variable false-positive case) and the relaxed mode ofassert_clauses_agree!/4(:all, empty, agreeing, disagreeing required-name sets).2 new behavioural tests in
Bond.MultiClauseDispatchTestcovering the Photon-shape relaxation and the re-engagement when a parameter- referencing contract is added.FAQ entry "How are multi-clause functions handled?" gains a subsection describing the positional rule and the trivial-contract affordance.
Requirements
- Unchanged. Elixir
~> 1.14.
[0.17.1] - 2026-05-31
A purely additive release closing a doc-symmetry gap: per-function
@pre/@post already get rendered into each function's @doc as
#### Preconditions / #### Postconditions sections, but module-level
@invariant declarations had no rendered home. Readers landing on a
struct module's docs page couldn't see what invariants the module
ensures unless the author had hand-written them up.
Added
Auto-generated
## Invariantsmoduledoc section for any module with@invariantdeclarations. The section names the struct, explains the implicitsubjectbinding (so readers without prior Bond context can read the assertions), lists each invariant in the samelabel: expressionform used by per-function contract docs, and notes when invariants fire plus thedefpexemption.## Invariants Bond ensures the following invariants hold for every value of `%BoundedStack{}` produced by or passed into this module's public API. Inside each assertion, `subject` refers to the value being checked. non_negative_capacity: subject.capacity >= 0 size_within_capacity: length(subject.items) <= subject.capacity These invariants are checked automatically on entry to and exit from every public function in this module. Private functions are exempt by the Eiffel convention.Special cases:
- Users who already wrote a
@moduledocget the generated section appended after their authored content. - Users who wrote
@moduledoc falsehave their decision respected — no section is added and the module remains hidden. - Users with
@invariantbut no@moduledocget a moduledoc synthesised containing just the Invariants section, so the contracts surface in the generated docs. :invariantsset to:purge(compile-time disable) suppresses the auto-generated section, matching the per-function contract-doc suppression rule.
- Users who already wrote a
Changed
Bond.Compiler.ContractDocs.moduledoc_invariants_section/3— new internal function producing the markdown section. Reuses the samelabel: expressionformatting as the existing per-function doc generation so labelled and bare invariants render consistently across function-level and module-level contract docs.Bond's
__before_compile__hook now augments the user module's:moduledocattribute viaModule.put_attribute/3at compile-end, reading the current value and appending the generated section.README's
@invariantsection gains a new "Generated documentation" subsection describing the auto-generation behaviour and the special cases above.
Internal
- New test/support fixtures
(
BondTest.SynthesizedModuledocInvariant,…HiddenModuledocFixture,…PurgedInvariantsFixture) exercise the synthesised, hidden, and purge code paths. 17 new tests acrossBond.Compiler.ContractDocsTest(unit) andBond.ModuledocInvariantsTest(end-to-end viaCode.fetch_docs/1).
Requirements
- Unchanged. Elixir
~> 1.14.
[0.17.0] - 2026-05-30
0.17.0 closes the longest-standing bug surfaced by real-world dogfooding: multi-clause functions whose first body clause had shape-specific patterns silently broke callers using a different shape. The fix is a per-clause wrapper redesign that preserves Elixir's natural multi-clause dispatch, plus a new rule that all clauses must agree on the top-level parameter name at each position when Bond is attaching contracts.
0.17.0 also fixes a latent semantic bug in Bond.Predicates.~>/2: the
implication operator was a def, so both sides were eagerly evaluated.
The natural "shape-dependent assertion" pattern
(is_struct(x, Mod) ~> (x.id > 0)) crashed on non-struct inputs to the
antecedent. ~> is now a defmacro that short-circuits.
Breaking changes (minor)
Bond.Predicates.~>/2is now a macro, not a function. The expansion isif antecedent, do: !!consequent, else: true, so the consequent is only evaluated when the antecedent is truthy. This matches the logical reading of implication and makes shape-dependent assertions safe to write.# Was: both sides evaluated eagerly — `String.length(x)` raised # FunctionClauseError when `x` wasn't a binary. @pre is_binary(x) ~> (String.length(x) > 0) # Now: `String.length(x)` is only evaluated when `is_binary(x)` # is truthy. Same source, correct semantics.The migration impact is limited: code that passed
~>as a function capture (&Bond.Predicates.~>/2) no longer compiles. Use a function wrapper or the underlyingimplies?/2(still a function and still eagerly evaluated) instead. The infix usage that's common in contracts is unchanged.Multi-clause functions with contracts must agree on top-level parameter names across all clauses. Heterogeneous naming raises
CompileErrorat the function's compile site. Pre-0.17.0 Bond used the first clause's params for the wrapper head verbatim, silently breaking callers whose shape matched a non-first clause; the new rule makes the constraint explicit and pushes naming consistency (often a readability win regardless of Bond).# Was (silently broken): callers passing strings hit # FunctionClauseError inside Bond's generated code. def lookup(conn, %Game{} = g, %GameFilm{} = f), do: ... def lookup(conn, league, conference) when is_binary(league), do: ... # Now: rename for consistent positional meaning across clauses. def lookup(conn, %Game{} = resource, %GameFilm{} = scope), do: ... def lookup(conn, resource, scope) when is_binary(resource), do: ...Wildcard clauses (
def f(_)) and literal-pattern clauses (def f(0)) don't bind a top-level name at that position — they adopt whatever name a sibling clause provides. So the commondef try_init(_)-paired-with-def try_init(capacity)pattern works unchanged.For shape-dependent assertions across clauses, use the
~>implication operator (which now short-circuits, per above). Per-clause contracts may be added in a future release if the consistent-naming restriction turns out to bite real code.
Added
Bond.Compiler.Clauses— new internal module owning clause- shape utilities:top_level_names/1,canonical_names/1,assert_clauses_agree!/3(the validator),rewrite_clause_params/3(canonical-name binding + underscore-prefix of unused names), andunderscore_prefix_unused/2.Bond.Compiler.ClauseWrapper— new internal module owning per-clause wrapper emission. Extracted fromAnnotatedFunction(which is on the FSM's hot path) to keep that file small and avoid the parallel-compile race the project first encountered in 0.13.0.
Changed
Wrapper emission switches from single-wrapper to per-clause. For an N-clause user function, Bond now emits N wrapper clauses, each preserving the user's pattern (with destructured names underscore-prefixed where the wrapper body doesn't reference them). Elixir's natural multi-clause dispatch routes each call to the appropriate user clause via
super/N. Wrong-shape inputs raiseFunctionClauseErrorat the wrapper layer, matching the pre-Bond behaviour.Lifted assertion defps' parameter heads diverge by clause count.
- Single-clause functions keep the user's pattern in the lifted
defp head, so contracts can still reference destructured names
from the head (e.g.
current_countfrom%__MODULE__{count: current_count} = state— the contracts-and-concurrency guide example works unchanged). - Multi-clause functions use the canonical top-level names as bare
vars. Contracts can only reference those names; shape-dependent
assertions use
~>.
- Single-clause functions keep the user's pattern in the lifted
defp head, so contracts can still reference destructured names
from the head (e.g.
Destructure-in-head wrapper warnings (the original #3 from the Photon dogfood) are silenced. Bond's per-clause wrapper now underscore-prefixes any destructured name the wrapper body doesn't reference. The lifted defp's pattern (for single-clause functions) still binds those names, so contract-side access is unaffected.
Internal
Bond.Compiler.Invariantssimplifications. The destructure-only invariant handling that ran in 0.16.x is subsumed by the canonical- name rewrite —Invariants.rewrite_call_params/2andInvariants.params_split/3are no longer called from emission. They remain in the module for now; cleanup deferred to a later release.Bond.Compiler.AnnotatedFunctionshrunk from 448 → 430 lines via the ClauseWrapper and Clauses extractions, well below the historical baseline where the parallel-compile race surfaced.Test fixture migration. The existing
BondTest.InvariantSmoke. try_new/1(wildcard adopts canonical) andBondTest.Stack.new/N(all clauses agree oncapacity) work unchanged under the new rule. The unit-test fixture inBond.Compiler.AnnotatedFunctionTest(previouslylist/map) migrated to consistentinput.
Requirements
- Unchanged. Elixir
~> 1.14.
[0.16.2] - 2026-05-28
A patch release covering eight issues surfaced by dogfooding Bond 0.16.1 on a real-world Elixir umbrella application (Photon, ~200+ modules). Six fixes bundle here; two (#2 wrapper-head shape leak, #3 destructure-in- head unused warnings) are deferred to 0.17.0 pending a design conversation.
Changed
Remote function calls are now valid as the outermost expression of an assertion. Pre-0.16.2,
@pre String.starts_with?(x, "foo")was rejected byBond.Compiler.Assertion.is_assertion_expression/1— the AST's head is a{:., _, _}3-tuple, not an atom, so the guard failed. Workaround was an== truesuffix on every such assertion. The relaxed guard accepts the remote-call shape, includingMap.has_key?(m, :k),Enum.all?(xs, &f/1),String.starts_with?(s, "prefix"), and Erlang calls (:erlang.is_atom).No
@docemission ondefp. Contracts on private helpers previously triggered Elixir's "@doc is always discarded for private functions" warning on every contracted defp, making the combination unusable without compile-time noise.Bond.Compiler.ContractDocs. doc_clauses/4now short-circuits for:defpkind. The contracts themselves continue to fire — the warning was the only blocker.Bond.Predicatesmoduledoc gains an "Operator precedence" section documenting the~>/<~left-associativity trap.A ~> pattern <~ Bparses as(A ~> pattern) <~ B, where the LHS of<~becomes an arbitrary expression containing_and fails to compile. The fix is parens around the inner operator. Same trap surfaced as a boxed callout in the main moduledoc's Assertion Syntax section so readers see it before they fall into it.Telemetry section gains a concrete metadata-map example showing the
{name, arity}shape of:function, the sorted-binding-list shape of:binding, and a note on:assertion_idstability for aggregation pipelines.Assertion Syntax section in the moduledoc now shows remote-call examples and explicitly notes which forms aren't valid (bare literals, bare variables, non-call expressions).
Fixed
@pre is_binary(x), positive: x > 0(bare assertion mixed with a labelled one) and@pre is_integer(x), x > 0(two bare assertions in a single call) previously fell through to Kernel's@/1and died with "expected 0 or 1 argument for @pre, got: 2" — a confusing error that didn't point at the parse issue. Bond now matches these shapes at the macro layer and raises a clearCompileErrorsuggesting either label-every-assertion (keyword-list form) or separate@pre/@postlines. Same catch-all added for@invariant.Bond-shaped diagnostics on malformed assertions. When the user wrote an assertion that didn't satisfy
is_assertion_expression/1(@pre 42,@pre :foo,@pre "hello"), Bond previously surfaced a bareFunctionClauseErrorfromAssertion.new/5with a stacktrace that dumped the fullMacro.Env. NewBond.Compiler.Assertion.validate_expression!/2is called from bothregister_assertion/5andregister_invariant/4, and raisesCompileErrorwith the env's file/line, the expression's source (viaMacro.to_string/1), and a one-sentence hint at valid forms.
Internal
- Test coverage filled across each fix (+36 tests, 256 total):
- 6 unit tests in
assertion_test.exsfor the relaxed AST guard plus the newvalidate_expression!/2validator. - 8 behavioural tests in a new
BondTest.RemoteCallAssertionsfixture proving remote-call assertions work end-to-end in@pre,@post,@invariant, andcheck/1— both success and violation paths. - 4 behavioural + diagnostic tests for
defpcontracts, including acapture_io(:stderr, ...)assertion that no@doc-discarded warning fires during compilation. - 9 behavioural tests covering the new bare-vs-labelled
CompileErrorcatch-alls and verifying all five existing valid forms still compile cleanly.
- 6 unit tests in
Deferred (to 0.17.0)
#2 wrapper-head shape leak. Bond's override head uses the first body clause's params verbatim, so multi-clause functions with a shape-specific first clause silently break callers using a different shape (a
def fn(conn, %Game{}, %GameFilm{})clause alongside a siblingdef fn(conn, league, conference) when is_binary(league)clause will misroute string callers). The right fix needs a design conversation on whether to use a shape-neutral wrapper with restricted contract refs vs a per-clause wrapper that preserves dispatch faithfully.#3 destructure-in-head wrapper warnings. When the first body clause has destructure like
def f(%Mod{a: x, b: y} = z)and the wrapper body uses onlyz, Elixir warns about unusedx/y. Partially subsumed by #2's resolution.
Requirements
- Unchanged. Elixir
~> 1.14.
[0.16.1] - 2026-05-27
A patch release covering a 1.0-prep test-coverage audit (no behavioural change, just locking down behaviour that wasn't directly tested) plus a refresh of the supporting guides for content that had drifted out of date with 0.16.0.
Changed
guides/about.md— full rewrite. The previous version's feature TODO list still showed conditional compilation (shipped in 0.10/0.11) and invariants (shipped in 0.13) as unchecked items, and the framing read like marketing copy. New version is structured as "what Bond is, when to reach for it, background" — same length, every paragraph carrying information.guides/getting-started.md—@invariantsection added. The tutorial previously mentioned@invariantonly in "Next steps." A reader following it linearly would never learn there was a third contract kind. New section between "Inline checks" and "Disabling contracts in production" introduces it with aBoundedStackexample and thesubjectbinding, then points at the moduledoc for the full reference. The intro line at the top mentions@invariantalongside@pre/@post/check/1. The disabling-in-production config snippet now lists:invariants(was listing three of four keys).
Fixed
guides/faq.md— "When does Bond check invariants?" description brought current. The previous text said destructure-only function heads (def foo(%__MODULE__{f: v}, ...), no= name) emit a compile-time warning and skip the pre-check. The 0.16.0 release lifted that restriction — Bond now rewrites the override clause to capture the struct under a generated name and the pre-check fires. Multi-struct heads are also noted (weren't previously).guides/getting-started.md— dead anchor. The "Next steps" link to the Invariants section used the pre-0.16.0 anchor#module-invariants; updated to#module-invariant-for-struct-modulesmatching the renamed section.
Internal
Test coverage filled across seven gaps from a 1.0-prep audit (+16 tests, 220 total; all green). No behavioural change — each fill verifies behaviour that was already in place but lacked a direct test:
Invariant telemetry.
[:bond, :assertion, :failure]fires with:kind => :invarianton invariant violations. Documented since 0.13.0; previously only the other three kinds had assertions on the event.@invariantruntime modes. Two tests cover (a)put_env :bond, :invariants, falseskips evaluation, and (b) flipping back totruere-engages it. The runtime-toggle path was tested for@pre/@postbut not@invariant.Compound
andguards. Behavioural confirmation thatis_struct(x, __MODULE__)nested inside anandguard triggers the pre-invariant check. (Compiler-level detection was already covered.) Theorcase is deliberately not covered — it's a latent unsafe pattern worth a separate design discussion.No-struct heads. Behavioural confirmation that a function whose head doesn't expose the struct silently skips pre- invariant evaluation — passing non-struct arguments returns cleanly rather than crashing on a
subject.<field>access.Migration
CompileErrors. The legacy@invariant <name>, <expr>and the two arity-2checkshapes (removed in 0.16.0) now have direct assertions that they raiseCompileErrorwith the migration message at the call site.Bond.Test.assert_check_violation/2. The helper existed alongside itsprecondition/postcondition/invariantsiblings but had no test.old(...)runtime integration. Compiler-level extraction and precompilation were covered; the runtime path (does the snapshotted value end up correctly bound when the postcondition evaluates?) had no direct test. NewBond.OldRuntimeTestcovers success and failure paths plus the capturedbinding()at failure.
Coverage audit findings worth keeping in mind for future releases (not addressed in 0.16.1):
- Compound
orguards containingis_struct(_, __MODULE__)are a latent unsafe pattern. Bond's detection recognisesxas the struct parameter, but the pre-invariant fires unconditionally — so a runtime input matching a non-struct alternative crashes in the invariant body rather than raising a cleanFunctionClauseError. - Relatedly, the override clause doesn't reproduce the user's
function-head guard. Calling
Smoke.reverse(5)(wherereversehaswhen is_struct(stack, __MODULE__)) hits Bond's pattern-less override, fires the pre-invariant against the integer, and crashes inside the invariant body before super dispatches to the user's def for the properFunctionClauseError.
Neither issue surfaces in normal use (callers pass arguments of the right shape) — they're worth a fix pass before 1.0 but not shippable as a patch.
- Compound
Requirements
- Unchanged. Elixir
~> 1.14.
[0.16.0] - 2026-05-26
0.16.0 is the first 1.0-prep release. It tightens the public API in two
places where the surface had accumulated friction: @invariant drops its
required binding-name argument in favour of an implicit subject binding,
and check/2 drops its two string-label forms in favour of check expr
and check label: expr. Both legacy shapes now raise CompileError at the
call site with a migration message.
Breaking changes (minor)
@invariant <name>, <expr>was removed. The new form is@invariant <expr_or_kw>— no binding-name argument. Invariant expressions reference the implicitsubjectbinding, which Bond rebinds at every check site to whichever struct parameter the function head exposes (detected automatically across%__MODULE__{} = namepatterns,is_struct(name, __MODULE__)guards, and%__MODULE__{...}destructures).# Was: @invariant stack, non_negative_capacity: stack.capacity >= 0, size_within_capacity: length(stack.items) <= stack.capacity # Now: @invariant non_negative_capacity: subject.capacity >= 0, size_within_capacity: length(subject.items) <= subject.capacityFunction bodies don't change —
def push(%__MODULE__{} = stack, item)keeps its parameter namedstack; Bond detects and rebindssubjectto it automatically. The legacy 2-arg shape raises aCompileErrorwith the migration message.check/2was removed. The two string-label forms (check "label", exprandcheck expr, "label") are gone — they were redundant with the keyword-list form, which already carries a label:# Was: check "x is a number", is_number(x) check is_number(x), "x is a number" # Now: check x_is_number: is_number(x)check expr(bare) andcheck label: expr(keyword) are the two remaining forms. The legacy 2-arg shape raises aCompileErrorwith the migration message.
Added
Multi-struct heads in
@invariant.def merge(%__MODULE__{} = a, %__MODULE__{} = b)now triggers invariant checks on both struct parameters in left-to-right order, withsubjectrebinding to each in turn. Previously only the first detected struct param was checked.Destructure-only heads in
@invariant.def head(%__MODULE__{items: [first | _]})(no= name) now participates in pre-invariant checks. Bond rewrites the override clause head to add a capturing binding (%__MODULE__{items: [first | _]} = __bond_subject_0__) so the struct passes cleanly to the lifted invariants defp. Previously this shape was skipped silently with a documented (but unimplemented) warning.This also closes a latent bug in the override emission:
super(...)previously spliced raw destructure patterns as expressions, which would fail at compile time on patterns like[h | _]if a user had ever tried it with@pre/@post. The capture rewrite passes the original input through cleanly.Bond.Compiler.Invariants.detect_struct_params/2— internal helper that finds every struct-bearing parameter in a function head, returning a list of{:bound, var, idx}or{:destructure, idx}descriptors. Replaces the single-structfind_struct_arg/2removed below.
Changed
Doc-generation logic extracted into
Bond.Compiler.ContractDocs. Pure refactor — no user-visible change. Shaves ~80 lines offBond.Compiler.AnnotatedFunction, which is on the FSM's hot path. A shorterAnnotatedFunctionreduces the window for the parallel-compile race first encountered (and partially mitigated) in 0.13.0.Bond.Compiler.Assertiondrops the:binding_namefield. The invariant body now hardcodes thesubject = bond_invariant_valuerebind. The struct shrinks from 8 fields to 7.Bond.Compiler.Invariantssimplified. Removed the legacy single-struct helpersfind_struct_arg/2,struct_arg/2,pre_invariant_stmts/5, and the supporting AST walkers. New emission usesdetect_struct_params/2+all_pre_invariant_stmts/5+rewrite_call_params/2end-to-end.Moduledoc reorganised. Sections regroup as "what you write" (Usage → Assertion syntax →
@invariant→check/1→old) then "how you operate" (Documenting contracts → Conditional compilation → Telemetry → PBT). The 0.10 → 0.11 migration table is dropped, and the long Agent race-condition narrative inoldmoves to thecontracts-and-concurrencyguide.Telemetry
:kinddocumentation updated to include:invariant(the event was already emitted since 0.13.0; the docs were stale).
Requirements
- Unchanged. Elixir
~> 1.14.
[0.15.0] - 2026-05-25
0.15.0 closes a correctness gap in conditional compilation: previously
:preconditions, :postconditions, and :invariants could be toggled
independently in any combination, including combinations that produced
diagnostically-misleading errors (e.g. postconditions on while
preconditions are off — a "postcondition failure" might really mean
the caller broke their contract, not the function).
0.15.0 enforces the natural chain preconditions ≤ postconditions ≤ invariants both at compile time and at runtime. :checks remains
independent of the chain.
Breaking changes (minor)
Compile-time validation of
:purgecombinations.:purgeon a lower kind now requires:purgeon every higher kind in the chain.Bond.Compiler.resolve_config/3raisesCompileErrorwith an explanation otherwise.Migration: if you used
config :bond, preconditions: :purgewithout also purging postconditions/invariants, choose one:# Was: config :bond, preconditions: :purge # Option A — also purge the chain (preserves the original intent # if you wanted zero overhead): config :bond, preconditions: :purge, postconditions: :purge, invariants: :purge # Option B — runtime-disable instead of purge (keeps the code, # operator can flip on at runtime): config :bond, preconditions: falsefalseis unaffected — runtime-disabling a single kind is unchanged. Only:purgeparticipates in the compile-time check.
Added
Runtime chain propagation. When a lower kind is
falseat runtime (Application.put_env(:bond, :preconditions, false)), every higher kind is also skipped automatically, regardless of its own setting. Enforced inBond.Runtime.Eval.should_evaluate?/3via the new optional third argument carrying the compile-time defaults of every lower kind.One-time-per-process propagation log. The first time a higher kind is skipped because a lower one is runtime-off, Bond emits a
Logger.warningdescribing the chain constraint, the offending pair, and theApplication.put_envinvocation that would bring the higher kind back. Deduped per (higher, lower) pair via a Process-dictionary marker — long-running OTP processes get exactly one warning per pair.
Changed
Bond.Runtime.Eval.should_evaluate?/2is nowshould_evaluate?/3with an optionalchain_defaultsmap; the 2-arity call still works via default and is unchanged behaviour-wise for:preconditionsand:checks(both have no lower kinds).
Requirements
- Unchanged. Elixir
~> 1.14.
[0.14.0] - 2026-05-24
0.14.0 adds Bond.PropertyTest — a property-based testing layer that
uses Bond's contracts as the oracle. The hard part of PBT is usually
writing the predicate that distinguishes right from wrong outputs;
contracts already supply that at every call site. PBT just feeds random
inputs through already-instrumented code.
Added
Bond.PropertyTest.contract_holds/2— single macro, two forms:Form 1 (single function).
contract_holds &Mod.fn/N, args: [gen0, ...]expands to a property block that calls the function with random arguments and lets Bond's runtime contracts fail the property on any violation. StreamData shrinks to the minimal counterexample.Form 2 (module sequence).
contract_holds Module, constructors:, transformers:, observers:expands to a property block that generates random sequences of operations over a struct module and runs them. State is threaded through transformers; observers don't advance state but the pre-invariant still fires. The module's@invariants are the oracle. Supports%Mod{}and{:ok, %Mod{}}return shapes;{:error, _}terminates the sequence cleanly. Common option:nameoverrides the auto-generated property description.
The macro dispatches by first-arg AST shape (function reference vs module alias).
use Bond.PropertyTest— brings inExUnitPropertiesand imports thecontract_holdsmacro. Raises aCompileErrorat the use site with installation instructions if:stream_dataisn't available.Bond.PropertyTest.Sequence— internal helper module owning the sequence generator and runner used by Form 2.New FAQ entry: "How does Bond compose with StreamData / property-based testing?".
Changed
:stream_datamoves fromonly: [:dev, :test]to a regular dep withoptional: true. Users who want PBT now add{:stream_data, "~> 0.6"}to their own deps; users who don't pay no cost.
Requirements
- Unchanged. Elixir
~> 1.14.
[0.13.0] - 2026-05-23
0.13.0 adds @invariant declarations for struct modules — module-scoped
properties that hold across every public function in the struct's defining
module. Where @pre/@post constrain a single function call, @invariant
constrains the struct itself.
Added
@invariant <name>, <kw_or_expression>annotation. Same shape as@pre/@post: a labelled keyword-list of assertions, or a single unlabelled expression. The first argument is the variable name the expression refers to (e.g.stackin@invariant stack, length(stack.items) <= stack.capacity).Invariants are checked at the boundaries of every public function in the module:
- On entry, when the function head pattern-matches
%__MODULE__{} = nameor has anis_struct(name, __MODULE__)guard. - On exit, against the return value if it's
%__MODULE__{}or{:ok, %__MODULE__{}}. Other return shapes fall through with no check. - Never for
defp— private functions are exempt by the Eiffel convention (they often hold transiently-invalid state).
When a function destructures
%__MODULE__{...}in its head without binding the whole struct to a variable, Bond emits a compile-time warning suggesting%__MODULE__{...} = nameto enable the pre-check.- On entry, when the function head pattern-matches
Bond.InvariantError— new exception parallel toPreconditionError/PostconditionError/CheckError. Raised on invariant violation; carries the same metadata shape.Bond.Test.assert_invariant_violation/2— ExUnit helper mirroring the existing pre/post/check helpers.:invariantsconditional-compilation key. Joins:preconditions,:postconditions, and:checks. Sametrue | false | :purgevalue space; same runtime toggleability viaApplication.put_env/3; same:overridesanduse Bond, invariants: …support.Bond.Compiler.Invariants— new internal module owning the invariant emission logic (struct-arg detection, pre-/post-invariant call sites, the lifted invariants defp). Kept separate fromBond.Compiler.AnnotatedFunctionfor separation of concerns and to avoid parallel-compile scheduling issues with the larger combined file.
Changed
[:bond, :assertion, :failure]telemetry events now also fire for invariant violations, with:kind => :invariantin the metadata. No subscriber changes are needed — existing handlers attached to the event automatically pick up the new kind.The internal
Bond.Compiler.Assertionstruct gains a:binding_namefield, populated only on:invariantassertions from the declaration's first argument.Bond.Compiler.AnnotatedFunctiongains an:invariantsfield plusput_invariants/2andhas_invariants?/1helpers.override?/1widens to emit overrides for public functions in modules with@invariants, even when the function has no per-function@pre/@post.Bond.Compiler.CompileStateFSMtracks module-scoped invariants alongside the per-function preconditions/postconditions. Invariants don't transition the FSM into:contracts_pending(they don't attach to a "next function") and aren't flushed by function definitions.
Requirements
- Unchanged. Elixir
~> 1.14.
[0.12.0] - 2026-05-22
0.12.0 lands two internal-shape changes that compose on top of the
0.11.0 conditional-compilation work: contract closures move out of
override clauses into named private functions on the user's module
(reducing injected code per contract'd function), and :telemetry
events fire on assertion failures.
Added
[:bond, :assertion, :failure]telemetry event. Fires once per contract violation —@pre,@post, orcheck— immediately before the correspondingBond.PreconditionError/Bond.PostconditionError/Bond.CheckErroris raised. Single event family for all three kinds; consumers filter on the:kindmetadata. Measurements carry:system_timeand:monotonic_time; metadata carries:kind,:module,:function,:label,:expression,:assertion_id,:file,:line, and:binding. See the new "Telemetry" section in theBondmoduledoc / README.{:telemetry, "~> 1.0"}is now a regular dependency.Bond.Runtime.Eval.should_evaluate?/2— internal helper that performs theApplication.get_env/3runtime guard. Used by the emission shape (see "Internal" below) to avoid allocating the assertion-evaluation closure when the runtime guard says skip.
Changed
Per-function assertion closures are lifted into named
defps on the using module:__bond_preconditions__<fun>__<arity>and__bond_postconditions__<fun>__<arity>. The override clause itself is now a small wrapper that calls these viaBond.Runtime.Eval.evaluate_preconditions/1/evaluate_postconditions/1. The big inline assertion-evaluation AST that used to be re-emitted into every override is gone; the BEAM carries one tiny override + one defp per non-purged kind, rather than the whole eval body inlined per function.Runtime guard moved into
Bond.Runtime.Eval. The override callsshould_evaluate?(:preconditions, <compile_time_mode>)and only builds the assertion-evaluation closure when that returnstrue. TheApplication.get_env/3lookup logic lives entirely inBond.Runtimerather than being inlined at every contract'd function.Bond.check/1,2routes through the same throw/catch path as@pre/@post. All three kinds now produce{:assertion_failure, info}throws caught byBond.Runtime.Eval, which fires the telemetry event and raises. This unifies the plumbing across the three kinds; previouslycheckraised inline.Stacktrace pruning now also filters frames whose function name starts with
__bond_(the lifted defps), so failures continue to point at the user's call site rather than into Bond-generated plumbing.Benchmark on the project fixture (
bench/runtime_check_overhead.exs, trivial@pre is_number(x)in a tight loop):mode 0.11.0 0.12.0 :purge~48 ns ~34 ns true~155 ns ~143 ns false~89 ns ~91 ns The
truepath improves because the override no longer re-emits the full assertion-eval AST inline. Thefalse(runtime-skip) path is flat within noise —should_evaluate?/2short-circuits before the closure is allocated.
Fixed
Bond.CheckError'smessage/1no longer crashes when the error's:functionmetadata is missing (regression introduced and fixed internally during thecheckplumbing unification).
Requirements
- Unchanged. Elixir
~> 1.14.
[0.11.0] - 2026-05-21
0.11.0 reshapes the conditional-compilation config introduced in 0.10.0
around a new value space — true | false | :purge per kind — and adds two
new features that compose on top of it: runtime toggling without
recompilation, and per-module overrides.
Breaking changes (minor)
config :bond, <kind>: falseno longer compiles contracts out. It now means "compiled in, runtime guard defaults to off." If you usedfalsein 0.10.0 to get zero-overhead behaviour, change it to:purgeto preserve that behaviour.truecontinues to work as before (with the addition of runtime toggleability — see below).
Added
:purgemode for each contract kind. Setting any of:preconditions,:postconditions, or:checksto:purgecauses Bond to emit no code for that kind. The resulting BEAM contains no contract logic; per-call overhead is zero. Contract documentation for that kind is also suppressed.Runtime toggling. When a kind is compiled with
trueorfalse, the emitted override carries a runtime guard:Application.get_env(:bond, <kind>, <compile_time_value>). The contract is evaluated unless the runtime value is exactlyfalse. Operators can flip contracts on or off viaApplication.put_env/3from a remote console — no recompilation needed. The compile-time value sets the default for the runtime guard.Benchmark on the project fixture (
bench/runtime_check_overhead.exs, trivial@pre is_number(x)in a tight loop)::purge~48 ns/call,false~89 ns/call (~40 ns guard overhead),true~155 ns/call (guard plus assertion eval).:overridesconfig for per-module rules. A list of{Module | Regex, opts}tuples. Module-atom keys match exactly;Regexkeys match against the source-visible module name (noElixir.prefix). Use this to opt specific modules in or out of contract compilation without touching their source. Example:config :bond, preconditions: true, overrides: [ {MyApp.HotPath, preconditions: :purge, postconditions: :purge}, {~r/Workers\\./, postconditions: false} ]use Bond, optsper-module options. Pass any of:preconditions,:postconditions,:checksdirectly at theusesite to override global and:overridessettings for that module.defmodule MyApp.HotPath do use Bond, preconditions: :purge, postconditions: :purge endPrecedence:
use Bondopts > exact-atom:overridesmatch > firstRegex:overridesmatch > global config.Bond.Compiler.resolve_config/3— internal helper exposed for testing that combines global config,:overrides, anduse Bondopts into the final per-module mode map.
Changed
Bond.Compiler.AnnotatedFunction.apply_contract/2now expects each kind in the config map to betrue | false | :purgerather than a boolean. The function returnsnilwhen both kinds resolve to:purge; in all other cases it emits the override with the appropriate runtime guards.Bond.check/1,2now expands to a runtime-guarded call when the resolved:checksmode istrueorfalse, and to:ok(a compile-time no-op) when the mode is:purge.
Requirements
- Unchanged. Elixir
~> 1.14.
[0.10.0] - 2026-05-21
The headline feature of 0.10.0 is conditional compilation of contracts. You can now compile some or all of your contracts out entirely via application config, with zero per-call overhead for disabled contracts. The release also adds an ExUnit helper module, polishes error reporting, and substantially rewrites the user-facing documentation.
Added
Conditional compilation via
:bondapplication config. Three keys, read at compile time viaApplication.compile_env/3::preconditions(defaulttrue) — whenfalse, no precondition evaluation is emitted in override clauses, and the auto-generated#### Preconditionsdoc section is omitted.:postconditions(defaulttrue) — same for postconditions.:checks(defaulttrue) — whenfalse, everycheck/1,2macro call in modules thatuse Bondexpands to:okand the wrapped expression is not evaluated. (Don't put side effects insidecheck.)
When both
:preconditionsand:postconditionsare disabled for a function, Bond emits no override at all. The function runs exactly as written, with zero per-call overhead. The function's auto-generated contract docs are also suppressed in that case.See the new "Conditional compilation" section in the
Bondmoduledoc.Bond.Testmodule withassert_precondition_violation/2,assert_postcondition_violation/2, andassert_check_violation/2macros for testing contract violations in ExUnit. Field expectations (:label,:expression, etc.) can be exact values orRegexpatterns.New
guides/faq.mdanswering the questions that come up most: why contracts when I have ExUnit, will contracts slow down prod, how does Bond compare to Norm, what does Bond do that typespecs don't, the Assertion Evaluation rule, default-arg behaviour, multi-clause handling.
Changed
Assertion failure messages pretty-print the captured
binding/0withinspect/2 ... pretty: true, limit: 20, printable_limit: 200, width: 80, so small bindings stay compact and large structs no longer dominate the failure output.Stack traces of raised assertion exceptions are pruned to omit
Bond.*frames. Failures point at the user's call site rather than intoBond.Runtime.Eval.Bondmoduledoc / README restructured. Leads with a five-lineAccount.withdrawexample and a one-paragraph elevator pitch. The Wikipedia quote moves out. Assertion syntax recommends the keyword-list form as primary. NewConditional compilationsection. TheMath.sqrtexample remains as the "showing everything" sample.guides/getting-started.mdexpanded into a step-by-step walkthrough: first@pre, postcondition withresult, labelled assertions, predicates,oldexpressions, inline checks, disabling in prod, and ExUnit integration.
Internal
- New private function in
Bond.Runtime.Evalthat prunes Bond frames from the captured stack trace before raising. Bond.Compiler.AnnotatedFunction.apply_contract/1is nowapply_contract/2taking acontract_configmap. The__before_compile__/1callback reads the config from a@__bond_contract_config__module attribute set by Bond's__using__/1.
Requirements
- Unchanged. Elixir
~> 1.14.
[0.9.1] - 2026-05-21
A patch release covering documentation cleanup left over from the 0.9.0 refactor plus a handful of usability improvements.
Added
.formatter.exsis now published with the Hex package and declareslocals_without_parensforcheck/1,check/2, andold/1. Downstream projects can pick these up withimport_deps: [:bond]in their own.formatter.exs.- Assertion-failure messages now include an
at: <file>:<line>line so the source location is clickable in editors.
Changed
binding/0captured in assertion-failure info is sorted by name so failure messages are reproducible across runs.
Fixed
- README/moduledoc no longer references the removed Bond.def/2 and
Bond.defp/2 macros, eliminating
mix docscross-reference warnings. - The
getting-startedguide installation hint now references the current version. - CHANGELOG no longer auto-links the removed
define_function_with_contract/4helper.
Internal
- Removed the vestigial
:contextfield fromBond.Compiler.Assertion. - Tightened the
Bond.Compiler.AnnotatedFunctionmoduledoc.
[0.9.0] - 2026-05-21
This release is a large internal refactor with no breaking changes to the
public API. @pre, @post, and check/1,2 all behave the same as in 0.8.x.
Changed
- Bond no longer overrides
Kernel.def/2andKernel.defp/2. Contracts are now applied via Elixir compiler hooks (@on_definition,@before_compile,@after_compile). This makes Bond more robust against changes in Elixir's macro expansion semantics, eliminates a class of macro-hygiene issues, and plays nicer with other macros that produce function definitions. - Multi-clause functions are now wrapped by a single override clause that
delegates to
super/1rather than having contract logic inlined into each clause. Elixir's normal pattern matching handles dispatch inside thesupercall. - Assertion failures are signalled by a throw / catch instead of being
raised inline. Each
@pre/@postgroup compiles to an anonymous function that throws{:assertion_failure, info}on the first failure;Bond.Runtime.Evalcatches it and raises the appropriate exception type. - Functions with contracts now get auto-generated
PreconditionsandPostconditionssections in their documentation even if the user did not attach a@docthemselves. Previously contract documentation was only emitted when a@docwas present. - Internal modules are reorganised into
Bond.Compiler.*(compile-time) andBond.Runtime.*(run-time) namespaces.
Internal
- New modules:
Bond.Compiler.AnnotatedFunction(multi-clause function model),Bond.Compiler.FunctionDefinition,Bond.Compiler.CompileStateFSM(rewritten),Bond.Runtime.Eval. - Removed internal modules
Bond.Compiler.AnnotatedFunctionClauseandBond.Compiler.LegacyCompileStateFSM, along with thedefine_function_with_contract/4helper they used. Bond.Compiler.Assertionnow carries a stable random:idfor use in error reporting and future internal tooling.
Requirements
- Unchanged. Elixir
~> 1.14.
[0.8.3] - 2024-11-08
Released before this changelog was established. See the git history for details.