Skip to content

Releases: nerdmenot-swift/assay

Release list

Assay 1.0.0

Choose a tag to compare

@github-actions github-actions released this 16 Oct 13:06

The first public release. Everything in it is "added" by definition; the highlights that
distinguish it:

  • @Schema macro decoding for JSON (straight from bytes into the struct), YAML,
    XML and TOML (via a format-neutral RawValue projection) — no Codable, no
    CodingKeys, measured at 5–9× Foundation on the published corpus
    (Benchmarks/RESULTS.md; one arm64 Mac, stated as such).
  • Errors that name the byte: every decode and validation failure carries a code,
    structured params, a path, and a source span; four renderers including terminal
    carets and RFC 9457 problem details. All the errors are collected, not just the
    first — and the error path is faster than Foundation's throw-on-first.
  • Validation (@Validate, @Check/@AsyncCheck, @Preprocess, @Transform,
    @Fallback) type-checked at macro expansion with purpose-written diagnostics.
  • Date + @DateFormat: hand-written ISO-8601 / unix / RFC 9110 / fixed-pattern
    parsers (pure arithmetic, no ICU, Foundation-free core), ordered candidate chains
    with warnings on fallback matches, 5.40× Foundation's .iso8601 strategy, verified
    exact against Foundation on 2,279 instants.
  • [String: T] dictionary fields, fully recursive, with the "worst case"
    measured at 6.95× rather than assumed.
  • Key handling: compile-time key conversion (.snakeCase and friends), aliases
    that warn which one matched, @Extras open maps, unknown-key policies with
    Damerau–Levenshtein did-you-mean.
  • Security by construction: XXE unfetchable, expansion bombs budget-capped,
    limits first-class (see SECURITY.md).
  • Verification as a feature: differential oracles against JSONSerialization,
    Yams/libyaml, and Foundation's XMLParser; deterministic fuzzing; live-allocation
    gate; compile-time budget gate (~81 ms per type against a 100 ms ceiling).
  • Rows and column stores are not part of Assay (2026-09-11). ColumnarSource,
    ColumnDecodable, RowBatch, RowDecoder<T>, RowSink and @Schema(sources: true)
    were built, measured and removed before release. Not for being slow — the columnar path
    was the fastest thing here at 11 ns/row — but because nothing depended on it, the audience
    is small, and a decoder that also owns column stores is two libraries wearing one name.
    It cost ~1,900 lines and doubled the expansion cost of any type that used it.
    T.validate(_:) is the answer for a fast external reader: it decodes at its own speed in
    its own module, and Assay runs the rules afterwards. ROADMAP.md keeps the short record; if
    it returns it will be a separate package.
  • The value model is 2.1× faster (2026-09-11): JSON.Value.parse allocated a path
    array per value in the document, for a diagnostic path nothing reads unless the document
    is malformed. The corpus-wide sweep goes 1.51× → 3.35× over JSONSerialization, and
    the DOM-vs-DOM gap against yyjson closes from 16× to 7×.
  • A type mismatch on the YAML/XML/TOML/plist path now carries a caret even when the
    field has no rules; previously only JSON did.
  • @Key(_:or:) now actually warns which alias matched (2026-09-10) — alias_matched,
    on the JSON and YAML/XML/TOML paths. Three documents had promised it and no code did.
  • TOML (AssayTOML, 2026-09-10): a hand-written TOML 1.0.0 parser passing the
    official toml-test suite in CI (709 cases at the time of writing), differential against
    toml++,
    parse(toml:)/diagnose(toml:), SchemaFormats.toml, WireFormat.toml, and
    encodedTOML() with nil members omitted and every other null reported
    (docs/TOML.md).
  • Encoding for JSON, YAML, XML and TOML (@Schema(encodes: true)), with round-trip as a
    stated law and a closed exception list (docs/ENCODING.md).
  • Unions — @Schema(discriminator: "type") and .untagged — decode and encode,
    JSON only (docs/UNIONS.md).
  • Assayer<T> runtime schemas, @Wraps, @Inline, @Key(path:), @OneOrMany,
    @XML placement and @XML(root:), @Schema(context:), parse(plist:),
    parse(body:contentType:accepting:) and jsonSchema(for:).
  • Collections report one issue per bad element and continue; a dictionary value's
    issue names its key (m.j, d.a[1]). A backticked property name (`default`)
    decodes. A UTF-8 BOM is skipped. @Check misuse, undecodable field types (Set,
    tuples, Data, URL, T!, generic structs…) and a non-@Schema nested type all get
    purpose-written diagnostics instead of has no member '_assay'.
  • One error type. JSON.Value.parse, YAML.parse and XML.parse throw AssayError
    like every other entry point, with the source retained, so their failures render carets
    too. JSONValueError, YAMLParseError and XMLParseError are gone.
  • Errors print. print(error), "\(diagnosis)" and (with AssayFoundation)
    localizedDescription show the caret render; they used to show a reflection dump.

Renamed before release, no deprecation shims: Discriminator.none → .untagged (the
old spelling warned on every use — it resolved to Optional.none), and the assertion
rules .trimmed/.lowercased → .isTrimmed/.isLowercase (they never normalised;
@Preprocess does). Every message-less rule now has an (or:) overload.

Known limitations at this release: unions have no YAML/XML path (docs/UNIONS.md); and,
deliberately deferred with reasons in ROADMAP.md, @Key(path:) refuses index segments,
StandardSchema waits on a third package, there is no incremental parsing of a single
document (a decision, not a gap), and the .past/.future date rules wait on a clock seam.

Changes made before the first tag

No version was tagged before 1.0.0. These are the changes made on main in the weeks
before it, kept for anyone who built against an untagged commit.

Breaking

  • Five names now follow the API Design Guidelines. A naming pass against the guidelines
    and against how swift-collections, swift-nio, swift-argument-parser and Foundation spell
    the same things:

    was is why
    AssayReader.advanceBy(_:) advance(by:) the preposition belongs in the label; the stdlib spells it advanced(by:)
    Diagnosis.truncatedIssues issuesWereTruncated a Bool must read as an assertion — if d.truncatedIssues reads like a collection
    AssayReader.atEnd isAtEnd Foundation's own Scanner.isAtEnd
    KeyRange.simple isSimple same rule as above, and the initialiser label with it
    PreprocessOp PreprocessStep no abbreviations, and it rhymes with PathStep now
  • PathComponent is now PathStep. issue.path is [PathStep], and the cases are
    unchanged — .key("email"), .index(3). Vapor exports a PathComponent of its own for
    routing, so an app with both imported could not write the bare name as a type: Swift
    reports "'PathComponent' is ambiguous for type lookup". Pattern matches and inferred uses
    were never affected, which is why it took a collision audit to notice. Renamed rather than
    documented because almost nobody spells this type — the macro emits it fully qualified, and
    users read issue.path — so the rename costs one line in a migration and removes a
    collision permanently.

  • EncodedBytes.toArray() is now Array(_:). Array(try value.encodedJSON()) rather
    than try value.encodedJSON().toArray(). The API Design Guidelines put a non-mutating
    conversion on the destination type as an initialiser — Array(someSequence),
    String(someCharacters) — and toArray() is the Objective-C spelling of the same idea.
    The initialiser is consuming, so the copy is made and the original freed, which is what a
    caller asking for an Array wants. Renamed now because it costs a line here rather than a
    deprecation cycle later.

  • encodedJSON(), encodedYAML(), encodedXML() and encodedTOML() return
    EncodedBytes, not [UInt8].
    The writers own the buffer they write into, so a write is
    a store rather than an Array append with a uniqueness check behind it — 36,004 of those
    per base/encode call, about a sixth of the call — and finish() hands the allocation
    over instead of copying the document into a fresh Array. EncodedBytes is ~Copyable
    because it owns a heap allocation and must free it exactly once: use withUnsafeBytes to
    write it somewhere with no copy, text() for a String, or Array(_:) where a value type
    is genuinely needed, which copies visibly at the call site. jsonText() and friends are
    unchanged, and EncodeDiagnosis.bytes stays a plain [UInt8] and stays Sendable — it is
    the diagnostic path, and one copy there is worth more than the counter. Encoding measures
    8.75× JSONEncoder at 50 items against 2.98× before this and the two emitter changes
    beside it. docs/EFFICIENCY.md rows 5 and 22.

  • A number that cannot be represented as a Double is now an error, on every format.
    1e309 decoded to +infinity and 1e-400 to zero; both are refused with
    number_overflow on the JSON and TOML paths, and on the YAML path resolve to a string so
    the schema reports must be a number, found "1e309". Reading a number as a different
    number is the one failure a decoder must not have, and this codebase refuses it elsewhere
    (a 128-bit plist integer is refused rather than truncated). Foundation's JSONDecoder
    throws on every one of these and toml++ rejects them, so Assay was the outlier.
    Subnormals are values and still decode — 5e-324 is the least positive Double, has
    an exact bit pattern, and is accepted. A document relying on a literal silently becoming
    infinity or zero will now get an issue.

  • IssueCode.xml_expected_element is removed. It was unreachable: both of the XML
    parser's parseElement call sites already establish that the current byte is < before
    calling, so the guard inside it could never fail. The guard is gone too — a check tha...

Read more