# Changelog All notable changes to PureYAML are 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] ### Added - `Tagged.Pair` now stores its key as a full node (`keyNode`), closing issues #12 and #13: alias mapping keys (`*b : value`, yaml-test-suite E76Z/26DV) parse on the `Tagged` path instead of throwing `expectedScalarKey`, key anchors register and re-emit, and a key's presentation style (a deliberately quoted `"on":`) survives dump and reparse. `key: String` and `keyTag` remain as read-through computed accessors (an alias key resolves to its anchored scalar's text), and the `init(key:keyTag:value:)` convenience still builds plain string keys, so most call sites compile unchanged. A style-less hand-built key keeps the traditional plain-when-safe emission convention rather than inheriting the document scalar policy. The corpus round-trip law now compares key nodes structurally, and alias-key documents move from the rejected column to round-tripping. - Standard-tag typed accessors on `Tagged.Scalar` (issue #11): `timestampValue` parses a `!!timestamp` scalar under the YAML 1.1 timestamp grammar into the new pure-Swift `Tagged.Timestamp` (calendar components, timezone offset, and `secondsSinceEpoch` via the days-from-civil closed form; epoch expectations in the tests are measured against Python's independent implementation), and `binaryValue` base64-decodes a `!!binary` scalar into `[UInt8]`, accepting unpadded and block-scalar-wrapped content and rejecting corrupt input whole. Yams hands back Foundation `Date`/`Data`; this package is mechanically Foundation-free, so the bridges are one-liners the caller owns: `Date(timeIntervalSince1970:)` and `Data(_:)`. `!!set`/`!!omap` are deliberately skipped: `Model.Mapping` already preserves order and duplicates without a special tag. - `Emitting.Options` gains `canonical`, `explicitDocumentStart`, `explicitDocumentEnd`, and `lineWidth`, completing issue #10 (the earlier `indentWidth`/`sortKeys` slice deliberately deferred these). Canonical mode emits the YAML-specification canonical form matching libyaml's layout: explicit `---`, an explicit tag on every node (carried tags on the `Tagged` path, the value's implicit tag otherwise), double-quoted scalars, and flow collections with one `? key : value,` entry per line; anchors and alias references survive on the `Tagged` path. `lineWidth` wraps flow collections one entry per line when the single-line form overflows, and folds plain block scalars at single-space boundaries onto more-indented continuation lines; a fold that would manufacture syntax (a segment ending in `:`, a continuation starting with `#` or an indicator) or disturb consecutive spaces is vetoed and the line stays long, so the value always survives reparse. Gated by golden-output tests plus reparse-equivalence tests for every option. - Anchors, aliases, and per-node presentation styles now survive the `Tagged` tree (issue #9, the feature-parity audit's biggest blocker to switching from Yams). `Tagged.Scalar`/`.Sequence`/`.Mapping` gain `anchor` and `style` fields (`Tagged.ScalarStyle`: plain, single/double quoted, literal, folded; `Tagged.CollectionStyle`: block, flow), and `Tagged.Node` gains an `alias(anchor:node:)` case carrying both the reference and its resolution, so value-oriented consumers read through it without a resolution step (`modelValueErasingTags` matches `parse(_:)`'s expansion) while emitters re-emit the reference. Cycles are impossible by construction: an anchor registers only after its node fully composes, so a self-referential alias stays an `undefinedAlias` parse error. - `PureYAML.dump(_ node: Tagged.Node, options:)` and `Tagged.Dumper`: emit a tagged tree back to YAML with anchors (`&name`), alias references (`*name`, never expanded copies), tags (`!!short` for built-ins, `!` otherwise), and per-node styles that win over the document-wide policy. Literal blocks handle keep-chomping (`|+`), strip-chomped single lines (`|-`), leading blank lines, tab-led and whitespace-only interior lines, and whitespace-led first lines via an explicit indentation indicator (`|2`); folded scalars re-emit as `>` when the folding rules invert exactly, degrading to a literal block (value still exact) otherwise. Plain scalars re-emit their source spelling, so `NO` resolved as a bool does not normalize to `false` and an empty null stays empty. The round-trip law is corpus-tested over every real-world fixture: `parseTagged(dump(parseTagged(d)))` equals `parseTagged(d)` with exactly one documented style degradation (single-quoted content containing line breaks re-emits double-quoted; the value is unchanged). - Add `Emitting.Options.indentWidth` and `.sortKeys` (issue #10's first slice), closing two of the four gaps a feature-parity audit against Yams found in the emitter (`PureYAMLYamsComparison`'s `FEATURE-PARITY.md`). `indentWidth` replaces every hardcoded `indent + 2` in `Dumper.swift`/ `Dumper.Mapping.swift` with `indent + options.indentWidth`; verified with a round-trip test (dump at a non-default width, reparse, confirm the value survives) since indentation is purely a rendering choice and must never affect what a document means. `sortKeys` sorts a mapping's pairs by their string key before rendering, in both block and flow style, but only when every key in that mapping is a plain string: a mapping with any `Model.Key.mapping`/`.sequence` key is left in insertion order regardless of the option, since there is no well-defined lexicographic ordering across mixed key kinds to sort by. Off by default, since insertion order is what makes a duplicate-key-preserving `Mapping` round-trip meaningfully in the first place. Deliberately scoped to stop here: `width`-based line wrapping and canonical mode (the other two gaps the audit found) are each a correctness surface of their own (real line-folding logic for the former, a defined canonical form for the latter) and remain open, not silently dropped from this change's scope. - Reject raw control characters anywhere in the input, matching libyaml's own reader-level behavior. Found by directly comparing `PureYAML.parse()` against Yams on a 221MB real-world OpenAPI/compose-file corpus (150 files): a genuinely public file with corrupted, mojibake-looking encoding contains a raw C0 control character, which Yams's underlying libyaml rejects unconditionally at the reader level ("control characters are not allowed"), but `PureYAML.parse()` silently accepted and built a value tree from. `PureYAML` already had a printable-character predicate matching libyaml's `c-printable` set (`CharacterClass.isPrintable`), but it was only ever applied narrowly to anchor names (`LibYAML.Scanner.Anchor.swift`), never to the input generally; the `Validation.PreflightScanner`'s separate `invalidControlCharacterColumn` check exists but is opt-in via `validate()`, not part of what `parse()` itself runs. `Reader` now records the index of the first non-printable scalar in whatever it has buffered (`init`/`append(_:)`, covering both batch and streaming input), and `fetchNextToken()` throws once actual scanning has reached that position (gated on `reader.index >= badIndex`, not just "one exists"), so a real syntax error earlier in the document is not hidden behind a control character occurring later, matching the order libyaml's own byte-by-byte reader would surface them in. New `ParseError` case `.invalidControlCharacter(line:column:)`, translated from the scanner error the same way `.tabIndentation` already is. Verified with 5 new tests (a bare control character rejected at the exact position; a tab in scalar content, and CR/LF, both still legal, since `isPrintable` already correctly includes them; a control character 5000 lines into a document still caught; an earlier real syntax error not masked by a later control character) plus a full re-run of the official yaml-test-suite conformance (still 333/333 parse-level, 208/208 value-level of comparable cases, unchanged) and the whole-suite streaming differential (still clean), confirming this closes a real gap without regressing anything already passing. Negligible throughput impact measured on the existing 9.5MB benchmark fixture (within normal run-to-run noise). Disclosed limitation: the check is applied in `fetchNextToken()`, which `scanStep()`'s plain-scalar resume path (`resumePlainScalar`, the streaming API) does not route through, so a token containing a control character can be returned to a streaming caller one token before this fires. `scanAll()`/`PureYAML.parse()`, the primary target here (matching what the corpus comparison actually calls), always calls `fetchNextToken()` again before returning, so it is caught before a batch parse completes. - Add a public `PureYAML.Streaming` namespace (issue #4), exposing the internal `LibYAML.Scanner.scanStep()` proven below for the first time as a callable public API rather than a module-internal implementation detail. Needed because `scanStep()` and everything in `LibYAML.Scanner.Streaming.swift` were internal-only: no consumer outside the `PureYAML` module, including the separate `PureYAMLStreaming` incubator package that issue #4 names as this feature's actual intended user, could call it. `PureYAML.Streaming.Scanner` (`init()`, `feed(_:)`, `finish()`, `next() throws -> Outcome`) is a thin wrapper with no logic of its own, translating `scanStep()`'s `ScanOutcome`/internal `LibYAML.Token` into public `Outcome`/`Streaming.Token` types under public naming that does not leak the libyaml port's internal type names (`Streaming.Token.Kind` mirrors `LibYAML.TokenType`'s 22 cases 1:1; `Streaming.Token.ScalarStyle` mirrors the scanner's internal `Parsing.ScalarStyle`, a distinct 5-case type from the existing public `Emitting.ScalarStyle`, which is an output rendering policy, not a scanned style; `Streaming.Token.range` reuses the existing public `Model.SourceRange`/`SourcePosition` rather than inventing a new position type). `Token.Kind.init(_:)`'s mapping switch is split across three chained initializers purely to stay under the project's cyclomatic complexity lint limit (a mechanical 1:1 case mapping over 22 cases has no way to be less than 22-branches complex in one function); the split carries no semantic meaning. Proven with a new differential test (`PublicStreamingScannerTests.swift`) distinct from the existing `StreamingScannerDifferentialTests.swift`: that existing proof covers whether `scanStep()` itself is correct, but does not exercise the public wrapper or its translation layer at all, so a wrong case mapping or a dropped field there would not have been caught by it. The new proof drives the public `Streaming.Scanner` at several chunk sizes and asserts it matches the batch scanner's own tokens, translated through the identical `Streaming.Token.init(_:)` the public scanner itself uses, clean across the whole yaml-test-suite corpus. Deliberately scoped to stop here: this makes the public API exist and proves it correct, but does not yet rewire the separate `PureYAMLStreaming` package's own `ResumableDocumentScanner` (currently a naive line-based `---` heuristic scanner with no relationship to real YAML grammar) onto it. That rewiring is real, separate, cross-repo work with its own build/test/CI cycle, deferred to a follow-up round rather than folded in here. - Implement persisted mid-token suspension for plain scalars (issue #4), the deferred piece the previous entry predicted would eliminate the checkpoint floor entirely. `scanPlainScalar()`'s loop (`LibYAML.Scanner.PlainScalar.swift`) is now an explicit `PlainScalarPhase` state machine (`.start`, `.consumingContent`, `.afterContent`, `.consumingBreaks`, `.afterBreaks`) that throws `PlainScalarSuspended` (carrying its accumulated `PlainState`, indent, start mark, and phase) the moment a lookahead runs dry, and `scanStep()` resumes it directly from `Scanner.suspendedPlainScalar` with no checkpoint at all, rather than rolling back and rescanning the whole token from its start on every chunk boundary inside it. Getting this correct took four rounds of bugs, each found by the existing whole-yaml-test-suite streaming differential test (`PUREYAML_RUN_YAML_TEST_SUITE=1 swift test --filter "StreamingScannerDifferentialTests|YAMLTestSuiteGapsTests" --no-parallel`), none of them hypothetical: (1) checking `reader.ranDry` only once at the loop's end is not enough, since a nested lookahead (`documentMarkerAhead()`, the `:`-followed-by-blank check, `blankLineAhead()`) can independently go dry and produce a wrong-but-plausible answer that gets used by the next step before an end-of-loop check would ever see the taint; fixed by checking immediately after every peek-dependent decision, before its result is used for anything. (2) A resume must never redo a pre-check that only applies at a fresh entry point (the document-marker/comment check in `.start`, the blank-or-break guard in `.afterContent`): resuming a dry-interrupted content or break run via `.start`/`.afterContent` again can wrongly end a scalar right at a literal `#` or reinterpret an in-progress break run, since those positions are not guaranteed to be fresh boundaries. Fixed with dedicated `.consumingContent`/`.consumingBreaks` phases that re-enter the same in-progress call with no pre-check. (3) `dispatchToken()` itself has an `isDocumentMarker` pre-check, ahead of ever calling `fetchPlainScalar()`, that can suffer the identical dry-lookahead problem; since nothing can undo a wrong dispatch after the fact, `scanStep()` only trusts a suspend enough to resume it when real content was already accumulated (`phase == .start` with an empty `state.string` retries from scratch instead). (4) `blankLineAhead()` inside `consumePlainBreaks` is itself a multi-character lookahead distinct from the single already-confirmed `peek()` before it, so it can independently go dry; fixed by checking `ranDry` right after computing its result, before a tab-violation decision trusts it. A synthetic worst-case test was added for this specifically because the 9.5MB real-world fixture used above has no plain scalars long enough to exercise it (`test_measureLongPlainScalar`, one 1MB single-line plain scalar). It caught a fifth bug, a real one, not a suspicion: the first working version of `resumePlainScalar` took the whole `PlainScalarSuspended` as a by-value parameter and read `var state = suspended.state` inside it, but `suspended.startMark` being read again at the very end kept the whole parameter (including its `state.string` reference) alive as an ordinary borrowed value for the call's entire duration, aliasing `state.string` to it throughout `runPlainScalarLoop` and forcing copy-on-write to fully copy the entire accumulated scalar on every single resume; for a scalar rebuilt across many small chunks that is O(N^2) in the scalar's length, not the O(N) this feature exists to guarantee, and measured **29.05x** at chunk-size 16 on the synthetic case, worse than not suspending at all. Passing `state` `inout` directly, with the alias broken at the call site (`scanStep()`'s new `resumeSuspendedPlainScalarIfNeeded()`, overwriting `suspended.state` with a fresh, unrelated value immediately after copying it out and before ever calling into `resumePlainScalar`) fixed it to **3.67x** at the same chunk size, with no correctness regression (whole-suite differential clean in both debug and release, full default suite of 597 tests passing). A single before/after data point does not by itself distinguish "fixed" from "smaller constant, still quadratic", so a second, permanent test (`test_longPlainScalarStaysLinear`) checks the actual complexity claim: per- byte cost at a fixed chunk size (16) across a 16x range of scalar lengths (250KB to 4MB), asserting the largest measured per-byte cost stays under 3x the smallest (generous headroom over the ~1.08x actually measured; a real O(N^2) regression would blow well past 3x as length grows). Genuinely O(N) work should hold per-byte cost flat regardless of total length, which this confirms rather than assumes from the single 1MB case above. Measured honestly rather than assumed to have delivered the predicted win: on the 9.5MB real-world fixture, the checkpoint floor and rescan-driven tail are statistically unchanged from before this feature existed (floor 1.75x versus the prior entry's 1.75x-1.76x; 10.74x at chunk-size 16 versus the prior entry's 11.54x, both within measurement noise across runs). The prior entry's prediction that mid-token suspension "would eliminate the floor too" is corrected here: it eliminates rescanning specifically for plain scalars that themselves span a chunk boundary, but `github-rest-api.yaml`'s plain scalars are short enough that this was never a meaningful contributor to its measured floor in the first place, and the floor's actual cause (a `Scanner` struct copy taken on every `fetchNextToken()` call whenever `!isFinal`, regardless of token type, established in the entry below) is untouched by a plain-scalar-specific fix. The synthetic long-scalar test above is the fixture that actually demonstrates this feature's value (a 29x-worse-than-no-suspension bug, fixed down to 3-4x); the real-world fixture simply does not have the shape of input this feature targets. - Add `Tests/StreamingScannerPerformanceTests.swift` (issue #4), measuring `scanStep()`'s overhead over `scanAll()` on the largest real-world fixture (github-rest-api.yaml, 9.5MB) across a spread of chunk sizes, gated behind `PUREYAML_STREAMING_PERF_MEASURE=1`. First pass ran in debug mode (release test builds hit an unrelated `PureYAMLJSON` test-target issue) and showed a ~1.5x floor even in a control with `isFinal: true` from construction, where `ranDry` can never be set and no rollback ever happens; fixed by skipping the checkpoint entirely in that case, which only closed part of the gap in debug numbers (1.49x to 1.38x). `sample`(1) profiling of that debug build pointed at `mutating throws` methods on multi-field structs (`consumePlainContent`, called from every plain scalar), which is a known trigger for defensive retain/copy traffic under `-Onone` that the optimizer removes in release. Confirmed by working around the release-test-build issue with `-Xswiftc -enable-testing`: in release, the `isFinal: true` control measured **1.04x**, essentially batch parity, meaning the checkpoint-skip fix was actually a full fix for that case, not the small, unexplained one it looked like in debug mode. What remains splits into two distinct costs, decomposed by one more measurement rather than assumed: feeding the whole 9.5MB file as a single chunk (`isFinal` stays false until the true end, so at most one token in the entire file could ever actually be rescanned) still measured 1.75x, the same as 1,000,000-scalar chunks (1.74x, ~10 chunks) and 65536-scalar chunks (1.74x). Real rescanning is not the floor's cause, since there is essentially no rescanning to have caused it here. The floor is the cost of copying the whole `Scanner` on every `fetchNextToken()` call whenever `isFinal` is false, whether or not that copy is ever used to roll back; since a chunked run's `isFinal` only becomes true once, on the last chunk, that copy is taken for virtually every token in the file. Only *shrinking* chunk sizes below roughly 4096 scalars adds a second, genuinely rescan-driven cost on top of that floor (11.54x at 16 scalars). Two follow-up checks, not assumed: ruled out buffer-population strategy as a contributor (`Reader.init`'s `Array(string.unicodeScalars)` versus `Reader.append(_:)`'s `append(contentsOf:)` measured 1.00x against each other on the same 9.5MB string, no scanning involved), and re-profiled with `sample`(1) on the release build this time (the first profile, in the entry above, was debug-only and already shown to be misleading for magnitude; it turns out to also be misleading for location). The release profile's hottest symbols are String-building-adjacent (`_StringGuts.append`, `UnicodeScalarView.distance(from:to:)`, `consumePlainContent`) rather than a separately named "copy of Scanner" symbol, most likely because the optimizer inlines the checkpoint's struct copy directly into `scanStep()` (190 samples on its own, disproportionate to its few lines of source) rather than outlining it as in debug, and the extra live reference the checkpoint holds to `indentEngine.tokens` forces conservative uniqueness handling that shows up as slower string mutation nearby rather than as its own line item. The structural cause, confirmed by an A/B the project already had (not a new one): the checkpoint code path is still the only difference between the 1.04x `isFinal: true` control and the 1.75x-1.76x single-chunk/large-chunk results, both driven through the identical per-token loop. This corrects the previous entry's conclusion in an important way: the deferred persisted mid- token suspension would not merely eliminate the rescan-driven tail while leaving the floor untouched, it would eliminate the floor too, since it removes the need for a checkpoint entirely rather than making one cheaper. A cheaper checkpoint, absent mid-token suspension, is not yet designed: every field the checkpoint covers can be mutated before a dry point is reached within a single fetch, so no smaller subset of fields is obviously safe to skip, and `isFinal: true` remains the only provably safe condition for skipping it outright. - Broaden the real-seed mutation property suite (issue #6) with three mutation classes named in that issue but not yet covered: `invalidControlCharacter` (a raw control byte injected into a line), `mixedTabSpaceIndentation` (a tab after leading spaces, distinct from the existing pure-tab case), and `truncatedFlowCollection` (an unterminated `[a, b, c` appended after the real seed sample, exercising the deep parser's `malformed` error rather than the preflight scanner). The test now also asserts diagnostic quality, not just pass/fail: every matching diagnostic's `line` must fall within the mutated document, and its `column`, when present, must be 1-based. Some `ParseError` cases (`.tabIndentation`) are line-only by design, so column is checked when present rather than required. - Add a resumable, one-token-at-a-time scanner entry point, `Scanner.scanStep()` (internal), the first increment of true chunked input streaming (issue #4). `Reader` gains `isFinal`/`append(_:)`/`markFinal()`: past the buffered end while `!isFinal`, `peek(_:)` flags `ranDry` instead of asserting true end of input. `scanStep()` snapshots the scanner before each `fetchNextToken()` and restores it in full whenever a fetch runs dry, so a fetch spanning a chunk boundary is retried from scratch after more input arrives rather than misread as an early end of input. Every existing call site keeps `isFinal: true` (the default), so `scanAll()` and all batch parsing are unchanged; `ranDry` can never be set when `isFinal` is true, by construction, not just by test. A token already queued is not always safe to release: libyaml's simple-key mechanism can still splice a KEY token in front of it once a later `:` is found, so `scanStep()` ports libyaml's `yaml_parser_fetch_more_tokens` release condition, holding the queue front until no possible simple key still targets that position. Proof: `Tests/StreamingScannerDifferentialTests.swift` drives `scanStep()` at several chunk sizes (1, 2, 3, 7, 64, whole-input) over representative cases always, and over the entire yaml-test-suite manifest (402 cases) behind `PUREYAML_RUN_YAML_TEST_SUITE=1`, asserting the resulting token sequence (or thrown error) is byte-identical to `scanAll()`. That proof surfaced a real bug, now fixed: `lastTokenForbidsInlineSequence()` (`LibYAML.Scanner.Guards.swift`) read `indentEngine.tokens.last?.type` to check whether a `key: - a` value indicator bars an inline block sequence. Batch mode never dequeues, so the queue never empties and `.last` always works; `scanStep()` releases and discards tokens as soon as they are safe, so the queue can be empty exactly when this check runs. `IndentEngine` now tracks `lastToken` explicitly, kept in sync with `tokens.last` after every enqueue/insert/roll/unroll, which is provably identical to the old `tokens.last` read in batch mode (nothing is ever dequeued there) and survives dequeuing in streaming mode. Not yet wired into `PureYAMLStreaming`: `scanStep()` and its supporting types are internal, and true mid-scalar suspension (a plain/block/flow scalar spanning a chunk boundary currently re-scans from the last released token rather than resuming mid-token) is deferred, tracked at . ### Changed - The validation framework's public types are renamed to match the OpenAPIKit validation idiom's canonical names: `Check` is now `Validation`, `Issue` is `ValidationError`, `Issue.Collection` is the top-level `ValidationErrorCollection` (with `.issues` renamed to `.values`), `SubjectContext` is `ValidationContext`, `BuiltinCheck` is `BuiltinValidation`, and `AnyCheck` is `AnyValidation`. `ValidationError.description` drops its severity prefix and renders `" at root of document"` / `" at path: "`. `Validator` is now a reference-type `final class` with a two-tier `nonReferenceDefaultValidations`/ `referenceDefaultValidations` split, generic over both the walked `Subject` type and the cross-cutting `Document` via a new `Traversable` protocol; `Tagged.Validator` is now a type alias over the same shared class instead of its own duplicated traversal implementation. The JSON representability rule set (`PureYAMLJSON`) is built with the same tiered constructor. Adds the `unwrap` combinator. - `parseTagged(_:)`/`parseTaggedStream(_:)` now preserve alias references as `alias` nodes instead of expanding them into copies of the anchored node, and populate the new `anchor`/`style` fields from the source. Consumers switching exhaustively over `Tagged.Node` gain a case; `Tagged.NodeKind` gains `.alias`. Resolved values are unchanged: `modelValueErasingTags` and `Tagged.Constructor` read through aliases. `Tagged.Validator` walks each anchored subtree once at its definition site and does not re-walk it per alias reference. ### Fixed - A plain scalar that starts with `0` and continues with a sign is text, not an octal integer. `Int(_, radix:)` accepts a leading sign, so `0-3` (a CPU set, a port range) read as the integer -3, and `0x-1`, `0b-1` and `0o-1` as negative numbers. After a radix prefix only digits may follow. Octal (`0755`), hexadecimal, binary and signed integers read as before. - A string the emitter wrote without quotes could be read back as something else, so the string `yes` (and `no`, `on`, `off` in every case), `0x1F`, `0b1`, `0o17`, `1_000`, `.inf` and `.nan` did not survive a dump and parse. `PureYAML.Parsing.PlainScalar` is now the one definition of how a plain scalar reads, and the parser and the emitter both take it from there; the emitter quotes every string that does not read back as itself. Also quoted now: `<<` (the merge key) and `...` (the document end marker), and a string with a CR+LF break, which was written raw because Swift counts CR+LF as one character. `PlainScalarRoundTripTests` holds 128 awkward strings at the root, as a mapping key and value, and as a sequence item, in block and flow style. `PureYAML.JSON.Convert` no longer refuses `{"s":"yes"}`. - Stream dump no longer doubles the `---` document marker under `explicitDocumentStart` or `canonical`. The stream path now owns marker emission and renders each document body marker-free, so a two-document stream reparses as two documents instead of four (an empty document ahead of each real one). - `Tagged` flow-mapping keys now use flow-aware plain-scalar safety, so a key containing a flow delimiter (`a,b`, `[`, `{`) is quoted instead of emitted bare, which previously reparsed as several entries. - Plain scalars ending in `:` now fall back to quoting on both the model and `Tagged` emit paths. A trailing colon reparses as a mapping key with an empty value, so the emitter must never leave one plain. - A mapping key that is an alias to a defined but non-scalar anchor now raises `expectedScalarKey` instead of the misleading `undefinedAlias`; a genuinely undefined alias key still raises `undefinedAlias`. - `Tagged` duplicate-key detection now keys on the resolved scalar value plus effective tag rather than the source spelling, so `0x1` and `1` (both `int(1)`) are reported as duplicates while `1` (`int`) and `"1"` (`str`) are not, and custom-tagged keys stay distinct. - Dumping a value nested deeper than the emitter's nesting limit (the same bound the parser enforces) now returns a documented, reparseable marker document instead of overflowing the stack. Such a value cannot round-trip through PureYAML's own parser, so it is refused rather than emitted as YAML the parser would reject. - `Tagged.Timestamp` accepts a leap-second value of `60` per the YAML 1.1 timestamp grammar, treating it as 60 elapsed seconds in the epoch math. - `Tagged.Dumper` honors `sortKeys` in its block and canonical paths for mappings whose keys are all plain, untagged, unanchored string scalars, matching the model dumper's `orderedPairs` rule. - `parse`/`parseStreaming` now reject an anchor-expansion ("billion laughs") bomb instead of accepting it in milliseconds and handing a caller a value whose logical size is exponential in the alias nesting depth. `enforceNestingLimit` bounds physical collection depth in the raw event stream, which does nothing against a handful of shallow, small anchors each aliased many times with each alias nested inside the next: a 25-level, fan-out-9 document (~1.5KB) expands past 9^25 logical nodes while raw event depth stays at 2. `EventStreamComposer` now charges a total expanded-node budget incrementally as the document streams through -- every scalar, alias resolution, and completed container charges its own marginal cost, with each anchor's expanded size cached at definition and looked up in O(1) when later aliased -- so a bomb is rejected within a few alias levels without ever materializing or walking the exponential tree it would otherwise produce. A document with genuinely wide but non-exponential alias reuse (the same anchor referenced thousands of times at one level, no nesting) still parses normally. - `TaggedNodeCorpusRoundTripTests` now normalizes `URL.lastPathComponent` through an explicit `as String?` coercion before use: this toolchain's swift-corelibs-foundation returns `String?` where Darwin's Foundation returns `String`, and the mismatch failed the Linux build outright (an unrelated pre-existing gap surfaced while verifying the anchor-expansion fix above on the Linux CI relay). ## [0.2.0] - 2026-07-05 ### Changed - Merge the libyaml-port line into main. The ported engine is the sole parser; the hand-rolled `Scanner`/`TokenEventParser` files and their post-fork patches are removed. Adaptations made in the merge: the JSON-representability rules move from the retired legacy `Rule` type onto the Check algebra (`BuiltinCheck.mappingKeysAreStrings`, `BuiltinCheck.numbersAreFinite` in `PureYAMLJSON`); the permissive tag-handle pre-pass (`Scanner.tagHandleIssues`) is removed because the ported engine rejects an undefined handle at parse time (official suite case QLJ7), so the validation report carries it as a parse diagnostic; and tests pinned to old-engine behavior or error text were updated to the reference-verified behavior (libyaml rejects an explicit-key continuation ending at a value colon, and suite case QB6E requires rejecting a quoted continuation at column 0). The unreleased entries below come from two parallel lines of work, merged in one step: the libyaml-port line (listed first), whose ported engine is now the sole parsing engine, and the earlier hand-rolled-engine hardening line (listed second). Engine-internal file references in the second group are historical; the behaviors they describe are carried by the port and guarded by the whole-suite conformance gate. ### Changed - Refresh `docs/YAML_TEST_SUITE_CONFORMANCE.md` to match the manifest: the headline is 402 passing, 0 gap; the "remaining gaps" narrative is retired; a closing section records the libyaml-port cutover and the final fix series, and the manifest is named as the source of truth over the prose. ### Added - The located parse now reports an exact end position for scalars, not just a start: the scalar event carries the scanner token's end mark, so a `LocatedValue.scalar`'s range covers the whole token (end exclusive, one past the last character) instead of being start-anchored. Collections already spanned open to close. The event dump compares value and style, not marks, so conformance is unaffected; the whole-suite gates stay green. - Add an opt-in located parse, `PureYAML.parseLocated(_:)` and `PureYAML.parseStreamLocated(_:)`, returning a `Model.LocatedValue` tree parallel in shape to `Value` where every node carries its source span (1-based line and column) and located children. Collections span from their opening to closing event, scalars carry a start-anchored span, and aliases and merge-inherited pairs point at their reference site. The plain `Value`, its equality, and the conformance oracle are untouched: locations are built only when the streaming composer is asked for them, so the default value path is byte-identical. A whole-suite test proves the located projection equals a plain parse for every passing case, including through merge keys and aliases. - Begin a faithful Swift port of libyaml's scanner under `PureYAML.Parsing.LibYAML`, to replace the hand-rolled approximation whose indentation and simple-key bugs the test suite surfaced. The first piece is the indentation engine, a line-for-line translation of `yaml_parser_roll_indent` and `yaml_parser_unroll_indent`: a block collection opens only when content is strictly more indented than the current level, which fixes the `- a:` followed by a same-column `b:` mis-nesting by construction. The port is verified against libyaml (built from the `CYaml` source) and lands as a parallel implementation, swapped in only when it matches the reference with no regression. See `docs/LIBYAML_PORT.md`. No public behavior changed yet. ### Fixed - Reject an under-indented or tab-indented continuation in a multi-line quoted scalar. A quoted scalar's continuation must be indented past the parent block, so `"a`\n`b"` (continuation in column 0) and `"bar`\n`\tbaz"` (tab indentation) are malformed. The error is deferred until the closing quote is found, so an unterminated quote still reports the missing quote (its root cause) rather than the indentation symptom. Surpasses libyaml on QB6E and DK95/01, bringing whole-suite conformance to 402/402. - Reject a tab used to indent flow-collection content. A tab is never indentation, so flow content reached through a tab below the continuation indent (`- [`\n `\tfoo`) is malformed, even though the tab inflates the column. A tab at or past the indent stays separation, and a tab-only blank line stays blank. Surpasses libyaml on Y79Y/003. - Reject a block scalar whose leading empty line is more indented than its first content line. libyaml auto-detects the deeper indentation and silently drops the content; the auto-detection now errors when the content line is less indented than an earlier empty line. Surpasses libyaml on S98Z. - Reject a multi-line implicit key in a flow sequence. A flow-sequence single-pair implicit key is single-line (`c-s-implicit-json-key`), so its `:` must sit on the key's line: both `[ "key"\n :value ]` and `[ key\n : value ]` are malformed. A flow MAPPING adjacent value may still span lines (`{ "foo"\n :bar }` stays valid), so the rejection is scoped to flow levels that are sequences, tracked alongside the flow-level stack. The key/`:` line comparison catches the plain-key form too, which folds its line break. Surpasses libyaml on ZXT5 and DK4H. - Reject a block sequence that opens on the same line as an earlier node. A `-` entry mid-line after an inline mapping value (`key: - a`) or a node property (`&anchor - x`) is invalid, while a `-` after another `-` stays a valid compact sequence (`- - a`) and an explicit key's value on a later line (`? k`\n`: - a`) stays valid. The inline case is told apart by a pending simple key (an explicit key's has gone stale across the line break). Surpasses libyaml on 5U3A and SY6V. - Reject a flow-collection continuation line that is not indented past the parent block. YAML requires multi-line flow content to be more indented than the enclosing block context, so `flow: [a,` / `b]` (with `b` in column 0) and the equivalent multi-line flow mapping are malformed; libyaml does not enforce it. A closing `]`/`}` or a leading `,` may still sit at any indentation. Surpasses libyaml on 9C9N and VJP3/00. - Reject content after a `...` document-end marker on the same line. Only whitespace and a comment may follow `...`; unlike `--- x`, a `... x` cannot carry inline content. Surpasses libyaml on 3HFZ. - Resolve a scalar with the non-specific `!` tag as a string. The `!` tag forces resolution by kind alone, with no implicit typing, so `! 12` is the string "12" rather than the integer 12. The typed value now honours it (the event stream already did). Surpasses libyaml on S4JQ. - Reject a bare `-` before a flow indicator inside a flow collection. Per `ns-plain-first` a `-` begins a plain scalar in flow only when a flow-safe character follows; `[-]` and `[-, -]` are malformed, not a sequence of `-` scalars. libyaml reads the `-` as a scalar. Surpasses libyaml on G5U8 and YJV2. - Reject a comment that is not preceded by whitespace. A `#` only begins a comment after a space, a tab, or at the start of a line; a `#` adjacent to the previous token (`key: "value"# c`, `[ a ]#c`, `[ a,#c`, a `>#c` block scalar header) is malformed. The check uses the character immediately before the `#`, since a plain scalar consumes its own trailing blanks. libyaml accepts the adjacent form. Surpasses libyaml on 9JBA, CVW2, SU5Z, and X4QW. Whole-suite conformance is now 388/402. - Reject a directive that follows a document without an explicit `...` end marker. Directives apply to the document that follows, so the previous document must be closed with `...` before them; after an implicit end a `%YAML`/`%TAG`/reserved directive is now an error. Also reject a `%YAML` version followed immediately by `#` with no separating space (`%YAML 1.1#...`), since a comment requires leading separation. Restores reject-side conformance lost when the libyaml port became the sole engine (9HCY, EB22, MUS6/00, MUS6/01, RHX7). - Refresh `yaml-test-suite-manifest.yaml` (now version 3): the ported engine's whole-suite conformance is 384/402, up from the recorded 331, after the canonical-event and reject-side work. 53 cases the manifest still marked as gaps now pass. - Keep the final line break of a block scalar that ends at end of input without a trailing newline. A whitespace-only last line still counts as one line break under keep (`+`) chomping, so `- |+` followed by a spaces-only unterminated line yields `"\n"` rather than `""`. With this the ported engine emits the exact canonical event stream for every valid case in the vendored YAML test suite (308/308), surpassing libyaml (252) and the previous hand-rolled parser (267). Surpasses libyaml on JEF9/02. - End a root-level block scalar at a `---` or `...` document marker in column 0. Now that a root block scalar can take indentation 0, its content loop reaches column-0 lines, so a marker line must end the scalar instead of being folded into it. Without this a `--- |` literal swallowed the following `...`, the next directives, and later documents. Surpasses libyaml on W4TN. - Fold over a tab-only blank line inside a multi-line plain scalar. A plain scalar may span blank lines, and a blank line that contains a tab is still blank, so the tab is not indentation. libyaml rejects any tab below the scalar indentation; the scanner now rejects it only when it indents real content, not when the rest of the line is blank. Surpasses libyaml on DK95/04. - Accept a tab as in-line separation while still rejecting it as indentation. A tab after a `-`, `?`, or `:` indicator, or before a flow collection, is valid separation, but libyaml refuses to skip it whenever a simple key is allowed and reports "found character that cannot start any token". The scanner now skips tabs in the inter-token whitespace and records whether one preceded the token; a block indicator or an implicit mapping key reached through a tab is rejected (the tab would be indentation), while a scalar value or flow collection is accepted. So `- foo:\tbar`, `-\t-1`, `: -\tb`, `\t[ ]`, `\t{}`, and a space-then-tab continuation parse, while tab-indented keys and `-\t-` style nested entries are still rejected. Surpasses libyaml on 6BCT, 6CA3, A2M4, DK95/00, Q5MG, and Y79Y/010. - Parse a flow sequence entry that begins with `:` as a single-pair mapping with an empty key (`[ : v ]`). libyaml hands the leading VALUE token to its node parser and fails. The flow-sequence-entry parser now opens the implicit mapping on a leading `:` and leaves the indicator for the value state, so the value is parsed rather than dropped. Surpasses libyaml on CFD4. - Apply the YAML 1.2 flow `:` rule instead of libyaml's "always a value indicator in flow". In a flow collection a `:` only ends a node when it is followed by whitespace, the end of input, or a flow indicator, or when it is adjacent to a JSON key (a quoted scalar or a flow collection used as the key). Otherwise the `:` is plain-scalar content. So `[:x]` and `{x: :x}` keep the scalar `:x`, `http://foo.com` and `::vector` stay single scalars, `"key":value` and `{JSON: like}:adjacent` bind the value across the adjacent `:`, and `omitted value:,` is a key with an empty value rather than a scan error. The pending simple key now records whether it is a JSON node, and the plain scalar scanner ends (rather than rejecting) at a `:` before a flow indicator. Surpasses libyaml on 4ABK, 58MP, 5T43, DBG4, FRK4, and HM87/00, with 9MMW (mixed adjacent/separate keys) still correct. - Treat `?` in a flow collection as plain-scalar content unless it is followed by whitespace, the end of input, or a flow indicator. libyaml makes every `?` in flow context a mapping-key indicator, dropping the `?` from `?foo`, but `ns-plain-first` lets a `?` followed by a flow-safe character begin a plain scalar. So `{ ?foo: bar }` keys on `?foo` and `[?x]` is the scalar `?x` rather than an empty single-pair mapping. Surpasses libyaml on 652Z and HM87/01. - Accept a tab as block-scalar content once the indentation is satisfied. The block-scalar break scanner rejected any tab seen while auto-detecting the content indentation, but a tab at or beyond the content column is content, not indentation. The tab is now an error only below the content column (the parent indent + 1 floor while auto-detecting), so `foo: |-` / `bar` lines whose content begins with a tab parse, while a tab used as indentation (a tab-only line shallower than the content) is still rejected. Surpasses libyaml on 96NN/00, 96NN/01, R4YG, and Y79Y/001. - Auto-detect indentation 0 for a root-level block scalar. The block-scalar scanner used 0 as the "indentation not yet detected" sentinel and floored the detected indent at 1 (as libyaml does in `scan_block_scalar_breaks`), so a `--- >` or `--- |` whose content begins in column 0 detected no content and the following line was misread as a new document ("did not find expected document start"). The sentinel is now -1, leaving 0 a representable detected indent, and the floor is the parent indent + 1 (0 at the document root). Surpasses libyaml on DK3J, FP8R, and M7A3. - Parse block mappings with empty (null) keys. A `:` value indicator with no preceding key on its line denotes a mapping entry whose key is empty, which YAML 1.2 permits but libyaml rejects with "did not find expected key". The block-mapping-key parser now emits the empty scalar key and lets the value state consume the indicator, fixing the canonical event streams for `: a` style mappings, explicit empty keys (`? &a a` / `: *a`), empty keys nested in sequences (`- :`, `- ? : x`), and mixed empty-key/value entries. Surpasses libyaml on these seven test-suite cases (2JQS, 6M2F, M2N8/00, NHX8, S3PD, SM9W/01, UKK6/00). - Parse flow mappings with empty (null) keys. The same `:`-without-a-key entry is valid inside `{ ... }`, where libyaml hands the bare VALUE token to its node parser and fails with "did not find expected node content". The flow-mapping-key parser now emits the empty scalar key for a leading `:`, so `{ : }` and `{ key: value, : empty key }` parse, completing the multi-document NKF9 case. - Emit no document for a stream whose only content is comments or whitespace. A comment-only or empty stream has zero documents in YAML 1.2 (matching libyaml and the test suite), but the event stream was emitting a spurious empty null document. Found by differential testing against libyaml over real-world YAML (a comment-only `values.yaml`) in addition to the canonical event gate. The single-document value API still maps an empty stream to `null`, so `PureYAML.parse("")` is unchanged. Fixes 8G76, 98YD, and AVM7, raising exact canonical-event conformance to 267 of 308. - Strip the trailing folded line break from a flow-context plain scalar. A plain scalar inside `[...]`/`{...}` whose next line begins with a flow indicator (`,` `]` `}`) or the `:` value indicator was retaining the line break folded to a trailing space (for example `[\nfoo: bar\n]` produced the value `"bar "`), which YAML 1.2 section 7.3.3 forbids. Derived from the plain-scalar production and verified against the canonical event gate: fixes 10 official YAML test suite cases (4FJ6, 4MUZ/02, CT4Q, LP6E, NKF9, QF4Y, VJP3/01, Y79Y/002, ZF4X, ZK9H) with no regression, raising exact canonical-event conformance to 264 of 308. ### Added - Add an authoritative canonical-event conformance gate. The parser's event stream is serialized into the official YAML test suite event (yeast) format and compared exactly to each case's vendored `test.event`, which checks anchors, tags, scalar styles, flow vs block, and explicit document markers for every valid case (including the roughly half with no `in.json` projection). This replaces the JSON-projection oracle, which was lossy: measured against ground truth, 254 of 308 valid suite cases emit exactly correct events (40 emit a wrong event stream, 14 false-reject), where the JSON proxy had reported a higher figure. The gate records the gap set and fails on any regression or unrecorded fix. All 402 cases' `test.event` files are vendored alongside their inputs. ### Changed - Record whether a document boundary was explicit in the parser `Event` model (`documentStart`/`documentEnd` now carry an `explicit` flag), so a `---`/`...` marker is distinguished from an implicit boundary. This is required for exact canonical-event comparison and resolves 89 cases that were previously correct but unverifiable. ### Fixed - Make the YAML test suite conformance gate comprehensive and repair the regressions it revealed. The gap-only manifest (which deleted a case once fixed, so nothing guarded it afterward and later work could silently regress it) is replaced by a full manifest of all 402 official cases (`Tests/Fixtures/yaml-test-suite-manifest.yaml`), each with its `id`, `name`, `localPath`, conformant `expected` behavior, `oracle`, `byteCount`, `lineCount`, and a `status` (`passing` | `gap`). Every case's files are vendored under `Tests/Fixtures/yaml-test-suite/`. The gated sweep (`PUREYAML_RUN_YAML_TEST_SUITE=1`) now asserts every `passing` case still passes and every `gap` still reproduces, so a regression in any passing case fails the sweep. Result at the pinned commit: **329 of 402 passing, 73 gap.** - Stop over-rejecting tabs. A tab is invalid only as block-structure indentation (`scanner.c` `scan_to_next_token` / `scan_block_scalar_breaks` / `scan_plain_scalar`). Block indentation is now measured in spaces only; a tab is left for the token scanner, which allows it inside block-scalar content once a leading space fixes the content column (96NN/00, 96NN/01, R4YG, Y79Y/001), as separation before a scalar value (DK95/00) or a flow node (6CA3, Q5MG, J3BT), and on a whitespace-only blank line (DK95/03, DK95/07), while still rejecting genuine tab indentation (4EJS, Y79Y/000, Y79Y/004-008, DK95/06). The batch scanner and the incremental token cursor were unified onto a single `scanStep`, since their divergence previously let the streaming parser miss a tab error the batch scanner caught. - Restore anchors/aliases as mapping keys for `26DV`: an anchor on a `key:` value line followed by an indented `*alias :` key now anchors the nested mapping rather than the alias key (which cannot carry properties); `&b *alias` on one line is still rejected (SU74). - Restore flow bare-tag/anchor keys for `WZ62` (`{ foo : !!str, !!str : bar, }` to `{"foo":"", "":"bar"}`): a flow key introduced by a spliced `mappingKey` uses the same key parser as the implicit form, so a bare tag before the `:` composes an empty tagged scalar key instead of a mapping-as-key. - Complete flow-collection (`[...]`, `{...}`) parsing for keys, empty nodes, and multi-line scalars, fixing 10 official YAML test suite cases and removing them from the conformance gap manifest. The scanner now implements libyaml's simple-key mechanism inside flow context: a potential key position is recorded before a plain or quoted scalar, an anchor or tag, or a nested `[`/`{` opener, and a later `:` value indicator splices a `mappingKey` token in front of that node so a collection or property-carrying node can be a flow-mapping key. In flow context a `:` is now always a value indicator and a `?` always an explicit key indicator regardless of the following character (matching libyaml's `flow_level || IS_BLANKZ_AT` checks), so `"k":v`, `{k}:v`, and a trailing `?` parse correctly; the implicit-key forward lookahead is restricted to block context. A multi-line plain key folds in full before its `:` is read. The flow parser was rebuilt around libyaml's flow productions (`flow_sequence_entry`, `flow_mapping_key`/`value`), composing empty scalar keys and values where absent. The streaming token cursor holds an open flow collection's tokens until it closes so the retroactive key splice stays valid. Fixed false-rejects 4FJ6, 8KB6, 9BXH, 9MMW, CN3R, DFF7, FRK4, NJ66, UT92 and corrected values for CT4Q. The flow-node-as-block-mapping-key cases LX3P and 6BFJ remain tracked (they need the block-context simple-key splice, a separate change). - Parse multi-line plain scalars and block scalars at the document root, fixing 35 official YAML test suite cases and removing them from the conformance gap manifest. The scanner now tracks the enclosing block-collection indent (libyaml's `parser->indent`) separately from per-line indentation, defaulting to -1 at the bare document root, so a plain scalar's continuation lines fold using the spec rule "more indented than the enclosing block": a root scalar folds its column-0 continuations (single break to space, blank line to newline, per-line whitespace stripped) while a scalar inside a collection still ends at a sibling line. A `---` document-start marker may now be followed by the document's root node on the same line (`--- foo`, `--- [1, 2]`, `--- |`, `--- >`), and a block scalar whose body sits at the document root's column 0 captures that body and ends at the next `---`/`...` marker. Fixed false-rejects 35KP, 4CQQ, 4Q9F, 6JQW, 82AN, 93WF, 96L6, 9MQT/00, 9YRD, B3HG, DK3J, EX5H, EXG3, FP8R, RZT7, T26H, UGM3, XLQ9; corrected values for 27NA, 2G84/02, 2G84/03, 4WA9, 6FWR, 6LVF, A2M4, K54U, KSS4, L383, T5N4, W4TN; and started correctly rejecting the invalid 9KBC, ZVH3, 9MQT/01. The still-imperfect M7A3 (multi-document `...` splitting) and NB6Z (tabs on empty plain-scalar lines) now parse but with mismatched values and stay tracked. Added root multi-line plain scalar regression tests. - Escape carriage returns and line feeds in double-quoted output per unicode scalar rather than per Character. A CR+LF pair forms a single Swift extended grapheme cluster, so the previous Character-level escape switch matched neither `\r` nor `\n` and emitted the pair unescaped; the folding parser then read it back differently, breaking parse/dump/parse stability for values containing CRLF (observed on the Zoom OpenAPI corpus seed). Added a dumper regression test. - Tightened error detection so 15 more official YAML test suite cases are handled correctly, repairing four error-detection regressions and removing them from the conformance gap manifest. Tabs are now rejected when they would supply block-structure indentation: a tab at the start of a block line, a tab in a block scalar's indentation, and a tab in separation position that precedes a nested block node (a `-` entry, a `?` key, or a compact mapping key) all fail, while a tab used purely as in-line separation before a scalar value stays valid (4EJS, Y79Y/000, Y79Y/004, Y79Y/005, Y79Y/006, Y79Y/008, Y79Y/009 now reject; 6BCT and Y79Y/010 now parse with matching values; DC7X and J3BT no longer falsely reject). A `---`/`...` document marker at column 0 is now rejected when it appears inside an open flow collection (N782) or inside an open quoted scalar (RXY3, 5TRB) rather than silently ending the document. A second `%YAML`/`%TAG` directive section that opens a new document without an intervening `...` suffix is rejected (MUS6/01). An anchor or tag attached to an alias node, and an anchor-plus-alias on one node, are rejected (SU74, SR86). Remaining accepted-invalid cases that need `---`-line content scanning or full simple-key tracking (for example 2G84/00, 2G84/01, 9MQT/01, ZCZ6) are left tracked in the gap manifest. - Added YAML directive handling and completed document-boundary support, fixing three root causes. First, a `%` now begins a directive only in directive position (before any document content and before the `---` that starts a document, including immediately after a `...` end marker); everywhere else, and in any flow context, `%` is ordinary plain text rather than a misfiring directive. Second, `%YAML` is parsed leniently per the spec: a `1.x` version is accepted (a higher minor is treated as 1.2 with a warning rather than an error), while an unsupported major version, a duplicate `%YAML` in one document, or extra non-comment fields are rejected; reserved and unknown directives (and `%TAG` handle definitions) are accepted and ignored, and a directive must be followed by a `---` document start. Third, a `...` end marker now closes the current document so that following content begins a fresh document in the multi-document stream parser (`parseStream`), while the single-document `parse` still rejects multi-document input. This makes 11 official YAML test suite cases parse with matching values (2LFX, 5TYM, 6WLZ, 6ZKB, 7Z25, 9DXL, 9WXW, BEC7, MUS6/03, MUS6/05, MUS6/06), correctly rejects four previously accepted-invalid cases (9MMA, B63P, H7TQ, SF5V), and unblocks 6LVF and W4TN from parse errors to value comparisons that now depend on separate scalar gaps (`---`-line content and literal block chomping). - Rewrote literal (`|`) and folded (`>`) block scalar content handling to follow YAML 1.2 §8.1 semantics, fixing four root causes: interior blank lines are no longer dropped from literal scalars; folded scalars now apply true §8.1.3 line folding (a single break between equally indented non-empty lines folds to a space, blank lines stay literal newlines, and more-indented lines keep both their leading whitespace and their surrounding breaks unfolded); tabs inside block scalar content are preserved instead of being rewritten as spaces (only the block indentation columns are stripped); and the `+` keep chomping indicator is now supported alongside clip and strip. The block scalar is now scanned as a single unit (header line plus all content lines) with indentation auto-detection, mirroring the reference scanner, rather than reassembled from a lossy per-line token array. This makes 24 additional official YAML test suite cases pass: 4QFQ, 5LLU, 6VJK, 7T8X, 96NN/00, 96NN/01, DWX9, F6MC, F8F9, H2RW, JEF9/00, JEF9/01, K527, K858, L24T/00, L24T/01, M29M, M9B4, MJS9, P2AD, R4YG, TS54, W9L4, and Y79Y/001. - Implemented YAML 1.2 flow (quoted) scalar line folding for both single (`'`) and double (`"`) quoted scalars, mirroring the reference scanner's `yaml_parser_scan_flow_scalar`. Multi-line quoted scalars now fold correctly: trailing whitespace before a break is stripped, leading whitespace on a continuation line is stripped, a single break between non-empty lines folds to one space, and N consecutive breaks yield N-1 literal line feeds (the first and last lines keep their own leading and trailing spaces as content). A double quoted backslash at end of line now suppresses the break entirely (no space, no newline). Completed the double quoted escape set with `\xXX` (2 hex digits) and `\UXXXXXXXX` (8 hex digits), and added the previously missing `\0 \a \v \e`, literal `\`, and the named breaks `\N \_ \L \P`, alongside the existing `\uXXXX` (including surrogate pairs). This makes 30 additional official YAML test suite cases pass: 3RLN/00, 3RLN/01, 3RLN/02, 3RLN/03, 3RLN/04, 3RLN/05, 4ZYM, 565N, 5GBF, 6WPF, 7A4E, 8UDB, 9SA2, 9TFX, DE56/00, DE56/01, DE56/02, DE56/03, DE56/04, DE56/05, DK95/02, DK95/08, G4RS, NAT4, NP9H, PRH3, Q8AD, T4YY, TL85, and XV9V. - Composed an empty (null) plain scalar wherever a node is grammatically required but absent, instead of raising "expected a YAML node". This single production mirrors `parser.c`'s empty-scalar fallback and covers every position a node can go missing: after a tag or anchor property with no following node, an absent mapping value, an absent mapping key (both the plain `: value` form and the explicit `?` form), and the empty key and value forms inside flow collections. An empty node carrying an anchor or tag is explicit; a bare absent node with no property in a grammatically-required position is still a parse error, matching the reference parser. The empty scalar resolves through the normal scalar resolver against an empty value, so `!!str` yields `""`, `!!null` and a bare empty yield null, and an empty key resolves like any other empty scalar. Implemented in `Sources/Parsing/TokenEventParser.EmptyNode.swift` with the flow and block call sites in the sibling `TokenEventParser.*` files. This makes 20 additional official YAML test suite cases pass: 2JQS, 2XXW, 5WE3, 6M2F, 7W2P, BU8L, CFD4, JTV5, KK5P, LE5A, M2N8/00, NHX8, NKF9, PW8X, RZP5, S3PD, SM9W/01, UKK6/02, WZ62, and XW4D. CT4Q now parses but still mismatches its expected JSON (a separate flow multi-line plain-scalar folding gap) and is re-recorded as `json-mismatch`. - Accepted node properties (anchor, tag) and aliases in mapping-key position, so a mapping key is parsed as the full node it is: it may carry `&anchor` and/or `!tag` properties, and it may itself be an `*alias`. Fixed four root causes. (1) The scanner now terminates an anchor or alias name at the first character outside libyaml's `IS_ALPHA` set (ASCII letters, digits, `-`, `_`), mirroring `yaml_parser_scan_anchor`, so `*alias:` reads the alias `alias` followed by a `:` mapping indicator instead of swallowing the colon into the name. (2) The block-mapping parser now begins a pair at leading properties or an alias, not only at the key indicator the scanner emits after them, and properties on the same line as the first key attach to that key node rather than to the mapping (`&a a: b` anchors the key `a`, while `&a` alone on its own line anchors the map). (3) An anchor or tag at value position with no node following on the same line now composes an empty (null) value that still carries the property, so `a: &anchor` registers `anchor` as null and a later `*anchor` resolves. (4) An anchored scalar now registers its resolved value (so an empty anchored scalar registers null, not `""`). The implicit-key scanner also now recognizes a key with trailing spaces before its `:` (`"top1" :`). Implemented in `Sources/Parsing/Scanner.swift`, `Scanner.Helpers.swift`, `TokenEventParser.swift`, `TokenEventParser.EmptyNode.swift`, `TokenEventParser.Helpers.swift`, `TokenEventParser.Block.swift`, `TokenEventParser.Flow.swift`, `TokenCursor.swift`, and the anchor registration in `EventStreamComposer.swift` and `EventComposer.swift`. This makes 10 additional official YAML test suite cases pass: 26DV, 6KGN, 74H7, 87E4, 9KAX, E76Z, G9HC, HMQ5, LQZ7, and ZWK4 (G9HC is an invalid-anchor case that is now correctly rejected). 2SXE now parses but still mismatches its expected JSON (a separate compact block-mapping structural gap) and is re-recorded as `json-mismatch`. ### Added - Track conformance against the official YAML test suite (github.com/yaml/yaml-test-suite, `data` branch). The 182 cases PureYAML does not yet handle correctly are vendored under `Tests/Fixtures/yaml-test-suite/` with a machine-readable manifest (`Tests/Fixtures/yaml-test-suite-gaps.yaml`) recording each gap's category and current behavior. A new opt-in sweep (`bash scripts/check-yaml-test-suite.sh`, gated behind `PUREYAML_RUN_YAML_TEST_SUITE=1`) reproduces every recorded gap so future fixes must update the manifest. `docs/YAML_TEST_SUITE_CONFORMANCE.md` documents the measured result (220/402 passing) and the ordered plan for closing the gaps. ### Added - Opt-in located parse: `parseStreamLocated` returns a source-span projection of every document, each node carrying its `SourceRange` (line/column start and end) through `Model.LocatedValue` and `Model.LocatedPair`. It runs alongside the plain parse without changing it, for callers that need exact source positions (diagnostics, surgical fixes, language servers). - `%TAG` directives are now scoped per document rather than globally, and a tag shorthand whose handle is not defined in its own document is diagnosed instead of silently resolved. - Canonical event-stream conformance gate: the parser is checked against the official yaml-test-suite event (yeast) format, the strongest oracle, comparing anchors, tags, scalar styles, flow vs block, and aliases for every valid case including those with no `in.json`. It ships an explicit recorded-gap set (never a silent skip) and a CI workflow that fetches the suite and runs it on every push. - The tagged validator now enforces mapping-key uniqueness, the invariant the coverage manifest already claimed for `Tagged.Mapping` and `Tagged.Node` but no check enforced. `Tagged.Mapping` keeps every parsed pair, so a duplicate key survives into the tree; `Tagged.BuiltinCheck` `.mappingKeysAreUnique` (a `Check` on the one framework) reports each duplicate at its key path, exactly as the plain model does. It ships failing, near-miss, and nested fixtures, and the tagged validator's two built-in checks are pinned by description. The `Model.Value` coverage reason is corrected to note that merges and aliases are resolved at parse time, so they are not a validation-phase invariant: every `validated` manifest entry now names an invariant a check actually enforces. - A finding-coverage meta-test makes validation coverage a tested law, now across both validating layers: every default check, in the model validator and in the tagged validator, must ship a failing fixture and a near-miss (just-valid) fixture. The meta-test asserts the fixture set covers exactly each layer's default checks, naming any default check without fixtures or any fixture for a check that no longer ships. Each check is exercised in isolation, in a validator holding only it, so a fixture cannot lean on a sibling check to fail and the law stays precise as a layer grows past one check. A planted orphan fixture was confirmed to fail both the set-equality and the isolation tests. - Type-directed validation, the core of the validation-rules idiom: a `Check` carries a positive description, a `when` predicate, and a Bool or multi-error check, and fires only on values of its `Subject` type wherever they occur in the tree. `AnyCheck` erases the subject type and dispatches by runtime type, guarding against an optional or a wrong type matching. The validator gains a `checks` list and a `validating(_:)` overload for checks, and the traversal offers each node the values it may specialise on (the value, and for a mapping the mapping and its keys). `BuiltinCheck.mappingKeysAreUnique` is the type-directed counterpart of the legacy rule, firing on `Model.Mapping` directly. The machinery has its own negative tests (wrong type, optional, and false predicate yield nothing; the same type twice yields two errors) and the builtin ships a failing and a near-miss test. - The validator is now configurable by rule identity, per the validation-rules idiom: `withoutValidating(_ description:)` removes a rule by its description (the removal identity, so descriptions must be unique and stable), and `validationDescriptions` lists the active rules in application order. A configuration-pin test asserts the exact descriptions of the blank and default validators, so adding, removing, or rewording a rule is a deliberate change. - A validation-coverage gate (`scripts/check-validation-coverage.sh`, wired into `scripts/check-all.sh` and CI) enforcing the validation-rules discipline that every public type is either validated or excluded. It enumerates the public surface from the compiler symbol graph (the authoritative source, not a grep heuristic) and fails the build, by name, if any public type is missing from `scripts/validation-coverage.tsv` or any manifest entry is no longer public. All 58 public types are classified: the value models carrying a runtime invariant (mapping-key uniqueness) are `validated`, and the caseless namespaces, closed sum types, data-transfer values, error types, and engine/machinery are `excluded` with a one-line reason each. This is the first phase of adopting the OpenAPIKit-style validation idiom; the type-directed `Validation` framework that backs the `validated` entries follows. - An opt-in conformance harness against the official YAML test-suite, the canonical YAML 1.2 corpus (`Tests/YAMLTestSuiteConformanceTests.swift`, `scripts/fetch-yaml-test-suite.sh`). Gated behind `PUREYAML_RUN_YAML_TEST_SUITE=1` and a separate fetch, so the data is never vendored. It reports two levels: parse-level (a should-parse case must parse; a should-error case must be rejected; failures histogrammed by cause) and the stricter, honest value-level, comparing the parsed tree to each case's `in.json`. Value-level baseline: 130/170 (76.5%) of comparable single-document cases, exposing 40 documents that parse without error but to the wrong tree (e.g. double-quoted line folding, scalar resolution, block-scalar indentation) which parse-level cannot see. This turns "best YAML library" from an assertion into a tracked number. ### Changed - The validation framework is now a single value type across the whole package. The legacy `Rule` and its `Context` are removed: a rule was exactly a `Check` (it fired on every node, switched on the value itself, and carried the root), so it was a second copy of the one check machinery (validation-rules.md, principle 10). The model validator now holds only `checks`, its default is the type-directed `BuiltinCheck.mappingKeysAreUnique`, and `validating(_:)` takes a `Check`. Custom whole-tree rules are written as `Check` with the former `context.root` now `context.document`; behaviour is byte-identical (same issues, paths, and order), so the public `validate`/`collect`/`blank` surface is unchanged. - The tagged-node validator no longer carries its own copy of the validation machinery. The type-directed `Check`, its erasure `AnyCheck`, and its `SubjectContext` are now generic over both the subject and the document (the validated tree's root type), so one framework serves both layers: the model tree validates with a `Model.Value` document, the tagged tree with a `Tagged.Node` document. `Tagged.Rule` and the tagged validation `Context` are removed; the tagged validator keeps only its own document type, its built-in check (`Tagged.BuiltinCheck`), and its traversal. This is the one-framework, no-per-layer-duplication discipline of the validation-rules idiom (principle 10); the tagged validator's public surface (`validate`, `collect`, `blank`, `validating`) is unchanged except that `validating` now takes a `Check` instead of a `Tagged.Rule`. ### Fixed - Tag-handle diagnostics gathered before a scan-aborting error are now reported rather than dropped. The diagnostic accessor ran the scan through `try?`, which discarded the whole scanner state, and the tag-handle issues collected on it, on any error; a document with an undeclared `!handle!` and a later fatal error reported nothing. The scanner now returns its partial state alongside the error so the diagnostics survive the abort. - A repeated document-end marker (`...` immediately followed by another `...`) is now accepted. - A root-level block scalar (`|`/`>` as the whole document) now ends at a following document marker (`---`/`...`) rather than folding the marker into its content. - An empty explicit key in a flow mapping now parses. - An explicit mapping key that is only a node property (`? &e` then `: &a`) now parses as an empty key carrying that property, completing the empty-property-key work for the explicit-key form. The block parser rejected the `:` after a property-only explicit key with an `expectedNode` error; `parseNode` now treats a `:` in node position as an empty property-carrying key, the same node `emitEmptyNodeOrThrow` already produced for a property at end of input. yaml-test-suite PW8X now passes (parse-level 314/333 to 315/333); should-error rejections and value-level matches are unchanged. - A block mapping key consisting only of a node property (`&a : v`, `!!str : v`) is now parsed as an empty implicit key carrying that property, the block analogue of the flow `{ !!str : v }` case. The scanner synthesized an empty mapping key only for a bare `: v` line; a line whose sole content before the `:` was an anchor or tag emitted no key marker, so the parser rejected the `:` with an `expectedNode` error. The scanner now tracks non-property content per line and synthesizes the empty key after a property, including directly after a block entry (`- !!str : v`). This makes yaml-test-suite FH7J pass; PW8X still needs the empty-anchored explicit-value form (`: &a`). Parse-level conformance rises from 313/333 to 314/333; should-error rejections and value-level matches are unchanged. - A line beginning with `%` inside a block scalar is now kept as literal content rather than being misread as a directive. A zero-indent literal or folded scalar sits at column 0, where the directive scan runs, so a content line such as `%!PS-Adobe-2.0` was rejected as an unsupported directive. The directive scan now runs only outside a block scalar; a real `%YAML`/`%TAG` directive and the rejection of a stray `%` in other content are unchanged. This is groundwork for yaml-test-suite W4TN, which additionally needs block-scalar termination by `...` and per-document directives, so conformance counts are unchanged. - A block sequence value carrying an inline property (`sequence: !!seq` then `- entry` at the key's column) is now correctly terminated by a sibling mapping key. Such a zero-indent sequence has its entries at the mapping key's own indentation, so a following key ends it. The untagged form already stopped at the sibling key, but the property-bearing form was routed through the generic node path, which read the sibling key as a mixed sequence-and-mapping entry and rejected the document. The zero-indent sequence is now handled before that path, with or without a property. Fixes yaml-test-suite 57H4; raises parse-level conformance to 313/333 (94.0%) and value-level to 206/206 (100.0%), with the should-error rate unchanged. - Node properties that span their own indented lines above a further-indented node now attach to that node (`key: &anchor` then ` !!map` then ` a: b`). An inline anchor with a tag on the next indented line, above a mapping indented deeper still, left the node introduced by another indent token, which the parser could not read and rejected as a missing node. The indented mapping value now descends through that deeper indent, carrying the accumulated properties to the node. Fixes yaml-test-suite BU8L; raises parse-level conformance to 312/333 (93.7%) and value-level to 205/205 (100.0%), with the should-error rate unchanged. - A node property written on its own indented line above a zero-indent block sequence (`seq:` then ` &anchor` then `- a` back at the key's column) now binds to that sequence. The grammar lets a block-in-block sequence sit at its parent's indentation while its property sits one level deeper, so the indent's matching dedent falls between the property and the entries. The parser had read the property as an empty anchored value and then rejected the entries as unexpected; it now consumes that dedent and attaches the property to the sequence. Fixes yaml-test-suite SKE5; raises parse-level conformance to 311/333 (93.4%) and value-level to 204/204 (100.0%), with the should-error rate unchanged. - An alias node may now serve as an implicit mapping key (`*a : value`). The scanner emits the implicit-key indicator before a scalar key followed by a `:` but did not for an alias, so the parser, which already accepts an alias node in key position, never entered the pair and rejected the alias as an unexpected token. The scanner now emits the same indicator before an alias that a `:` (after optional spaces) follows, in block context. A `:` written directly against the alias stays part of its name, since `:` is a valid anchor-name character. Fixes yaml-test-suite E76Z and 26DV; raises parse-level conformance to 310/333 (93.1%) and value-level to 203/203 (100.0%), with the should-error rate unchanged. - A property written inline before a block mapping's first key (`&a key: value`) now binds to that key rather than to the whole mapping. The YAML grammar requires a block collection's own properties to be followed by a line break, so a property on the first key's line belongs to the key, not the collection. The parser had attached it to the mapping, where an anchor registered only at the mapping's end, too late for an alias inside one of the mapping's values to resolve. A property on its own line above the key still binds to the mapping. Fixes yaml-test-suite 2SXE (anchor names include a trailing `:`, so `&a:` and `&a` are distinct anchors, already handled by the scanner) and HMQ5 (a key carrying both a tag and an anchor); raises parse-level conformance to 308/333 (92.5%) and value-level to 201/201 (100.0%), with the should-error rate unchanged. - An anchor on an empty mapping value (`a: &anchor`) now registers that value's implicit null node, so a later alias resolves to null instead of failing as an undefined alias. Two bugs compounded: the parser dispatched the anchor-only value position to a nested block mapping, absorbing the sibling key (`b:`) as the anchored node's child instead of emitting the empty anchored node; and the stream composer registered an anchor by its raw scalar text rather than the resolved node, so even a registered anchor on an empty scalar came back as `""` and a typed scalar (`&a 42`) came back as text. The value position now emits an empty anchored node when only a sibling mapping key follows, and an anchor registers the composed value. Fixes yaml-test-suite 6KGN and a latent typed-scalar anchor bug; raises parse-level conformance to 306/333 (91.9%) and value-level to 199/199 (100.0%), with the should-error rate unchanged. - A `%YAML` directive with a higher minor version than 1.2 is now accepted rather than rejected. The spec calls for processing a document with a higher minor version, with a warning, so `%YAML 1.3` is read as a YAML 1.x document instead of erroring. The version check now accepts any well-formed `1.minor` version and rejects only a different major version (`2.0`) or a malformed one. Three tests that asserted `%YAML 1.3` was an error were corrected to use `2.0`. Raises yaml-test-suite parse-level conformance to 305/333 (91.6%), with the should-error rate unchanged. - A plain scalar that is an inline block sequence item value (`: - b`) now stops folding at a later block entry that returns to the sequence's own indent, so the dashes form the rest of the sequence rather than folding into the first item. A block entry only recorded its inline node's indent as a valid implicit level, not the dash's own indent, so a continuation could not tell a sibling entry from content, and `? a` / `: - b` / ` - - c` / ` - d` folded into one scalar. The entry's indent is now recorded as a sibling level too (except under an explicit key, whose sequence key drives implicit indentation itself), while a block entry more indented than its container stays plain content. Brings yaml-test-suite value-level conformance to 197/197 (100.0%), with the parse and should-error rates unchanged. - A leading blank line of a block scalar now keeps any whitespace past the content indentation as content, as a blank line within the scalar already did. The leading blank lines (those before the first content line) were only counted, so an otherwise-blank `␣\t` line under a one-space content indent lost its tab, and a folded scalar that began with such a line dropped the leading tab. The leading blanks now record their whitespace and emit the surplus once the content indentation is known, so the tab survives. Raises yaml-test-suite value-level conformance to 196/197 (99.5%), with the parse and should-error rates unchanged. - A flow mapping entry may now omit its `:` value indicator, mapping its key to null: `{ a }` and `{ a, b }` are flow sets. The parser required a `:` after every flow mapping key and rejected these, and a multi-line key with no value (`{ multi`/`line, a: b}`) with it. A value now parses only when a `:` follows the key; otherwise the key maps to null. A test that asserted `{ a, }` was an error was corrected, since it is a valid set. Raises yaml-test-suite parse-level conformance to 304/333 (91.3%) and value-level to 195/197, with the should-error rate unchanged. - A plain scalar key in a flow collection may now span lines. A flow key, unlike a block implicit key, is allowed to be multi-line, so `{ multi` / ` line: v }` is the key `multi line`. The scalar continuation stopped at the `:` value indicator in every non-explicit case, dropping the second line, so the parser mis-split the entry. Folding across the `:` now also applies in flow, while a block implicit key still stops before such a `:`. Raises yaml-test-suite parse-level conformance to 301/333 (90.4%) and value-level to 193/195, with the should-error rate unchanged. - An explicit key (`?`) inside a flow collection is now parsed correctly. The flow `?` did not mark an explicit key, so the following scalar synthesized its own implicit key: `{? foo : bar}` came out as `{{foo: bar}: null}` rather than `{foo: bar}`, and `[? foo : bar]` likewise. The explicit-key state is now tracked per flow level on the flow context, so the key scalar is not turned into an implicit key, a `:` at that level is the pair's value indicator, a `,` ends the key, and a nested collection's `:` does not affect an outer key (`{? {a: b} : c}`). The key may also span lines, folding to one key, which restores the multi-line flow case `[? foo\n bar : baz]`. Raises yaml-test-suite value-level conformance to 192/194 (99.0%), with the parse and should-error rates unchanged. - An explicit key (`? foo`) whose plain scalar spans lines now folds a continuation line that ends at the value `:`. `? foo` then `bar : baz` is the key `foo bar`, not an error: the `:` is the key's value indicator, so the line stays key content and folds in, where before the scalar dropped it and the parser stalled. An implicit key is unchanged, since there a continuation line ending at `:` is a new mapping key and the scalar still stops before it. The block test-suite rates are unchanged (no comparable case isolates this), and the flow form needs separate explicit-key work, but the parser now folds block explicit keys correctly. - An explicit block scalar indentation indicator (`|2`, `>1`) on an inline mapping value now measures from the mapping's indentation. A value like `- aaa: |2` has the inline mapping as its parent, but that mapping sits at an implicit indentation off the main stack, so the indicator was added to the stack's level (0) instead of the mapping's (2), making the content indentation too small and pulling the next key into the scalar. The deepest applicable implicit indentation is now folded in for an inline mapping value, while a direct sequence item (`- |1`) keeps the sequence's own level. Raises yaml-test-suite value-level conformance to 191/194 (98.5%), with the parse and should-error rates unchanged. - Compact nested block sequences (`- - x`) now separate their outer items correctly. Such a sequence carries no indent/dedent tokens, only block entries at their columns, but the sibling-entry loop collected every following block entry regardless of column, so `- - x` then `- - y` became one outer item holding both rather than two. A sequence now collects siblings only at its own column, so an entry at a smaller column ends it and belongs to the enclosing sequence. Raises yaml-test-suite value-level conformance to 190/194 (97.9%), with the parse and should-error rates unchanged. - A block scalar whose only lines are blank now carries those blank lines through chomping at stream end. Such a scalar never establishes a content indentation, so its blank lines stayed buffered and were dropped, making `keep: |+` over a blank line resolve to the empty string instead of a line break. The buffered blanks are now flushed as content at stream end, so keep (`|+`/`>+`) preserves them while clip and strip trim them: `keep: |+` over one blank line is `"\n"`. Raises yaml-test-suite value-level conformance to 189/194 (97.4%), with the parse and should-error rates unchanged. - A scalar carrying an explicit non-core tag now stays a string instead of being implicitly resolved. A node with a tag is not subject to plain-scalar resolution, so a local tag (`!foo`), the non-specific `!`, or an unknown verbatim URI fixes the node as the string of its content: `! 12` is the string `12`, not the integer, and `!Thing false` is the string `false`, not a boolean. Recognized core tags (`!!int`, `!!bool`, ...) and untagged scalars still resolve. Two compatibility tests that asserted the previous behavior were corrected. Raises yaml-test-suite value-level conformance to 188/194 (96.9%), with the parse and should-error rates unchanged. - A tab before a `#` now ends a plain scalar and begins a trailing comment, as a space already did, since a comment opens after any white space. `c: d\t#X` is the value `d` with a comment, not the literal `d\t#X`. A tab inside a plain scalar with no `#` after it stays part of the value, and a `#` glued to content with no white space is still content. Raises yaml-test-suite value-level conformance to 187/194 (96.4%), with the parse and should-error rates unchanged. - A blank line next to a more-indented line in a folded block scalar (`>`) now keeps both line breaks. Between two non-more-indented lines a blank run of N folds to N breaks (the fold is absorbed into the first), but next to a more-indented line nothing folds, so the absorbed break survives and the run yields N+1 breaks. The folder counted N breaks in every case, collapsing the paragraph spacing around indented blocks. Raises yaml-test-suite value-level conformance to 186/194 (95.9%), with the parse and should-error rates unchanged. - Plain scalar line folding now strips tabs, not only spaces, around continuation lines. A tab is not indentation, so a continuation line's leading tab (`\t3rd`) is folding white space and folds away rather than entering the value, and a line of only white space that holds a tab (`␣␣\t`) is an empty line that folds to a line break, not an extra space. Raises yaml-test-suite value-level conformance to 183/194 (94.3%), with the parse and should-error rates unchanged. - A tab in block scalar content past the block's indentation is now kept verbatim instead of being re-expanded to spaces. A block scalar is indented with spaces only, so a line like `␣\ttext` under a one-space indent strips the space and keeps the tab, yielding `\ttext`; the scanner measured the leading whitespace as a tab-expanded width and rebuilt the surplus as spaces, turning the tab into three spaces. The surplus is now reconstructed from the actual leading characters, so a tab stays a tab while surplus spaces stay spaces. Raises yaml-test-suite value-level conformance to 180/194 (92.8%), with the parse and should-error rates unchanged. - Mid-line separation white space between tokens now includes tabs, not only spaces, completing the tab-as-separation handling. An indicator followed by a tab no longer leaks the tab into the next node: `?\tkey` is the key `key`, not `\tkey`, and a run of tabs between a `:` and its value (`a:\t\tb`) is separation. This closes the post-indicator tab leak noted when the indicator boundary itself was fixed. Tabs as indentation are still handled separately by the line-start scan, and the yaml-test-suite rates are unchanged (no comparable case isolates this), but the parser now matches the spec on tab separation. - A tab now follows the `:` mapping-value indicator the way a space does, in both block and flow context, since a tab is separation white space in YAML. `foo:\tbar` is the mapping `{foo: bar}` rather than the plain scalar `foo:\tbar`. A `:` with no following white space at all (`foo:bar`) stays plain scalar content. Raises yaml-test-suite parse-level conformance to 300/333 (90.1%), with the should-error rejection rate unchanged. - A tab now ends a leading indicator the way a space does, since a tab is separation white space in YAML (only indentation forbids a tab). A document marker, block entry, or explicit key followed by a tab (`---\tscalar`, `-\titem`, `?\tkey`) is recognized as the indicator rather than folded into a plain scalar. Raises yaml-test-suite value-level conformance to 175/192 (91.1%), with the parse and should-error rates unchanged. - A flow sequence element that begins with `:` is now parsed as a single-pair mapping with an empty (null) key, e.g. `[ : value ]` is `[{null: value}]`. The parser raised "expected a YAML node" for the leading `:`; it now emits the empty-key mapping, mirroring the way `[ a: b ]` (a scalar key followed by `:`) already becomes a mapping element. Plain elements and scalar-key mapping elements are unchanged. Raises yaml-test-suite parse-level conformance to 298/333 (89.5%), with the should-error rejection rate unchanged. - A flow mapping entry may now begin with its `:` indicator, giving an empty (null) key, e.g. `{ : value }` or `{ a: b, : c }`, or carry only properties before the `:`, e.g. `{ !!str : value }`. The parser required a node before every flow mapping `:` and raised "expected a YAML node"; it now emits a null key in that position, the same way an empty value after the `:` is already handled. The allowance is local to flow mapping key position, so an entry with no `:` at all (`{ a, }`) stays an error. Raises yaml-test-suite parse-level conformance to 297/333 (89.2%) and value-level to 174/192, with the should-error rejection rate unchanged. - A flow collection followed by a `: ` indicator on the same line is now parsed as an implicit block mapping key, e.g. `[a, b]: value` or `{a: 1}: value`. The scanner looks ahead from the flow opener to its matching close (skipping quoted scalars so their brackets and colons do not miscount) and, when a same-line `:` follows, emits the implicit-key marker a scalar key would get, so the flow node is parsed as the key through the ordinary mapping path. A block implicit key must lie on one line, so a flow collection spanning a line break before its `:` (`[23 / ]: 42`) stays an error, and a flow node that no `:` follows, or that is a mapping value, is unaffected. Raises yaml-test-suite parse-level conformance to 294/333 (88.3%) with no change to the should-error rejection rate. - A block scalar that is the document's own root node now folds or keeps content that begins at column 0, e.g. `>` or `--- |` followed by unindented lines. The document node's indentation is -1, so its content at column 0 is still more indented than its parent, but the scanner read the base level as 0 and required content to be strictly deeper, leaving the lines to be mis-scanned as a stray scalar after the document. The scanner now recognizes a root block scalar (one with no enclosing mapping value, explicit key, block-entry indent, or flow context on its header line) and establishes its content indentation at column 0. A non-root block scalar keeps its parent's indent as the floor, so an under-indented sequence item or mapping value is still rejected. Raises yaml-test-suite parse-level conformance to 291/333 (87.4%) and value-level to 173/191 (90.6%). - Content after a document-end marker (`...`) now begins a new bare document. A stream such as `--- scalar / ... / key: value` or `a / ... / b` is two documents, but the scanner rejected the content and the parser expected the document to end at the marker. The scanner now reopens the stream after `...`, and the document loop returns at a `...` so the outer loop parses the next document. A trailing `...` with nothing after it still leaves a single document. Lifts official test-suite parse-level conformance from 288/333 (86.5%) to 289/333 (86.8%), with no regression. - An anchor or tag with no node after it now denotes an empty node. A property always attaches to a node and an empty node is allowed, so `&anchor` alone, `key: &a`, or a `!!str` with nothing after it (`x: !!str` is the empty string, `x: !!null` is null) is the property applied to a null node rather than a missing-node error. A real tagged or anchored value is unaffected. Lifts official test-suite parse-level conformance from 287/333 (86.2%) to 288/333 (86.5%) and value-level by one. - An implicit null mapping key is now parsed. A `:` that opens a line with no key before it (`: value`, a bare `:`, or a null key among other entries) is an entry with a null key, which the parser previously rejected as a missing node. The scanner synthesizes the empty key, tracking a pending explicit `?` key so that an explicit key's value indicator on the next line, including after a flow complex key (`? {a: b}` then `: c`), is not mistaken for a null key. Lifts official test-suite parse-level conformance from 283/333 (85.0%) to 287/333 (86.2%), with no regression. - A directive after a document-end marker (`...`) now begins the next document instead of being rejected. A multi-document stream where each document carries its own directives (`%TAG ...` / `--- doc` / `...` / `%TAG ...` / `--- doc`) is now accepted, since a directive following `...` opens the next document's directive section. A directive after document content within the same document is still rejected, and a directive after `...` still requires its own `---`. Lifts official test-suite parse-level conformance from 278/333 (83.5%) to 283/333 (85.0%), with no regression. - A block sequence entry can no longer follow a flow collection that closed earlier on the same line (`- { y: z }- invalid`, `- [a] - b`); the entry would begin a sequence inline after a complete node. The scanner now tracks a top-level flow close on the current line and rejects a block entry after it, while a nested block entry (`- -`, which has no preceding flow close), a flow collection on its own line, and nested flow collections are unaffected. Lifts official test-suite parse-level conformance from 276/333 (82.9%) to 277/333 (83.2%), with no regression. - A mapping value's anchor or tag on the line below the key must now be indented past the key. A property at the key's own indentation is ambiguous with a sibling and is rejected, whether it is the only property (`seq:` then `&anchor` in column 1) or a later one after an inline first (`key: &x` then a `!!tag` in column 1). An inline property, a property indented under the key, and a block sequence at the key's indent (which carries no property) stay valid. Lifts official test-suite parse-level conformance from 275/333 (82.6%) to 278/333 (83.5%), with no regression. - A `-` immediately before a flow indicator is no longer treated as a scalar. In a flow collection a `-` that begins a plain scalar must be followed by a non-indicator character, so `[-]`, `[-, -]`, and `{a: -}` are now rejected. A dash followed by ordinary content is unaffected: negative numbers (`[-1, -2]`), a dash inside a scalar (`a-b`), a dash starting a word (`-x`), and a quoted dash all stay valid. Lifts official test-suite parse-level conformance from 273/333 (82.0%) to 275/333 (82.6%), with no regression. - A block scalar leading blank line that is more indented than the content is now rejected. The spec forbids a leading empty line from containing more spaces than the first non-empty line, so a block scalar whose blank lines are wider than its content (`|` then a 5-space blank then 2-space content) no longer parses. Indentation is counted in spaces, not tab-expanded width, so a tab on a blank line (`␣\t`) is not treated as a deeper indent. Leading blanks within the content indent are unaffected. Lifts official test-suite parse-level conformance from 270/333 (81.1%) to 273/333 (82.0%), with no regression. - A document marker line (`---` or `...`) inside a multi-line quoted scalar is now rejected. Such a line is ambiguous with a document boundary, so the spec forbids it within a scalar; the scanner previously folded it into the value. The marker must begin a line and be followed by a boundary to be rejected, so inline dashes (`"a --- b"`), a `---` with no following boundary (`---b`), and an indented `---` remain ordinary content. Lifts official test-suite parse-level conformance from 268/333 (80.5%) to 270/333 (81.1%), with no regression. - A block sequence can no longer start on the same line as its anchor or tag (`&a - x`). A block collection must begin on the line after its node properties, so an anchor or tag immediately before the first block entry is now rejected. An anchor or tag on its own line above the sequence, inside a sequence item (`- &a x`), before a flow sequence (`&a [x]`), or on a scalar is unaffected. Lifts official test-suite parse-level conformance from 267/333 (80.2%) to 268/333 (80.5%), with no regression. - An alias node can no longer carry an anchor or tag. An alias is a bare reference, so `&b *a` (an anchor on an alias) and a tag on an alias are now rejected; the parser previously dropped the property and accepted the document. A bare alias and an anchor or tag on a real node are unaffected. Lifts official test-suite parse-level conformance from 266/333 (79.9%) to 267/333 (80.2%), with no regression. - A comment can no longer interrupt a multi-line plain scalar. A comment terminates a plain scalar, so content that tries to continue it on a following line (`key: word1` then a `#` line then ` word2`, or `a` then `b # ...` then `c`) is now rejected instead of being silently merged back into the value. A genuine multi-line plain scalar and a trailing comment after a value are unaffected. The parser path that re-merged the scanner-split scalar is removed, since the scanner only splits a plain scalar this way when a comment broke its folding. Lifts official test-suite parse-level conformance from 264/333 (79.3%) to 266/333 (79.9%), with no regression. - A quoted scalar that spans a line break can no longer be a block implicit key (`"c⏎ d": 1`). An implicit key in block context must lie on a single line, so a quoted scalar whose open and close quotes sit on different lines is now rejected when used as such a key. The rule is block-only: a flow mapping key may span lines (`{ "a⏎ b": 1 }`), and a multiline quoted value, a single-line quoted key, and an explicit key (`? "a⏎ b"`) are all unaffected. Lifts official test-suite parse-level conformance from 261/333 (78.4%) to 264/333 (79.3%), with no regression. - A block sequence can no longer be an implicit key's value on the same line as its `:` (`key: - a`); the value must begin on a following line, or use flow style (`key: [a]`). Like the inline nested mapping rule, this is specific to an implicit key: an explicit- or null-key `:` opens its line, so a compact sequence after it stays valid, as do a sequence on the next line, a nested sequence (`- - a`), a flow sequence, and a negative number (`key: -1`). Lifts official test-suite parse-level conformance from 260/333 (78.1%) to 261/333 (78.4%), with no regression. - An inline nested block mapping is now rejected. Block style allows only one mapping pair per line, so a scalar followed by `:` after a block-level value on the same line (`a: b: c`, `a: 'b': c`, `x: { y: z }in: valid`, or trailing content after a quoted value) no longer parses as a nested mapping. The scanner suppresses the implicit key it would otherwise synthesize once the line already holds a pair. A value indicator that opens its line is an explicit- or null-key indicator, whose value may legitimately be a compact mapping (`? k` then `: a: 1`), so that, flow mappings (`{a: b, c: d}`), next-line nesting, and colons inside scalars are unaffected. Lifts official test-suite parse-level conformance from 256/333 (76.9%) to 260/333 (78.1%), with no regression. - A `...` document-end marker no longer accepts content on its own line. Only a comment or blank may follow the marker, so `... invalid` is now rejected; the scanner previously skipped the rest of the line. A bare `...`, a trailing comment (`... # done`), and a `...` not followed by a boundary (`...junk`, which is an ordinary plain scalar, not a marker) are unaffected. Lifts official test-suite parse-level conformance from 255/333 (76.6%) to 256/333 (76.9%), with no regression. - A directive must now be followed by an explicit `---` document start. A stream that ends, or reaches a `...` document-end marker, while a directive is still awaiting its document (`%YAML 1.2` alone, or `%YAML 1.2` then `...`) is now rejected, as the YAML grammar requires a directive to head an explicit document. This applies to any directive, including an unrecognized one, which is still otherwise ignored. A directive followed by `---` (with content, inline, or empty) is unaffected. Lifts official test-suite parse-level conformance from 253/333 (76.0%) to 255/333 (76.6%), with no regression. - The `%YAML` directive is now validated. It takes exactly one parameter, the version, optionally followed by a comment, and at most one may appear per document; an extra parameter (`%YAML 1.2 foo`) or a second `%YAML` directive is now rejected. Previously any tokens after the version were ignored and duplicate directives were accepted. A well-formed directive, with or without a trailing comment, is unaffected. Lifts official test-suite parse-level conformance from 251/333 (75.4%) to 253/333 (76.0%), with no regression. - A flow collection no longer accepts an empty entry. A comma separates entries, so one must precede it; a leading comma (`[ , a ]`), a repeated comma (`[ a, , b ]`), or the same in a flow mapping is now rejected, while a single trailing comma (`[ a, b, ]`) and empty collections (`[]`, `{}`) stay valid. The parser previously skipped a comma without checking that an entry came before it. Lifts official test-suite parse-level conformance from 249/333 (74.8%) to 251/333 (75.4%), with no regression. - Double-quoted escape sequences now follow the full YAML 1.2 escape table. Previously the decoder recognized only a subset (`\" \\ \/ \b \f \n \r \t \u`) and silently passed any other character through, so invalid escapes such as `\.` and `\'` were accepted while valid ones such as `\x`, `\v`, `\e`, `\0`, `\N`, `\_`, `\L`, `\P`, `\U`, and backslash-space were mangled (`\x0d` became the text `x0d`). The decoder now maps every defined escape to its character, handles `\x` (8-bit) and `\U` (32-bit) hex escapes alongside `\u`, treats a backslash before a line break as an escaped line break that joins the lines with no separator, and rejects an unknown or truncated escape. Lifts official test-suite parse-level conformance from 247/333 (74.2%) to 249/333 (74.8%) and value-level from 166 to 170 of comparable cases (90.4%), with no regression. - A `#` glued to earlier content on its line (e.g. `"value"# c`, `[ a, b ]#c`, `>#c`) is now rejected: the spec requires a comment to be preceded by whitespace or to open a line, and such input was previously accepted as a comment. A `#` that opens a line, follows a space, or sits inside a plain scalar (`a#b`, where it is literal content) is unaffected. The check tracks whether real content has been scanned on the current line, rather than comparing token line numbers, because the streaming tokenizer drains its buffer and a block scalar's blank line emits an empty scalar attributed to the next line. Lifts official test-suite parse-level conformance from 243/333 (73.0%) to 247/333 (74.2%); four wrongly-accepted invalid documents are now correctly rejected with no valid document regressing. - A block scalar followed by a less-indented comment line no longer fails with a spurious "unexpected indentation". When a comment ends a block scalar, its own column was processed as a dedent and, landing on a width no indentation level held, raised an error; a comment's indentation is now left out of block structure. Relatedly, a whitespace-only line indented past a block scalar's content level now keeps its surplus spaces as content instead of collapsing to empty, so such a line contributes the correct value. Lifts official test-suite parse-level conformance from 240/333 (72.1%) to 243/333 (73.0%) and value-level matches. - A quoted mapping key may now have a space before its `:` (`"key" : value`). A plain key consumes the space before the colon into the scalar, but a quoted key stops at its closing quote, leaving the space, and the implicit-key check looked only at the immediate next character. It now looks past spaces for the colon, as the spec allows. Quoted keys with no space, quoted values, and plain keys are unaffected. Lifts official test-suite parse-level conformance from 238/333 (71.5%) to 240/333 (72.1%). - A block mapping pair after the first may now carry an anchor or tag on its key (`k: 1` / `&b key2: 2`). The first pair's key properties were consumed when the mapping was entered, but later pairs were not, so an anchored key past the first failed with "expected mapping key". The pair parser now consumes leading key properties for every pair, and the mapping continues across an anchor or tag that begins a key. Lifts official test-suite parse-level conformance from 235/333 (70.6%) to 238/333 (71.5%). - An explicit mapping key (`? key`) with no following `:` now maps to null instead of failing to parse, so several `? key` entries in a row (a set, for instance) each map to null. The parser required a `:` after every explicit key; it now treats a missing one as an implicit null value. Explicit keys with values, and complex keys generally, are unaffected. Lifts official test-suite parse-level conformance from 229/333 (68.8%) to 235/333 (70.6%). - An unrecognized directive (e.g. `%FOO`) before a document is now ignored, as the YAML spec requires (a warning, otherwise no effect), instead of being rejected with an error. `%YAML` and `%TAG` are unchanged, and a directive after content (where one cannot appear) is still rejected. Lifts official test-suite parse-level conformance from 227/333 (68.2%) to 229/333 (68.8%). - A plain scalar at the document root now folds across lines that sit at column 1, so a bare multi-line value such as `a` / `b` / `c` becomes `"a b c"` instead of failing to parse. The continuation check required a continuation to be more indented than the scalar, but a root scalar has nothing enclosing it, so its enclosing-block indent is -1 and a continuation may start at column 1. The continuation now stops at a document marker (`---` / `...`), so a scalar never folds across a document boundary. Lifts official test-suite parse-level conformance from 221/333 (66.4%) to 227/333 (68.2%). (A stream test that asserted two bare plain lines were rejected as extra content was corrected: that input is a valid multi-line scalar; genuine extra content, like a flow node followed by a scalar, is still rejected.) - A multi-line single-quoted scalar now folds its line breaks, like the double-quoted fix below; both share the same flow line folding, so the fold logic is now one helper. Lifts official test-suite value-level conformance from 143/169 (84.6%) to 147/169 (87.0%), with no regression. - A multi-line double-quoted scalar now folds its line breaks instead of keeping them raw. The decoder appended real line breaks and continuation-line indentation verbatim, so `"a\n b"` came back as `"a\n b"` rather than `"a b"`. It now applies flow folding: a single break folds to a space, a blank line to one newline, and whitespace adjacent to a break (a line's trailing, the next line's leading) is stripped, while first-line leading and last-line trailing whitespace and `\n` escapes are preserved. Lifts official test-suite value-level conformance from 135/169 (79.9%) to 143/169 (84.6%), with no regression; parse-level is unchanged because these documents already parsed, just to the wrong tree. - A root node that begins on the document-marker line (`--- >`, `--- |`, `--- foo`, `--- {a: 1}`) is no longer discarded. The marker scanner skipped the rest of the `---` line unconditionally, so a block-scalar header was dropped and its body was mis-read as a plain scalar (folded to spaces, no trailing newline) and a same-line plain or flow node was lost entirely. The document start now keeps any real same-line content for the parser, skipping only trailing spaces or a comment. Lifts official test-suite value-level conformance from 130/170 (76.5%) to 135/169 (79.9%), with no regression; parse-level is unchanged because these documents already parsed, just to the wrong tree, which only the value-level metric could see. - A multi-line plain scalar whose value begins on its own line under a mapping key now folds its continuation lines instead of stopping after the first. The continuation check compared the next line's indent against the value's own pushed indent, so a continuation at the same indent failed the strict `>`; it now compares against the parent block's indent, matching the spec (continuation must exceed the mapping's indentation, not the value's). Lifts official test-suite parse-level conformance from 211/333 (63.4%) to 221/333 (66.4%), with no regression across the existing suite. ### Changed - Split multi-type source files so each file declares exactly one top-level type (`docs/rules/code-style.md`): the Tagged node component types, the validation report types, `TokenKind`, and the event-stream composer helpers each move to their own file. Public type names are unchanged. `scripts/check-namespacing.sh` is now namespace-aware and detects multiple sibling types declared inside `extension` blocks, which the previous column-anchored scan missed. - Merge-key (`<<`) override resolution now lives in a single internal helper, `Parsing.MergeResolution`, shared by the buffered and streaming event composers. Both paths previously carried an identical copy of the inherited-versus-local pair merge, so the subtle precedence rules (local keys win, earlier sources win, inherited-then-local ordering) now have one source of truth. No behavior change. ### Removed - Remove three `Parsing.ParseError` cases that were never thrown: `emptyDocument` (empty and comment-only documents parse to `null`), `expectedMappingKey` (superseded by `unexpectedToken` carrying an "expected mapping key" message), and `tabIndentation` (tabs in indentation are reported by the validation layer's `PreflightScanner`, not the parser). No reachable behavior changes. ### Fixed - Preserve blank lines in block scalars. The scanner dropped every blank line within and after a block scalar, so a literal `|` lost its internal blank lines, a folded `>` turned a blank line into a space instead of a newline, and the `|+` / `>+` keep indicator could not preserve trailing blank lines. Blank lines are now captured as empty content in both the batch and streaming scan paths (which had duplicated the loop), and `blockScalarValue` applies literal versus folded line semantics and clip/strip/keep chomping correctly. A block scalar without blank lines is unchanged. - Keep a more-indented line inside a folded `>` block literal, with its line breaks preserved, rather than folding it into the surrounding text. - Preserve leading blank lines between a block scalar header and its first content line. They precede the indent token, so they are buffered and emitted as empty content once the content indentation is established. ### Documentation - Correct `SECURITY.md`: fix the reporting contact address and replace the scope section, which described an unrelated static-site generator, with the YAML parsing, validation, and emitting surface that is actually in scope. ## [0.1.5] - 2026-06-23 ### Documentation - Document why `maxNestingDepth` is the necessary and sufficient safety bound for deeply nested input: a `Model.Value` is a recursive tree whose every operation (compose, emit, compare, hash, and especially deallocate) recurses with nesting, and Swift cannot deallocate a recursive value type iteratively, so bounding the tree's depth at parse time is the only complete protection. Records the measured headroom, why the bound cannot be raised without an iterative parser (and would be pointless past the ~5,000 `deinit` limit), and the caveat that trees built by encoding a deep `Codable` value bypass it. ## [0.1.4] - 2026-06-23 ### Fixed - Reject documents nested beyond a fixed depth (`maxNestingDepth`) with a `nestingTooDeep` parse error instead of overflowing the recursive-descent parser's stack. A pathologically nested input (for example hundreds of nested `[[[...]]]` or deeply indented mappings) previously crashed the process; it now fails cleanly. The limit is well above the nesting of real-world specs, which are unaffected. ## [0.1.3] - 2026-06-23 ### Fixed - Parse a leading `#` in block scalar content (`>`, `>-`, `|`, `|-`) as literal text instead of a comment. A block scalar whose first content line began with `#`, such as an OpenAPI `$ref: >-` folded onto `#/components/...`, was previously dropped to an empty string. ## [0.1.2] - 2026-06-06 ### Added - Document the real-world YAML corpus, fuzz/property gate, and CI artifact contract for production hardening. - Add machine-readable parser diagnostic codes to validation report payloads and cover malformed production-shaped YAML with 10+ structured diagnostics. - Vendor a 114-file real-world YAML corpus with a machine-readable manifest, default metadata checks, and an opt-in full parse/validate/round-trip gate. - Add large public APIs.guru Stripe and Zoom OpenAPI YAML specs to the opt-in corpus gate. - Add a reduced synthetic ACME Zephyr fixture set for Stitcher-style multi-file OpenAPI inputs and bundled output. - Add a full renamed Birch-style ACME fixture catalog with 594 YAML files, preserving module/file structure while replacing private organization names, domains, and documentation URLs. - Add YAML 1.2 JSON-subset compatibility for compact flow mappings such as `{"openapi":"3.0.0"}`, including regression coverage for colons inside flow mapping values. - Add JSON compatibility tests and artifacts that compare PureYAML against Swift's JSON decoder across JSON roots, escapes, Unicode surrogate pairs, exponent numbers, nested values, and whitespace. - Preserve round-trip fidelity for mapping keys that require quotes, including trailing-space keys found in the large Zoom OpenAPI corpus seed. - Add an opt-in Yams differential script that compares parse behavior for the checked-in real corpus without adding Yams to the PureYAML package manifest. - Add an opt-in Yams diagnostic comparison script that records PureYAML structured malformed-input diagnostics alongside Yams parser errors. - Add an opt-in Yams validation comparison script proving structured duplicate key validation on YAML that both parsers can load. - Add an opt-in Yams throughput script and scheduled/manual CI artifact for release-mode parser performance comparison on representative corpus seeds. - Add an opt-in PureYAML phase profiler that times scanner, event parser, composer, lazy parse, validation, and dumping phases on slow corpus seeds. - Compose normal parser output from events as they are emitted, avoiding a full intermediate event array while keeping `parseEvents` available for explicit event materialization. - Lazily scan tokens for normal `parse` and `parseStream` calls, avoiding a retained full token array on the main parser path. - Track reader positions with a UTF-8 cursor so ASCII-heavy scanner advancement avoids per-character string allocation while preserving byte-accurate marks. - Add deterministic generated corpus properties for valid YAML round trips and malformed real-seed mutations with structured diagnostics. - Write corpus run artifacts under `.build/pureyaml-artifacts/`, including real seed summaries, generated mutation summaries, seed metadata, and environment details. - Add a scheduled/manual CI corpus job that runs the corpus and Yams differential gates and uploads the generated artifact bundle. ## [0.1.1] - 2026-06-06 ### Added - Add platform CI badges and an active production-hardening roadmap to the README. ### Changed - Add release-mode build and test gates to the macOS, Linux, Windows, and WASM verification scripts. - Update GitHub Actions checkout steps to `actions/checkout@v6` so CI uses the current Node 24 action runtime. ### Fixed - Treat more-indented plain scalar continuation lines that start with `-` as text unless they appear at a known sequence-entry indentation. This fixes OpenAPI-style `allOf` descriptions while preserving nested block sequences such as `- - 123`. - Parse explicit scalar keys whose `:` value starts a same-line mapping and continues with indented mapping siblings, matching large OpenAPI schema keys. ## [0.1.0] - 2026-06-05 ### Added - Bootstrap PureYAML as a root Swift package with one `PureYAML` library product, no external SwiftPM dependencies, no C sources, no generated parser targets, and no Foundation requirement in the library target. - Add the core pure-Swift YAML model with ordered mappings, sequences, scalars, duplicate-key preservation, and first-class complex mapping keys through `Model.Pair.keyNode`. - Add parser support for block mappings, block sequences, comments, flow collections, quoted strings, literal and folded block scalars, anchors, aliases, YAML directives, document markers, merge keys, complex mapping keys, multi-document streams, and selected explicit built-in scalar tags. - Add deterministic YAML dumping with block output by default, optional flow collections, conservative plain scalars, conservative literal block scalars, complex mapping key dumping, and multi-document stream dumping. - Add tag-preserving parsing with `PureYAML.parseTagged(_:)` and `PureYAML.parseTaggedStream(_:)`, plus tagged validation rules for unsupported built-in tags and tags applied to the wrong node kind. - Add `PureYAML.Tagged.Constructor` for caller-owned tagged construction with exact missing-handler and kind-mismatch errors, recursive path context, and a model-value fallback that preserves mapping order and duplicate keys. - Add path-aware validation with duplicate-key checks, custom rules, rule predicates, strict and non-strict behavior, warning collection, stream document indexes, deterministic validation paths, and direct value-tree validation for states ordinary loaders may collapse. - Add diagnostic-first validation reports, raw-source preflight scanning, `Validation.ReportError`, and Markdown, YAML, and JSON report rendering for application-owned files or error bodies. - Add scalar, keyed, unkeyed, nested, dynamic-key, dictionary-like, and super-coder `Codable` conversion APIs with exact path-aware errors. - Add fixture-backed compatibility coverage for scalar spellings, explicit tags, collections, anchors, unsupported YAML gaps, literal block emission, downstream documents, and deliberately failing validation fixtures. - Add a real-world YAML fixture corpus with representative short, medium, large, and very large documents, including OpenAPI, Kubernetes, GitHub Actions, Docker Compose, Prometheus, and cert-manager YAML. - Add macOS, Linux, Windows, and WASM verification gates, hosted CI, a full local verification script, a pre-push hook, usage documentation, migration boundaries, attribution, and release notes. ### Changed - Lower the package tools version to Swift 6.1 for hosted macOS CI compatibility. - Harden duplicate-key validation to use set membership while preserving exact diagnostics. - Make validation rules immutable after construction. - Preserve keyed super-encoder field order even when delayed child encoders are written in a different order than they were requested. - Make keyed default `superEncoder()` and `superDecoder()` use the standard `super` mapping key instead of the current mapping. - Resolve Yams-compatible plain scalar spellings for `yes` and `no` booleans, radix-prefixed integers, numeric separators, and `.inf` and `.nan` floats. - Broaden opt-in literal block emission while preserving exact parser round-trips for every emitted block scalar. - Harden block mapping, multiline plain-scalar parsing, merge expansion, tagged parsing, lower-column mapping siblings, indented root fragments, JSON-style flow mapping pairs, tab-indented corpus files, and empty collection emission.