Packages

Under heavy development: not for production use before 1.0.0. DXN (Data eXchange Notation) for Elixir -- .dxn text, .dxnb binary, and .dxns schema documents, built on the Ichor grammar compiler.

Current section

Files

Jump to
dextrin CHANGELOG.md
Raw

CHANGELOG.md

# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.2.0] - 2026-10-19

Versioned schemas, extra fields, the conformance suite's adapter and a
document that keeps its comments. Breaking: a 0.1.x `.dxns` file has
to be rewritten, and decoding a checked struct answers a
`Dextrin.Struct`.

### Migrating from 0.1.x

- **`.dxns` files.** The body lists the fields directly, in the order
  `fields: @ordered %{...}` gave them, and a schema without a version
  is version 1:

  ```text
  # 0.1.x
  User: %schema{closed: true, fields: @ordered %{name: :string, email?: :string}}
  # 0.2.0
  User: %schema{name: :string, email?: :string}
  ```

  An unconverted file is refused when it is compiled (`schema/compile`:
  `field "fields": unrecognized type expression`), never read as
  something else.
- **`closed:`, `forbidden:` and `refine-fn:` are gone.** A struct may
  carry fields its schema does not declare. Where `closed: true` refused
  them, decode with `strict: true` (or `Dextrin.Registry.put_strict/2`),
  which refuses every undeclared field as `schema/undeclared-field`. A
  `forbidden:` list or a refine predicate becomes a check in the
  schema's materializer (`Dextrin.Registry.put_struct_materializer/3`),
  which may refuse.
- **A checked struct decodes to a `Dextrin.Struct`**, not a string-keyed
  map. A materializer that answers the map you had keeps the old shape:
  `fn _name, _version, fields -> {:ok, fields} end`.
- **`.dxnb` written by 0.1.x still reads**, and so does text: its structs
  carry no version, and a struct without a version is read as the latest
  version loaded. Proven against bytes 0.1.3 wrote, in
  `test/binary/v0_1_3_test.exs`.
- **Rewrite stored 0.1.x data before you load a version 2.** Once a
  version 2 is loaded, an unversioned struct is read as version 2, and a
  `.dxnb` struct with version 1's fields no longer matches it
  (`schema/field-count`). Decoded and encoded again with 0.2.0, the same
  value is written as `User(1)`, and from then on it is read as version 1
  and handed to the materializer to upgrade.

### Added

- `Dextrin.Document` -- a `.dxn` document with everything it holds, its
  comments, blank lines and commas as well as its value: `parse/2` (with
  `Dextrin.decode/2`'s options and errors), `to_value/1` (what `decode/2`
  gives) and `render/1` (the source, byte for byte, when nothing was
  edited). Built on Ichor's lossless parse of the same grammar.
- `Dextrin.Document.replace/3`, `delete/2` and `insert/4` -- explicit
  edits by path (a map's key, a struct's field name, an index). Every byte
  an edit does not touch renders as it was; a replaced value keeps the
  comments around it; a deleted entry takes the comment blocks above it
  and its trailing block, and nobody else's; an inserted entry goes above
  the comments of the entry it lands before, laid out like its
  neighbours.
- `Dextrin.Document.format/2` -- a document laid out the way the pretty
  printer lays out its value, with every comment kept beside the entry
  it belongs to. Layout only: every token is written as it was, and a
  comment-free document comes out exactly as `Dextrin.encode(value,
  pretty: true)` writes its value.
- **`mix dextrin.format` keeps comments** (`--mode pretty`, the default):
  it formats through `Dextrin.Document.format/2`, where it reprinted the
  value and lost every comment. `--mode condense` still drops them: a
  comment ends its line, and condensed output is one line.
- `Dextrin.Document.update/3` -- the document holding a new value, by the
  fewest explicit edits, answered as a changeset. Equal values are left
  as written; lists compare with Myers' diff. A deleted item followed by
  an inserted one is ambiguous, and `on_ambiguity:` decides: `:safe`
  (the default) refuses only where the deleted item owns a comment,
  `:guess` reads it as a replace, `:error` refuses every one.
- `Dextrin.Registry.from_options/1` and `Dextrin.Error.from_parse/1`, what
  `decode/2` and `Dextrin.Document.parse/2` share.
- `Dextrin.Canonical.print/1` -- the canonical form of a value
  (`DXN.md` §1.5): one `.dxn` rendering fixed down to the byte, with
  maps and sets in canonical order, floats by one exact algorithm and
  one fixed set of string escapes, so values from different
  implementations compare as text.
- `Dextrin.Error`'s `category` -- the error's kind as `DXN.md` §5 names
  it (`"syntax"`, `"binary"`, `"encode/unsupported"`, ...), so errors
  compare across implementations without comparing messages.
  `Dextrin.Error.action/2` takes it; schema errors have none yet.
- `mix dextrin.conformance REQUEST ANSWER` and `Dextrin.Conformance` --
  this implementation's adapter for the Dextrin Conformance Suite,
  answering a JSON batch of cases with canonical forms, base64 bytes or
  error categories.
  It also answers `format` and `edit` cases, a `Dextrin.Document` laid
  out or edited, as `text`.
- The tutorial's §8, "Evolving a schema": a worked example of versions,
  reading across them, the materializer, the positional risk and
  `mix dextrin.compat`. The cheatsheet gains versions and extra fields.
- `Dextrin.Schema.Compat.check/2` and `mix dextrin.compat OLD NEW` --
  the version discipline check (`DXN.md` §4.8), for CI against the
  previous release's schema files: a version whose content changed
  without a new version, or a version that went down, fails; what the
  latest version's materializer has to upgrade is listed either way.

### Changed

- Licensed under the Apache License 2.0 (`Apache-2.0`); it was MIT.
- The Node.js port is published as `@joetjen/dextrin`; the links to it
  say so. The unscoped npm `dextrin` is deprecated.
- Requires `ichor_runtime ~> 0.4` (was `~> 0.2`); `ichor ~> 0.5` builds
  the DXN parser, now generated with `--lossless` for the comment-keeping
  document to come. Decoding runs the same code as before.
- An error of Dextrin's own found while parsing text -- a map key that is
  not allowed, a schema violation -- travels through Ichor in
  `Ichor.Error`'s `data` and comes back as itself, now with Ichor's
  line and column; `Dextrin.Error.format/1` shows them.
- **An absent optional field is never written as `nil`** (`DXN.md`
  §4.3): `.dxnb` fills every position, so `encode_binary` writes an
  absent field's default where it has one and otherwise refuses
  (`schema/missing-field`). It wrote `nil`, which the same schema then
  refused to read back. `nil` in an optional field stays an error unless
  the type is `{:nilable t}`.
- **Every schema violation carries its `DXN.md` §5 category**:
  `schema/field-count`, `schema/missing-field`, `schema/type-mismatch`
  and `schema/shadowed-field`, decoding text or binary and encoding
  alike. They came back as `syntax` from text, `binary` from binary,
  and with no category from an encode. `Dextrin.Schema.validate/3`,
  `validate_encode/3` and `validate_encode_tree/2` answer a
  `Dextrin.Error` instead of a string.

- `DXN.md` gains §1.5 (canonical form), §1.6 (map keys: only `string`,
  `keyword`, `integer`, `uuid`, `date`, `bytes`, `symbol` and `char`)
  and §5 (error categories).
- **Schemas are versioned** (`DXN.md` §4.8). A schema is written
  `%schema(N){field: type, ...}`: the body lists the fields directly, in
  the order `.dxnb` encodes them, and the header holds only the version
  (version 1 without one). `fields:` and `@ordered` are gone. Each
  `.dxns` document describes one version; the registry keeps schemas by
  name *and* version (`Dextrin.Registry.fetch_struct_schema/3`, with `/2`
  answering the latest). Loading a version already loaded is a
  `schema/duplicate-version` error; `Dextrin.Registry.replace_struct_schema/3`
  hot-swaps a loaded version and refuses one that is not
  (`schema/not-loaded`).
- **A struct literal may carry its version**: `%User(3){...}`,
  `%User(3)[...]`, kept in `Dextrin.Struct`'s new `version` field and
  written back by the printer, the formatter and the canonical form.
- **Every struct written against a schema carries the latest loaded
  version of it**, in text (`%User(3){...}`, and `%User(1){...}` when
  version 1 is the only one) and in `.dxnb`, where the type name becomes
  `"User(3)"`. A struct with no schema keeps its own version, or none.
  `Dextrin.Registry.as_written/2` decides; `Dextrin.Struct.type_name/2`
  and `parse_type_name/1` spell it. A `.dxnb` type name whose version is
  not a positive integer is a `binary` error. `DXN.md` §4.8 documents
  how to deploy a schema change.
- **A struct is read according to the version it was written with**
  (`DXN.md` §4.8, `Dextrin.Schema.Reader`), in text and binary alike. The
  latest version, or none, is read exactly. A newer version is read
  against the latest: text by name, binary by position, dropping items
  beyond the reader's fields and leaving missing optional fields absent.
  An older version that is loaded is read with its own schema and handed
  to the materializer, and refused as `schema/no-materializer` without
  one; an older version that is not loaded is refused as
  `schema/unknown-version`.
- **One materializer per schema, for every version** (`DXN.md` §4.8).
  `Dextrin.Registry.put_struct_materializer/3` takes a function of
  `(schema_name, version, fields)`, `fields` keyed by string; it is
  called for every decoded struct of its schema, upgrades older
  versions, and its result is checked against the latest schema — a
  refusal or a rejected result is `schema/materializer`.
  `Dextrin.Schema.Provider`'s optional callback becomes
  `dxn_materialize/3` accordingly.
- **Without a materializer, a checked struct decodes to a
  `Dextrin.Struct`** at its schema's latest version, fields in the
  schema's order, instead of a plain string-keyed map — so it encodes
  back to the text it came from. An optional field that was absent stays
  absent instead of becoming `nil`.
- `Dextrin.Schema.Validator.materialize/4` is replaced by `resolve/3`;
  building the value is `Dextrin.Schema.Reader`'s job.
- **A struct may carry extra fields** (`DXN.md` §4.7), kept in
  `Dextrin.Struct`'s new `extra` list and written back with it: in text
  as entries — after a positional struct's items,
  `%User(3)["a" "a@x" nick: "b"]` (the grammar's positional rule gains
  trailing entries) — and in `.dxnb` as one trailing map under the new
  private tag 215. A keyed struct's undeclared names, and any entry keyed
  by something other than a name, are extras. An extra never shadows a
  declared field (`schema/shadowed-field`); the materializer receives
  extras with the declared fields; `strict: true` refuses them
  (`schema/undeclared-field`, `Dextrin.Registry.put_strict/2`). A
  registered application struct's runtime keys beyond its `defstruct`
  are written as its extras (`Dextrin.Struct.runtime_extra/1`).
- A keyed struct entry whose key is not a name — `%Point{1 => 2}` — is an
  extra field instead of an error.
- **A struct is matched only by a struct** (`DXN.md` §4.7). A field
  typed `User` accepts a struct named `User`, or the application struct
  registered for `User`; a plain map, any other value, and an application
  struct registered for no schema or another are type errors. Until now
  every non-struct value, and every unregistered application struct, was
  accepted. `DXN.md` §4.7 states the rule and its test: a host value
  always means the same DXN type, schema or not.
- **Removed: `closed:`, `forbidden:` and `refine-fn:`**, and with them
  `Dextrin.Schema.compile/3`'s and `Dextrin.Schema.FileResolver.for_paths/2`'s
  `predicates` argument. A struct may carry fields its schema does not
  declare; `strict: true` refuses the undeclared ones, and the
  schema's materializer checks what a predicate checked.
- Schema compile errors are `Dextrin.Error`s with the `schema/compile`
  category, not bare strings.
- **A map key must be one of the eight types of `DXN.md` §1.6**
  (`string`, `keyword`, `integer`, `uuid`, `date`, `bytes`, `symbol`,
  `char`). Any other key -- a float, `nil`, a boolean, a time, a
  collection, ... -- is refused as `key/not-allowed` when decoding text
  or binary and when encoding, in maps and ordered maps alike.
  `Dextrin.MapKey` holds the rule.
- `DXN.md` §1.5 states that nothing is normalised: a value is written
  as it was given, so `1.50M` keeps its scale and `-0.0M` its sign, in
  the canonical form as in every encoder.

### Fixed

- A symbol whose name is not an identifier is refused by the text
  writers (`encode/unsupported`): `encode/2`, the pretty printer and the
  canonical form wrote its name verbatim, which read back as something
  else, or as nothing (`""`). `.dxnb` keeps any name.

- A keyed struct written to `.dxnb` went on the wire in the order of
  its keys rather than its schema's field order, so `%Point{y: 2, x: 1}`
  came back with its fields swapped.

## [0.1.3] - 2026-08-18

### Fixed

- `decimal` bumped `~> 2.1` -> `~> 3.0` -- `mix hex.audit` flagged the resolved `2.4.1` against a real, published MEDIUM-severity advisory (EEF-CVE-2026-32686, "unbounded exponent in decimal enables unauthenticated DoS") while adding a downstream consumer. This package parses untrusted `.dxn`/`.dxnb` input directly into `Decimal.new/1,3` (`lib/dextrin/text/actions.ex`, `lib/dextrin/binary/decoder.ex`) -- exactly the attack surface the advisory describes, not an incidental transitive path. Every `Decimal.*` call this package makes (`new/1`, `new/3`, `to_float/1`, `to_string/2`) is unchanged, stable core API across 2.x and 3.x -- confirmed by the full existing test suite passing unmodified against `3.1.1`.

## [0.1.2] - 2026-08-14

### Added

- `Dextrin`'s own moduledoc now names its two sibling ports
  (`node-dextrin` on npm, `php-dextrin` on Packagist) directly,
  alongside the existing README "Other language implementations"
  section and `mix.exs` package links.

### Fixed

- `Dextrin.Text.Formatter` (`encode/2`'s `pretty: true` path) rendered
  a map entry's *key* incorrectly in two cases, both traced to
  `render_entry/4` conflating "render this as a value" with "render
  this as a keyword key":
  - A string-typed key that happened to look like a bare identifier
    (e.g. `"id"`) rendered as keyword-shorthand (`id: 1`) instead of
    the quoted arrow form (`"id" => 1`) — silently changing the key's
    type from `string` to `keyword` on the next decode. This is also
    why a schema-materialized struct (whose field map is always
    string-keyed) round-tripped incorrectly once pretty-printed.
  - A map keyed by exactly the atom `:nan`, `:positive_infinity`, or
    `:negative_infinity` — the same atoms a *float* `NaN`/`Infinity`/
    `-Infinity` decodes to — rendered the key as the float sigil
    (`Infinity => ...`) instead of the keyword it actually was
    (`positive_infinity: ...`), because the key fell through to the
    same code path that (correctly, in *value* position) special-cases
    those three atoms.

  `Dextrin.Text.Printer` (the compact, non-pretty path) was not
  affected — only the multi-line formatter had this bug. Found via a
  cross-language round-trip check against `node-dextrin`/`php-dextrin`.

## [0.1.1] - 2026-08-03

### Changed

- Bumped `ichor_runtime` to `~> 0.2` (and dev-only `ichor` to `~> 0.3`, its
  matching release) for the fix to `Ichor.Actions`' `eval_all` sometimes
  evaluating sibling captures out of source order. Regenerated
  `lib/dextrin/text/grammar/native.ex` via `mix ichor.gen` and updated
  the private `@ordered`-map re-entry helper in `Dextrin.Text.Actions`
  for `ichor_runtime`'s breaking change to raw capture data: an ordered
  `[{name, value}]` list instead of a plain map. No user-visible
  behavior change.

## [0.1.0] - 2026-07-30

### Added

- **`.dxn` text**: full grammar support for all 30 types DXN.md §1.3
  defines, via a hand-authored Aether grammar (`priv/grammar/dxn.aether`)
  compiled at build time through Ichor's `Grammar.Native` backend.
- **`.dxnb` binary**: a hand-rolled CBOR codec (`Dextrin.Binary.Encoder`/
  `Decoder`) covering the full type mapping, the private tag block
  (200-214), the duration bitmask and regex flags byte, and both
  value-sharing extensions (string-only, tag 256/25; general arbitrary-
  value sharing, tags 28/29 — decode-only required, encode opt-in via
  `share: true`, gated by a size-aware threshold rather than a fixed rule).
- **`.dxns` schema documents**: `Dextrin.Schema.compile/3` compiles a
  decoded `.dxns` document (itself plain `.dxn` data, no new grammar)
  into a `Dextrin.Registry` — struct schemas with required/optional
  (`?`-suffixed keys)/closed/forbidden fields and `refine` constraints,
  plus reusable named types composed from the fixed 13-form type_expr
  vocabulary. Enforcement is automatic, fail-fast, and symmetric:
  `Dextrin.decode/2`/`decode_binary/2` check every registered struct
  name unconditionally on the way in; `Dextrin.encode/2`/
  `encode_binary/2` do the same automatic whole-tree check on the way
  out (`validate: false` opts out), plus an opt-in `schema:` check for
  a nameless top-level value.
- **`Dextrin.Registry`**: the shared extension point for both `struct`
  (schema-driven, `put_struct_materializer/3`/`put_struct_module/3`)
  and `custom-tag` (`put_tag/3`/`put_tag_encoder/4`), plus a lazy
  `put_resolver/2` hook for on-demand schema loading.
- **`Dextrin.Schema.Std`**: a small standard library of common named
  types (`PositiveInteger`, `NonEmptyString`, `Percentage`, ...),
  opt-in via `Std.registry/1`.
- **`Dextrin.Schema.FileResolver`**: one reasonable, swappable
  convention resolving `Namespace/Name` references to
  `<path>/Namespace.dxns` files on disk.
- **Value types**: `Dextrin.Symbol`, `Dextrin.Keyword` (never Elixir
  atoms — decoding untrusted data can't exhaust the atom table),
  `Dextrin.Tuple`, `Dextrin.Array`, `Dextrin.OrderedMap`,
  `Dextrin.SortedSet`, `Dextrin.Struct`, `Dextrin.Duration`,
  `Dextrin.Rational`, `Dextrin.Uuid`, `Dextrin.Uri`, `Dextrin.Bytes`,
  `Dextrin.Char`, `Dextrin.CustomTag` — small wrapper structs only
  where Elixir has nothing native that fits without losing information.
- **`Dextrin.Text.Formatter`**: multi-line, indented `.dxn` rendering
  (`pretty/2`), built on top of `Dextrin.Text.Printer`'s single-line
  default.
- **`mix dextrin.*` tasks**: `validate`, `encode`, `decode`, `format`,
  `gen.schema` (scaffold a `.dxns` file from an existing Elixir
  struct's field list), and `gen.unicode` (regenerate the grammar's
  Unicode `XID_Start`/`XID_Continue` identifier ranges from the latest
  Unicode Character Database — a deliberate, reviewed action, never
  run at build time).
- Conformance fixtures covering `DXN.md` §3's full worked example,
  per-type round-trip tests, cross-format equivalence tests, grammar
  hazard regression tests, and a fuzz/malformed-input pass over the
  binary decoder.

- `Dextrin.encode/2` gained `pretty:` and `indent:` opts — `pretty:
  true` produces multi-line, indented output (`indent:` sets spaces
  per level, default 2) instead of the single-line/compact default.
  Either way, schema validation runs the same; formatting and
  validation are independent concerns.
- `Dextrin.Schema.Provider`, a behaviour letting a struct's own library
  ship a DXN schema for it without that library ever depending on
  `dextrin` — the schema, the target struct module, and an optional
  materializer are declared on a small, separately-compiled companion
  module (meant to be guarded behind an `optional: true` dependency),
  and `Dextrin.Schema.register_provider/2` compiles and wires it into
  a `Dextrin.Registry` in one call from the consuming application.
- Property-based tests (`stream_data`/`ExUnitProperties`, already a
  declared dependency but previously unused): a shared
  `Dextrin.Generators` module, round-trip properties covering
  `encode/2`/`decode/2`, `encode_binary/2`/`decode_binary/2`,
  `share: true`, and `pretty: true` against randomly generated values,
  a `Dextrin.Binary.FuzzTest` asserting `decode_binary/2` never raises
  or hangs on arbitrary or bit-flipped input, and a per-type property
  suite (`Dextrin.RoundTripByTypeTest`) covering every DXN type
  individually rather than mixed into one nested-structure generator.
  These found several of the bugs listed below on their very first run.
- `Dextrin.encode/2`/`encode_binary/2` accept a bare Elixir atom
  anywhere a `keyword` value is expected — as a map key (`%{x: 1}`,
  matching the shorthand's own colon syntax) or standalone
  (`Dextrin.encode(:ok)`) — exactly as if it were
  `Dextrin.Keyword.new(Atom.to_string(atom))`. `DXN.md` §1.3's own type
  table documents `keyword`'s Elixir counterpart as "Elixir atom".
  `nil`/`true`/`false` are unaffected — those stay their own literals.
- `Dextrin.decode/2`/`decode_binary/2` gained a `trusted:` opt
  (default `true`) mirroring the above on the way in: `keyword` decodes
  as a real Elixir atom by default, the same natural type `encode/2`
  now accepts. Pass `trusted: false` — per call, or once on a reused
  registry via the new `Dextrin.Registry.put_trusted/2` — for any
  source you don't fully control; only then does `keyword` fall back
  to `Dextrin.Keyword.t()`, so a `String.to_atom/1` call is never
  reachable from attacker-controlled text. `symbol` is unaffected
  either way — it stays `Dextrin.Symbol.t()` regardless of `trusted`,
  mirroring `encode/2`'s own choice to treat a bare atom as a stand-in
  for `keyword` only, never `symbol`.

### Changed

- **Breaking:** `Dextrin.Registry.new/0`'s `trusted` field — and so
  `decode/2`/`decode_binary/2`'s effective default — is `true`, not
  `false`. Any code relying on the previous behavior (`keyword`
  decoding as `Dextrin.Keyword.t()` with no opt given at all) needs
  `trusted: false` added explicitly. This is deliberate: `keyword`'s
  wrapper only ever existed to keep untrusted input from reaching
  `String.to_atom/1` unbounded; assuming a trusted source (your own
  config, your own application's data) by default matches how the
  type is documented (`DXN.md` §1.3) and how most callers actually use
  this library, at the cost of needing an explicit opt-out for
  genuinely untrusted/network input.

- **Breaking:** `Dextrin.Text.Printer`'s (and so `Dextrin.encode/2`'s
  default, non-`pretty:` output) is now maximally compact: no space
  after a map/struct entry's `:`, no space around `=>`, and entries/
  positional-struct fields are comma-joined with no trailing space
  (`%{x:1,y:2}`, `%Point{x:1,y:2}`, `%{1=>"one"}`) rather than the
  previous `%{x: 1, y: 2}`-style spacing. DXN's own grammar treats
  whitespace and commas as insignificant everywhere, so this loses no
  information and every value still round-trips identically through
  `decode/2`; only the default *rendering* got smaller. List/tuple/
  set/array element separators and `@tag value`'s space are unchanged
  (the former have nothing to gain — comma and space are both a
  single byte — and the latter's space can't be dropped in general for
  a custom tag whose value could start with an identifier character).
- **Breaking:** `Dextrin.Text.Formatter.pretty/2` now returns
  `{:ok, String.t()} | {:error, Dextrin.Error.t()}`, matching
  `Dextrin.Text.Printer.print/2`'s contract, instead of a bare
  `String.t()` that raised `ArgumentError` on an unencodable value.
  Also gained the same `indent:` opt `Dextrin.encode/2`'s new
  `pretty:`/`indent:` opts use internally (default 2 spaces per
  nesting level, previously a fixed, non-configurable 2).
- Adopted ichor's new `mix ichor.gen`/`ichor_runtime` split: the `.dxn`
  lexer/parser is now generated ahead of time into
  `lib/dextrin/text/grammar/native.ex` (checked in, regenerated via
  `mix ichor.gen` whenever `priv/grammar/dxn.aether` changes) instead
  of being produced by `use Ichor` at `dextrin`'s own compile time.
  `Dextrin.Text.Grammar` is now a thin, hand-documented wrapper around
  the generated `Grammar.Native`. This lets `mix.exs` depend on the
  small `ichor_runtime` package (the only thing the generated code
  actually calls) as an ordinary runtime dependency, while `ichor`
  proper (the Aether front-end, format importers, `Grammar.Analysis`,
  both codegen backends) moves to `only: :dev, runtime: false` — the
  bulk of Ichor no longer ships in a `dextrin` release. Both are now
  published to Hex separately (`ichor ~> 0.2.1`, `ichor_runtime ~>
  0.1.0`) and referenced as ordinary Hex dependencies — no `git:`/
  `sparse:`/`override:` needed, since `ichor`'s own `mix.exs` now
  depends on `ichor_runtime` the same way. Migrating surfaced a genuine
  gap in that split, fixed upstream: the raw-capture-node re-evaluation
  entry point needed by `Dextrin.Text.Actions` at actual decode time
  (for `@ordered %{...}`) had stayed on the top-level, dev-only `Ichor`
  module instead of moving to `ichor_runtime`; it's now
  `Ichor.Actions.evaluate_node/3`.
- Added `credo`, `dialyxir`, `sobelow`, and `excoveralls` as dev/test
  tooling, plus a `mix precommit` alias (`format`, `compile
  --warnings-as-errors`, `credo --strict`, `sobelow`, `test`,
  `dialyzer`) run before every commit. Fixed everything it surfaced:
  canonical module layout (`moduledoc`/`use`/`alias`/...) across the
  mix tasks and text pipeline modules; `Dextrin.decode/2`'s spec
  under-declared its own return type (the underlying grammar engine
  can report a list of errors, not just one) — widened to match, which
  also resolved three "dead code" warnings in the mix tasks' own error
  handling; removed a dead `Duration.microsecond || {0, 0}` fallback
  (that field is never `nil`).
- Every hand-rolled "walk a collection, thread an accumulator, halt on
  the first non-`{:ok, _}` step result" reduce across
  `Dextrin.Schema.Compiler`, `Dextrin.Schema.Validator`,
  `Dextrin.Binary.Encoder`, `Dextrin.Text.Printer`, and
  `Dextrin.Text.Actions` now uses `Ichor.Toolkit.Result.reduce_ok/3`/
  `map_ok/3` instead of a hand-rolled `Enum.reduce_while/3`.
- Documentation reworked throughout: every module's docs are now
  self-contained (no references to an external design document), and
  the normative `DXN.md` format specification moved under
  `guides/dxn/DXN.md`, alongside a full set of tutorials, examples,
  and cheatsheets for both this library and the DXN format itself.
  `DXN.md` itself gained a new normative §4, "Schema documents
  (`.dxns`)" — the `type_expr`/`refine`-constraint vocabulary,
  `%schema{}`/`%field{}` shape, named types, cross-file references, and
  enforcement semantics, written implementation-independent (this
  library's own choices stay documented separately, in
  `Dextrin.Schema`'s own module docs) — closing a gap where `.dxns`
  was documented everywhere *except* the one place meant to be
  the authoritative, cross-implementation reference for it.

### Fixed

- Struct literal parsing (`%Point{x: 1, y: 2}`) and `.dxns` schema
  compilation both depended, internally, on `keyword`/map-key text
  always decoding as `Dextrin.Keyword`/`Dextrin.Symbol` — exposed as a
  real regression the moment `trusted: true` became the default, not
  merely a test-assertion mismatch: struct literals failed to parse at
  all, and any schema whose own `.dxns` source was decoded trusted
  failed to compile. Fixed at the boundaries that actually needed it:
  `Dextrin.Text.Actions`'s struct-field-name resolution now accepts a
  bare atom the same way it already accepted `Dextrin.Keyword`
  /`Dextrin.Symbol`; `Dextrin.Schema.Compiler.compile/3` normalizes a
  decoded `.dxns` document's atoms back to `Dextrin.Keyword` once, up
  front (mirroring `Dextrin.Schema.Validated.strip/1`'s exact
  recursive-walk shape), so its own `.dxns`-vocabulary pattern
  matching needs no changes at every site; a schema's `enum` literals
  and a `:keyword`-typed field's actual data can now be either shape
  independently, compared correctly either way instead of only ever
  matching the untrusted shape. `default: nil`/`true`/`false` field
  values are deliberately *not* touched by that normalization (only
  `.dxns` syntax positions are) — and `x: :nil` as a schema field's
  *type* (the `"nil"` primitive) has its own dedicated fix, since
  `String.to_atom("nil")` and the bare literal `nil` are, once
  decoded, the exact same unrecoverable Elixir value.
- `Dextrin.Binary.Decoder`'s handling of the `time` tag (210) had no
  fallback clause — a malformed payload that wasn't an integer crashed
  with `FunctionClauseError` instead of a clean `{:error, _}`, the one
  tag missed in the earlier decoder-hardening pass. Found by the
  property-based fuzz test firing again after the `trusted:` default
  flip added more generated shapes to exercise it with.

- `Dextrin.Text.Printer`/`Dextrin.Text.Formatter` no longer produce
  unparseable text for a map entry or struct field whose
  `Dextrin.Keyword`/field name isn't a bare `identifier` (empty,
  containing spaces, ...) — `map_entry`'s colon-shorthand (`DXN.md`
  §1.2) only exists for an `identifier`; both printers now fall back
  to the arrow form (`:"name" => value`) for anything else, instead of
  emitting invalid syntax like `%{"":0}` or a bare `: 0` that failed to
  parse back at all. `Dextrin.Text.Printer.bare_identifier?/1` is now
  public so both printers share one rule.
- `Dextrin.Binary.Decoder` no longer crashes (raises) on a malformed
  `.dxnb` document that places a `tuple`/`array`/`set`/`sorted-set`
  tag around a non-array payload, a bignum/`uuid` tag around a
  non-bytes payload, a `uri`/`symbol`/`keyword` tag around non-text, a
  `char` tag around a non-codepoint or out-of-range value, a `date`
  tag around a non-integer, or a `timestamp`/`datetime` epoch outside
  the representable range — every one of these now returns a clean
  `{:error, %Dextrin.Error{}}` instead of an unhandled
  `FunctionClauseError`/`ArgumentError`/`CaseClauseError`. Found by the
  new property-based fuzz test, not a hand-picked case.
- `Dextrin.Text.Printer.print/2` no longer crashes on a value with no
  DXN representation at all (a PID, a port, a reference, a function, a
  raw Elixir tuple that isn't `Dextrin.Tuple`, ...) — a clean
  `{:error, %Dextrin.Error{}}` now, matching `encode/2`'s own typespec,
  instead of an unhandled `FunctionClauseError`.
- `@ordered %{}` (an *empty* ordered map) failed to parse at all
  (`"@ordered requires a map literal argument"`), even though `%{}` on
  its own decodes fine — the action handler required its raw capture
  tree to have a `:map_entry` key present, which a zero-entry map
  simply doesn't have (the same reason the ordinary, non-`@ordered`
  `map_lit` handler already defaults it to `[]`). Found by the new
  per-type property suite.
- `Dextrin.Binary.Decoder` now accepts `DXN.md` §2.4's CBOR string
  -reference sharing extension (tags 256/25) — previously an
  otherwise-well-formed `.dxnb` document using it (to compactly
  represent repeated `symbol`/`keyword` text or repeated struct type
  names) failed to decode at all (`"unrecognized CBOR tag 25"`), which
  violated §2.4's "a conforming decoder MUST accept it" requirement.
  `dextrin`'s own encoder still never produces tags 256/25 (that
  remains out of scope, same as before) — this is decode-side
  acceptance only, same stance as §2.5's tag 28/29 support.
- `Dextrin.encode/2`/`encode_binary/2` can now encode a registered
  application struct (`Dextrin.Registry.put_struct_module/3`) directly
  — previously, only a hand-built `Dextrin.Struct` could actually be
  serialized; a real struct registered via `put_struct_module/3` was
  recognized by automatic encode-time *validation* but had no path to
  actually being written out, forcing callers to reconstruct a
  `Dextrin.Struct` from the real struct's own fields by hand before
  encoding. `Dextrin.Text.Printer`/`Dextrin.Binary.Encoder` now rebuild
  the equivalent `Dextrin.Struct` automatically, in the compiled
  schema's own canonical field order.