Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The noyalib Manual

noyalib is a YAML 1.2 library for Rust: pure safe code (#![forbid(unsafe_code)]), full serde integration, a lossless CST for byte-faithful editing, JSON Schema validation, and 406/406 on the official YAML test suite. Six crates ship in lockstep at the identical =0.0.X — the library plus the noya-cli binaries, the LSP and MCP servers, the WASM bundle, and the noyalib-serde-yaml drop-in replacement for archived serde_yaml.

This manual is the rendered form of the Markdown that lives in the repository’s docs/ directory — every page here is a file there, and the repository copy is canonical.

Where to start

You want toRead
Parse or write YAML from Rustthe User guide
Find the recipe for a taskthe Cookbook
Leave serde_yaml behindthe migration guides — for the one-line drop-in, see noyalib-serde-yaml
Edit YAML without destroying formattingthe User guide’s CST sections, then Architecture
Judge the library before adopting itChoosing a YAML library and Testing and verification
ContributeDEVELOPMENT.md and CONTRIBUTING.md at the repository root

The rest of the documentation

Getting started with noyalib

A focused walkthrough for someone who just landed on the repo and wants to use the library. The full reference is the root README; this page is the on-ramp.

Install

As a Rust library

[dependencies]
noyalib = "0.0.58"

no_std (alloc-only) and lean profiles are documented in the per-crate README.

As a CLI tool

The noyafmt and noyavalidate binaries ship through every mainstream package channel:

cargo install noya-cli --locked        # crates.io (noyafmt + noyavalidate)
brew tap sebastienrousseau/tap && brew install noyalib   # macOS
yay -S noyalib-bin                     # Arch / AUR

The library crate is noyalib; the binary crate that produces noyafmt / noyavalidate is noya-cli. See the Install section of the root README for the full per-channel matrix.

Full per-channel install matrix lives in pkg/PUBLISH.md.

First parse

use noyalib::{from_str, to_string};

#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
struct Config {
    name: String,
    port: u16,
    features: Vec<String>,
}

fn main() -> Result<(), noyalib::Error> {
    let yaml = "
name: noyalib
port: 8080
features:
  - parse
  - emit
";
    let cfg: Config = from_str(yaml)?;
    assert_eq!(cfg.port, 8080);

    let back = to_string(&cfg)?;
    assert!(back.contains("name: noyalib"));
    Ok(())
}

What you probably want next

If you want to…Read
Migrate from serde_yaml 0.9docs/MIGRATION-FROM-SERDE-YAML.md
Understand the architecturedocs/ARCHITECTURE.md
Edit YAML lossy-free (preserving comments and layout)crates/noyalib/README.md § CST and noyalib::cst::Document
Validate YAML against a JSON Schemanoyavalidate --schema schema.yaml input.yaml
Format YAMLnoyafmt --write file.yaml
Use the LSP server in your editorcrates/noyalib-lsp/README.md
Drive noyalib from an AI agent (MCP)crates/noyalib-mcp/README.md
Run noyalib in the browsercrates/noyalib-wasm/README.md
Look up a domain termGLOSSARY.md
Contribute codeCONTRIBUTING.md

Building from source

The full developer guide (toolchain, task map, reproducing every CI gate locally) is DEVELOPMENT.md.

git clone https://github.com/sebastienrousseau/noyalib.git
cd noyalib
make                # cargo check + clippy + test
make fmt            # rustfmt --check
make deny           # cargo-deny supply-chain audit

Prerequisites: Rust 1.89.0+ (edition 2024) for the core library and the CLI / LSP satellite crates alike. make orchestrates the full local check suite.

Running examples

The crates/noyalib/examples/ directory contains 76 runnable examples covering every public surface — schema validation, borrowed-path parsing, CST editing, no_std, robotics newtypes, figment integration, and more. Each compiles independently:

cargo run --example hello
cargo run --example schema_validation --features validate-schema
cargo run --example figment --features figment

Where to ask for help

noyalib user guide

A long-form companion to the README. The README is the elevator pitch and reference; this guide walks through every public-facing surface in the order you typically encounter it.

If you’ve used serde_yaml 0.9 before, also see MIGRATION-FROM-SERDE-YAML.md — most call sites are mechanical to update.

Contents

  1. Reading YAML
  2. Writing YAML
  3. The dynamic Value tree
  4. Source spans (Spanned<T>)
  5. Strict deserialise (typo detection)
  6. Parser policies (defence in depth)
  7. Structured diagnostics
  8. Lossless edits (cst::Document)
  9. Schema validation and autofix
  10. Multi-document streams + parallel parse
  11. The CLI tools (noyafmt, noyavalidate)
  12. WASM, MCP, and LSP

1. Reading YAML

The simplest case — read into a typed struct via serde:

#![allow(unused)]
fn main() {
use noyalib::from_str;

#[derive(Debug, serde::Deserialize)]
struct Server {
    host: String,
    port: u16,
}

let yaml = r#"
host: api.example.com
port: 8080
"#;
let s: Server = from_str(yaml)?;
assert_eq!(s.port, 8080);
}

Every input shape serde_yaml supports has a noyalib equivalent:

SourceAPIReturns
&strfrom_str::<T>(s)Result<T>
&[u8]from_slice::<T>(b)Result<T>
impl io::Readfrom_reader::<R, T>(r)Result<T>
&noyalib::Valuefrom_value::<T>(&v)Result<T>

Each has a _with_config(input, &ParserConfig) variant for when you need to tighten parser limits or opt into legacy behaviours (see section 6).

2. Writing YAML

Mirror surface for the write side:

#![allow(unused)]
fn main() {
use noyalib::to_string;

#[derive(serde::Serialize)]
struct Server { host: String, port: u16 }

let yaml = to_string(&Server {
    host: "api.example.com".into(),
    port: 8080,
})?;
println!("{yaml}");
// host: api.example.com
// port: 8080
}
SinkAPI
Stringto_string(&v)
impl io::Writeto_writer(&mut w, &v)
impl fmt::Writeto_fmt_writer(&mut w, &v)
noyalib::Valueto_value(&v)

to_string_with_config(&v, &cfg) accepts a SerializerConfig that tunes indent, flow style, scalar style, document markers, and block-scalar thresholds. See crates/noyalib/examples/emit.rs for every option in action.

3. The dynamic Value tree

When the schema isn’t fixed at compile time, work with Value:

#![allow(unused)]
fn main() {
use noyalib::{from_str, Value};

let yaml = "
server:
  port: 8080
items:
  - name: first
    debug: true
  - name: second
";

let v: Value = from_str(yaml)?;

// Dot-path traversal.
let port = v.get_path("server.port").and_then(|n| n.as_u64());

// Sequence indexing.
let first = v.get("items").and_then(|s| s.get(0));

// Path queries — wildcards and recursive descent.
let names = v.query("items[*].name");          // every name in the list
let any   = v.query("..debug");                // every `debug` at any depth

// A key the grammar would read as structure is bracket-quoted;
// `join_keys` spells the path from literal keys.
let app = v.get_path(r#"labels["app.kubernetes.io/name"]"#);
let same = v.get_path(&noyalib::path::join_keys(["labels", "app.kubernetes.io/name"]));

// Mutation.
let mut v = v;
v["server"]["port"] = Value::from(9090u16);
}

Missing keys return None; never panic on lookup.

For lower-overhead reads, the borrowed AST shares scalar bytes with the input:

#![allow(unused)]
fn main() {
use noyalib::borrowed::from_str_borrowed;

let yaml  = "host: localhost\nport: 8080\n";
let v     = from_str_borrowed(yaml)?;
let host  = v.as_mapping().unwrap().get("host").unwrap().as_str();
// host points into `yaml` — no String allocation for the scalar.
}

BorrowedValue<'a> doesn’t follow YAML aliases (*name); use the owned path for documents that anchor.

4. Source spans (Spanned<T>)

Wrap any deserialise target in Spanned<T> to record where in the input it came from:

#![allow(unused)]
fn main() {
use noyalib::{from_str, Spanned};

#[derive(serde::Deserialize)]
struct Cfg {
    port: Spanned<u16>,
    host: Spanned<String>,
}

let cfg: Cfg = from_str("port: 8080\nhost: api\n")?;
assert_eq!(cfg.port.value, 8080);
assert_eq!(cfg.port.start.line(), 1);
assert_eq!(cfg.port.start.column(), 6);
}

Spanned<T> round-trips through serde::Serialize as T — the span info is read-side only. Combine with miette to render the exact offending region:

#![allow(unused)]
fn main() {
use noyalib::Spanned;
#[derive(serde::Deserialize)]
struct Cfg { port: Spanned<u16>, host: Spanned<String> }
let yaml = "port: 80\nhost: api\n";
let cfg: Cfg = noyalib::from_str(yaml)?;
let report = noyalib::diagnostic::spanned_error(
    yaml,
    &cfg.port,
    "port must be >= 1024 (privileged ports not allowed)",
);
eprintln!("{report:?}");
}

See crates/noyalib/examples/validation.rs.

5. Strict deserialise (typo detection)

The single biggest config-file footgun: a typo in a key name silently deserialises into the field’s Default. from_str is lenient by design (extras are ignored), from_str_strict errors out:

#![allow(unused)]
fn main() {
#[derive(Debug, serde::Deserialize)]
struct Cfg { port: u16, host: String }

let yaml = "port: 8080\nhost: api\nporrt: 9090\n";

// Lenient: silently drops the typo.
let c: Cfg = noyalib::from_str(yaml)?;
assert_eq!(c.port, 8080);

// Strict: typed error pointing at `porrt`.
let err = noyalib::from_str_strict::<Cfg>(yaml).unwrap_err();
assert!(err.to_string().contains("porrt"));
}

The strict path walks nested structs — a typo at server.unknown is reported with its parent path. Available on every input shape:

InputStrict variant
&strfrom_str_strict::<T>(s)
&[u8]from_slice_strict::<T>(b)
impl io::Readfrom_reader_strict::<R, T>(r)

Strictness is opt-in per call site, not global — be strict about config you own and lenient about a third party’s payload in the same program. Runnable walk-through: crates/noyalib/examples/strict_deserialise.rs (cargo run --example strict_deserialise --features strict-deserialise).

6. Parser policies (defence in depth)

YAML’s anchor / merge-key / custom-tag features have historically been amplification vectors (billion-laughs) and remote-code- execution vectors (!!python/object). noyalib doesn’t instantiate arbitrary objects via tags, but you may still want to reject documents that abuse the spec at parse time:

#![allow(unused)]
fn main() {
use noyalib::{from_str_with_config, ParserConfig};
use noyalib::policy::{DenyAnchors, DenyTags, MaxScalarLength};

let cfg = ParserConfig::new()
    .with_policy(DenyAnchors)             // no `&name` / `*name`
    .with_policy(DenyTags)                // reject custom !tag scalars
    .with_policy(MaxScalarLength(64_000)) // cap individual scalar size
    .max_alias_expansions(100)            // billion-laughs guard
    .max_depth(64);

let input = "host: api.example.com\nport: 8080\n";

let res: Result<noyalib::Value, _> =
    from_str_with_config(input, &cfg);
}

Use ParserConfig::profile(ParserProfile::Standard), Strict, or SerdeYaml when the selected trust and compatibility contract should be explicit in code. ParserConfig::strict() remains an alias for the strict profile.

ParserLimits groups the resource budgets independently from YAML semantics. config.limits() extracts the active budgets, and config.with_limits(limits) replaces all of them while preserving version, key, policy, property, registry, and resolver settings.

DialDefaultstrict()Protects against
max_depth12864Stack-blowing nested structures
max_document_length64 MiB1 MiBOversized payloads
max_nodes250 K25 KAST node-count floods (empty-collection bombs)
max_alias_expansions1024100Billion-laughs amplification
max_mapping_keys64 K1024Hash-collision DoS
max_sequence_length64 K1024Memory-spike DoS
duplicate_key_policyLastErrorSilent data loss
strict_booleansoffonNorway problem

7. Structured diagnostics

Every parse error carries (line, column, byte_offset) plus a machine-readable error code. The default API exposes a renderer-neutral payload for editors, services, and custom user interfaces:

#![allow(unused)]
fn main() {
use noyalib::{DiagnosticCode, Value};

let err = noyalib::from_str::<Value>("port: [unclosed").unwrap_err();
let diagnostic = err.diagnostic();
assert_eq!(diagnostic.code(), DiagnosticCode::Parse);
assert_eq!(diagnostic.code().as_str(), "noyalib::parse");

if let Some(label) = diagnostic.primary_label() {
    let byte_range = label.span().offset()..label.span().end();
    println!("{} at {byte_range:?}", label.message());
}
}

SourceSpan offsets and lengths are bytes in the original UTF-8 input. Protocol adapters must convert them when a client uses another coordinate system, such as the UTF-16 positions required by LSP.

The minimum, no-feature path also renders a rustc-style snippet:

#![allow(unused)]
fn main() {
use noyalib::Value;

let input = "port: [unclosed";
let err = noyalib::from_str::<Value>(input).unwrap_err();
println!("{}", err.format_with_source(input));
// error: expected ',' or ']'
//   --> input.yaml:1:7
//    |
//  1 | port: [unclosed
//    |       ^^^^^^^^^ here
}

Enable --features miette to render the same payload through the miette::Diagnostic interface with cargo / rustc-style ANSI output. Enable --features ariadne for an ariadne::Report; both adapters consume the canonical code, help, severity, and label list rather than maintaining their own mappings.

// Needs `miette` in *your* Cargo.toml alongside noyalib's `miette`
// feature, so this block is shown rather than compiled here.
fn main() -> miette::Result<()> {
    let cfg: Config = noyalib::from_str(yaml)
        .map_err(|e| miette::Report::new(e)
            .with_source_code(yaml.to_owned()))?;
    Ok(())
}

See crates/noyalib/examples/diagnostic.rs.

8. Lossless edits (cst::Document)

from_str → to_string round-tripping discards comments and exact whitespace by design — the YAML data model excludes both. When you need byte-faithful editing (Renovate-style version bumps, manifest patchers, formatters), use the lossless CST instead:

#![allow(unused)]
fn main() {
use noyalib::cst::parse_document;

let mut doc = parse_document(r#"
Production config
server:
  host: api.example.com
  port: 8080      # bind to public IP
"#)?;

// Surgical edit — only the touched span is rewritten.
doc.set("server.port", "9090")?;
println!("{doc}");
//
// # Production config
// server:
//   host: api.example.com
//   port: 9090      # bind to public IP
//
// (Comment and trailing whitespace preserved byte-for-byte.)
}

The CST exposes:

APIUse
parse_document(s) / parse_stream(s)Read, under the default ParserConfig
parse_document_with_config(s, &cfg) / parse_stream_with_config(s, &cfg)Read under a ParserConfig (budgets, the alias-to-anchor ratio, key policies); the document keeps it for every later re-parse
doc.edit()Queue non-overlapping byte-range replacements and atomically commit them with one complete-document validation
doc.set(path, fragment)Write a literal scalar
doc.set_value(path, &Value)Write any Value
doc.entry(path)Chainable mutable handle (18 methods, smart items[0] paths)
doc.remove(path)Delete a key or sequence item, including multi-line / nested block values (guarded)
doc.rename_key(path, new_key)Rename a mapping key, value untouched (new key spelled in the old key’s style)
doc.key_span(path)Read-only byte span of an entry’s key token (companion to span_at; powers duplicate-key diagnostics)
doc.swap_items(path, i, j)Exchange two block-sequence items, structure preserved (guarded + rollback)
doc.move_item(path, from, to)Move a block-sequence item to a new index, atomic (built on swap_items)
doc.set_inline_comment(path, text)Set/replace the trailing # comment on a single-line node (guarded)
doc.remove_inline_comment(path)Remove the trailing # comment on a node (no-op if absent)
doc.set_leading_comment(path, text)Set/replace the leading # comment block above a single-line mapping key
doc.remove_leading_comment(path)Remove the leading comment block above a mapping key (no-op if absent)
doc.push_back(path, fragment)Append to a sequence
doc.insert_entry_value(map, key, &v) / push_back_value(path, &v) / insert_after_value(item, &v)Insert a typed value auto-formatted via cst::Emit — quoted so it re-parses as data, not YAML syntax (guarded + rollback)
doc.rename_anchor(old, new)Rename an &anchor and every *alias (incl. << merges) in one atomic edit; refuses a name that collides with another anchor (guarded + rollback)
doc.materialise_aliases_of(name)Inline every *name reference
doc.indent_unit()Detect 2- / 3- / 4-space conventions
doc.dominant_quote_style()", ', or plain

See crates/noyalib/examples/lossless_edit.rs and crates/noyalib/examples/entry_api.rs.

9. Schema validation and autofix

Under --features validate-schema:

#![allow(unused)]
fn main() {
use noyalib::{from_str, schema_for, validate_against_schema, JsonSchema};

#[derive(serde::Serialize, serde::Deserialize, JsonSchema)]
struct ServerConfig {
    /// Port the server binds on.
    port: u16,
    /// Hostname or IP literal.
    host: String,
}

let schema = schema_for::<ServerConfig>()?;
let data: noyalib::Value = from_str("port: 8080\nhost: api")?;
validate_against_schema(&data, &schema)?;
}

The matching coerce_to_schema(&mut data, &schema) engine rewrites obvious type slips — e.g. port: "8080" (quoted string where the schema declares integer) — back to the schema’s expected type, then re-validates. This is the library engine behind noyavalidate --fix. See crates/noyalib/examples/schema_validation.rs.

9b. Custom YAML tags (Value::Tagged)

YAML lets any node carry a “tag” — a type label like !Color, !Celsius, !!python/object. noyalib surfaces these on the default from_str::<Value> path so downstream code can dispatch on them safely (no global type registry, no runtime code lookup, no attacker-controlled instantiation):

#![allow(unused)]
fn main() {
use noyalib::{from_str, Value};

let v: Value = from_str("!Color '#ff8800'\n")?;
match &v {
    Value::Tagged(t) => {
        println!("tag: {}", t.tag().as_str());          // "!Color"
        println!("inner: {:?}", t.value().as_str());    // Some("#ff8800")
    }
    _ => unreachable!(),
}
Ok::<(), noyalib::Error>(())
}

This contract holds for all three tag-bearing shapes:

YAMLResulting Value
!Custom 'hello'Value::Tagged(t), t.tag() == "!Custom", t.value() == Value::String("hello")
!List [a, b]Value::Tagged(t), t.tag() == "!List", t.value() == Value::Sequence(...)
!Map {k: v}Value::Tagged(t), t.tag() == "!Map", t.value() == Value::Mapping(...)
!!str 42Value::String("42") (core tag — resolves)
!!int 42Value::Number(Integer(42)) (core tag — resolves)

With the lossless-u64 feature enabled and ParserConfig::lossless_u64_integers(true), !!int scalars in (i64::MAX, u64::MAX] resolve as Value::Number(Unsigned(_)).

Reading through the wrapper. Two helpers on Value step through the tag for transparent reads:

#![allow(unused)]
fn main() {
use noyalib::{from_str, Value};
let v: Value = from_str("!Color '#ff8800'\n")?;
assert_eq!(v.untag_ref().as_str(), Some("#ff8800"));    // borrow-friendly
let owned = v.untag();                                  // consumes
Ok::<(), noyalib::Error>(())
}

Typed targets see through tags transparently. A #[derive(serde::Deserialize)] struct Foo { ... } against !Foo {x: 1} yields Foo { x: 1 } — the typed visitor never observes the tag string. This is what lets schema-tagged YAML inputs deserialise into bare structs.

Opt out via TagRegistry. When you want noyalib to strip known tags inline on the streaming path (no AST detour, no wrapper to step through), register them:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use noyalib::{from_str_with_config, ParserConfig, TagRegistry};

let cfg = ParserConfig::new()
    .tag_registry(Arc::new(TagRegistry::new().with("!Color")));
// Now `!Color "#ff8800"` deserialises directly into a String,
// no Tagged wrapper, no AST fallback.
}

Reject custom tags entirely. For schema-strict contexts — audit pipelines, security-sensitive ingest — policy::DenyTags fails any document carrying a non-core tag at parse time:

#![allow(unused)]
fn main() {
use noyalib::{from_str_with_config, ParserConfig, Value};
use noyalib::policy::DenyTags;

let cfg = ParserConfig::new().with_policy(DenyTags);
let bad: Result<Value, _> = from_str_with_config("k: !Foo 1\n", &cfg);
assert!(bad.is_err());
}

Round-trip a Tagged value back to YAML. The generic to_string serialises through serde, which routes Value::Tagged(...) as a single-entry map (the right shape for serde_json interop) — losing the YAML-tag wire form. For lossless emit, the dedicated to_string_value / to_writer_value family bypasses the serde pipeline:

#![allow(unused)]
fn main() {
use noyalib::{from_str, to_string_value, Value};
let v: Value = from_str("!Color '#ff8800'\n")?;
let yaml = to_string_value(&v)?;
// `yaml` re-parses into an equivalent `Value::Tagged`.
assert!(matches!(noyalib::from_str::<Value>(&yaml)?, Value::Tagged(_)));
Ok::<(), noyalib::Error>(())
}

Known limitation: the tag-preserving fast path on the deserialise side is engaged only when the target is exactly T = Value (detected via [std::any::TypeId]). Wrapper targets like Spanned<Value>, Vec<Value>, Option<Value>, HashMap<_, Value> route the inner deserialise through the standard transparent-unwrap path. If you need both spans and tag preservation, parse twice (once into Value for tag-aware queries, once into Spanned<T> for the span-aware view).

10. Multi-document streams + parallel parse

YAML’s --- document separator lets one file carry many independent documents (Kubernetes manifests, audit-event streams). The eager API:

#![allow(unused)]
fn main() {
use noyalib::Value;
#[derive(serde::Deserialize)]
struct MyConfig { a: i64 }
let stream = "a: 1\n---\na: 2\n";
use noyalib::{load_all, load_all_as};

// `load_all` yields a lazy iterator of `Result<Value>`; collect it.
let docs: Vec<Value>      = load_all(stream)?.collect::<Result<_, _>>()?;
// `load_all_as` deserializes every document and returns a `Vec<T>` directly.
let typed: Vec<MyConfig>  = load_all_as::<MyConfig>(stream)?;
}

For large streams, the parallel feature unlocks linear-with- cores throughput:

#![allow(unused)]
fn main() {
#[derive(serde::Deserialize)]
struct MyConfig { a: i64 }
let stream = "a: 1\n---\na: 2\n";
// Drop-in for `load_all_as`. Pre-scans `---` boundaries on a
// single thread, then deserialises each document concurrently.
let docs: Vec<MyConfig> = noyalib::parallel::parse(stream)?;
}

The pre-scan is O(input_len); the per-document work parallelises across the Rayon thread pool. Services can supply a bounded, caller-owned pool instead of using Rayon’s global pool:

#![allow(unused)]
fn main() {
let stream = "---\na: 1\n---\na: 2\n---\na: 3\n---\na: 4\n";
let pool = noyalib::parallel::ThreadPoolBuilder::new()
    .num_threads(2)
    .build()?;
let docs = noyalib::parallel::parse_with_config_in_pool::<noyalib::Value>(
    stream,
    &noyalib::ParserConfig::default(),
    &pool,
)?;
assert_eq!(docs.len(), 4);
}

The pool owns the concurrency limit and lifecycle. Inputs with fewer than four documents remain sequential to avoid scheduling overhead.

10b. Error-recovering parser for LSP / IDE (recovery feature)

The default from_str family returns Err at the first syntax violation. LSP / IDE consumers need the opposite contract: keep going past errors, build a best-effort partial tree, collect every error encountered so the editor can show a complete diagnostics list and offer autocomplete on the recoverable subtrees.

#![allow(unused)]
fn main() {
// Cargo.toml: noyalib = { version = "0.0.58", features = ["recovery"] }
use noyalib::recovery::parse_lenient;

let half_typed = "name: noyalib\nfeatures: [recovery, sval\n# ^ unclosed\n";
let r = parse_lenient(half_typed);
assert!(!r.is_complete);
println!("errors: {}", r.errors.len());
// r.value is the best-effort recovered tree.
}

Three-pass recovery: strict pass → DuplicateKeyPolicy::Last retry → line-truncation retry. Multi-document input split on ---; each document recovered independently. Error collection bounded by LenientConfig::max_errors.

See crates/noyalib/examples/recovery_lenient.rs.

10c. Native async parsing on tokio (tokio feature)

For high-concurrency services parsing YAML from network sources, the tokio feature provides bounded drain helpers and a backpressured document stream:

// Needs an async runtime and, for pattern 2, `tokio-util` in *your*
// Cargo.toml, so this block is shown rather than compiled here.
// Cargo.toml: noyalib = { version = "0.0.58", features = ["tokio"] }
use noyalib::tokio_async::{async_yaml_stream, from_async_reader_multi};

// Pattern 1: drain-and-parse
let docs: Vec<MyDoc> = from_async_reader_multi(&mut reader).await?;

// Pattern 2: backpressured stream, one parsed document per item
let documents = async_yaml_stream::<_, MyDoc>(reader);

Per-document boundaries follow the YAML 1.2.2 §9.1.2 --- grammar: column-0 marker followed by whitespace or EOL. Parsing each complete document is synchronous when the stream is polled; the stream prevents additional reads until the consumer requests the next item.

See crates/noyalib/examples/tokio_async_reader.rs.

10d. sval streaming adapter (sval feature)

Alternative to the default serde route for callers wanting to skip serde_derive’s compile-time overhead or the binary-size cost of serde monomorphisation. The adapter implements sval::Value for the noyalib value graph so any sval::Stream consumer can read it:

// `sval` and the `sval::Stream` you hand it are *your* dependencies,
// so this block is shown rather than compiled here.
// Cargo.toml: noyalib = { version = "0.0.58", features = ["sval"] }
let value: noyalib::Value = noyalib::from_str("name: noyalib")?;
sval::Value::stream(&value, &mut my_stream)?;

serde remains the default route; sval is opt-in.

See crates/noyalib/examples/sval_streaming.rs.

11. The CLI tools (noyafmt, noyavalidate)

Two command-line companions ship from the split-out sebastienrousseau/noya-cli repo (split at v0.0.13, ADR-0005; released in strict lockstep):

# Format YAML in-place via the lossless CST.
noyafmt --write config.yaml

# Verify formatting without writing — for CI gates.
noyafmt --check ci/*.yaml

# Validate syntax + (optionally) JSON Schema 2020-12.
noyavalidate --schema schema.yaml in.yaml

# Validate + auto-fix obvious type slips.
noyavalidate --schema schema.yaml --fix in.yaml

Both use the same noyalib::cst::format + coerce_to_schema engines exposed to library callers. Distro-installable via crates.io (cargo install noya-cli --locked), Homebrew, AUR, and the other channels in pkg/PUBLISH.md.

12. WASM, MCP, and LSP

Three satellite crates — each split to its own repo (ADR-0005) and released in strict lockstep with this library — target specific deployment shapes:

  • noyalib-wasm (sebastienrousseau/noyalib-wasm). wasm-pack output published to npm as @sebastienrousseau/noyalib-wasm. Browser IDEs use it for live YAML formatting / validation; the bundle is ~338 KB after LTO. Split to its own repo in v0.0.12 (ADR-0005); releases in strict lockstep with this workspace.

  • noyalib-mcp (sebastienrousseau/noyalib-mcp). Model Context Protocol server speaking JSON-RPC over stdio. AI agents (Claude Code, GitHub Copilot, …) call parse, format, get, set, validate tools without needing a Rust toolchain. Available as npx @sebastienrousseau/noyalib-mcp (the wrapper bootstraps the binary) and as a GHCR container image ghcr.io/sebastienrousseau/noyalib-mcp. Split to its own repo in v0.0.13 (ADR-0005); releases in strict lockstep with this workspace.

  • noyalib-lsp (sebastienrousseau/noyalib-lsp). Language Server Protocol implementation. Editors get format-on-save, inline diagnostics, schema-driven hover docs. Bundled into the noyalib VS Code / Open VSX extension (pkg/vscode/).

Where next

Reporting issues / sending PRs

Filed at https://github.com/sebastienrousseau/noyalib. Reproducer YAML + the parser config you used together is the fastest path to a fix.

Ecosystem comparison

How noyalib lines up against the other Rust YAML libraries it is likely to be evaluated alongside. Cells reflect the state of the named crates as of 2026-05 (verified against the latest crates.io release of each); corrections welcome via PR.

For per-crate migration guides (function tables, behavioural notes, drop-in shim notes), see MIGRATION.md and the per-crate guides linked from there.

noyalibserde_ymlserde_yaml_ngsaphyryaml-rust2rust-yaml
YAML Test Suite100% strict (406/406)—————
Pure RustYesNo (C-FFI)No (C-FFI)YesYesYes
Zero unsafeYesNoNoYesYesYes
Serde integrationYesYesYesYesNoYes
Streaming deserYesNoNoNoNoNo
#![no_std]YesNoNoNoNoNo
Zero-copy scalarsYesNoNoNoNoYes
SIMD scanningYes (memchr + bitmask)NoNoNoNoNo
SWAR numeric parseYesNoNoNoNoNo
Parallel multi-docYes (parallel::parse)NoNoNoNoNo
DoS hardened~12 budgetsBasicBasicYesNoYes
Pluggable policiesYes (policy::Policy)NoNoNoNoNo
Secret interpolationYes (${VAR})NoNoYesNoNo
CST manipulationYes (cst::Document)NoNoNoNoNo
Native LSPYes (noyalib-lsp)NoNoNoNoNo
MCP serverYes (noyalib-mcp)NoNoNoNoNo
JSON Schema codegenYes (schema_for)NoNoNoNoNo
JSON Schema validateYes (validate_against_schema)NoNoNoNoNo
Schema-driven autofixYes (coerce_to_schema)NoNoNoNoNo
miette diagnosticsYesNoNoNoNoNo
WASM338 KBNoNoNoNoNo
Source spansYesNoNoYesNoNo
YAML 1.1 compatYesYesYesNoYesNo
SerializationYesYesYesYesNoNo
Path queriesquery("..name")NoNoNoNoNo
Zero-copy ASTBorrowedValue<'a>NoNoNoNoPartial

Reading the table

  • YAML Test Suite (100% strict) — noyalib is the only Rust YAML implementation that passes all 406 active cases in the YAML 1.2 official Test Suite under strict comparison. The other libraries either don’t track this metric publicly or apply lenience that lets cases the spec rejects pass through.
  • C-FFI rows — serde_yml and serde_yaml_ng (the active forks of dtolnay/serde-yaml) wrap libyaml via unsafe-libyaml in their dep tree. Pure-Rust alternatives don’t have this transitive C dependency.
  • Streaming deser — noyalib::from_str::<T> walks parser events directly into the typed target without building an intermediate Value AST. The other crates that do have serde integration build the AST first.
  • DoS hardening (~12 budgets) — max_depth, max_alias_expansions, max_document_length, max_sequence_length, max_mapping_keys, max_events, max_nodes, max_total_scalar_bytes, max_documents, max_merge_keys, plus the cumulative-alias-byte budget, the alias/anchor ratio guard, and the per-document size cap. See POLICIES.md for defaults and override knobs.

How to verify a row yourself

The cells were sourced from each crate’s Cargo.toml, docs.rs API surface, and recent release notes. To re-verify (e.g. after a new release elsewhere):

# Source-of-truth dump for any crate's exported surface:
cargo doc --no-deps -p <crate> --open

# Dependency tree (catches the libyaml C-FFI hop):
cargo tree -p <crate> --target all

If you spot a stale or wrong cell, file a correction at https://github.com/sebastienrousseau/noyalib/issues.

Benchmarks

All numbers below were measured on Apple M4 (aarch64-apple-darwin), Rust 1.95 stable, criterion --warm-up-time 2 --measurement-time 4. All libraries compiled with --release (LTO=fat, codegen-units=1, panic=abort). Run locally via cargo bench --bench comparison.

PGO — scripts/pgo.sh runs the full LLVM profile-guided optimisation pipeline (instrument → train against the YAML test suite + benches/fixtures/ → merge profdata → optimised rebuild). Adds 5-15% on top of the numbers below; recommended for production deployments.

For algorithmic-complexity guarantees (O(n) parser, O(d) stack depth, O(1) anchor lookup, etc.), see POLICIES.md §4 — Performance & algorithmic complexity.

Headline (5-way Rust YAML head-to-head, every fixture)

Compares noyalib against the five most-used pure-Rust YAML libraries: serde_yaml_ng (the de-facto ecosystem successor to the unmaintained serde_yaml), yaml-rust2 (the heaviest-tuned non-serde parser), serde_yml (the maintainer’s prior crate), yaml-spanned (the closest other span-tracking parser), and serde-saphyr (a high-performance newcomer).

The serde_yml and serde_yaml_ng figures below are recorded measurements, not rerunnable today: both crates were retired from benches/comparison.rs, serde_yml in v0.0.6 (RUSTSEC-2025-0068) and serde_yaml_ng in v0.0.58 (its unsafe-libyaml dependency is unmaintained, RUSTSEC-2024-0449), so that cargo deny and cargo audit stay clean without exemptions.

Deserialize (parse a YAML document into a Value AST)

Fixturenoyalibvs serde_yaml_ngvs yaml-rust2vs serde_ymlvs yaml-spannedvs serde-saphyr
simple (3 fields)1.40 µs1.84×1.36×1.96×1.69×2.00×
nested (20 fields)9.66 µs1.55×1.25×1.63×1.60×1.76×
large_list (500 items)920 µs1.42×1.19×1.48×1.38×1.69×
github_actions (deep + comments)46.4 µs1.66×1.25×1.72×1.74×1.73×
k8s multi-document85.1 µs1.42×1.11×———

(serde_yml, yaml-spanned, serde-saphyr ship single-document APIs only — no apples-to-apples cell on the multi-document fixture.)

Serialize (emit a typed value to YAML)

Fixturenoyalibvs serde_yaml_ng
simple (3 fields)290 ns4.34×
nested (20 fields)2.25 µs3.00×

Round-trip (deserialize + serialize)

Fixturenoyalibvs serde_yaml_ng
nested (20 fields)12.0 µs1.83×

Headline numbers

noyalib is faster than every Rust YAML library on every fixture measured. Speedup ranges:

CompetitorSpeedup range (deserialize)
serde-saphyr1.69×–2.00×
serde_yml1.48×–1.96×
serde_yaml_ng1.42×–1.84×
yaml-spanned1.38×–1.74×
yaml-rust21.11×–1.36×

Serialize: 3.00×–4.34× over serde_yaml_ng. Round-trip: 1.83× over serde_yaml_ng. The gap to yaml-rust2 is the narrowest because that library doesn’t carry the Spanned<T> plumbing, the per-tag Cow<'a, str> propagation, or the Value::Tagged preservation that noyalib does — paying for those costs is part of the engineering cost. Closing the remaining gap to ≥ 2× over yaml-rust2 runs through SemVer-breaking refactors (CompactString keys in Mapping, bump-arena event lifetimes, eliminating the Value AST on the typed path).

Typed deserialization (streaming, no Value AST)

from_str::<T> walks parser events directly into the typed target, bypassing the Value AST when the caller asked for a typed T. The streaming path bakes in YAML 1.2 semantics (<<: *alias merges natively, !!binary is propagated as a typed tag).

LibrarySimple structNested struct
noyalib1.22 µs7.08 µs
serde_yaml_ng2.10 µs (1.72×)11.0 µs (1.55×)

Why serialize is so far ahead

The serializer was built around itoa / ryu direct-write buffers (the fast-int / fast-float features) plus SIMD-driven quote-need detection that lets plain ASCII output emit borrowed bytes without escaping. serde_yaml_ng routes through fmt::Write and revalidates UTF-8 on output — that’s the source of the 3-4× gap.

Why deserialize against yaml-rust2 is closer

yaml-rust2 is a heavily-tuned parser that doesn’t carry the Spanned<T> plumbing, the per-tag Cow<'a, str> propagation, or the Value::Tagged preservation that noyalib does. The 1.12-1.35× gap reflects the engineering cost of those guarantees; yaml-rust2 is a good baseline because it’s the closest pure-Rust parser in feature shape. The path to ≥ 2× over yaml-rust2 on deserialize runs through SemVer-breaking refactors (CompactString keys in Mapping, bump-arena event lifetimes, eliminating the Value AST on the typed path).

Roundtrip (deserialize + serialize)

LibraryNested (20 fields)
noyalib12.7 us
serde_yaml_ng25.5 us (2.0x)

SIMD structural-discovery throughput

How fast each library can find every YAML delimiter in a 1 MiB real-shaped document. The structural-bitmask path replaces the classical “find one delimiter at a time” pattern with a 32-byte chunk that drains every delimiter via mask.trailing_zeros() before reloading. (benches/structural_bitmask.rs)

Path4 KiB64 KiB1 MiBvs memchr loop
scalar (byte-by-byte baseline)13.0 us206 us3.33 ms0.86x
memchr + find_any_of loop11.3 us179 us2.89 ms1.0x
StructuralIter (stable)2.7 us42.3 us681 us4.2x
StructuralIter (nightly-simd)1.20 us19.7 us311 us9.2x

serde_yaml_ng and serde-saphyr use byte-by-byte structural discovery — they sit alongside the scalar baseline row and lose to the 32-byte-bitmask path by an order of magnitude on the 1 MiB workload.

SWAR decimal-integer parsing

Plain-scalar integer resolution via the SIMD-Within-A-Register pipeline that folds 8 ASCII digits per u64 cycle. (benches/numeric_parse.rs)

Widthstdlib from_strSWARspeedup
8 digits8.12 ns3.74 ns2.17x
19 digits22.0 ns9.25 ns2.38x
i64::MAX24.6 ns9.75 ns2.52x
Bulk parse 1000 ints7.93 us5.38 us1.47x

How the SWAR pipeline works

The standard library’s i64::from_str walks one digit at a time, branching on each character to validate it’s 0..=9 and folding into the accumulator with acc * 10 + digit. SWAR fuses this loop:

  1. Load 8 bytes into a u64 register (one aligned mov).
  2. Subtract b'0' lane-wise (sub_packed).
  3. Validate every byte is < 10 via a single masked compare.
  4. Combine the eight digits into a single integer with two multiply-and-add rounds (the 0x010A0064... constant pipeline).

The result is an 8-digit chunk consumed in 6 instructions instead of ~24, and i64::MAX (19 digits) parses in 9.75 ns versus 24.6 ns — a 2.5x speedup. The full pipeline is documented inline in crates/noyalib/src/simd.rs.

Every SWAR pipeline has a portable byte-by-byte fallback that Miri verifies under big-endian (mips64) so the byte-order assumption stays explicit.

Parallel multi-document throughput

Linear scaling across CPU cores for ----separated streams (telemetry logs, audit exports, Kubernetes-resource snapshots). Pre-scan runs in O(input_len) on the main thread; the per- document parse work distributes across the Rayon thread pool. (benches/streaming_vs_value.rs, benches/large_doc_soak.rs)

#![allow(unused)]
fn main() {
#[derive(serde::Deserialize, PartialEq, Debug)]
struct MyType { kind: String }
let yaml = "kind: a\n---\nkind: b\n";
// Single-threaded baseline:
let docs: Vec<MyType> = noyalib::load_all_as(yaml)?;

// Parallel (off by default — pulls Rayon under `parallel`
// feature). Drop-in replacement, scales near-linearly with cores
// on multi-document inputs:
let docs: Vec<MyType> = noyalib::parallel::parse(yaml)?;
assert_eq!(docs.len(), 2);
}

Other Rust YAML libraries the comparison table covers run single-threaded.

Architecture validation

CapabilityMeasured Impact
Streaming deserializer (bypasses Value AST)30% faster (14.0 vs 19.4 us)
BorrowedValue<'a> (zero-copy AST)18% faster (16.0 vs 19.4 us)
Zero-copy scanner (Cow::Borrowed)12% fewer allocations (6.3 vs 7.1 us)
Span-free path (from_str default)34% less overhead (5.6 vs 8.5 us)
FxHasher for Mapping keysFaster key insertion and lookup
SIMD scanning (memchr)Faster delimiter search on large inputs
Path queriesvalue.query("items[*].name") with * and ..
DoS rejection (billion laughs)<3 us with ParserConfig::strict()
DoS rejection (deep nesting)<4 us

Reproduce: make bench-compare (the comparison bench with the compare-saphyr feature, so serde-saphyr is in the same run) and cargo bench --bench architecture.

Head-to-head with serde-saphyr (2026-09-06)

serde-saphyr is the other pure-Rust crate that passes the full official test suite, so it is the comparison that matters. Same run, same host, same command, every crate in the same criterion session:

make bench-compare   # cargo bench --bench comparison --features compare-saphyr

Host: Apple A18 Pro. Toolchain: rustc 1.98.0 (88d9e12ae 2026-08-18). Times are criterion medians in the default bench profile (no LTO tuning), so they sit above the headline table’s tuned numbers; the ratios are what to read.

Fixturenoyalibserde-saphyr 1.2.0ratioserde_yaml_ngyaml-rust2yaml-spanned
Deserialise simple2.15 µs5.49 µs2.55×3.76 µs (1.75×)2.70 µs (1.25×)3.73 µs (1.73×)
Deserialise nested13.32 µs38.34 µs2.88×19.26 µs (1.45×)16.13 µs (1.21×)21.81 µs (1.64×)
Deserialise large_list1.09 ms3.10 ms2.84×2.12 ms (1.94×)1.79 ms (1.64×)2.12 ms (1.94×)
Deserialise github_actions76.72 µs250.41 µs3.26×211.06 µs (2.75×)253.59 µs (3.31×)438.86 µs (5.72×)
Deserialise k8s_multidoc134.04 µs——380.09 µs (2.84×)248.18 µs (1.85×)—

serde-saphyr does not appear on the multi-document fixture because its from_str reads one document. Rerun the command to refresh this table; the numbers above are reproducible to within criterion’s reported confidence intervals.

v0.0.6 feature benchmarks

Three additional bench arms cover the optional surfaces added in v0.0.6 (recovery, sval, tokio). Run together with:

cargo bench --bench v006_features --features recovery,sval,tokio
GroupComparesInterpretation
recovery_strict_vs_lenientfrom_str vs recovery::parse_lenient on valid inputThe lenient wrapper should not regress strict throughput by more than rounding noise (recovery retries only execute on Err).
sval_vs_serde_valuefrom_str → Value (serde) vs streaming a pre-built Value through a no-op sval::StreamIsolates the per-event cost of the sval surface — useful when deciding whether the serde-free route is worth the extra dep.
tokio_async_drainfrom_slice (sync) vs tokio_async::from_async_reader against an in-memory BufReaderQuantifies the async fixed-overhead cost when I/O latency is zero; real network latency dwarfs this.

Project metrics

MetricValue
Source26,000+ lines across the workspace
Test suite6,900+ test entries + 580+ doctest entries + CLI smoke + stress/load suites; enumerate in CI for the exact revision
YAML Test Suite100% strict compliance: 406/406 attempted cases pass, 0 failures, 0 deliberate skips
Examples76 runnable examples
Coverage96%+ function coverage / 94%+ line coverage / 93%+ region coverage (CI-gated)
Dependencies5 unconditional + 3 default-on optional (itoa, ryu, serde_ignored) + 12 opt-in optional (miette, garde, validator, schemars, serde_json, jsonschema, figment, rayon, serde-saphyr, plus the three default-on opt-outs)
WASM binary338 KB (release, LTO)
MSRVRust 1.89.0 (whole lockstep set, since v0.0.58); the opt-in compare-saphyr bench feature needs 1.88+, inside that floor (see POLICIES.md)

Profile-Guided Optimization (PGO) — noyalib

Profile-Guided Optimization is a compiler technique that lays out hot paths inline, cold paths off the fast track, and optimises branch ordering based on the actual execution profile of a representative workload — rather than the static heuristics LLVM uses by default. For YAML parsing, where the scanner’s per-byte dispatch is the dominant cost, PGO typically yields 5–15% speedup on from_str::<Value> and cst::parse_document against representative inputs.

This document is the operational guide: how to build a PGO’d noyalib binary, what’s instrumented, and what gains to expect.

Status: opt-in. The default cargo install noya-cli path does not use PGO (PGO requires a two-pass build that’s longer and produces target-specific artefacts). Distro packagers and downstream teams who want the extra 5–15% can run scripts/pgo.sh to produce a PGO’d binary.

Quick start

# Run the full PGO pipeline against the default training corpus.
./scripts/pgo.sh

# Custom training input:
./scripts/pgo.sh path/to/representative-workload.yaml

# Train a different binary (default is noyafmt):
PGO_TARGET=noyavalidate ./scripts/pgo.sh

The script:

  1. Builds an instrumented binary with RUSTFLAGS=-Cprofile-generate=….
  2. Runs the instrumented binary against the training corpus (default: every YAML under tests/yaml-test-suite/ plus benches/fixtures/).
  3. Merges the per-process *.profraw files via llvm-profdata merge into a single merged.profdata.
  4. Builds the optimised binary with RUSTFLAGS=-Cprofile-use=…/merged.profdata.

Output: target/release/<binary> is now PGO’d. The script prints a hyperfine recipe for measuring the gain against the non-PGO baseline.

Requirements

  • rustc 1.89.0+. PGO has been stable since Rust 1.45; noyalib’s MSRV (1.89.0) easily clears that.
  • llvm-profdata on $PATH. Install via rustup component add llvm-tools-preview. The script auto-discovers the rustup-shipped binary if $PATH lookup fails.
  • Training corpus that resembles real production input. The default corpus (the YAML test suite + benches fixtures) is broad — for per-team optimisation, replace it with your own representative YAML.

What gets instrumented

PGO instruments every basic block of the trained binary including its statically-linked dependencies. For noyafmt / noyavalidate, that’s:

  • noyalib::parser::scanner — the per-byte dispatch loop.
  • noyalib::parser::events — the event-tree state machine.
  • noyalib::parser::loader — the AST builder.
  • noyalib::cst::* — when the binary touches the CST path (noyafmt --check, --fix).
  • noyalib::ser — emit-side hot paths.
  • Transitive deps — serde, indexmap, ryu, itoa, memchr.

The compiler then re-orders branches in the optimised pass so the most-taken arm of each match is checked first. For YAML, that’s overwhelmingly plain scalar in the scanner dispatch and Mapping / Sequence in the event-tree machine.

Expected gains

Measured on Apple M4, Rust 1.94 stable, 97 KB synthetic mapping-of-records document:

WorkloadBaselinePGOSpeedup
noyafmt --check (parse + canonical compare)5.2 ms4.4 ms1.18×
noyavalidate --schema (parse + JSON Schema check)9.1 ms7.9 ms1.15×
from_str::<Value> micro-bench2.69 ms2.31 ms1.16×
cst::parse_document micro-bench4.59 ms4.04 ms1.14×

Gains are workload-specific. Ranges of 5–20% are typical for programs whose hot path is well-defined and stable; YAML parsing is exactly that kind of workload.

Combining with the workspace [profile.release] settings

The PGO build composes with the workspace release profile (opt-level = 3, lto = "fat", codegen-units = 1, overflow-checks = true). The training pass uses the same release profile (instrumentation aside), so the optimised build benefits from both LTO + PGO together.

CI integration

The scripts/pgo.sh workflow is intentionally kept out of the per-PR CI path because:

  1. The two-pass build doubles the wall-clock cost.
  2. PGO artefacts are target-specific (per architecture).
  3. A small per-PR perf regression isn’t best caught via PGO — CodSpeed already tracks per-bench drift on the standard release profile.

PGO belongs in the release pipeline when the binary is about to ship to a wide audience. The .github/workflows/release-binaries.yml workflow is the natural integration point — build PGO’d artefacts for the published binaries (noyafmt, noyavalidate, noyalib-mcp, noyalib-lsp) per host triple.

Cross-platform notes

  • Linux: works out of the box with the rustup-shipped llvm-tools-preview.
  • macOS: same. Apple-silicon-specific PGO data is produced; do not reuse a Mac-trained merged.profdata on Linux x86_64.
  • Windows: works with llvm-profdata.exe from the rustup llvm-tools-preview component. The MSVC linker handles the -Cprofile-use= flag; no extra setup needed.

Trained merged.profdata is per host triple — re-run the script on each release runner.

Troubleshooting

  • “cannot find llvm-profdata” → install via rustup component add llvm-tools-preview.
  • “profile data file is empty” → the training step didn’t produce any .profraw files. Verify the binary actually executed against the training corpus (the script tees output via /dev/null; check exit codes).
  • “profile data is incompatible” → the rustc version that generated the profile differs from the one consuming it. Re-run the full pipeline on the current toolchain.

Further reading

Glossary

Domain vocabulary used across noyalib’s documentation, code, and commit messages. When a term has both a YAML-spec meaning and a noyalib-specific meaning, both are listed.

YAML specification terms

Anchor. A &name marker placed before a node so the node can be referenced later via an alias. Per the YAML 1.2 spec each document has its own anchor namespace; noyalib resets the anchor table on DocumentEnd.

Alias. A *name reference that resolves to the value previously labelled by an anchor of the same name within the same document. noyalib resolves aliases eagerly on both the owned (Value) and borrowed (BorrowedValue) paths.

Block style. Indentation-driven layout — the multi-line form most configuration files use. Contrast with flow style. noyalib’s formatter defaults to block style for non-leaf collections.

Flow style. JSON-shaped inline layout — [a, b, c], {k: v}. Mostly used for short collections inside a block document.

Plain scalar. A scalar with no quoting — port: 8080. The resolver determines its type from its lexical form (the core schema in YAML 1.2; the broad schema in YAML 1.1).

Folded scalar. A >-introduced multi-line scalar that joins lines with a space, preserving paragraph breaks.

Literal scalar. A |-introduced multi-line scalar that preserves every newline byte for byte.

Tag. An explicit type annotation — !!str, !!int, or a custom URI like !shopping. noyalib carries tags through the parse and emits them on serialise where present.

Document marker. --- (start) or ... (end). A YAML stream is a sequence of zero or more documents; the markers are optional for the first document but required between subsequent ones.

Directive. A %-prefixed line at the document head — %YAML 1.2 for version, %TAG !x! tag:example.com,2026: for tag prefix shorthands. Per the spec, at most one %YAML directive per document.

Merge key. The << key. When its value is a mapping (or a sequence of mappings), the merged keys are inherited unless overridden. noyalib supports three policies via MergeKeyPolicy::{Auto, Disabled, Strict}.

Surrogate pair. A two-\uXXXX JSON-style escape that combines into a single supplementary-plane code point via the UTF-16 algorithm. noyalib pairs them in double-quoted scalars; lone surrogates are rejected.

Sexagesimal. Base-60 integer notation — 1:30 reads as 1*60 + 30 = 90. YAML 1.2 dropped this from the core schema; noyalib accepts it under legacy_sexagesimal (auto-on with YamlVersion::V1_1).

Norway problem. YAML 1.1 resolves bare no to false. The country code for Norway is NO. Configuration files that store two-letter country codes were silently mis-typed for years until YAML 1.2 narrowed the boolean lexicon to true / false. noyalib reproduces the 1.1 behaviour under legacy_booleans.

noyalib architecture terms

Value. The owned, dynamically-typed YAML value enum — Null / Bool / Number / String / Sequence / Mapping / Tagged. Heap-allocated; the natural counterpart to serde_json::Value.

BorrowedValue<'a>. The zero-copy variant where string scalars and mapping keys are Cow<'a, str> and borrow directly from the input buffer. The shape is otherwise identical to Value.

Mapping. noyalib’s string-keyed map type. Insertion-ordered (backed by IndexMap). Mapping keys that the YAML resolver produces as non-strings (numbers, booleans, sequences) are stringified on entry.

MappingAny. The Value-keyed map type for documents whose mapping keys are themselves complex YAML values (sequences, mappings, etc.). Use this when string-keyed Mapping would lose fidelity.

Streaming deserializer. The pull-style from_str::<T> path that binds directly into T without materialising a Value intermediate. Faster and lower-memory than the loader path; the default for typed deserialisation.

Loader. The path that materialises Value first, then runs Deserializer<'_> against it. Used for dynamic-shape inputs and the strict-mode and span-tracking helpers.

Green tree. The immutable, byte-faithful CST that backs noyalib::cst::Document. Stores every byte of the source — content, whitespace, comments, indentation, line breaks. Edits rewrite the tree structurally and re-emit byte-for-byte where untouched.

Span tree. A side-table mapping every Value node to its source byte range. Built lazily by the loader when the caller requests Spanned<T> deserialisation or path-aware diagnostics.

Spanned scalar. A scalar value paired with its source byte range (start..end). Used by Spanned<T> for editor diagnostics and JSON-Schema $id-style cross-references.

Strict deserialise. from_str_strict<T> and friends — surface input keys that the target type does not declare as a typed Error::UnknownField listing every offending path. Backed by serde_ignored.

CST mutation. Editing through noyalib::cst::Document rather than re-serialising a modified T. Preserves comments, indent choices, and sibling formatting; the diff is local to the edited span.

Tag registry. A user-supplied TagRegistry that maps custom !tag URIs to handlers. Lets the streaming path strip or transform application-specific tags inline rather than routing through the AST fallback.

Cargo / build terms

MSRV. Minimum Supported Rust Version. The whole lockstep set — the noyalib core library and every satellite crate — shares a single floor of 1.89.0 as of v0.0.58.

#![forbid(unsafe_code)]. A crate-root attribute that makes any unsafe block a compile error. Applied to every crate in the workspace; non-negotiable.

Lean profile. The --no-default-features --features ["std"] build (or the minimal meta-feature alias) — drops itoa, ryu, and serde_ignored for FIPS / embedded / audit-heavy environments. 5 runtime deps instead of 8.

compat-serde-yaml. The drop-in shim crate feature that exposes a name-for-name surface compatible with serde_yaml 0.9. Every type it exposes is a noyalib-native type re-exported under the serde_yaml name; no upstream dep.

Doctest. A Rust code block (```rust) inside a docstring or README that cargo test --doc compiles and runs. noyalib has ~384 doctests; the noyalib README is wired into doctesting via #[doc = include_str!("../README.md")].

Migrating to noyalib

This is the umbrella index for migrating to noyalib from every actively-published Rust YAML crate. Each link goes to a standalone, self-contained guide with TL;DR diff, function table, behavioural notes, and migration checklist.

Crates.io / docs.rs state verified 2026-09-05. If the upstream API has shifted since, file an issue at https://github.com/sebastienrousseau/noyalib/issues and we’ll update.

Coming from serde_yaml and want zero source changes? Rename the package and keep every call site:

serde_yaml = { package = "noyalib-serde-yaml", version = "=0.0.52" }

noyalib-serde-yaml is the behavioural serde_yaml 0.9 shim, released in lockstep with the core. The guides below are for moving to the native API.

Pick your starting crate

Coming fromDrop-in for serde_yaml?Migration guide
serde_yaml 0.9.34+deprecated(the original — archived 2024-03)MIGRATION-FROM-SERDE-YAML.md
serde_yml 0.0.13mostly (repo archived)MIGRATION-FROM-SERDE-YML.md
yaml_serde 0.10.7yes (Cargo package = rename)MIGRATION-FROM-YAML-SERDE.md
serde-yaml-ng 0.10.0yesMIGRATION-FROM-SERDE-YAML-NG.md
serde-norway 0.9.42yesMIGRATION-FROM-SERDE-NORWAY.md
serde-yaml-bw 2.5.7no (breaking 2.x; 8-variant Value with Alias)MIGRATION-FROM-SERDE-YAML-BW.md
serde-saphyr 1.2.0no (no Value DOM, streaming-only)MIGRATION-FROM-SERDE-SAPHYR.md
yaml-spanned 0.0.3no (read-only, no to_string)MIGRATION-FROM-YAML-SPANNED.md

Already on noyalib from before the v0.0.12 workspace split? That move is covered separately in MIGRATION-WORKSPACE-SPLIT.md.

What to expect from noyalib regardless of source crate

Across every guide, three behavioural notes recur. They each default to safer / stricter behaviour than serde_yaml 0.9 and its forks; opt-outs are documented per guide.

  1. Value::Tagged is preserved through the Value data path. from_str::<Value>("!Custom 'hi'\n") returns Value::Tagged(t) (t.tag() == "!Custom", t.value() == Value::String("hi")). Typed targets (#[derive(serde::Deserialize)] struct Foo { … }) still see through the tag transparently — the preservation only affects the dynamic Value path.
  2. YAML 1.2 strict booleans by default. country: NO parses as Value::String("NO"), not Value::Bool(false). Opt into YAML 1.1 boolean recognition via ParserConfig::new().legacy_booleans(true) if you need the old behaviour for round-trip compatibility with legacy files.
  3. Multi-doc streams use load_all_as. noyalib::load_all_as::<T>(input)? returns a Vec<T> directly. The Deserializer::from_str(...).map(...) pattern from serde_yaml and its forks collapses to this one entry point.

What noyalib adds (no equivalent in any of the eight)

  • noyalib::from_str_strict::<T> — error on unknown keys.
  • noyalib::Spanned<T> — source-location wrapper for typed fields. (yaml-spanned covers a similar use case for the dynamic path; noyalib covers both dynamic and typed.)
  • noyalib::cst::Document — lossless byte-faithful edits with comment / whitespace preservation.
  • noyalib::ParserConfig / noyalib::SerializerConfig — unified, fluent-builder configuration covering every per-call toggle.
  • noyalib::validate_against_schema — JSON Schema 2020-12 validation against the parsed document.
  • noyalib::compat::serde_yaml — name-for-name re-export shim for the most common in-flight use serde_yaml::*; pattern, with no transitive serde_yaml 0.9 dependency.
  • A set of binaries: noyafmt (formatter), noyavalidate (schema validator + auto-fixer), noyalib-mcp (Model Context Protocol server for AI tooling), noyalib-lsp (Language Server Protocol implementation).

What noyalib does not do (yet)

  • No equivalent to serde-saphyr’s strict panic-free-by-construction proof — noyalib’s defence is #![forbid(unsafe_code)] plus the resource-limit gates, not formal verification.
  • No streaming Deserializer you can next() event-by-event from user code — noyalib::Deserializer is a serde-trait driver, not an event iterator.
  • No equivalent to serde-yaml-bw’s Value::Alias variant — noyalib resolves aliases at parse time per YAML 1.2 §7.1.

If your migration runs into a shape we don’t cover, file an issue at https://github.com/sebastienrousseau/noyalib/issues with the upstream crate version and the input that misbehaves.

Migrating to noyalib from other Rust YAML crates

serde_yaml 0.9 is unmaintained — the upstream dtolnay/serde-yaml repo was archived 2024-03-25 and RUSTSEC-2024-0320 flags every cargo audit run that depends on it. Several active forks now sit downstream (yaml_serde, serde-yaml-ng, serde-norway, serde-yaml-bw, serde_yml until it was archived 2025-09); the serde-saphyr and yaml-spanned crates take different design shapes (no DOM, span-aware parsing) but live in the same niche.

noyalib is a clean-room reimplementation of YAML 1.2 with the same serde data model, no unsafe code, and no libyaml C dependency. The bulk of this document is the name-for-name migration guide for serde_yaml 0.9 (the most common starting point); per-library shorthand for every other ecosystem crate ships at the bottom under Migrating from other YAML libraries.

TL;DR

For most call sites, the migration is mechanical:

-[dependencies]
-serde_yaml = "0.9"
+[dependencies]
+noyalib = "0.0"
-use serde_yaml::Value;
-let v: Value = serde_yaml::from_str(input)?;
-let s        = serde_yaml::to_string(&v)?;
+use noyalib::Value;
+let v: Value = noyalib::from_str(input)?;
+let s        = noyalib::to_string(&v)?;

If you want the change to be invisible to call sites, enable the compat-serde-yaml feature:

[dependencies]
noyalib = { version = "0.0", features = ["compat-serde-yaml"] }
-use serde_yaml;
+use noyalib::compat::serde_yaml;

Every public symbol the shim exposes is backed by a noyalib-native type — there’s no transitive dependency on the archived serde_yaml crate.

Function-by-function mapping

serde_yamlnoyalibNotes
serde_yaml::from_str::<T>(s)noyalib::from_str::<T>(s)Identical signature.
serde_yaml::from_slice::<T>(b)noyalib::from_slice::<T>(b)Identical.
serde_yaml::from_reader::<R, T>(r)noyalib::from_reader::<R, T>(r)Identical.
serde_yaml::from_value::<T>(v)noyalib::from_value::<T>(&v)Takes a reference (no clone).
serde_yaml::to_string(&v)noyalib::to_string(&v)Identical signature.
serde_yaml::to_writer(w, &v)noyalib::to_writer(&mut w, &v)Takes &mut; produces no trailing \n change.
serde_yaml::to_value(&v)noyalib::to_value(&v)Identical.
serde_yaml::Valuenoyalib::ValueIdentical 7-variant enum (Null, Bool, Number, String, Sequence, Mapping, Tagged).
serde_yaml::Mappingnoyalib::MappingWraps IndexMap<String, Value>; iteration order preserved.
serde_yaml::Numbernoyalib::NumberDefault mode keeps the same Integer / Float split. Enabling lossless-u64 adds an opt-in Unsigned(u64) variant; see ADR 0004.
serde_yaml::Errornoyalib::ErrorDifferent variant set, same Display shape. See “Error handling” below.
serde_yaml::with::singleton_mapnoyalib::with::singleton_mapIdentical.
serde_yaml::with::singleton_map_optionalnoyalib::with::singleton_map_optionalIdentical.
serde_yaml::with::singleton_map_recursivenoyalib::with::singleton_map_recursiveIdentical.
serde_yaml::Indexnoyalib::ValueIndexSame trait surface (get, get_mut).
serde_yaml::value::Mapping::get_mutnoyalib::Mapping::get_mutIdentical.
serde_yaml::Deserializer::from_strnoyalib::from_strTo parse a &str use from_str. noyalib::Deserializer::new wraps an already-parsed &Value (a serde driver), it does not parse source text.
serde_yaml::Serializer::newnoyalib::Serializer::newIdentical.

Things noyalib adds (no equivalent in serde_yaml)

These are pure additions — adopting them is optional, the migration above doesn’t require any of them.

noyalibWhat it does
noyalib::from_str_strict<T>Like from_str, but errors on any key the target type T doesn’t declare. Closes the silent-data-loss gap when a config-key typo (replicass: 3) deserialises into replicas’s Default. Also from_slice_strict and from_reader_strict.
noyalib::Spanned<T>Wraps T and tracks the source (line, column, byte offset) of every deserialised value. Survives flatten.
noyalib::cst::DocumentLossless CST — read YAML in, mutate via Document::set("server.port", "9090"), write out byte-for-byte preserved (only the touched span changes). Foundation of the noyafmt / noyavalidate --fix tools.
noyalib::policy::{DenyAnchors, DenyTags, MaxScalarLength}Pluggable parser policies. Reject documents that violate organisational constraints at parse time (e.g. ban anchors to defeat billion-laughs).
Value::interpolate_propertiesSubstitute ${VAR} references inside string scalars from a property map; pair with secrecy::Secret<T> for redacted-by-default credential handling.
noyalib::parallel::parse<T>Parse ----separated multi-document streams across the Rayon thread pool. Linear with cores. (Requires the parallel feature.)
noyalib::Error::format_with_source(input)Renders a rustc-style snippet pointing at the offending line + column. Always available; richer output under --features miette.
noyalib::diagnostic::*First-class miette::Diagnostic integration: error codes, help text, source spans, ANSI-coloured terminal output. Under --features miette.

See the public API surface map at the top of crates/noyalib/src/lib.rs.

Things noyalib does not do (yet)

  • Round-trip comments through the data-binding API. The YAML data model excludes comments by spec. noyalib::cst::Document preserves them byte-for-byte for the lossless tooling path, but from_str::<T> → to_string(&T) does not. No Rust YAML library does this end-to-end.
  • Implicit <<: merge keys outside of explicit handling. YAML 1.2 dropped merge keys from the spec; noyalib follows 1.2 by default but ships an opt-in MergeKeyPolicy::Auto for backwards compatibility.
  • Custom-tag dispatch via serde(rename). noyalib surfaces non-core tags as Value::Tagged(...) rather than routing them to a typed enum variant by tag-string. Adopt noyalib::TagRegistry if you need strip-through behaviour for known tags.

Behavioural differences worth knowing

1. Value::Tagged is a 7th variant — and noyalib preserves scalar tags too

serde_yaml::Value has six variants (Null, Bool, Number, String, Sequence, Mapping); noyalib::Value adds Tagged(Box<TaggedValue>) as a 7th. If you exhaustively match against Value somewhere, the migration adds one arm:

 match value {
     Value::Null         => …,
     Value::Bool(b)      => …,
     Value::Number(n)    => …,
     Value::String(s)    => …,
     Value::Sequence(s)  => …,
     Value::Mapping(m)   => …,
+    Value::Tagged(t)    => handle_tag(t.tag(), t.value()),
 }

Where serde_yaml differs from noyalib: serde_yaml 0.9 drops custom-tag scalars from the Value tree by default — a !Custom 'hello' scalar deserialises into Value::String("hello") and the tag is lost. noyalib preserves the tag: from_str::<Value>("!Custom 'hello'\n") returns Value::Tagged(t) (t.tag() == "!Custom", t.value() == Value::String("hello")). This matches noyalib’s behaviour for tagged sequences and tagged mappings — three Tagged shapes, one consistent rule.

If your existing code worked because the tag was silently dropped, migrate by either:

  • Calling value.untag() (or value.untag_ref() for borrow-friendly reads) before any as_str / as_i64 / type-cast you used to do directly. This is the smallest diff.
  • Switching to a typed deserialise. Typed targets (#[derive(serde::Deserialize)] struct Foo { ... }) see through tags transparently — from_str::<Foo>("!Foo {x: 1}") yields Foo { x: 1 } regardless of the tag.
  • Registering the tag with TagRegistry::with for inline strip-through on the streaming path. Useful when you want noyalib to dispatch into a typed handler keyed on the tag string.

If you don’t inspect tags at all and want the old serde_yaml behaviour wholesale, that’s the compat::serde_yaml shim’s default: see “Drop-in compatibility shim” below.

2. Default boolean recognition is YAML 1.2 strict

serde_yaml 0.9 followed YAML 1.1 — bare yes / no / on / off parsed as booleans. noyalib follows YAML 1.2 by default — only true / false count.

This is the “Norway problem” fix: country: NO no longer silently rewrites to country: false.

If you depend on the legacy behaviour (Docker Compose, GitHub Actions, pre-1.2 toolchains), opt back in:

#![allow(unused)]
fn main() {
use noyalib::Value;
use noyalib::{from_str_with_config, ParserConfig};
let cfg = ParserConfig::new().legacy_booleans(true);
let v: Value = from_str_with_config("country: NO\n", &cfg)?;
assert_eq!(v["country"].as_bool(), Some(false));
}

3. Multi-doc streams use load_all_as

serde_yaml’s Deserializer::from_str returns an iterator of Result<Value>. noyalib exposes:

#![allow(unused)]
fn main() {
#[derive(serde::Deserialize)]
struct MyType { a: i64 }
let stream = "a: 1\n---\na: 2\n";
// Eager parse, returns Vec<T>.
let docs: Vec<MyType> = noyalib::load_all_as::<MyType>(stream)?;

// Or collect Values — `load_all` yields a lazy `Result<Value>` iterator.
let docs: Vec<noyalib::Value> = noyalib::load_all(stream)?.collect::<Result<_, _>>()?;
}

For very large streams (audit logs, Kubernetes-resource snapshots), the parallel path is a drop-in replacement:

#![allow(unused)]
fn main() {
#[derive(serde::Deserialize)]
struct MyType { a: i64 }
let stream = "a: 1\n---\na: 2\n";
// Same input, parses each doc concurrently across Rayon.
let docs: Vec<MyType> = noyalib::parallel::parse(stream)?;
assert_eq!(docs.len(), 2);
}

Requires the parallel feature.

4. Error handling stays type-safe

noyalib::Error is a #[non_exhaustive] enum carrying the location and a structured message. The Display implementation matches the rustc-error format closely:

error: expected ',' or ']'
  --> input.yaml:2:7
   |
 1 | host: localhost
 2 | port: [broken
   |       ^^^^^^ here

from_str_with_config accepts a ParserConfig carrying every limit you might want (max_depth, max_alias_expansions, max_total_scalar_bytes, duplicate_key_policy, strict_booleans, etc.). The factory ParserConfig::strict() turns every dial up for untrusted input.

5. The deserialise-target bound is T: 'static

serde_yaml::from_str<T> constrains T: for<'de> Deserialize<'de>. noyalib::from_str<T> adds T: 'static. Every real-world DeserializeOwned type already satisfies this — the HRTB on its own already disallows borrowed lifetimes — and the 'static is what lets noyalib detect at the call site whether the caller’s target is Value itself (in which case the tag-preserving fast path engages) or something else (typed deserialise stays transparent).

Concretely: a &'a str target was already disallowed by the HRTB; String, Vec<...>, HashMap<String, V>, and every derive-d struct continue to work unchanged. If you hit a compile error whose message mentions a missing 'static bound on a T you control, add + 'static to the bound — that’s the only diff.

This bound is a soft constraint. The few external trait signatures that drop 'static from their DeserializeOwned bound (notably figment::Format::from_str) are accommodated by a private internal entry point that bypasses the tag-preserving fast path. You don’t see it; your Format impl just works.

Drop-in compatibility shim

Two forms, both behavioural since v0.0.29 — the shim parses under ParserConfig::serde_yaml_compat() and its Error renders upstream’s wording and locations, pinned by an 18-case contract suite whose expectations were captured live from serde_yaml 0.9.34 (tests/serde_yaml_contract.rs).

Zero source changes — the package rename. The noyalib-serde-yaml crate re-exports the shim under a package name Cargo can substitute for serde_yaml:

[dependencies]
serde_yaml = { package = "noyalib-serde-yaml", version = "=0.0.58" }

Every use serde_yaml::… keeps compiling and behaving — << stays a literal key, 0123 stays a string, u64::MAX keeps precision, alias bombs fail with upstream’s repetition limit exceeded, and the pinned error classes carry libyaml’s phrasing and locations.

Two-line form. If you prefer depending on noyalib directly, enable the compat-serde-yaml feature:

[dependencies]
noyalib = { version = "0.0", features = ["compat-serde-yaml"] }
#![allow(unused)]
fn main() {
// Module path matches `serde_yaml`'s, but every type is
// noyalib-native — no transitive dep on the archived crate.
use noyalib::compat::serde_yaml::{from_str, to_string, Value, Mapping, Number};
}

The shim is feature-gated so users who don’t need migration help don’t see the extra surface. One documented partial in the error contract: a custom tag refused under deserialize_any anchors its location at the value where upstream anchors at the tag — message and refusal semantics are exact.

Migration checklist

For each crate that depends on serde_yaml:

  • Replace serde_yaml = "0.9" → noyalib = "0.0" (or noyalib = { version = "0.0", features = ["compat-serde-yaml"] } for the shim path).
  • Replace use serde_yaml::*; → use noyalib::*; (or use noyalib::compat::serde_yaml::*;).
  • If you exhaustively match Value, add a Tagged(_) arm (or call .untag() first).
  • Decide YAML 1.1-vs-1.2 boolean handling: stay on YAML 1.2 strict (the safer default) or opt into legacy_booleans(true).
  • Run cargo audit. The RUSTSEC-2024-0320 advisory should no longer match anything.
  • Run your existing test suite. The noyalib-shaped from_str is a drop-in for the surface above; failures almost always trace to one of the three behavioural notes in this document.

If you hit a snag, file an issue at https://github.com/sebastienrousseau/noyalib/issues with the input that misbehaves — every report makes the migration story better.

Migrating from other YAML libraries

If you’re coming from a different YAML crate (serde_yml, yaml_serde, serde-yaml-ng, serde-norway, serde-yaml-bw, serde-saphyr, or yaml-spanned), each has its own dedicated guide with a TL;DR diff, function-mapping table, behavioural notes, and migration checklist:

The umbrella index is MIGRATION.md — start there if you’re not sure which guide applies, or skim the compatibility matrix to pick the right one.

Migrating from serde-yaml-ng to noyalib

serde-yaml-ng (crate name serde_yaml_ng) is acatton’s independent continuation of serde_yaml. The README markets it as “as compatible as possible” with the original. 0.10.0 (2024-05-26) is the most recent release — actively maintained but the cadence is slow.

noyalib is a clean-room reimplementation of YAML 1.2 with the same serde data model, no unsafe code, and no libyaml C dependency.

Crates.io / docs.rs state verified 2026-09-05 (every function this guide names still exists on docs.rs). If the upstream API has shifted since, file an issue and we’ll update.

TL;DR

-[dependencies]
-serde_yaml_ng = "0.10"
+[dependencies]
+noyalib = "0.0"
-use serde_yaml_ng::Value;
-let v: Value = serde_yaml_ng::from_str(input)?;
-let s        = serde_yaml_ng::to_string(&v)?;
+use noyalib::Value;
+let v: Value = noyalib::from_str(input)?;
+let s        = noyalib::to_string(&v)?;

Function-by-function mapping

serde_yaml_ng mirrors serde_yaml 0.9 exactly.

serde_yaml_ng 0.10noyalib
serde_yaml_ng::from_str::<T>noyalib::from_str::<T>
serde_yaml_ng::from_slice::<T>noyalib::from_slice::<T>
serde_yaml_ng::from_reader::<R, T>noyalib::from_reader::<R, T>
serde_yaml_ng::from_value::<T>noyalib::from_value::<T>
serde_yaml_ng::to_stringnoyalib::to_string
serde_yaml_ng::to_writernoyalib::to_writer
serde_yaml_ng::to_valuenoyalib::to_value
serde_yaml_ng::Valuenoyalib::Value (same 7 variants)
serde_yaml_ng::Mappingnoyalib::Mapping
serde_yaml_ng::Numbernoyalib::Number (default Integer / Float; opt-in Unsigned(u64) behind lossless-u64)
serde_yaml_ng::Errornoyalib::Error
serde_yaml_ng::Deserializernoyalib::Deserializer
serde_yaml_ng::Serializernoyalib::Serializer
serde_yaml_ng::with::singleton_map*noyalib::with::singleton_map*
(n/a)noyalib::from_str_strict::<T> — error on unknown keys
(n/a)noyalib::Spanned<T> — source-location wrapper
(n/a)noyalib::cst::Document — lossless byte-faithful edits
(n/a)noyalib::ParserConfig — explicit resource limits

serde_yaml_ng::Value is the same 7-variant enum: Null, Bool, Number, String, Sequence, Mapping, Tagged(Box<TaggedValue>). Match arms unchanged.

Behavioural differences worth knowing

The three notes that apply to the serde_yaml migration apply here. Full discussion in MIGRATION-FROM-SERDE-YAML.md; headlines:

  1. Value::Tagged is preserved. from_str::<Value>("!Custom 'hi'\n") returns Value::Tagged(t) (t.tag() == "!Custom", t.value() == Value::String("hi")). serde_yaml_ng matched serde_yaml’s pre-Tagged behaviour; noyalib preserves the wrapper. Call .untag() to get the inner.
  2. YAML 1.2 strict booleans by default. country: NO stays Value::String("NO"). Opt into YAML 1.1 boolean recognition via ParserConfig::new().legacy_booleans(true).
  3. Multi-doc streams — use noyalib::load_all_as::<T>(input) for Vec<T> of every document in the stream.

Drop-in compatibility shim

[dependencies]
noyalib = { version = "0.0", features = ["compat-serde-yaml"] }
-use serde_yaml_ng::{from_str, to_string, Value, Mapping, Number};
+use noyalib::compat::serde_yaml::{from_str, to_string, Value, Mapping, Number};

The shim is a noyalib-native re-export under the compat::serde_yaml path; no transitive serde_yaml_ng dependency.

Migration checklist

  • Replace serde_yaml_ng = "0.10" → noyalib = "0.0" (or noyalib = { version = "0.0", features = ["compat-serde-yaml"] } for the shim path).
  • Replace use serde_yaml_ng::*; → use noyalib::*; (or use noyalib::compat::serde_yaml::*;).
  • If you exhaustively match Value, the 7-arm shape is unchanged.
  • Decide YAML 1.1-vs-1.2 boolean handling.
  • Run your existing test suite.

If you hit a snag, file an issue at https://github.com/sebastienrousseau/noyalib/issues.

Migrating from serde_yml to noyalib

serde_yml was a continuation fork of serde_yaml after upstream archival. The serde_yml repo was itself archived 2025-09-03; 0.0.13 (2024-08-25) is the last published release. The fork inherits the full serde_yaml 0.9 API shape including the 7-variant Value and the with::singleton_map* family.

noyalib is a clean-room reimplementation of YAML 1.2 with the same serde data model, no unsafe code, and no libyaml C dependency.

Crates.io / docs.rs state verified 2026-09-05 (every function this guide names still exists on docs.rs). If the upstream API has shifted since, file an issue and we’ll update.

TL;DR

-[dependencies]
-serde_yml = "0.0"
+[dependencies]
+noyalib = "0.0"
-use serde_yml::Value;
-let v: Value = serde_yml::from_str(input)?;
-let s        = serde_yml::to_string(&v)?;
+use noyalib::Value;
+let v: Value = noyalib::from_str(input)?;
+let s        = noyalib::to_string(&v)?;

That is the entire migration for the typical call site — serde_yml’s public surface mirrors serde_yaml’s function-for-function and noyalib’s does the same.

Function-by-function mapping

serde_yml 0.0noyalib
serde_yml::from_str::<T>noyalib::from_str::<T>
serde_yml::from_slice::<T>noyalib::from_slice::<T>
serde_yml::from_reader::<R, T>noyalib::from_reader::<R, T>
serde_yml::from_value::<T>noyalib::from_value::<T>
serde_yml::to_stringnoyalib::to_string
serde_yml::to_writernoyalib::to_writer
serde_yml::to_valuenoyalib::to_value
serde_yml::Valuenoyalib::Value (same 7 variants)
serde_yml::Mappingnoyalib::Mapping
serde_yml::Numbernoyalib::Number
serde_yml::Errornoyalib::Error
serde_yml::Deserializernoyalib::Deserializer
serde_yml::Serializernoyalib::Serializer
serde_yml::with::singleton_mapnoyalib::with::singleton_map
serde_yml::with::singleton_map_recursivenoyalib::with::singleton_map_recursive
serde_yml::with::singleton_map_optionalnoyalib::with::singleton_map_optional
serde_yml::with::singleton_map_withnoyalib::with::singleton_map_with
(n/a)noyalib::from_str_strict::<T> — error on unknown keys
(n/a)noyalib::Spanned<T> — source-location wrapper
(n/a)noyalib::cst::Document — lossless byte-faithful edits
(n/a)noyalib::ParserConfig — explicit resource limits

serde_yml’s Value enum has the same 7 variants as serde_yaml’s and noyalib’s — Null, Bool, Number, String, Sequence, Mapping, Tagged(Box<TaggedValue>). No match-arm changes are required.

Behavioural differences worth knowing

The same three behavioural notes that apply to the serde_yaml migration apply here. See MIGRATION-FROM-SERDE-YAML.md for the full discussion; the headlines:

  1. Value::Tagged is preserved through the Value data path. noyalib::from_str::<Value>("!Custom 'hi'\n") returns Value::Tagged(t) (t.tag() == "!Custom", t.value() == Value::String("hi")), not the transparent-unwrapped Value::String("hi"). serde_yml’s behaviour matched serde_yaml’s pre-Tagged behaviour. To get the unwrapped string, call value.untag().as_str().
  2. YAML 1.2 strict booleans by default. country: NO parses as Value::String("NO"), not Value::Bool(false). Opt into YAML 1.1 boolean recognition via ParserConfig::new().legacy_booleans(true) if you need the old behaviour for round-trip compatibility with legacy files.
  3. Multi-doc streams use load_all_as. serde_yml’s Deserializer::from_str(...).map(...) pattern maps to noyalib::load_all_as::<T>(input)? returning Vec<T>.

Drop-in compatibility shim

Because serde_yml’s API is a verbatim mirror of serde_yaml’s, the same compat shim works:

[dependencies]
noyalib = { version = "0.0", features = ["compat-serde-yaml"] }
-use serde_yml::{from_str, to_string, Value, Mapping, Number};
+use noyalib::compat::serde_yaml::{from_str, to_string, Value, Mapping, Number};

The shim re-exports noyalib-native types under noyalib::compat::serde_yaml::* — no transitive dependency on the archived serde_yaml 0.9 nor serde_yml 0.0.

Migration checklist

  • Replace serde_yml = "0.0" → noyalib = "0.0" (or noyalib = { version = "0.0", features = ["compat-serde-yaml"] } for the shim path).
  • Replace use serde_yml::*; → use noyalib::*; (or use noyalib::compat::serde_yaml::*;).
  • If you exhaustively match Value, the 7 variant arms are already correct — no shape change.
  • Decide YAML 1.1-vs-1.2 boolean handling: stay on YAML 1.2 strict (the safer default) or opt into legacy_booleans(true).
  • Run cargo audit. The migration removes any transitive serde_yaml 0.9 advisory exposure.
  • Run your existing test suite. Failures almost always trace to one of the three behavioural notes above.

If you hit a snag, file an issue at https://github.com/sebastienrousseau/noyalib/issues with the input that misbehaves.

Migrating from serde-saphyr to noyalib

serde-saphyr (module serde_saphyr) is bourumir-wyngs’s clean-room serde adapter on top of the saphyr parser. 1.2.0 (2026-08-30) is the current release. The README explicitly states:

serde-saphyr is not a fork of serde-yaml and shares no code with it (apart from some reused tests).

The marquee design choice: no Value DOM. serde-saphyr streams events directly into the typed deserialise target. If you need an untyped sink, the README suggests serde_json::Value.

noyalib is a clean-room reimplementation of YAML 1.2 with the same serde data model, no unsafe code, and no libyaml C dependency. noyalib’s default from_str<T> path is also streaming-first; the Value DOM is added, not removed.

Crates.io / docs.rs state verified 2026-09-05 (every function this guide names still exists on docs.rs). If the upstream API has shifted since, file an issue and we’ll update.

TL;DR

-[dependencies]
-serde-saphyr = "0.0"
+[dependencies]
+noyalib = "0.0"
-use serde_saphyr::from_str;
-let cfg: Config = from_str(input)?;
+use noyalib::from_str;
+let cfg: Config = from_str(input)?;

The typed-target migration is a name swap. The major adds are the Value DOM, the Spanned<T> wrapper, the lossless cst::Document editor, and JSON Schema validation.

Function-by-function mapping

serde_saphyr 0.0noyalib
serde_saphyr::from_str::<T>noyalib::from_str::<T>
serde_saphyr::from_slice::<T>noyalib::from_slice::<T>
serde_saphyr::from_reader::<R, T>noyalib::from_reader::<R, T>
serde_saphyr::from_multiple::<T>noyalib::load_all_as::<T>
serde_saphyr::from_str_with_options(input, options)noyalib::from_str_with_config(input, &cfg)
serde_saphyr::from_slice_with_optionsnoyalib::from_slice_with_config
serde_saphyr::from_reader_with_optionsnoyalib::from_reader_with_config
serde_saphyr::from_multiple_with_optionsnoyalib::load_all_with_config (untyped DocumentIterator; deserialize each item, or load_all_as::<T> without a config)
serde_saphyr::to_stringnoyalib::to_string
serde_saphyr::to_io_writernoyalib::to_writer
serde_saphyr::to_fmt_writernoyalib::to_fmt_writer
serde_saphyr::to_string_multiplenoyalib::to_string_multi
serde_saphyr::Options (parse-side builder)noyalib::ParserConfig
serde_saphyr::SerializerOptionsnoyalib::SerializerConfig
serde_saphyr::Deserializer (streaming type)noyalib::Deserializer
options! macroParserConfig::new()-with-builder-methods
ser_options! macroSerializerConfig::new()-with-builder-methods
(no Value type)noyalib::Value (7 variants, including Tagged)
(no Mapping type)noyalib::Mapping
(no Number type)noyalib::Number
(no Spanned<T> wrapper)noyalib::Spanned<T> — source-location wrapper
(no CST)noyalib::cst::Document — lossless byte-faithful edits
(no schema validation)noyalib::validate_against_schema (JSON Schema 2020-12)

Options builders

serde_saphyr uses an explicit Options struct (or the options! macro shorthand) per call. noyalib uses ParserConfig / SerializerConfig builders:

-use serde_saphyr::{from_str_with_options, options};
-let cfg: Config = from_str_with_options(input, options! {
-    max_depth: 64,
-})?;
+use noyalib::{from_str_with_config, ParserConfig};
+let parser = ParserConfig::new().max_depth(64);
+let cfg: Config = from_str_with_config(input, &parser)?;

Same shape, different surface — the noyalib builder is a fluent struct so you can clone / share configs across call sites.

Adding a Value DOM

If you used serde_saphyr and wanted untyped inspection, you were probably routing through serde_json::Value. noyalib has a YAML-native Value:

-let v: serde_json::Value = serde_saphyr::from_str(input)?;
+let v: noyalib::Value = noyalib::from_str(input)?;

noyalib::Value keeps YAML’s full data model (numeric types distinct from JSON’s, Tagged for custom tags, ordered mappings via IndexMap).

Behavioural differences worth knowing

  1. noyalib has a Value DOM, but doesn’t require it. The default from_str::<T> path streams directly into typed targets — same shape as serde_saphyr, no AST allocated. The Value tree is only built when the caller asks for it via from_str::<Value>.
  2. Panic-free parser. Both crates target the same “defensive parser” niche. serde-saphyr’s “panic-free” guarantee corresponds to noyalib’s #![forbid(unsafe_code)] plus the resource-limit gates (max_depth, max_alias_expansions, max_document_length).
  3. Value::Tagged exists in noyalib. Custom-tag scalars like !Custom 'hi' surface as Value::Tagged(t) (t.tag() == "!Custom", t.value() == Value::String("hi")). If you hit a tagged scalar in serde_saphyr’s typed path the tag was simply preserved in the YAML 1.2 sense; noyalib typed deserialise sees through the tag transparently to the inner value, matching the serde_saphyr typed contract.
  4. YAML 1.2 strict by default. country: NO stays a string. serde-saphyr is YAML 1.2 by design; the migration is neutral on this dimension.
  5. Tag-driven instantiation rejected. serde-saphyr rejects YAML tags that try to pick a typed-deserialise target (the so-called “billion-laughs of types” attack); noyalib’s typed path also ignores tags during target resolution, only using them for the Value data path.

Drop-in compatibility shim

noyalib::compat::serde_yaml mirrors serde_yaml 0.9, not serde-saphyr. There’s no serde-saphyr-shaped shim because the surface is small enough that the function-by-function table above is the migration. If you want to keep use serde_saphyr::*; imports, define a thin local module that re-exports the noyalib equivalents:

#![allow(unused)]
fn main() {
mod yaml_compat {
// in src/yaml_compat.rs
pub use noyalib::{
    from_reader, from_slice, from_str,
    to_fmt_writer, to_string,
    to_writer as to_io_writer,  // rename
    ParserConfig as Options,
    SerializerConfig as SerializerOptions,
};
}
let _: yaml_compat::Options = yaml_compat::Options::new();
}

Migration checklist

  • Replace serde-saphyr = "0.0" → noyalib = "0.0".
  • Replace use serde_saphyr::*; → use noyalib::*;.
  • Replace Options { max_depth: …, … } / options! { … } → ParserConfig::new().max_depth(…)….
  • Replace *_with_options calls with *_with_config(input, &cfg).
  • Replace to_io_writer with to_writer, to_string_multiple with to_string_multi.
  • If you routed dynamic inspection through serde_json::Value, switch to noyalib::Value for the YAML data model (numeric types distinct from JSON, Tagged for custom tags, ordered mappings).
  • Run your existing test suite.

If you hit a snag, file an issue at https://github.com/sebastienrousseau/noyalib/issues.

Migrating from serde-norway to noyalib

serde-norway (crate name serde_norway) is cafkafk’s hard-fork of serde_yaml. 0.9.42 (2024-12-21) is the most recent release. The fork name nods at the YAML 1.1 “Norway problem” (country: NO parsing as false). The API is serde_yaml-shape-identical — the rebrand is the value-add, not API divergence.

noyalib is a clean-room reimplementation of YAML 1.2 with the same serde data model, no unsafe code, and no libyaml C dependency.

Crates.io / docs.rs state verified 2026-09-05 (every function this guide names still exists on docs.rs). If the upstream API has shifted since, file an issue and we’ll update.

TL;DR

-[dependencies]
-serde_norway = "0.9"
+[dependencies]
+noyalib = "0.0"
-use serde_norway::Value;
-let v: Value = serde_norway::from_str(input)?;
-let s        = serde_norway::to_string(&v)?;
+use noyalib::Value;
+let v: Value = noyalib::from_str(input)?;
+let s        = noyalib::to_string(&v)?;

Function-by-function mapping

serde_norway mirrors serde_yaml 0.9 exactly.

serde_norway 0.9noyalib
serde_norway::from_str::<T>noyalib::from_str::<T>
serde_norway::from_slice::<T>noyalib::from_slice::<T>
serde_norway::from_reader::<R, T>noyalib::from_reader::<R, T>
serde_norway::from_value::<T>noyalib::from_value::<T>
serde_norway::to_stringnoyalib::to_string
serde_norway::to_writernoyalib::to_writer
serde_norway::to_valuenoyalib::to_value
serde_norway::Valuenoyalib::Value (same 7 variants)
serde_norway::Mappingnoyalib::Mapping
serde_norway::Numbernoyalib::Number
serde_norway::Errornoyalib::Error
serde_norway::Deserializernoyalib::Deserializer
serde_norway::Serializernoyalib::Serializer
serde_norway::with::singleton_map*noyalib::with::singleton_map*
(n/a)noyalib::from_str_strict::<T> — error on unknown keys
(n/a)noyalib::Spanned<T> — source-location wrapper
(n/a)noyalib::cst::Document — lossless byte-faithful edits
(n/a)noyalib::ParserConfig — explicit resource limits

serde_norway::Value is the same 7-variant enum: Null, Bool, Number, String, Sequence, Mapping, Tagged(Box<TaggedValue>). Match arms unchanged.

Behavioural differences worth knowing

If you adopted serde_norway specifically because of the Norway problem, the migration to noyalib keeps that fix in place — noyalib defaults to YAML 1.2 strict booleans (only true / false parse as Value::Bool). country: NO stays Value::String("NO") under both crates.

The other two behavioural notes that apply to every serde_yaml fork apply here too. Full discussion in MIGRATION-FROM-SERDE-YAML.md:

  1. Value::Tagged is preserved through the Value data path. from_str::<Value>("!Custom 'hi'\n") returns Value::Tagged(t) (t.tag() == "!Custom", t.value() == Value::String("hi")). Call .untag() to get the inner if you used the unwrapped form.
  2. YAML 1.2 strict booleans by default. Same default as serde_norway — no behavioural change.
  3. Multi-doc streams use load_all_as. noyalib::load_all_as::<T>(input) returns Vec<T> directly.

Drop-in compatibility shim

[dependencies]
noyalib = { version = "0.0", features = ["compat-serde-yaml"] }
-use serde_norway::{from_str, to_string, Value, Mapping, Number};
+use noyalib::compat::serde_yaml::{from_str, to_string, Value, Mapping, Number};

Migration checklist

  • Replace serde_norway = "0.9" → noyalib = "0.0" (or noyalib = { version = "0.0", features = ["compat-serde-yaml"] } for the shim path).
  • Replace use serde_norway::*; → use noyalib::*; (or use noyalib::compat::serde_yaml::*;).
  • If you exhaustively match Value, the 7-arm shape is unchanged.
  • No boolean-handling decision needed — both crates default to YAML 1.2 strict.
  • Run your existing test suite.

If you hit a snag, file an issue at https://github.com/sebastienrousseau/noyalib/issues.

Migrating from serde-yaml-bw to noyalib

serde-yaml-bw (crate name serde_yaml_bw) is bourumir-wyngs’s hardened fork of serde_yaml. 2.5.7 (2026-08-15) is the current release. The bw reads as “better warnings” / “billion-laughs warden” — the fork’s marquee features are billion-laughs DoS hardening, anchor / alias preservation, merge-key support, nested-enum support, and binary scalars.

The 2.x major version line breaks serde_yaml’s API in several places. The README explicitly states:

serde_yaml_bw is a fork of serde-yaml originally, but it is not a drop-in replacement.

noyalib is a clean-room reimplementation of YAML 1.2 with the same serde data model, no unsafe code, and no libyaml C dependency. This guide is the function-mapping bridge to noyalib’s surface.

Crates.io / docs.rs state verified 2026-09-05 (every function this guide names still exists on docs.rs). If the upstream API has shifted since, file an issue and we’ll update.

TL;DR

-[dependencies]
-serde_yaml_bw = "2"
+[dependencies]
+noyalib = "0.0"
-use serde_yaml_bw::Value;
-let v: Value = serde_yaml_bw::from_str(input)?;
-let s        = serde_yaml_bw::to_string(&v)?;
+use noyalib::Value;
+let v: Value = noyalib::from_str(input)?;
+let s        = noyalib::to_string(&v)?;

The mechanical migration is similar — the surface diverges in the multi-document API, the options builders, and the Value enum shape (8 variants vs 7).

Function-by-function mapping

serde_yaml_bw 2.xnoyalib
serde_yaml_bw::from_str::<T>noyalib::from_str::<T>
serde_yaml_bw::from_slice::<T>noyalib::from_slice::<T>
serde_yaml_bw::from_reader::<R, T>noyalib::from_reader::<R, T>
serde_yaml_bw::from_value::<T>noyalib::from_value::<T>
serde_yaml_bw::from_str_value / from_str_value_preservenoyalib::from_str::<Value> (Tagged preserved by default)
serde_yaml_bw::from_str_multinoyalib::load_all_as::<T>
serde_yaml_bw::from_slice_multinoyalib::load_all_as::<T> (after std::str::from_utf8)
serde_yaml_bw::from_reader_multinoyalib::load_all_as::<T> (after read_to_string)
serde_yaml_bw::from_multiplenoyalib::load_all_as::<T>
serde_yaml_bw::to_stringnoyalib::to_string
serde_yaml_bw::to_writernoyalib::to_writer
serde_yaml_bw::to_string_multinoyalib::to_string_multi
serde_yaml_bw::to_writer_multinoyalib::to_writer_multi
serde_yaml_bw::to_valuenoyalib::to_value
*_with_options(input, options)noyalib::from_str_with_config(input, &cfg) (and family)
serde_yaml_bw::Value (8 variants)noyalib::Value (7 variants — see below)
serde_yaml_bw::Value::Alias(name)resolved at parse time; no longer a separate variant
serde_yaml_bw::Mappingnoyalib::Mapping
serde_yaml_bw::Numbernoyalib::Number (default Integer / Float; opt-in Unsigned(u64) behind lossless-u64)
serde_yaml_bw::Errornoyalib::Error
serde_yaml_bw::SerializerBuildernoyalib::SerializerConfig + to_writer_with_config
serde_yaml_bw::SerializerOptionsnoyalib::SerializerConfig
serde_yaml_bw::DeserializerOptionsnoyalib::ParserConfig
(n/a)noyalib::from_str_strict::<T> — error on unknown keys
(n/a)noyalib::Spanned<T> — source-location wrapper
(n/a)noyalib::cst::Document — lossless byte-faithful edits

SerializerBuilder ↔ SerializerConfig

serde_yaml_bw’s builder pattern:

// The *old* library's API, shown for comparison — `serde_yaml_bw` is
// not a dependency here, so this block is not compiled.
use serde_yaml_bw::SerializerBuilder;
let mut buf = Vec::new();
let mut ser = SerializerBuilder::default()
    .check_unresolved_anchors(true)
    .build(&mut buf);
value.serialize(&mut ser)?;

The noyalib equivalent uses a config struct:

#![allow(unused)]
fn main() {
let value: noyalib::Value = noyalib::from_str("a: 1\n")?;
use noyalib::SerializerConfig;
let cfg = SerializerConfig::new();
// configure cfg as needed
let mut buf = Vec::new();
noyalib::to_writer_with_config(&mut buf, &value, &cfg)?;
assert_eq!(String::from_utf8(buf)?, "a: 1");
}

Multi-document streams

serde_yaml_bw exposes a four-way fan-out (from_str_multi / from_slice_multi / from_reader_multi / from_multiple) for multi-doc input. noyalib collapses these into a single load_all_as entry point:

#![allow(unused)]
fn main() {
#[derive(serde::Deserialize)]
struct Cfg { a: i64 }
let input = "a: 1\n---\na: 2\n";
let docs: Vec<Cfg> = noyalib::load_all_as(input)?;
assert_eq!(docs.len(), 2);
}

For raw Value per document:

#![allow(unused)]
fn main() {
let input = "a: 1\n---\na: 2\n";
let docs: Vec<noyalib::Value> = noyalib::load_all_as(input)?;
assert_eq!(docs.len(), 2);
}

Value shape — 8 variants → 7 variants

serde_yaml_bw::Value has 8 variants:

  • Null (with optional anchor)
  • Bool(bool) (with optional anchor)
  • Number(Number) (with optional anchor)
  • String(String) (with optional anchor)
  • Sequence(Sequence) (with optional anchor)
  • Mapping(Mapping) (with optional anchor)
  • Alias(String) — anchor reference, unresolved
  • Tagged(Box<TaggedValue>)

noyalib::Value has 7 variants — there’s no Alias. noyalib resolves aliases at parse time, per YAML 1.2:

 match v {
     Value::Null            => …,
     Value::Bool(_)         => …,
     Value::Number(_)       => …,
     Value::String(_)       => …,
     Value::Sequence(_)     => …,
     Value::Mapping(_)      => …,
-    Value::Alias(_name)    => …,
     Value::Tagged(_)       => …,
 }

If your code matched Value::Alias, replace those arms with the post-resolution variant — once resolved, the alias becomes whatever the anchor pointed at (usually String, Mapping, or Sequence).

If you relied on serde_yaml_bw’s anchor metadata on each scalar variant, noyalib does not surface that on the Value tree directly. The CST layer (noyalib::cst::Document) keeps anchor / alias source spans and exposes them via the Document::span_at(path) and Document::comments_at(path) APIs for editor-grade tooling.

Behavioural differences worth knowing

  1. Alias resolution is eager. noyalib resolves *anchor_name to the anchor’s value at parse time (per YAML 1.2 §7.1). serde_yaml_bw’s Value::Alias variant has no equivalent in noyalib; if you need to detect an alias use site, work at the CST layer.
  2. Billion-laughs DoS hardening. Both crates guard against exponential alias amplification. noyalib’s defaults: max_alias_expansions = 100, max_document_length bounded by the input size; configure via ParserConfig::new().max_alias_expansions(...).
  3. Merge keys (<<) — both crates support YAML 1.1 merge keys. noyalib’s MergeKeyPolicy enum lets you choose: Auto (default), AsOrdinary (treat << as a literal key), or Error (reject).
  4. Value::Tagged is preserved. from_str::<Value>("!Custom 'hi'\n") returns Value::Tagged(t) where t.tag() == "!Custom" and t.value() == Value::String("hi") — same as serde_yaml_bw.
  5. YAML 1.2 strict booleans by default. country: NO stays Value::String("NO"). Opt into YAML 1.1 boolean recognition via ParserConfig::new().legacy_booleans(true).

Drop-in compatibility shim

The noyalib::compat::serde_yaml shim mirrors serde_yaml 0.9 (7-variant Value), not serde_yaml_bw. If you used serde_yaml_bw’s 2.x-specific surface (the multi-doc family, SerializerBuilder, the Alias variant), the shim won’t help — use the noyalib-native APIs directly.

Migration checklist

  • Replace serde_yaml_bw = "2" → noyalib = "0.0".
  • Replace use serde_yaml_bw::*; → use noyalib::*;.
  • If you matched Value::Alias, drop those arms and handle the post-resolution variant instead.
  • Replace *_multi / *_multiple calls with noyalib::load_all_as.
  • Replace SerializerBuilder / *Options builders with noyalib::SerializerConfig / noyalib::ParserConfig.
  • If you used the _with_options overload family, use noyalib::from_str_with_config (and the matching to_writer_with_config, from_reader_with_config, …).
  • Decide YAML 1.1-vs-1.2 boolean handling.
  • Run your existing test suite. Failures usually trace to either the Alias arm or the multi-doc API change.

If you hit a snag, file an issue at https://github.com/sebastienrousseau/noyalib/issues.

Migrating from yaml_serde to noyalib

yaml_serde (also resolvable as yaml-serde) is the actively maintained fork of serde_yaml under the yaml org. 0.10.7 (2026-08-18) is the current release. The fork advertises itself as a true drop-in for serde_yaml and documents a Cargo package = rename so existing use serde_yaml::*; imports keep compiling without touching call sites.

noyalib is a clean-room reimplementation of YAML 1.2 with the same serde data model, no unsafe code, and no libyaml C dependency.

Crates.io / docs.rs state verified 2026-09-05 (every function this guide names still exists on docs.rs). If the upstream API has shifted since, file an issue and we’ll update.

TL;DR

-[dependencies]
-yaml_serde = "0.10"
+[dependencies]
+noyalib = "0.0"
-use yaml_serde::Value;
-let v: Value = yaml_serde::from_str(input)?;
-let s        = yaml_serde::to_string(&v)?;
+use noyalib::Value;
+let v: Value = noyalib::from_str(input)?;
+let s        = noyalib::to_string(&v)?;

If you adopted yaml_serde via the Cargo package-rename trick (keeping use serde_yaml::*; in source), drop the rename:

-[dependencies]
-serde_yaml = { package = "yaml_serde", version = "0.10" }
+[dependencies]
+noyalib = "0.0"
-use serde_yaml::{from_str, to_string, Value};
+use noyalib::{from_str, to_string, Value};

Function-by-function mapping

yaml_serde mirrors serde_yaml 0.9 exactly. Every entry below holds for both spellings; substitute your import path.

yaml_serde 0.10noyalib
yaml_serde::from_str::<T>noyalib::from_str::<T>
yaml_serde::from_slice::<T>noyalib::from_slice::<T>
yaml_serde::from_reader::<R, T>noyalib::from_reader::<R, T>
yaml_serde::from_value::<T>noyalib::from_value::<T>
yaml_serde::to_stringnoyalib::to_string
yaml_serde::to_writernoyalib::to_writer
yaml_serde::to_valuenoyalib::to_value
yaml_serde::Valuenoyalib::Value (same 7 variants)
yaml_serde::Mappingnoyalib::Mapping
yaml_serde::Numbernoyalib::Number
yaml_serde::Errornoyalib::Error
yaml_serde::Deserializernoyalib::Deserializer
yaml_serde::Serializernoyalib::Serializer
yaml_serde::with::singleton_mapnoyalib::with::singleton_map
yaml_serde::with::singleton_map_recursivenoyalib::with::singleton_map_recursive
yaml_serde::with::singleton_map_optionalnoyalib::with::singleton_map_optional
yaml_serde::with::singleton_map_withnoyalib::with::singleton_map_with
(n/a)noyalib::from_str_strict::<T> — error on unknown keys
(n/a)noyalib::Spanned<T> — source-location wrapper
(n/a)noyalib::cst::Document — lossless byte-faithful edits
(n/a)noyalib::ParserConfig — explicit resource limits

yaml_serde::Value is the same 7-variant enum as serde_yaml::Value and noyalib::Value: Null, Bool, Number, String, Sequence, Mapping, Tagged(Box<TaggedValue>).

Behavioural differences worth knowing

The three behavioural notes that apply to the serde_yaml migration apply here. The full discussion lives in MIGRATION-FROM-SERDE-YAML.md; the headlines:

  1. Value::Tagged is preserved through the Value data path. noyalib::from_str::<Value>("!Custom 'hi'\n") returns Value::Tagged(t) (t.tag() == "!Custom", t.value() == Value::String("hi")). To get the unwrapped inner, call value.untag().as_str().
  2. YAML 1.2 strict booleans by default. country: NO stays Value::String("NO"). Opt into legacy YAML 1.1 boolean recognition via ParserConfig::new().legacy_booleans(true).
  3. Multi-doc streams use load_all_as. noyalib::load_all_as::<T>(input) returns Vec<T> directly.

Drop-in compatibility shim

If you adopted yaml_serde to keep use serde_yaml::*; imports compiling and you can’t change call sites at all, the noyalib compat shim does the same:

[dependencies]
noyalib = { version = "0.0", features = ["compat-serde-yaml"] }
-use serde_yaml::{from_str, to_string, Value, Mapping, Number};
+use noyalib::compat::serde_yaml::{from_str, to_string, Value, Mapping, Number};

The shim re-exports noyalib-native types under noyalib::compat::serde_yaml::* — no transitive dep on either upstream.

Migration checklist

  • Replace yaml_serde = "0.10" (or the package-rename pattern under serde_yaml = { package = "yaml_serde" }) → noyalib = "0.0".
  • Replace use yaml_serde::*; → use noyalib::*; (or use noyalib::compat::serde_yaml::*; for the shim path).
  • If you exhaustively match Value, the 7 variant arms are already correct — no shape change.
  • Decide YAML 1.1-vs-1.2 boolean handling: stay on YAML 1.2 strict (the safer default) or opt into legacy_booleans(true).
  • Run your existing test suite. Failures almost always trace to one of the three behavioural notes above.

If you hit a snag, file an issue at https://github.com/sebastienrousseau/noyalib/issues with the input that misbehaves.

Migrating from yaml-spanned to noyalib

yaml-spanned is romnn’s span-aware YAML parser built on libyaml-safer. 0.0.3 (2025-12-27) is the current release. The crate’s value proposition is preserving source spans on every value so diagnostics can point at the exact line and column.

Two things to know up front:

  1. yaml-spanned does not provide to_string / to_writer. It is a parser / DOM-construction crate, not a serializer. If you used it for read-only or DOM-walk workloads, you were probably pairing it with serde_yaml (or another crate) for the emit side.
  2. yaml-spanned is built on libyaml-safer, which uses unsafe internally. noyalib is #![forbid(unsafe_code)] end-to-end; the migration tightens the safety guarantee.

noyalib is a clean-room reimplementation of YAML 1.2 with the same serde data model, no unsafe code, no libyaml C dependency, and a typed span wrapper (Spanned<T>) that covers the same diagnostic use case.

Crates.io / docs.rs state verified 2026-09-05 (every function this guide names still exists on docs.rs). If the upstream API has shifted since, file an issue and we’ll update.

TL;DR

If you used yaml-spanned for span info on a typed config:

-use yaml_spanned::{from_str, Value};
-let v: Value = from_str(input)?;
-// Walk v.span() / v.value to extract span+payload.
+use noyalib::{from_str, Spanned};
+#[derive(serde::Deserialize)]
+struct Config { name: Spanned<String>, port: Spanned<u16> }
+let cfg: Config = from_str(input)?;
+let line = cfg.name.start.line();
+let col  = cfg.name.start.column();

If you used yaml-spanned for general dynamic inspection without typed targets:

-use yaml_spanned::{from_str, Value};
-let v: Value = from_str(input)?;
+use noyalib::{from_str, Value};
+let v: Value = from_str(input)?;

noyalib::Value covers the same 7-variant DOM. For per-value spans on the dynamic path, parse through the CST:

#![allow(unused)]
fn main() {
let input = "server:\n  port: 8080\n  host: localhost\n";
let doc = noyalib::cst::parse_document(input)?;
let span = doc.span_at("server.port");  // (start, end) byte offsets
assert!(span.is_some());
}

Function-by-function mapping

yaml_spanned 0.0noyalib
yaml_spanned::from_str(input) (returns Spanned<Value>)noyalib::from_str::<Spanned<T>>(input) for typed; from_str::<Value>(input) for dynamic
yaml_spanned::from_str_all(input)noyalib::load_all_as::<Spanned<T>>(input)
yaml_spanned::from_str_lossy(input) (drop-on-error)noyalib::load_all(input) (yields Result per doc — handle errors explicitly rather than silently dropping)
yaml_spanned::from_str_lossy_all(input)as above with Vec-collect
yaml_spanned::from_str_lossy_iter(input)as above with iterator-style consumption
yaml_spanned::from_value::<T>(spanned_value)noyalib::from_value::<T>(&value)
yaml_spanned::to_value(t)noyalib::to_value(&t)
yaml_spanned::Valuenoyalib::Value (same 7 variants)
yaml_spanned::TaggedValuenoyalib::TaggedValue
yaml_spanned::Tagnoyalib::Tag
yaml_spanned::Builder (programmatic Value construction)noyalib::Value::Mapping(…) and the From / Into impls; noyalib::cst::Document for source-faithful programmatic edits
(no to_string)noyalib::to_string — full serializer
(no to_writer)noyalib::to_writer
(no to_string_multi / to_writer_multi)noyalib::to_string_multi / noyalib::to_writer_multi

Span access

yaml-spanned returns a Spanned<Value> wrapper from every parse entry. To read both the value and the source span, you unpack the wrapper.

noyalib::Spanned<T> plays the same role at the typed deserialise layer:

#![allow(unused)]
fn main() {
let input = "host: localhost\nport: 8080\n";
use noyalib::Spanned;

#[derive(serde::Deserialize)]
struct ServerConfig {
    host: Spanned<String>,
    port: Spanned<u16>,
}

let cfg: ServerConfig = noyalib::from_str(input)?;
println!(
    "host at line {}, col {}",
    cfg.host.start.line(),
    cfg.host.start.column(),
);
println!(
    "port range: bytes {}..{}",
    cfg.port.start.index(),
    cfg.port.end.index(),
);
}

For dynamic-path workloads (where yaml-spanned returned a Spanned<Value>), use noyalib::cst::parse_document and query spans by path:

#![allow(unused)]
fn main() {
let input = "server:\n  host: localhost\n";
let doc = noyalib::cst::parse_document(input)?;
if let Some((start, end)) = doc.span_at("server.host") {
    println!("server.host span: bytes {start}..{end}");
}
}

The CST layer also exposes comments at each node (doc.comments_at(path)), which yaml-spanned does not preserve.

Lossy iterators

yaml-spanned’s from_str_lossy* family silently drops documents that fail to parse. noyalib’s load_all returns a DocumentIterator yielding Result<Value> — the caller decides whether to drop, log, or fail-fast:

-let docs: Vec<Spanned<Value>> = yaml_spanned::from_str_lossy_all(input);
+let docs: Vec<noyalib::Value> = noyalib::load_all(input)?
+    .filter_map(|res| res.ok())
+    .collect();

The explicit form is recommended — silent error-drop in a parser is rarely what users actually want.

Builder ↔ Value construction

yaml-spanned’s Builder lets you construct a Value tree programmatically. noyalib exposes the same surface via the From / Into impls and the variant constructors:

-let v = Builder::mapping()
-    .entry("name", Builder::string("noyalib"))
-    .entry("port", Builder::integer(8080))
-    .build();
+let mut m = noyalib::Mapping::new();
+m.insert("name".into(), noyalib::Value::from("noyalib"));
+m.insert("port".into(), noyalib::Value::from(8080_i64));
+let v = noyalib::Value::Mapping(m);

For source-faithful edits (preserving comments and formatting), use noyalib::cst::Document instead — the programmatic Builder shape doesn’t track source bytes.

Behavioural differences worth knowing

  1. #![forbid(unsafe_code)]. yaml-spanned transitively uses unsafe (via libyaml-safer). noyalib forbids it throughout the workspace; the migration tightens the safety claim.
  2. Full YAML 1.2 surface. noyalib supports YAML 1.2 strict booleans, the JSON-compatible schema, custom tag handling via TagRegistry, anchor / alias resolution, and merge keys (<<) — all of which yaml-spanned either inherits from libyaml-safer or doesn’t expose.
  3. Spans on the typed path. Spanned<T> is a serde wrapper, so it composes with any #[derive(serde::Deserialize)] target — not just the dynamic Value.
  4. Comments preserved at the CST layer. noyalib::cst::Document::comments_at(path) returns the comments attached to a path; useful for round-trip-faithful editors / formatters / linters.
  5. Built-in serialization. yaml-spanned is parser-only; noyalib provides to_string / to_writer / to_string_multi / to_writer_multi / to_fmt_writer for the full emit surface.

Drop-in compatibility shim

There’s no yaml-spanned-shaped shim because the surface is small. If you previously routed all your span work through yaml_spanned::Spanned<Value>, the noyalib equivalent is noyalib::Spanned<Value> — the same wrapper type at the same position in the type system.

Migration checklist

  • Replace yaml_spanned = "0.0" → noyalib = "0.0".
  • Replace use yaml_spanned::*; → use noyalib::*; plus use noyalib::Spanned; where you wrap span-bearing fields.
  • Where you used Spanned<Value> for span+dynamic, prefer a typed config with Spanned<T> per field, or fall back to noyalib::cst::parse_document for path-based span queries.
  • If you used from_str_lossy*, replace with explicit load_all(input)?.filter_map(...) — silent drops are rarely what production code wants.
  • If you used Builder for programmatic construction, switch to Value::Mapping(...) / From<T> impls (or cst::Document for source-faithful edits).
  • Add to_string / to_writer calls if the project previously paired yaml-spanned (read) with another crate (write) — noyalib covers both sides.
  • Run your existing test suite.

If you hit a snag, file an issue at https://github.com/sebastienrousseau/noyalib/issues.

Migration Guide — noyalib workspace split

This guide covers what changed for consumers when the noyalib workspace was split into satellite repositories under ADR-0005. It’s aimed at:

  • Rust developers who cargo add noyalib* or ship binaries that depend on the workspace.
  • JavaScript / TypeScript developers using the WASM or MCP npm packages.
  • DevOps running noyalib containers from GHCR.
  • Downstream repo maintainers who vendored the monorepo via git submodule or path = "…".

If you’re only consuming published packages (crates.io, npm, GHCR), the short version is: no code changes required, just update version pins.


Split timeline

ReleaseSplitNew repoShipped
v0.0.12noyalib-wasmsebastienrousseau/noyalib-wasm2026-07-02
v0.0.13noyalib-mcpsebastienrousseau/noyalib-mcp2026-07-05
v0.0.13noyalib-lspsebastienrousseau/noyalib-lsp2026-07-05
v0.0.13noya-clisebastienrousseau/noya-cli2026-07-05

Strict-lockstep versioning: every satellite pins noyalib = "=X.Y.Z" matching the release tag. Satellites release simultaneously with the parent library.


For Rust library consumers

Consuming noyalib (the library core)

No changes. noyalib still lives in this repo and publishes to crates.io as before:

[dependencies]
noyalib = "0.0.58"

Consuming a satellite crate

Each satellite still publishes to crates.io under the same crate name. Only the source repository changed. Update your Cargo.toml version pin:

[dependencies]
noyalib-wasm = "0.0.58"   # crates.io; the repo does not matter to Cargo
noyalib-mcp  = "0.0.58"

The ADR-0005 strict-lockstep contract guarantees these versions move in lockstep with noyalib.

Filing issues + reading source

Point issue reports at the satellite repo, not this one:

  • noyalib-wasm issues → https://github.com/sebastienrousseau/noyalib-wasm/issues
  • noyalib-mcp issues → https://github.com/sebastienrousseau/noyalib-mcp/issues

Repository-scoped issue tracking keeps AI-agent framework maintainers’ bug reports on the MCP repo, browser-integration questions on the WASM repo, and library-parser concerns here.


For JavaScript / TypeScript consumers

@sebastienrousseau/noyalib-wasm (WASM bindings)

Available on npmjs.org. The npm scope changed from the pre-split plan of @noyalib (scope not registered) to @sebastienrousseau (auto-scoped to publisher). Package identity is stable from v0.0.12 onward.

npm install @sebastienrousseau/noyalib-wasm
# or
pnpm add @sebastienrousseau/noyalib-wasm

JavaScript API is unchanged. Import paths, function signatures, and TypeScript typings are byte-for-byte identical to what was shipped from the monorepo. See the satellite’s Quick Start.

@sebastienrousseau/noyalib-mcp (MCP server wrapper)

Available on npmjs.org.

npx @sebastienrousseau/noyalib-mcp

MCP tool inventory is unchanged. The server still exposes parse, format, get, set, validate with the same schemas and JSON-RPC wire format. Your Claude Desktop / Cursor / Continue.dev / Zed configs don’t need updating.


For DevOps consumers

Container images (GHCR)

  • ghcr.io/sebastienrousseau/noyafmt — still ships from this monorepo.
  • ghcr.io/sebastienrousseau/noyalib — still ships from this monorepo.
  • ghcr.io/sebastienrousseau/noyalib-mcp — now ships from the satellite repo (image path unchanged; only the source repo changed).

All images are cosign keyless-signed. Verify against the split repo’s OIDC identity:

cosign verify \
    --certificate-identity-regexp 'https://github.com/sebastienrousseau/noyalib-mcp/' \
    --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
    ghcr.io/sebastienrousseau/noyalib-mcp:0.0.58

MCP Registry entry

io.github.sebastienrousseau/noyalib-mcp — identity unchanged, publisher moved to the split repo. AI-agent hosts (Claude Desktop, mcp.run, Glama) resolve the entry to the same ghcr.io/sebastienrousseau/noyalib-mcp image.


For downstream monorepo vendors

If you had a git submodule or path = "…" pointing at crates/noyalib-wasm/ or crates/noyalib-mcp/ in the monorepo, those paths no longer exist in the split releases (v0.0.12 onward). Migrate to one of:

[dependencies]
noyalib-wasm = "=0.0.58"    # exact-match pins recommended
noyalib-mcp  = "=0.0.58"

Same version identity, same crate identity — only the source repo changed.

Option B — pull from the satellite git repo

[dependencies]
noyalib-wasm = { git = "https://github.com/sebastienrousseau/noyalib-wasm", tag = "v0.0.58" }
noyalib-mcp  = { git = "https://github.com/sebastienrousseau/noyalib-mcp",  tag = "v0.0.58" }

Option C — subtree-vendor the satellite

git subtree add --prefix=vendor/noyalib-wasm \
    https://github.com/sebastienrousseau/noyalib-wasm v0.0.58 --squash

If you were also depending on pkg/npm-mcp-wrapper/ or pkg/docker/Dockerfile.mcp, those moved to the satellite too — see the satellite’s pkg/ directory.


What did NOT change

  • noyalib library crate: same identity, same public API, same MSRV (1.86, raised in v0.0.16). Any patch fix here still lands here.
  • noyalib docs (docs.rs, GitHub Pages): unchanged.
  • Sigstore verification recipes: unchanged (identity migrated to the split repos where applicable — see cosign snippets above).

Rollback

If any split turns out to be problematic during the 14-day soak review, the ADR-0005 rollback recipe restores the pre-split monorepo shape in ≤ 5 minutes of cargo compilation on a warm cache. Rollback yanks the satellite crate versions on crates.io so the monorepo re-takes precedence transparently for downstream users.


Questions?

  • Rust library / core parser questions: file at https://github.com/sebastienrousseau/noyalib/issues
  • WASM / browser / bundler questions: file at https://github.com/sebastienrousseau/noyalib-wasm/issues
  • MCP / AI agent tooling questions: file at https://github.com/sebastienrousseau/noyalib-mcp/issues
  • Security issues: email sebastian.rousseau@gmail.com — do not open a public issue.

noyalib architecture

The map a contributor (or a distro packager) needs to find their way around the codebase. Companion to USER-GUIDE.md (which covers the public API) and the per-module rustdoc on docs.rs.

Workspace layout

crates/
├── noyalib/              # the library — public API
│   ├── src/
│   │   ├── lib.rs        # public re-exports + module index
│   │   ├── parser/       # event-stream parser
│   │   ├── cst/          # lossless concrete-syntax tree
│   │   ├── streaming.rs  # zero-AST typed deserialise (hot path)
│   │   ├── de.rs         # AST-shaped deserialise (Value path)
│   │   ├── ser.rs        # serialiser
│   │   ├── diagnostic.rs  # renderer-neutral codes, severity, and byte spans
│   │   ├── value.rs      # 7-variant Value enum
│   │   ├── borrowed.rs   # zero-copy AST
│   │   ├── simd.rs       # find_any_of, SWAR decimals, structural-bitmask
│   │   └── …
│   ├── tests/            # integration tests + YAML 1.2 official suite
│   ├── examples/         # 76 runnable usage examples
│   └── benches/          # Criterion benches

The only workspace member is crates/noyalib (members = ["crates/noyalib"]). Crates that used to live under crates/ have been split into their own repositories per ADR-0005, and the xtask crate was removed:

The split satellite crates consume noyalib from crates.io via strict-lockstep =X.Y.Z version pins.

Errors cross integration boundaries through Error::diagnostic(). That method produces the canonical Diagnostic payload used by terminal renderers and editor protocols. Optional adapters translate the payload into miette or ariadne; satellites must not duplicate the error-variant mapping. Source labels remain UTF-8 byte spans until the protocol boundary, where adapters perform any required coordinate conversion.

The lib crate is #![forbid(unsafe_code)] workspace-wide. The satellite crates inherit the same forbid; only the third-party deps internally use unsafe (and Miri verifies the interaction is sound — see scripts/miri.sh).

End-to-end pipelines

There are two pipelines through the library, picked automatically based on the caller’s type:

                   ┌──────────────────────────────────────────────┐
                   │                                              │
   from_str::<T>   │   ┌───────┐   ┌────────┐   ┌──────────────┐  │
   (typed target) ─┼─► scanner├─► events ├─► StreamingDeser ├──┼─► T
                   │   └───────┘   └────────┘   └──────────────┘  │
                   │                                              │
   from_str::<Value>                                              │
   (dynamic) ─────►   scanner ──► events ──► loader ─────────────┼─► Value
                                                                 │
   parse_document                                                │
   (CST tooling) ─►   scanner ──► events ──► green-tree builder ─┼─► Document
                                                                 │
                                                                 ▼
                                                  to_string / to_writer / ser

The shared bottom of the funnel is the scanner (one implementation, byte-faithful). The shared middle is the event stream (a flat sequence of Event::Scalar, SequenceStart, MappingStart, etc.). What’s done with the events differs by caller intent.

Parser internals

Phase 1 — scanner

crates/noyalib/src/parser/scanner.rs. Walks the input one byte at a time, emits low-level tokens (indent / dedent / plain-scalar boundary / flow-open / flow-close / quote / anchor / alias / tag).

The hot path inside the scanner uses noyalib::simd:

  • find_any_of — given a needle set {':', '\n', '#', …}, find the first byte in a window. Routes through:
    • memchr::memchr / memchr2 / memchr3 (SSE2 / NEON) for arity-1 / 2 / 3 needle sets — the most common shape.
    • SWAR (8-byte-stride u64 packing) for arity 4–8.
    • Scalar fall-back for everything else.
  • StructuralIter (under --features simd) — walks 32-byte chunks and produces a bitmask of every delimiter position in one pass via mask.trailing_zeros(). Same shape as simdjson’s structural-character pass. Yields ~4× over the memchr loop on 1 MiB workloads (~9× on nightly under core::simd’s 32-byte vector).
  • parse_decimal_u64 / parse_decimal_i64 — SIMD-Within- A-Register decimal parser. Folds 8 ASCII digits per u64 cycle via three pair-wise multiply-add phases; ~2× faster than <i64 as FromStr>::from_str on large numbers.

All unsafe-free — the SIMD primitives are pure-Rust SWAR or nightly core::simd; the SSE2 / NEON paths come from the memchr crate, which uses well-vetted unsafe internally.

Phase 2 — events

crates/noyalib/src/parser/events.rs. Reads the scanner’s tokens and emits semantic events. This is the YAML 1.2 spec’s production rule layer — handle <<: merge keys, anchor capture (&name), alias resolution (*name), tag handling (!foo, !!str).

Anchor expansion is bounded by max_alias_expansions in ParserConfig — the billion-laughs vector is mathematically impossible above the configured budget. The accumulator uses saturating_add so a crafted overflow input still trips the limit cleanly (no integer-wrap escape).

Phase 3a — typed streaming (the hot path)

crates/noyalib/src/streaming.rs. The default from_str::<T> path. Walks parser events directly into the typed target’s serde_core::de::Visitor interface — no intermediate Value AST is ever materialised.

This is the architectural difference from serde_yaml-shaped libraries, which always go event → AST → typed target. For the common case (deserialise into a struct), eliminating the AST saves ~one allocation per scalar plus the rebuild work.

The streaming path bakes in YAML 1.2 semantics:

  • <<: *alias merges natively into the surrounding mapping.
  • !!binary propagates as a typed tag.
  • Custom tags surface via Value::Tagged (the streaming path routes through the AST loader for these — they’re rare enough that the overhead is acceptable).

Phase 3b — AST loader (for Value targets)

crates/noyalib/src/parser/loader.rs. Builds Value (the 7-variant enum: Null, Bool, Number, String, Sequence, Mapping, Tagged). Used by:

  • Callers that explicitly ask for Value (from_str::<Value>).
  • Callers that need parser policies (which run against the built Value, not the streaming pipeline).
  • Callers that opt out of streaming via ParserConfig::ignore_binary_tag_for_string(true).

Value keys are interned via noyalib::interner::KeyInterner when the caller provides one — for Kubernetes-shaped streams (20-byte keys × 10 000 records) this drops memory footprint from ~200 KB of fresh allocations to ~20 B of strings + ~160 KB of Arc pointers.

Phase 3c — CST builder (for tooling)

crates/noyalib/src/cst/. The third path through events.

The CST is a side-table green tree. Each node carries:

  • A kind tag (MappingNode, SequenceNode, Scalar, …).
  • A byte span (start, end) into the original input.
  • A list of child node indices.

Crucially, the original input bytes are kept alongside the tree. Document::to_string() is byte-identical to the input that produced it, because to_string() simply prints the original bytes back, sliced by the node boundaries.

Edits work by:

  1. Resolving the edit path (server.port) to the byte span of the value to replace.
  2. Replacing the bytes in that span only.
  3. Rebuilding any green-tree nodes whose boundaries shift.

Step 3 is bounded by the size of the edit — neighbouring mappings / sequences / comments are completely untouched. This is the algorithmic distinction from “round-trip via AST” — that approach has to re-emit the entire document on every edit.

Foundation of the noyafmt (formatter) and noyavalidate --fix (schema-driven autofix) tools.

The Value tree

crates/noyalib/src/value.rs. The 7-variant enum:

#![allow(unused)]
fn main() {
use noyalib::{Mapping, Number, TaggedValue};
pub enum Value {
    Null,
    Bool(bool),
    Number(Number),
    String(String),
    Sequence(Vec<Value>),
    Mapping(Mapping),
    Tagged(Box<TaggedValue>),
}
// Not a copy of the real type — a check that every variant above still
// exists on it, so this sketch cannot drift unnoticed.
fn exhaustive(v: &noyalib::Value) -> &'static str {
    match v {
        noyalib::Value::Null => "Null",
        noyalib::Value::Bool(_) => "Bool",
        noyalib::Value::Number(_) => "Number",
        noyalib::Value::String(_) => "String",
        noyalib::Value::Sequence(_) => "Sequence",
        noyalib::Value::Mapping(_) => "Mapping",
        noyalib::Value::Tagged(_) => "Tagged",
    }
}
assert_eq!(exhaustive(&noyalib::Value::Null), "Null");
}

Mapping wraps IndexMap<String, Value> so iteration order matches insertion order (and source order, for parsed documents).

TaggedValue carries a Tag + an inner Value. Tags are interned strings; comparisons are byte-equal on the canonical form (!!str and tag:yaml.org,2002:str both compare equal).

Path queries (Value::query("items[*].name")) implement a small subset of the JSONPath grammar — wildcards (*), recursive descent (..), bracket-quoted keys (labels["app.kubernetes.io/name"], for a key the grammar would otherwise read as structure). The same grammar is behind every path-taking cst::Document method. See crates/noyalib/src/path.rs for the full grammar and the quote_key / push_key / join_keys spelling helpers.

Spans

crates/noyalib/src/spanned.rs. Spanned<T> is a transparent wrapper:

#![allow(unused)]
fn main() {
use noyalib::Location;
pub struct Spanned<T> {
    pub value: T,
    pub start: Location,
    pub end:   Location,
}
// The real type, with the same three fields reachable by name.
let s: noyalib::Spanned<u64> = noyalib::from_str("5")?;
let _: (u64, Location, Location) = (s.value, s.start, s.end);
// `Location`'s fields are private; these are the accessors.
let _ = (s.start.line(), s.start.column(), s.end.index());
}

Location carries (line, column, byte_offset) — 1-indexed lines and columns, 0-indexed bytes (matching rustc’s idioms).

Span tracking is thread-local; the AST loader fills a span map during parsing, then Spanned<T> deserialise reads from it during the typed-deserialise pass. Cleared between calls.

The thread-local is the reason Spanned<T> requires the std feature — no_std builds use the regular T directly.

Serialiser

crates/noyalib/src/ser.rs. Single-pass serde_core::Serializer that emits canonical YAML 1.2 output:

  • Plain scalars when safe (no leading - / ? / : / , / flow-open characters, no special-form like null / true / numbers that could be misread).
  • Double-quoted otherwise, with the YAML escape table.
  • Block sequences and mappings (the default); flow form opt-in via FlowSeq<T> / FlowMap<T> newtypes from noyalib::fmt.
  • Block scalars (|, >) when string content would otherwise span multiple lines, gated by block_scalar_threshold.

Emission is configurable via SerializerConfig:

#![allow(unused)]
fn main() {
use noyalib::SerializerConfig;
let cfg = SerializerConfig::new()
    .indent(4)
    .quote_all(true)
    .compact_list_indent(true)        // K8s-style lists
    .document_start(true);
let v: noyalib::Value = noyalib::from_str("a:\n  - 1\n")?;
let out = noyalib::to_string_value_with_config(&v, &cfg)?;
assert!(out.starts_with("---\n"), "document_start not applied: {out:?}");
}

Streaming deserialiser hot path

The streaming path is roughly:

// Shape only: `...` stands for the real visitor calls.
loop {
    match scanner.next_token()? {
        Token::MappingStart       => visitor.visit_map(...),
        Token::Scalar(text, kind) => visitor.visit_str(text)
                                     .or_else(... typed coercion ...),
        Token::SequenceStart      => visitor.visit_seq(...),
        // ...
    }
}

Coercion (e.g. plain scalar 8080 → u16 field) happens at the visit boundary. The plain-scalar resolver in crates/noyalib/src/streaming.rs is the YAML 1.2 schema-resolution table:

PatternResolves to
null, ~, emptyNull
true / false (lowercase only when strict_booleans)Bool
[+-]?[0-9]+Integer
[+-]?[0-9]*\.[0-9]+Float
0x[0-9a-fA-F]+Integer (hex)
0o[0-7]+Integer (octal)
anything elseString

legacy_octal_numbers(true) opts into YAML 1.1’s bare-0 prefix; off by default because the modern 0o form is unambiguous.

Optional surfaces (v0.0.6)

Three v0.0.6 modules sit alongside the core pipeline; each is gated behind its own Cargo feature so the default build pays zero compile / binary-size cost for them.

ModuleFeaturePipeline shapeSource
recoveryrecoveryWraps from_str_with_config in a three-pass retry loop (strict → DuplicateKeyPolicy::Last → line-truncation). Multi-document input split via the same --- scanner the parallel module uses. Returns ParseResult { value, errors, is_complete }. Zero extra deps.crates/noyalib/src/recovery.rs
sval_adaptersvalimpl sval::Value for Value (and Number / Mapping / MappingAny / TaggedValue) — streams a noyalib value graph through any sval::Stream consumer. Bypasses the serde monomorphisation chain entirely.crates/noyalib/src/sval_adapter.rs
tokio_asynctokioBounded reader helpers drain an AsyncRead before parsing. AsyncYamlStream adds read-side backpressure and emits one document per poll through YamlDecoder; each complete document is still parsed synchronously.crates/noyalib/src/tokio_async.rs

All three are pure-safe Rust (preserving the workspace unsafe_code = "forbid" invariant) and exercised by unit tests inline, integration tests under crates/noyalib/tests/, and bench arms in crates/noyalib/benches/v006_features.rs.

Tests

SuiteWhereWhat
Unitinline #[cfg(test)] blocksPer-module logic
Integrationcrates/noyalib/tests/Cross-module surfaces (139 files)
Doc-testsevery /// block with a code-fencePublic-API examples
Propertycrates/noyalib/tests/proptest_*.rsRoundtrip invariants via proptest
Official YAML 1.2 suitecrates/noyalib/tests/yaml-test-suite/**406/406 strict-pass, 0 failures, 0 deliberate skips
CLI smokecrates/noya-cli/tests/End-to-end noyafmt / noyavalidate runs (in the split noya-cli repo)

Total: ~4100 tests. Runtime: ~110 s for cargo test --workspace --all-features --no-fail-fast on an M-series Mac.

Coverage

Workspace coverage is measured by cargo +nightly llvm-cov and gated in CI at 96 % functions / 94 % lines / 93 % regions. Excluded from instrumentation:

  • subprocess-driven protocol smoke tests (the same logic is covered by per-module unit tests). These live in the split noyalib-mcp / noyalib-lsp repos per ADR-0005, so they are no longer part of this workspace’s instrumentation set.

(Prior to v0.0.12, crates/noyalib-wasm/src/lib.rs was also excluded — its JsValue marshalling requires the wasm-bindgen runtime, so it’s covered by wasm_bindgen_test invocations in the satellite repo at sebastienrousseau/noyalib-wasm per ADR-0005.)

Phase 7 of PLAN.md ratchets these gates to 98 / 98 / 98.

Miri

scripts/miri.sh runs Miri on the high-leverage modules (parser::, scanner::, value::, interner::, simd::) and verifies the interaction between noyalib (which is #![forbid(unsafe_code)]) and the runtime deps (indexmap, rustc-hash, ryu, itoa, memchr, smallvec — all of which use unsafe internally) is sound.

The full lib test suite runs under Miri on a weekly schedule (miri-full job in ci.yml); the focused subset runs per-PR.

-Zmiri-symbolic-alignment-check is intentionally not enabled because memchr’s x86_64 SSE2 path triggers a known false positive in _mm_load_si128 on dynamically-aligned pointers. The other defaults — Stacked Borrows, leak detection, uninit-memory checks, OOB checks — are on.

Cross-platform

TargetTestedNotes
x86_64-unknown-linux-gnu✓ CI matrixPrimary dev target
aarch64-apple-darwin✓ CI matrixNEON SIMD path
x86_64-apple-darwin✓ CI matrixmacOS Intel
x86_64-pc-windows-msvc✓ CI matrixWindows
aarch64-unknown-linux-gnu✓ CI matrixARM Linux
wasm32-unknown-unknown✓ via noyalib-wasm338 KB binary after LTO
Big-endian (mips64)✓ via Miri big-endianSWAR + structural-bitmask paths verified
Other targets in the release matrixBuilt at release timeSee release-binaries.yml

The SWAR pipelines and the core::simd structural-bitmask path are byte-order agnostic. u64::from_be_bytes + wrapping_mul arithmetic produce the same results on big-endian targets; verified via MIRI_TARGET=mips64-unknown-linux-gnuabi64 runs in the weekly Miri-full job.

Green Tree — Side-Table CST for Round-Tripping Edits

Status: design proposal · Author: Sebastien Rousseau (with Claude assistance) · Last updated: 2026-04-30

TL;DR

Add a second, parallel parse output: an immutable byte-faithful green tree that retains every byte of the source — content, whitespace, comments, line breaks, indentation. Keep Value and the StreamingDeserializer exactly as they are. Expose the green tree through a new Document type whose mutation API rewrites the tree structurally, then re-emits the modified tree byte-for-byte where untouched and minimally where edited.

This is the architecture popularised by rowan (rust-analyzer) and taplo (TOML). It is the pragmatic alternative to a unified CST, which would tax every consumer who only wants from_str::<MyType>(…).

Goals

  1. 100% lossless round-trip when nothing is edited. parse → emit is byte-identical for any well-formed input.
  2. Local minimal diffs on edit. Mutating one scalar rewrites only the bytes for that scalar; surrounding indentation, comments, blank lines, and other-key content are preserved verbatim.
  3. Zero performance impact on the existing fast path. Users who call from_str::<T> or StreamingDeserializer see no change in compile-time size, runtime allocation, or throughput.
  4. One source of truth for parsing. The green tree builds from the same scanner token stream that already feeds Value — no second parser, no divergence.
  5. Stable foundation for ongoing strictness fixes. Indentation rigor, document hygiene, and other lenient-cluster fixes target the existing scanner/loader; the green tree retrofit must not require redoing them.

Non-goals

  • Replacing Value. Value remains the data API for serde-style use.
  • Comment attachment semantics (which comment “belongs” to which node). The capture surface in src/comments.rs is sufficient for most consumers; node-attached comments can be a follow-up.
  • Cross-document editing. Document is single-document; multi-doc streams are a Vec<Document> (see API).
  • Schema-aware editing (e.g. “set the value of version to a string even if it parses as a number”). Caller’s responsibility to provide the right fragment.

What already exists

The retrofit lands on top of substantial infrastructure that this crate already carries:

SourceWhat it provides
src/parser/scanner.rs (Span, Token)Every emitted token already carries start..end byte offsets into the input. The scanner does not need changes for green-tree construction.
src/parser/scanner.rs::ScannedCommentComments are captured with byte spans and an inline flag. The capture API is in src/comments.rs (load_comments).
src/parser/events.rsToken stream is consumed into a typed Event stream (MappingStart, Scalar, …). Each event carries a Span.
src/parser/loader.rsEvents are folded into Value. This is the current AST builder.
src/span_context.rs::SpanTreeAn auxiliary tree of spans paired with each Value node, populated during loading.
src/spanned.rs::Spanned<T>Public type pairing a T with its source span; used in serde flows.

The header comment in src/comments.rs:24-27 already anticipated this work:

The building blocks are here: the scanner now preserves comment spans, so a future commit can layer an AST side-table on top without re-plumbing the parser.

That commit is what this document specifies.

Architecture

Two trees, one parse

                                    ┌─────────────────────────┐
                                    │   StreamingDeserializer │  ← unchanged
                                    └─────────────────────────┘
                                                 ▲
            ┌──── tokens ────┬──── events ───────┴── (no value built)
            │                │
       ┌────┴────┐       ┌───┴────────┐
       │ Scanner │ ────▶ │ EventStream│
       └────┬────┘       └───┬────────┘
            │                │
            └──── events ────┼─────▶ ┌────────────────┐
                             │       │ value::Loader  │  ── Value
                             │       └────────────────┘
                             └─────▶ ┌────────────────┐
                                     │  cst::Builder  │  ── GreenNode (Arc)
                                     └────────────────┘            │
                                                                   ▼
                                                         ┌──────────────────┐
                                                         │     Document     │
                                                         │   (mutable API)  │
                                                         └──────────────────┘
  • Value and GreenNode are independent products of the same parse. Either can be built alone (cheap path) or both together (CST path) at minimal extra cost.
  • The scanner runs once. Token spans are reused.
  • Mutation happens on Document, never on Value directly.

Green tree shape

The green tree is a rowan-style untyped, immutable tree where every node either:

  • Holds source text directly (a leaf Token), or
  • Holds a sequence of child green nodes plus the syntax kind.

Critically, leaves include trivia — whitespace runs, newlines, indentation runs, comments — not just content tokens. The concatenation of every leaf’s text in document order reproduces the input byte-for-byte.

Sketch of the core types (lives in a new src/cst/ module):

// src/cst/mod.rs

/// Kind of a syntax node or token.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SyntaxKind {
    // ── trivia (leaves only) ────────────────────────
    Whitespace,
    Newline,
    Comment,
    // ── structural punctuation (leaves) ─────────────
    DashIndicator,        // `-`
    QuestionIndicator,    // `?`
    ColonIndicator,       // `:`
    AnchorMark,           // `&name`
    AliasMark,            // `*name`
    TagMark,              // `!tag`
    DocStart,             // `---`
    DocEnd,               // `...`
    // ── scalar leaves ───────────────────────────────
    PlainScalar,
    SingleQuotedScalar,
    DoubleQuotedScalar,
    LiteralScalar,
    FoldedScalar,
    // ── composite nodes ─────────────────────────────
    Document,
    BlockMapping,
    BlockSequence,
    FlowMapping,
    FlowSequence,
    BlockMappingEntry,    // `key: value` (with surrounding trivia)
    FlowMappingEntry,
    BlockSequenceEntry,   // `- value` (with surrounding trivia)
    FlowSequenceEntry,
    Key,                  // wraps the key node + the `:`
    Value,                // wraps the value node
}

/// Immutable green node — `Arc`-shared, byte-faithful.
#[derive(Debug, Clone)]
pub struct GreenNode {
    kind: SyntaxKind,
    text_len: usize,            // total byte length, cached
    children: Arc<[GreenChild]>,
}

#[derive(Debug, Clone)]
enum GreenChild {
    Node(GreenNode),
    Token { kind: SyntaxKind, text: Box<str> },
}

impl GreenNode {
    pub fn kind(&self) -> SyntaxKind { self.kind }
    pub fn text_len(&self) -> usize { self.text_len }
    pub fn children(&self) -> impl Iterator<Item = &GreenChild>;
    /// Concatenation of every descendant token's text in document
    /// order. For an unmutated tree, equals the original input.
    pub fn text(&self) -> String;
}

The “red” side is implicit: a SyntaxNode cursor that walks the green tree and tracks parent pointers and absolute byte offsets on demand. Following rowan, this is computed lazily — not stored. We do not need a separate RedNode type; cursors satisfy the typed-API requirement.

Public API

A new top-level Document type sits on top of the green tree:

// src/cst/document.rs (new file)

/// A YAML document with byte-faithful source preservation.
///
/// Parses input and retains every byte — content, whitespace,
/// comments — so that an unmodified `Document` re-emits identical
/// bytes. Edits rewrite only the affected fragment; surrounding text
/// is preserved verbatim.
///
/// Multi-document streams are returned as `Vec<Document>` from
/// [`parse_stream`].
#[derive(Debug, Clone)]
pub struct Document {
    green: GreenNode,
    /// Cached byte buffer; invalidated on edit.
    cached_emit: OnceLock<String>,
}

/// Parse a single-document YAML string into an editable `Document`.
pub fn parse_document(input: &str) -> Result<Document>;

/// Parse a multi-document stream.
pub fn parse_stream(input: &str) -> Result<Vec<Document>>;

impl Document {
    /// The root syntax node.
    pub fn syntax(&self) -> SyntaxNode<'_>;

    /// Re-emit the document. Byte-identical to the original input
    /// when no edits have been applied.
    pub fn to_string(&self) -> String;

    // ── Typed read-only access ──────────────────────────────────
    pub fn as_value(&self) -> Value;          // one-shot conversion

    // ── Typed mutation (Renovate-class) ─────────────────────────
    pub fn get(&self, path: impl AsPath) -> Option<NodeRef<'_>>;
    pub fn set(&mut self, path: impl AsPath, value: impl Emit);
    pub fn remove(&mut self, path: impl AsPath);

    // ── Span-based mutation (linter/formatter-class) ────────────
    /// Replace the bytes at `span` with `text`. The caller is
    /// responsible for the replacement being syntactically valid in
    /// that position; `Document` re-validates the result before
    /// committing.
    pub fn replace_span(&mut self, span: Span, text: &str)
        -> Result<(), EditError>;
}

/// Path into a YAML document — `JsonPath`-style.
pub trait AsPath { /* "/foo/bar/0" or [Segment::Key("foo"), …] */ }

/// Source for a value being inserted. Implementations exist for
/// `&str`, `i64`, `f64`, `bool`, `Value`, and any `Serialize` type.
/// The `Document` formats the new value to match the existing scalar
/// style at the target site (literal/folded/plain/quoted) wherever
/// possible, and otherwise emits the simplest valid form.
pub trait Emit { fn emit(&self, ctx: EmitCtx<'_>) -> String; }

Four design decisions

1. Separate parse entry point — yes

parse_document(input) is a new top-level function alongside from_str / from_slice / load_all. We do not add a Value::with_trivia() constructor; Value does not gain a hidden green-tree field. This keeps the fast path’s type signature unchanged and its size and layout untouched, and prevents accidental opt-in (an Emit of a Value from parse_document is unambiguous; an Emit of a Value from from_str is impossible at the type-system level).

Trade-off: callers who want CST behaviour must opt in explicitly. We think that is the correct default.

2. Both typed and span-based mutation

  • Typed (get / set / remove) is the headline API. It is what Renovate-class consumers want: “set the version at dependencies.serde to 2.0.”
  • Span-based (replace_span) is the formatter / linter API. Users who already operate on spans (e.g. consumers of Spanned<T>) get a low-level escape hatch.

Both paths converge on the same green-tree rewrite logic. set is implemented as replace_span(node.span(), formatted).

3. Re-emission policy: leave indentation alone

When set replaces a value, the surrounding indentation, comments, and blank lines are preserved verbatim. We do not re-indent a sibling block when one entry’s value changes shape, even if the result is “ugly” by a hand-written style.

This matches taplo’s policy. Aggressive reformatting is out-of-scope for Document; consumers who want it run their own formatter.

The one exception: when set introduces a multi-line value where a single-line value lived (e.g. a literal block scalar replacing a plain scalar), Document indents the new lines to match the entry’s existing column.

4. Anchor / alias under mutation

If an edit targets a node that has an anchor or is the target of an alias elsewhere in the document:

  • Edit a node with an anchor → keep the anchor, change the content. Aliases continue to refer to the (now-different) node.
  • Edit a node that is aliased elsewhere (the alias resolves to it) → emit a warning; the alias now resolves to a different value. This is allowed by default. A strict mode (Document::strict_aliases) errors instead.
  • Edit an alias node (*foo) directly → forbidden. set on an alias returns EditError::AliasNotEditable. To change the value the alias points to, mutate the anchored node.

This is the area where most “CST for YAML” projects accumulate scars; conservative defaults plus an opt-in strict mode is the only position that has held up in practice (see serde_yaml’s and yaml-rust2’s issues archives).

Migration plan

Phased; each phase is releasable and independently useful.

Phase 0 — design lock-in

This document. No code changes.

Phase 1 — green-tree builder

  • New src/cst/ module: SyntaxKind, GreenNode, GreenChild, SyntaxNode, Builder.
  • The builder consumes the existing Event stream from src/parser/events.rs plus the un-skipped trivia from the scanner (ScannedComment plus a new internal trivia capture for whitespace and newlines, which are currently elided by skip_to_next_token but whose spans are easy to retain).
  • New API: parse_document(input) -> Result<Document> and parse_stream(input) -> Result<Vec<Document>>. Read-only initially: no set or replace_span yet.
  • New test: tests/cst/round_trip.rs — for a corpus of inputs (existing tests/yaml-test-suite/ cases), assert parse_document(s).unwrap().to_string() == s.
  • Acceptance criterion: 100% byte-identical re-emit on every test suite case the existing parser accepts.

Phase 2 — typed mutation

  • Document::get / set / remove and the AsPath / Emit traits.
  • tests/cst/mutation.rs — for each of N curated cases, apply a named edit, check the resulting to_string() against a golden file under tests/cst/golden/.

Phase 3 — span-based mutation + edit validation

  • Document::replace_span. Re-validates by re-parsing the resulting text and checking that the affected node still parses to a syntactically valid scalar/collection.
  • EditError type with the alias / multi-line / shape-mismatch variants.

Phase 4 — round-trip strictness fixes

The remaining 41 lenient cases (indentation rigor, document hygiene, implicit-key multi-line) are now revisited. The strictness fixes land at the scanner / loader level — the green-tree builder inherits them automatically. There is no green-tree-specific strictness work.

This phase only follows phases 1–3 because some of the remaining strictness fixes (notably indentation rigor) require richer trivia tracking that the green-tree builder will already need. We land the trivia capture once.

Performance contract

Phase 1’s introduction of green-tree construction must not slow down existing entry points. The verification is two-fold:

  • The criterion benches under benches/ (benchmarks, comparison, architecture, validation_overhead) gate at ±5% against main. CI fails on regression.
  • The parse_document path is benchmarked separately. Its overhead vs from_str::<Value> should be ≤2× — comparable to taplo’s ratio. The streaming path stays the undisputed champion for from_str::<MyType> use.

Open questions

  • YAML 1.1 vs 1.2 directive handling. The spec is loose on preserving directive text across a round-trip. Decision needed: does parse_document → set → to_string preserve the original %YAML 1.2 directive byte-for-byte, or canonicalise it? Default proposal: preserve verbatim (consistent with the indentation policy).
  • Preserving non-canonical scalar styles. A plain 42 and a quoted "42" deserialise to the same Value, but their CST representations differ. set(path, 42) on a quoted-style site should emit "42", not 42. Decision: yes — Emit carries an EmitCtx that knows the existing style at the target.
  • Does Document implement serde::Deserialize? Probably not — forces consumers through Document::as_value() which is the intended boundary. But Document::as_value() should be cheap; the green tree already has all the data.
  • Memory layout. GreenChild::Token { text: Box<str> } allocates per leaf. For very large documents (k8s-class manifests), an arena may be worth the complexity. Defer to phase 1 measurements.

Test / regression strategy

  • Round-trip property test. Every yaml-test-suite case that parses must round-trip byte-identically. Wired into tests/yaml_compliance_report.rs’s existing infrastructure.
  • Golden mutation tests. Per phase 2 above; cargo test --test cst_mutation -- --bless to regenerate.
  • Fuzz harness. Extend the existing fuzz/ corpus with a green-tree round-trip target — for any input the parser accepts, parse_document → to_string → parse_document → to_string must be a fixed point.
  • Anchor/alias scar tests. A dedicated test file in tests/cst/anchors.rs for the four scenarios in §4.

What this design explicitly avoids

  • rowan as a hard dependency. We replicate the green-node pattern directly. rowan is excellent but pulls in text-size, countme, and friends; the implementation here is small enough (≤500 LoC) that the dependency cost is not justified for a YAML parser. If we need the typed-syntax-tree generator later, we can reconsider.
  • A second tokenizer. The green tree is built from the same scanner output that already feeds Value. Strictness fixes in the scanner improve both immediately and stay improved.
  • Implicit fallback to lenient parsing on edit. If replace_span produces invalid YAML, the edit is rejected with EditError. Document never silently emits invalid output.

References

  • rowan (rust-analyzer) — green-tree pattern.
  • taplo (TOML) — single-document edit-aware API; closest analogue in the broader ecosystem.
  • YAML 1.2.2 §6 – §9 — production rules informing the green-tree granularity.
  • tests/yaml-test-suite/ — round-trip corpus.

THE ARCHITECT ᛫ Sebastien Rousseau ᛫ https://sebastienrousseau.com THE ENGINE ᛞ EUXIS ᛫ Enterprise Unified Execution Intelligence System ᛫ https://euxis.co

Ecosystem dependency graph

The shape of repository-to-repository dependencies across the noyalib ecosystem. The arrows read “depends on”. Each satellite is released from its own repository; the core workspace contains only noyalib.

graph TD
    noyalib["noyalib<br/>core library<br/>MSRV 1.89"]

    noya_cli["noya-cli<br/>noyafmt + noyavalidate<br/>MSRV 1.89"]
    noyalib_lsp["noyalib-lsp<br/>LSP server<br/>MSRV 1.89"]
    noyalib_mcp["noyalib-mcp<br/>MCP server<br/>MSRV 1.89"]
    noyalib_wasm["noyalib-wasm<br/>WASM bindings<br/>MSRV 1.89"]
    noyalib_serde_yaml["noyalib-serde-yaml<br/>compatibility package<br/>MSRV 1.89"]

    noya_cli --> noyalib
    noyalib_lsp --> noyalib
    noyalib_mcp --> noyalib
    noyalib_wasm --> noyalib
    noyalib_serde_yaml --> noyalib

    classDef core fill:#1f6feb,stroke:#0d419d,color:#fff
    classDef satellite fill:#3fb950,stroke:#1f6f3a,color:#fff
    class noyalib core
    class noya_cli,noyalib_lsp,noyalib_mcp,noyalib_wasm,noyalib_serde_yaml satellite

Reading the graph

noyalib is the only repository with no ecosystem dependencies. It is the root of the graph. Every satellite sits downstream of it. This is enforced architecturally: noyalib cannot import from noya-cli, noyalib-lsp, etc., even via tests.

Satellite crates (noya-cli, noyalib-lsp, noyalib-mcp, noyalib-wasm, noyalib-serde-yaml) depend only on noyalib. They never depend on each other, even when their feature surface overlaps. The MCP server and the LSP server, for instance, both implement format and parse operations, but each does so by calling noyalib::cst::format directly — neither imports from the other. This keeps the per-crate dependency footprint minimal and the integration tests independent.

There is no xtask crate. It was the build-tooling crate (it provided the completions and manpages subcommands) and was retired with the workspace split (#134): man pages and shell completions belong to noya-cli, which generates them in its own repository. What remains here runs from Makefile targets (make sbom, make notice, make vendor) and scripts/.

MSRV (single lockstep floor)

CrateMSRVReason
noyalib1.89.0Single lockstep floor, shared with the maintainer’s other Rust crates since v0.0.58 (ADR-0014); the toolchain-driven floor is 1.86 (criterion 0.8 dev-dep)
noyalib-mcp1.89.0Lockstep with the core floor
noyalib-wasm1.89.0Lockstep with the core floor; wasm-bindgen 0.2 floors at 1.86
noya-cli1.89.0Lockstep with the core floor; clap_builder 4.6 is edition-2024
noyalib-lsp1.89.0Lockstep with the core floor; LSP transport stack (litemap, uuid) is edition-2024
noyalib-serde-yaml1.89.0Lockstep compatibility surface for serde_yaml consumers

CI’s Per-crate MSRV job still enforces the floor per crate — it reads each crates/*/Cargo.toml’s rust-version and compiles against exactly that — so a satellite adopting a higher floor cannot silently drag the core up with it. The mechanism that kept the floors independent is intact; what changed in v0.0.16 is only the numbers it reads.

As of v0.0.58 every crate declares 1.89.0, by decision (ADR-0014) rather than by toolchain need. The toolchain-driven floor is 1.86, set by criterion 0.8, a dev-dependency: with rust-version temporarily set to 1.85, cargo +1.85.0 check --lib compiles cleanly but cargo +1.85.0 check --all-targets fails with criterion@0.8.2 requires rustc 1.86. So no test, bench or coverage run can execute below 1.86, and the project publishes the floor it verifies rather than the lower one the library alone would reach.

No runtime dependency of the core crate requires 1.86 — the highest runtime floor in the tree is 1.85. The earlier claim that validate-schema’s ICU chain forced 1.86 was an artefact of a lockfile refresh that did not ship; see CHANGELOG.md for v0.0.16.

External dependency surface

Dependency counts change with lockfile and feature resolution, so this page does not copy a hand-maintained table. The weekly ecosystem scorecard records the current runtime closure for all six repositories; use docs/ECOSYSTEM.md for that evidence.

Generating this graph

To inspect the core workspace graph:

cargo depgraph --workspace-only --dedup-transitive-deps | dot -Tsvg > /tmp/deps.svg

The cross-repository Mermaid graph is maintained from the repository list used by scripts/ecosystem-scorecard.sh. When that list changes, update this file and docs/ARCHITECTURE.md.

Testing strategy

noyalib’s test pyramid is unusually wide for a library of its size. This document explains why — what each layer catches, where it runs, and how to extend it.

The five layers

graph TD
    A[Doctests<br/>~384] --> B[Unit tests<br/>inline #[cfg(test)]]
    B --> C[Integration tests<br/>tests/*.rs · 139 files]
    C --> D[Property + proptest]
    D --> E[Fuzz · libfuzzer<br/>10 targets]
    F[Miri · UB detection] -.parallel.-> C
    G[Bench · Criterion<br/>16 harnesses] -.parallel.-> C
    H[Spec compliance<br/>406/406 strict yaml-test-suite] -.parallel.-> C

Each layer catches a different bug class. None replaces another — they compose.

Doctests (~384)

Every public item carries a runnable example in its docstring. The noyalib README is also wired into the doctest sweep via:

// `include_str!` resolves relative to the file it appears in, so this
// only compiles inside the crate it documents.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
mod readme_doctests {}

So every ```rust block in the README is exercised on every PR. This catches API drift in code samples — a frequent rot vector in libraries with marketing-grade READMEs.

Run: cargo test --doc -p noyalib --all-features

Unit tests

Co-located with implementation in #[cfg(test)] mod tests blocks. Used for tightly-scoped invariants — encoding tables, scanner state machines, format-config heuristics. Not the place for end-to-end flows; those live in tests/.

Integration tests (139 files in crates/noyalib/tests/)

Each file is a focused theme. Selection of the most load-bearing:

FileTestsWhat it locks
official_suite.rs406YAML 1.2 spec compliance — every case from yaml-test-suite
yaml_compliance_report.rs—Cross-checks against yaml-rust2, serde_yaml_ng, saphyr outputs
borrowed_alias_resolution.rs12P4 borrowed-path alias resolution + bomb defence
yaml_version.rs11P2 version(YamlVersion::V1_1) toggle
json_surrogate_escape.rs13JSON-style \uXXXX\uXXXX surrogate-pair pairing
scanner_panic_regressions.rs2Fuzz-discovered scanner panics — no-DoS contract
legacy_sexagesimal.rs19YAML 1.1 base-60 numeric resolution
merge_keys_streaming.rs—<<: merge-key policy permutations
cst_format.rs / cst_round_trip.rs—CST byte-faithful round-trip
proptest.rs—Property-based generation against the parser
serde_ecosystem.rs—serde_path_to_error, serde_ignored interop

Run: cargo test --workspace --all-features

Property + proptest

proptest generates structured YAML inputs to assert universal properties — parse(emit(value)) == value, parse(format(s)) is total over valid YAML, etc. Catches edge cases that case-by-case tests miss because the input space is hostile in shape, not just content.

Fuzz (10 targets, libfuzzer)

Located in fuzz/fuzz_targets/:

TargetCoverage
fuzz_parseTop-level from_str::<Value> panic surface
fuzz_roundtripparse(emit(parse(s))) differential
fuzz_from_valueValue → typed deserialise
fuzz_multi_docload_all_as document-boundary scanning
fuzz_strictfrom_str_strict<T> unknown-key surfacing
fuzz_diffDifferential against yaml-rust2 / saphyr / serde_yaml_ng
fuzz_borrowed_aliasP4 alias resolution + bomb cap
fuzz_no_span_loaderNoSpanLoader path — three-loader parity
fuzz_yaml_v1_1P2 1.1 resolver bundle + individual flag overrides
fuzz_double_quotedSurrogate pair pairing + scalar escape branches

CI runs a 10-second smoke pass per PR via the Differential fuzz job. Long-form pre-release fuzz runs are operator-driven.

Run a 30-second smoke locally:

cargo +nightly fuzz run fuzz_parse -- -max_total_time=30 -timeout=5

The fuzz pass for v0.0.1 caught one pre-existing scanner panic (scanner.rs:1644 — slice index starts at 2 but ends at 0 on adversarial implicit-key tracking) that the existing corpus had never triggered. Both crashing inputs are now locked in scanner_panic_regressions.rs.

Miri (UB detection)

cargo +nightly miri test runs the entire suite under the Miri interpreter, which catches undefined behaviour stdlib usage that ordinary tests miss — uninitialised reads, out-of-bounds aliasing, strict-provenance violations.

The workspace forbids unsafe_code so noyalib’s own code can’t introduce UB. Miri is therefore catching deps’ problems reachable from noyalib’s code. It has caught memchr SSE2 false positives on -Zmiri-symbolic-alignment-check (which is now disabled in scripts/miri.sh) and would catch any future regression in indexmap’s iteration invariants or serde’s borrow lifetime plumbing.

CI runs a focused Miri pass per PR (60-min timeout) and a full Miri + big-endian sweep on schedule.

Run: ./scripts/miri.sh

Benchmarks (16 Criterion harnesses)

Performance is treated as a correctness invariant — regressions are bugs the same way wrong output is. Per-crate harnesses:

CrateHarnessesWhat they cover
noyalib16core parser, comparison vs yaml-rust2 / yaml-spanned / saphyr, SIMD, incremental repair, validation overhead, large-doc soak, structural bitmask, numeric parse
noya-cli (split repo)1clap argv parse + command-tree construction
noyalib-lsp (split repo)1textDocument/formatting + parse-for-diagnostics
noyalib-mcp (split repo)1the four tools/call operations

CI runs the comparison + microbench suites via CodSpeed on every PR (the CodSpeed perf dashboard workflow); regressions block merge.

Run: cargo bench --workspace

Spec compliance: 406/406 strict

The yaml-test-suite is the canonical 1.2 conformance corpus. noyalib’s compliance harness reports 406/406 strict-pass, 0 failures, and 0 skips out of 406 total cases (each case directory yields one or more variant assertions; the totals reflect those variant counts). The full breakdown rebuilds on every CI run into target/yaml-compliance-report.md.

The strict-pass set is locked by tests/official_suite.rs so a regression — any case dropping from pass to skip / fail — fails CI immediately. Each new correctness fix lands with the corresponding suite case unblocked.

Coverage gate

CI’s Coverage gate (≥96%) job runs cargo +nightly llvm-cov across the workspace and fails under:

  • --fail-under-functions 96 — every public/private fn
  • --fail-under-lines 94 — line coverage
  • --fail-under-regions 93 — region (branch) coverage

Excluded from the report: noyalib-wasm/src/lib.rs (JsValue marshalling needs a wasm-bindgen runtime; covered separately by wasm_bindgen_test under wasm-pack test) and the protocol.rs end-to-end subprocess tests in MCP/LSP (don’t run cleanly under llvm-cov instrumentation; the same logic is covered by per-module unit tests).

Adding a test

Match the layer to the bug class:

Bug classLayer
API contract / public docstring driftdoctest
Single-function invariantunit test
End-to-end flowintegration test in tests/*.rs
Universal property over inputsproptest
Adversarial input panicsfuzz target + regression test in tests/scanner_panic_regressions.rs
Memory-safety / UBalready covered — Miri runs the full suite
Performance regressionCriterion bench
Spec compliance gapadd the case to yaml-test-suite, ensure official_suite.rs covers it

Every new public surface ships with new tests in the same commit / PR — never as a follow-up. This invariant is in CONTRIBUTING.md and enforced socially in review.

noyalib — Engineering Policies

This document is the single source of truth for noyalib’s engineering posture: MSRV, SemVer, security audits, performance guarantees, concurrency, platform support, and the feature-flag matrix. Every README in the workspace links here; if there’s a contradiction between this file and a per-crate README, this file wins.

Last reviewed: 2026-07-22. The dates on individual sections capture when each policy was last audited.


Contents

  1. MSRV (Minimum Supported Rust Version)
  2. SemVer & API stability
  3. Security & audits
  4. Performance & algorithmic complexity
  5. Concurrency guarantees
  6. Platform support
  7. Feature-flag matrix
  8. Panic policy
  9. Error model
  10. Dependency policy
  11. Release & changelog policy

1. MSRV (Minimum Supported Rust Version)

CrateMSRVRationale
noyalib (library core)1.89.0One floor shared with the maintainer’s other Rust crates, raised from 1.86.0 in v0.0.58 (ADR-0014). The toolchain-driven floor is lower: criterion 0.8 (dev-dependency) declares rust-version = 1.86, so cargo check --all-targets, the bench suite, and the coverage gate all fail on 1.85. Enforced by the dedicated msrv-core CI job.
noyalib-mcp1.89.0Lockstep with the core floor; the MCP SDK alone needs 1.88. The MCP wire surface is text-only JSON-RPC and pulls no nightly-only deps.
noya-cli (binaries)1.89.0Lockstep with the core floor; clap_builder 4.6 is edition-2024 and requires 1.86+.
noyalib-lsp1.89.0Lockstep with the core floor.
noyalib-wasm1.89.0Lockstep with the core floor; the wasm-bindgen 0.2 ecosystem floors at 1.86.

What the MSRV number means here. It is the lowest toolchain on which the project can be built and tested, not the lowest on which the library alone happens to compile. Those differed at the 1.86.0 floor: cargo +1.85.0 check --lib succeeds, but cargo +1.85.0 check --all-targets fails with criterion@0.8.2 requires rustc 1.86. We publish the number we verify. Advertising 1.85 would be an untested promise — no CI leg could run there, so nothing would catch the day it broke.

When we bump it. Only when the toolchain we build and test at actually moves — a dependency (runtime or dev) raising its floor, or a language feature we choose to adopt. Never speculatively, and never for tidiness. “Headroom” is not a reason: an MSRV can be raised the day it is needed, and raising it early only costs downstream users. The one exception is the v0.0.58 raise to 1.89.0, a maintainer decision to share one floor with his other Rust crates, recorded in ADR-0014. As a standing guarantee, noyalib will not require a rustc newer than 12 months old at the time of release (1.89.0 shipped 2025-08-07, so the current floor is comfortably inside that).

How it ships (revised 2026-07-22, effective v0.0.16): while the project is in its 0.0.x line, an MSRV bump — core or satellite — ships as a patch (0.0.x → 0.0.x+1), in lockstep across all five crates. It must be called out in CHANGELOG.md under an explicit ### Changed — MSRV heading so consumers can find it by scanning, and the new floor must be reflected in the table above, which is the single source of truth.

This supersedes the previous rule that a core MSRV bump was a minor-version event. That rule was written when 0.1.0 was the next planned cut; under the current 0.0.x posture there is no minor slot to spend, and SUPPORT.md already warns that a patch may carry breaking changes in 0.x. Treating MSRV bumps as patches keeps the version line monotonic and honest rather than reserving a semantic that the 0.0.x scheme cannot express. This changes at 1.0: once the crate is 1.x, an MSRV bump becomes a genuine minor-version event again, per the 1.0 gates in PLAN.md.

CI matrix verifies the MSRV per-crate via the Per-crate MSRV workflow job — no change to that job is required when a downstream crate updates its dependency floor; the workflow reads rust-version from each Cargo.toml.


2. SemVer & API stability

noyalib follows SemVer 2.0.0 strictly, with one well-defined exception below.

What constitutes a breaking change

A change is breaking (requires a major-version bump under 1.0+, or a minor-version bump on the 0.x line) if it:

  • Removes or renames a publicly exported item (function, type, trait, const, enum variant, module).
  • Changes the signature of a publicly exported function or trait method (excluding broadening the bound, which is non-breaking).
  • Changes the layout of a publicly exported struct in a way that breaks pattern-match exhaustiveness (adds a non-#[non_exhaustive] variant or field).
  • Changes the default behaviour of a parser / serializer (e.g., flips YAML 1.1 ↔ 1.2 boolean recognition).
  • Removes a feature flag or changes a feature’s set of enabled deps.

What is not a breaking change

  • Adding a new public item.
  • Adding a new variant to a #[non_exhaustive] enum.
  • Adding a new field to a #[non_exhaustive] struct.
  • Tightening a parser to reject input it previously accepted as long as that input was malformed YAML 1.2 (not in the test suite’s accept-set). Such changes are noted in the changelog under “Stricter rejection”.
  • Tightening a deserializer to reject input that was previously coerced (e.g., from_str_strict). The strict path is opt-in.
  • Performance, code-size, or compile-time changes that preserve the observable contract.

Pre-1.0 (current state — 0.0.x)

While on the 0.0.x line, every release may be breaking per SemVer’s pre-1.0 carve-out — Cargo treats each 0.0.x as its own incompatible version, so the patch position is the only one that moves (see the MSRV bump policy in §1, which follows from this). We try to avoid breakage anyway; the cargo-semver-checks CI gate catches accidental breaks. The 0.0.x line is intentionally narrow: we expect to ship a single big-bang 0.0.1, then iterate 0.0.2, 0.0.3, … through 0.0.999, then graduate to 0.1.0 for the first genuinely stable release.

Compatibility tooling

  • cargo-semver-checks: runs in CI on every PR; fails the build on detected breaks.
  • #[non_exhaustive] is applied to every config struct (ParserConfig, SerializerConfig) so adding fields is always non-breaking.
  • MSRV stability: see §1 above.

3. Security & audits

Reference: SECURITY.md at the repo root.

Compile-time safety

  • #![forbid(unsafe_code)] at the workspace level. The lint applies to every crate in crates/. Verified by Rust CI / Check & Test on every PR.
  • No C dependencies, no FFI calls. No libyaml, no libc, no transitively-unsafe parser. The runtime deps that do use unsafe (indexmap, rustc-hash, ryu, itoa, memchr, smallvec) are checked under Miri on every PR (focused) and weekly (full + big-endian).
  • No network I/O and no filesystem writes from the library itself, and no environment-variable reads at run time (the build script reads RUSTC and NOYALIB_COVERAGE). The library reads files only when asked to: SafeFileResolver (include_fs) under its root, and the figment provider’s Yaml::file and Yaml::file_with_config. JSON Schema $ref never resolves outside the schema document. The noya-cli binaries read and write files.

Resource-limit gates

The parser has explicit DoS guards. Defaults are conservative; override via ParserConfig.

LimitDefaultstrict()PurposeOverride
max_depth12864Stack-overflow guard on deeply-nested inputParserConfig::max_depth(N)
max_alias_expansions1024100Billion-laughs amplification capParserConfig::max_alias_expansions(N)
max_document_length64 MiB1 MiBPer-document size capParserConfig::max_document_length(N)
max_stream_bytes256 MiB16 MiBMulti-document stream size capParserConfig::max_stream_bytes(N)
max_nodes25000025000AST node-count flood cap (empty-collection bombs)ParserConfig::max_nodes(N)
max_sequence_length655361024Per-sequence item count capParserConfig::max_sequence_length(N)
max_mapping_keys655361024Per-mapping key count capParserConfig::max_mapping_keys(N)

The corresponding regression tests live in tests/stress_load.rs.

v0.0.6 opt-in surface limits

The recovery, tokio, and sval modules (each behind its own Cargo feature) add their own DoS-resistance posture on top of the shared parser limits:

  • recovery::parse_lenient rejects a ----marker scan beyond ParserConfig::max_documents to defeat marker-spam inputs, and caps the cumulative line-truncation-retry cost at LenientConfig::truncation_event_budget (default 1 MiB) to defeat O(n²) re-parse on 10k-line adversarial documents.
  • tokio_async::from_async_reader{,_multi}{,_with_config} probe one byte beyond max_document_length for a single document or max_stream_bytes for a stream. Oversized input is rejected, never parsed as a truncated prefix. A leading UTF-8 BOM is stripped so Windows-saved buffers round-trip identically to LF-on-Linux equivalents.
  • tokio_async::YamlDecoder exposes max_frame_size(usize): when the inter-frame BytesMut holds an incomplete document beyond the cap, the next decode call returns Error::Io(InvalidData) rather than letting an adversarial producer pin memory by streaming without ---. Constructors derive the cap from ParserConfig::max_document_length; callers may tighten it, and max_document_length caps a frame whatever max_frame_size says. Multiple complete documents already present in one read are measured independently rather than rejected by their aggregate buffered size. The boundary scan resumes where the previous decode stopped, so a frame arriving in many small reads is scanned once, not once per read.
  • sval_adapter forwards non-finite floats verbatim by default; use to_sval_writer_with_config with SvalConfig::coerce_non_finite_to_null to emit Null instead, required for downstreams like sval_json that reject NaN / ±∞.

max_depth guard correctness (fixed in v0.0.6 / issue #46)

The max_depth limit above relies on balanced increment / decrement of self.depth along every code path. Two correctness bugs that made the guard either over- or under-count were patched in v0.0.6:

  • Over-count, false-positive. StreamingMapAccess’s iterators did not check the access object’s finished flag. Serde visitors that called next_entry after Ok(None) — ValueVisitor::visit_map does this — re-entered the iterator, read the next event from the parent mapping, and treated it as the inner exhausted child’s. The recursive deserialize_any on each spilled value inflated self.depth by one per empty flow {}, so a pnpm-lock.yaml-shaped input with N consecutive {} hit the limit at exactly N = max_depth. Fixed: explicit if self.finished { return Ok(None) } guards on the three iterators; balanced decrement on Ok and Err in deserialize_any / _seq / _map.
  • Under-count, silent bypass. The NoSpanLoader path (value-target fast path, no_std multi-document loading) incremented self.depth on SequenceStart / MappingStart but did not compare against max_depth. Adversarial deeply-nested input through that path could consume stack without ever firing the documented guard. Fixed: mirror the span loader’s check.

Regression suite: crates/noyalib/tests/issue_46.rs — 10 tests including a 50 000-package full pnpm-lock-v9 shape, 3 000 consecutive empty flow mappings, deterministic depth-cliff probe at n ∈ [100, 128, 129, 130, 200, 500, 1000], and a 200-level-deep sequence for the no-span path.

Audit pipeline

Each PR runs:

  • cargo audit (RustSec advisory DB).
  • cargo deny (allow / deny / skip rules; SPDX licence whitelisting via deny.toml).
  • cargo vet (vetted-deps register at supply-chain/).
  • cargo semver-checks (API break detection).
  • CodeQL (GitHub-native static analysis).
  • Differential fuzz (10 s smoke run, comparing noyalib against serde_yaml_ng / yaml-rust2 for divergent acceptance behaviour).
  • Miri (focused) — verifies the supply-chain unsafe interaction surface is sound.

Weekly (or on-demand) jobs:

  • Soak fuzz (per-target).
  • Soak Miri (full suite + big-endian via mips64 cross).

Supply-chain provenance

OpenSSF Scorecard posture

The scorecard report lives at scorecard.dev/viewer/?uri=github.com/sebastienrousseau/noyalib. v0.0.6 lifts the score from 6.5/10 to ~9/10 by closing every check that is fixable in source — the four remaining items below require external action and are tracked here so contributors can see the residual gap:

CheckStatusAction
Token-Permissions✓ fixedTop-level workflow tokens demoted to contents: read; writes scoped per-job.
Pinned-Dependencies✓ fixedEvery GitHub Action uses: is pinned by full commit SHA, with the human-readable tag in a trailing comment.
Dependency-Update-Tool✓ fixed.github/dependabot.yml covers cargo, github-actions, and npm ecosystems on a weekly schedule.
Vulnerabilities✓ fixedserde_yml / libyml dropped from the bench dev-deps in v0.0.6 (RUSTSEC-2025-0067 + -0068); Cargo.lock is now clean.
CII-Best-PracticesexternalApply for the OpenSSF Best Practices Badge at https://www.bestpractices.dev/. The self-assessment maps directly onto noyalib’s existing CI / policies posture; tracked for v0.1 milestone.
Code-Reviewpartial mechanical.github/workflows/auto-approve-dependabot.yml auto-approves patch + minor Dependabot bumps (Dependabot is the author, github-actions[bot] is the approver — different identities, scorecard counts them as reviewed). Major version bumps + human PRs still need a real second reviewer; the score lifts toward 10/10 over the next ~30 Dependabot merges.
Branch-ProtectionexternalRepo-admin UI configuration required: enable “require approvals”, “require codeowners review”, “last push approval”.
ContributorsexternalImproves organically as the project gains maintainers / contributing organisations.

Disclosure

Report security issues to sebastian.rousseau@gmail.com. Initial response within 48 hours; mitigation plan within 7 days of confirmation. Full policy at SECURITY.md.


4. Performance & algorithmic complexity

The reference target is constant memory per input byte on the parser hot path, with a clear structural bound on every operation. The headline numbers below were captured on 2026-05-08 against a 97 KB synthetic mapping-of-records document.

OperationThroughputBig-ONotes
from_str::<Value> (parse + AST)~36 MB/sO(n) in input bytesstreaming-first; AST walks the event stream in one pass
from_str::<T> (typed)~50–80 MB/sO(n)bypasses the AST when the target is fully typed
cst::parse_document (CST)~21 MB/sO(n)builds green tree alongside parse
to_string (emit)~150 MB/sO(n) in tree sizesingle-pass write, no re-parse
Document::set(path, value)sub-ms on 1 MB inputsO(d + len(path))d = path depth

Algorithmic guarantees

  • Parser: single pass, O(n) time, O(d) stack (d = nesting depth, capped at max_depth).
  • Loader: O(n) events from parser → O(n) allocations for the AST.
  • Anchor resolution: each alias is a hash-map lookup (O(1) expected), and the cumulative byte-budget for resolved aliases is bounded by max_document_length to defeat billion-laughs amplification.
  • Owned mapping: IndexMap-backed with randomized hashing on hosted builds; key insertion is O(1) amortised and ordered iteration is O(n). Allocation-only no_std targets retain deterministic Fx hashing.
  • Path lookup (Document::get(path)): O(d + len(path)) worst case where d is path-depth (no cross-cuts).
  • Schema validation: O(n × s) where n = document size and s = schema size; bounded by jsonschema’s own guarantees.

Where the SIMD lives

  • Decimal-integer parsing (simd::parse_decimal_*) — SWAR.
  • Plain-scalar termination scan — memchr + SWAR fallback.
  • Structural-bitmask iteration (simd::SimdScanner) — SWAR.

Every SIMD pipeline has a portable fallback verified by Miri under big-endian (mips64) so the byte-order assumption stays explicit.

Benchmark methodology

  • cargo bench runs the criterion suite under [profile.release] with opt-level = 3, lto = "fat", codegen-units = 1, overflow-checks = true.
  • CodSpeed tracks per-PR drift on every benchmark.
  • Measurements above are single-thread; see §5 for the parallel-parsing story.

Profile-Guided Optimization (PGO) — opt-in 5-15% on top

The default cargo install noya-cli build runs the workspace release profile (opt-level = 3, lto = "fat", codegen-units = 1). A two-pass PGO build adds another 5-15% speedup on the parser hot path by laying out branches based on the actual execution profile rather than LLVM’s static heuristics.

PGO is opt-in: distro packagers and downstream teams who ship the binaries to a wide audience can run scripts/pgo.sh to produce a PGO’d binary. The full pipeline (instrumented build → train against representative corpus → optimised rebuild via llvm-profdata merge) is documented at docs/PGO.md.

Per-host-triple training is required — a Mac-trained merged.profdata cannot be reused on Linux x86_64.

Known performance non-goals

  • We do not match serde-saphyr’s typed-target throughput on the smallest inputs — the AST loader has a per-document setup cost that pays off on documents larger than ~64 KB.
  • We do not zero-allocate; the typed deserialise still allocates for String, Vec, and the like at boundary conversions.
  • We do not support memory-mapped streaming over multi-GB documents; the entire input must fit in &[u8] / &str.

5. Concurrency guarantees

Send / Sync bounds

TypeSendSyncNotes
Value, Mapping, Number, Tag, TaggedValueyesyesAll-owned, no interior mutability.
ErroryesyesAll-owned (boxed inner data).
cst::DocumentyesyesImmutable after construction; set takes &mut self.
ParserConfig, SerializerConfigyesyesPlain config structs.
ParserConfig::policies (Arc<dyn Policy>)yesyesThe Policy trait inherits Send + Sync.
Deserializer<'de>yes ('de: 'static)yesBorrows the source; lifetimes propagate.
Serializer<W>depends on Wdepends on WInherits from the writer.

Parallelism story

  • Single-document parse is single-threaded. It’s fast enough that splitting a single doc across cores costs more than it gains for typical inputs.
  • Multi-document streams can be parsed in parallel via noyalib::parallel::parse::<T>(input) (gated behind the parallel feature). Each document parses on its own rayon job.
  • Service-owned concurrency uses parallel::parse_with_config_in_pool(input, config, pool) so the caller controls the worker count, thread lifecycle, names, and stack sizes without touching Rayon’s global pool.
  • The Deserializer itself is not Sync-after-construction in a useful way — there’s no parallel access to a single document’s events.

Anchor / alias semantics under concurrency

Anchors are resolved at parse time (per YAML 1.2 §7.1). Once resolution completes, the resulting Value tree is fully owned, so there is no shared-mutable state across threads. Sending a parsed Value between threads is the same as sending any other owned data structure.

Reentrance

Every public parse / emit function is reentrant. There is no mutable global state. The library does not initialise any singletons, does not register any signal handlers, and does not spawn background threads on its own (only the parallel feature opts in to rayon thread-pool use).


6. Platform support

Tier 1 (CI-verified every PR)

TargetToolchainNotes
aarch64-apple-darwinstable, nightlyM-series Macs (developer host)
x86_64-unknown-linux-gnustable, nightlyGitHub ubuntu-latest runners
x86_64-pc-windows-msvcstable, nightlyGitHub windows-latest runners

Tier 2 (verified on demand / weekly)

TargetVerification
mips64-unknown-linux-gnuabi64Miri (big-endian sanity, scheduled job)
wasm32-unknown-unknownwasm-pack test --node (per-PR)

Bare-metal, no_std (CI-verified every PR)

Built with --no-default-features; alloc is required, std is not present at all on these targets.

TargetVerification
thumbv7em-none-eabihfcargo check --no-default-features --lib --locked --target … (per-PR)
riscv32imac-unknown-none-elfsame
aarch64-unknown-nonesame

See § no_std (alloc-only) build for why these carry the invariant that wasm32-unknown-unknown cannot.

no_std (alloc-only) build

The noyalib crate compiles cleanly with --no-default-features against core + alloc. The Per-crate no_std (alloc-only) build workflow job runs exactly that on every PR.

Do not add --features minimal when you want no_std. minimal is defined as minimal = ["std"] — it is a dependency-budget alias that turns std back on while dropping itoa, ryu and serde_ignored (see §7, Feature compatibility). Combining it with --no-default-features yields a std build, so it silently does not test what the name suggests. This document previously recommended that exact combination; corrected in v0.0.16.

Bare-metal included. no_std here means alloc-only, and it is verified on five configurations on every PR: the native host, wasm32-unknown-unknown, and the three bare-metal *-none targets thumbv7em-none-eabihf, riscv32imac-unknown-none-elf and aarch64-unknown-none.

The bare-metal targets are the ones that actually hold the line. wasm32-unknown-unknown has std available, so a dependency pulling it in unconditionally passes there — which is exactly how #210 went unnoticed. A *-none target has no std at all, so the same mistake fails the build.

This section previously said bare-metal did not build, and named two blockers. Both were fixed and the text was not updated:

  1. indexmap, rustc-hash and memchr are now all declared default-features = false, so nothing pulls std transitively.
  2. FxHashMap / FxHashSet are hashbrown::HashMap / HashSet with FxBuildHasher (see the no_std prelude in lib.rs) rather than the rustc_hash aliases, which do not exist without std.

If you need bare-metal support, please open an issue — it is a tractable change, but it is a deliberate non-goal today rather than an oversight.

When in no_std mode:

  • from_reader / to_writer (which need std::io) are unavailable. Use from_slice / to_string and route through your own writer impl.
  • figment integration unavailable.
  • miette integration unavailable.
  • The noyavalidate binary unavailable (it bundles miette/fancy).

Cross-platform behavioural notes

  • Line endings: parse / emit normalize to \n regardless of platform. The CST preserves source bytes verbatim, including CRLF (\r\n), if the input contained them.
  • File paths in noya-cli / noyalib-mcp: handled via std::path::Path; Unicode-faithful on macOS / Linux, Windows handles non-Unicode paths by fall-through to UTF-8 lossy.
  • Atomic file writes in noyalib-mcp::tool_set: write to a sibling temp file, fsync, then rename. POSIX: atomic. Windows: MoveFileExW(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) semantics — atomic-with-flush.
  • Number formatting: deterministic via ryu / itoa. No locale dependency. 0.1 + 0.2 displays as 0.30000000000000004 exactly the same on every platform.

7. Feature-flag matrix

noyalib (the library) — defaults: std, fast-int, fast-float, strict-deserialise.

FeatureDefault?What it addsCost (deps)
stdyesstd-aware error trait, std::io reader/writer(transitive only)
fast-intyesbranchless integer formatting via itoaitoa
fast-floatyesbranchless float formatting via ryuryu
strict-deserialiseyesfrom_str_strict::<T> rejects unknown keysserde_ignored
minimalnometa: std only, drops the three accelerators(none)
miettenoError::Diagnostic impl for rich CLI errorsmiette
validate-schemanoJSON Schema 2020-12 validationjsonschema, serde_json
schemanoderive helpers for schemarsschemars, serde_json
gardenogarde validator integrationgarde
validatornovalidator validator integrationvalidator
figmentnoFigment provider implementationfigment
parallelnoparallel::parse::<T> on rayonrayon
simdnooptional explicit SIMD acceleration(none — uses portable_simd / std::simd via cfg)
compat-serde-yamlnoname-for-name shim under noyalib::compat::serde_yaml(none)
noyavalidatenometa — pulls validate-schema + miette/fancy(transitive)
wasm-optnopost-build wasm-opt pass marker (used by noyalib-wasm)(none)
nightly-simdnoenables nightly-only std::simd paths in simd.rs; gracefully no-ops on stable(none — gated by cfg(noyalib_nightly))
compare-saphyrnobench-only — pulls serde_saphyr into the comparison harness; never built into a release artefactserde_saphyr (dev-only)

Feature compatibility

  • minimal is not additive with fast-int / fast-float / strict-deserialise — using minimal means you opted out of those three. cargo build --features minimal builds with only std enabled.
  • validate-schema and schema both pull serde_json; if you enable both, you get exactly one copy.
  • parallel is std-only — there’s no rayon no_std.
  • compat-serde-yaml is independent and pulls no extra deps.
  • nightly-simd requires a nightly toolchain at build time; gracefully no-ops to portable scalar fallbacks when built on stable. Not enabled in default — opt-in via cargo +nightly build --features nightly-simd.
  • compare-saphyr is not intended for production builds. It compiles in serde_saphyr purely so the comparison benches in crates/noyalib/benches/comparison.rs can pit noyalib head-to-head against it; downstream packagers must not enable this feature in shipped binaries.

8. Panic policy

The library API does not panic on well-formed input. Panic sources, exhaustively:

  1. unwrap / expect in tests and examples — never on user-facing code paths.
  2. Internal invariants — crate::error::invariant_violated is the canonical panic site; firing it indicates a bug that should be reported. The function’s docstring documents the conditions that should make it unreachable.
  3. Allocator OOM — Rust’s default behaviour. A user with a custom global allocator that returns null will see the process abort, not unwind, because we set panic = "abort" in [profile.release].

panic = "abort" in release means panics terminate the process; do not rely on catching them. Library callers who need panic safety should use std::panic::catch_unwind in debug builds; in release, treat any panic as a bug to file.


9. Error model

Public surface uses a single error type per crate (noyalib::Error, noya_cli::Error, etc.). The noyalib::Error enum is #[non_exhaustive] and carries location info wherever possible.

Invariants

  • Every public from_* function returns Result<T, Error>, never panics on malformed input.
  • Every public to_* function returns Result<…, Error>, never panics on representable values.
  • Errors include a Location { line, column, byte_offset } whenever the underlying source span is available.
  • Error::diagnostic() is the canonical adapter boundary. It exposes stable namespaced codes, protocol-neutral severity, help text, and ordered byte spans without requiring a rendering dependency.
  • Error: Send + Sync + 'static — safe to propagate across threads and into anyhow / eyre / Box<dyn Error>.

miette integration

With features = ["miette"], Error implements miette::Diagnostic by adapting Error::diagnostic(), so CLI tools render rich location-aware output without a second error-code or label mapping. The noyavalidate binary is the canonical example. The ariadne feature consumes the same payload.

Truncation

Long source snippets in error messages are truncated to a configurable budget via Error::format_with_source_truncated (default 4 KB) so the diagnostic doesn’t dump megabytes of context into a CI log.


10. Dependency policy

Default-on dependencies (5 unconditional + 3 default-on optional)

  • serde (no-default-features, with derive + alloc)
  • indexmap (>=2, <2.11) — ordered mappings
  • itoa (default-on optional via fast-int)
  • ryu (default-on optional via fast-float)
  • serde_ignored (default-on optional via strict-deserialise)

Opt-in dependencies (gated by feature)

miette, garde, validator, schemars, serde_json, jsonschema, figment, rayon, plus the three default-on opt-outs covered in §7.

Adoption rules

  • New dependencies require explicit rationale in the Cargo.toml comment block.
  • Every dep is checked against cargo-vet (vetted-deps register) and cargo-deny (allow / deny / skip rules).
  • No wildcard version requirements (the cargo-deny config rejects them). Every entry pins a >= lower bound and an < upper bound; this protects against transitive silent-bumps.
  • Audit advisories (cargo audit) fail the build on any flagged dep.

cargo-vet exemption conventions

supply-chain/config.toml is machine-generated — cargo vet fmt strips comments, so the conventions governing it are recorded here instead of inline.

  • Exemptions are version-pinned. Bumping a dependency invalidates its exemption and the crate re-enters the unaudited set. This is deliberate: it is why a blanket cargo update is not a routine operation here (see the v0.0.16 notes, where a 71-crate refresh put 81 crates back on the unaudited list at once).
  • suggest = false means “deliberately parked” — cargo vet suggest will stop proposing it. New exemptions are added without it, so they stay on the suggest list until someone either audits them or consciously parks them. The absence of suggest = false is a to-do marker, not an oversight; do not add it in bulk to quieten the suggest output.
  • Never add exemptions in bulk to make a red gate green. An exemption is an assertion that a human looked. If a change produces more new exemptions than a reviewer can actually read, the change is too large — split it.

11. Release & changelog policy

  • The CHANGELOG.md follows Keep a Changelog 1.1.0 with explicit [Unreleased] rolling section.
  • Every commit ending up in a release is annotated with the Assisted-by: trailer per the Linux kernel coding-assistants standard so AI tooling provenance is auditable.
  • Releases are tagged v0.0.X, GPG-signed, and the cargo-semver-checks gate must pass before a tag is pushed.
  • Yanking is reserved for genuine security issues; behavioural bugs ship a fix rather than a yank.

Open an issue if any policy here is unclear

These policies are intended to be operational, not aspirational. If you hit a case where the policy doesn’t tell you what to expect, file an issue at https://github.com/sebastienrousseau/noyalib/issues with the scenario — every clarification we add here closes a future ambiguity.

MSRV and deprecation policy

Written down because it was being applied from memory, and memory lost twice in a single release cycle — see Why this exists.


Minimum Supported Rust Version

Current MSRV: 1.89.0, declared as rust-version in crates/noyalib/Cargo.toml and enforced by the msrv-core and msrv-per-crate CI jobs.

What the MSRV covers

The library, on its default features and on --all-features. It does not cover:

  • development tooling (cargo-vet, cargo-audit, coverage);
  • dev-dependencies, which may require a newer toolchain than the library does — see below, because this is the subtle one;
  • the nightly-simd feature, which requires nightly by definition.

Raising the MSRV

  • A raise ships only on the breaking-change axis — during the 0.0.x series that is the patch number (the same axis every breaking change uses; see the SemVer section of the crate docs), and from 0.x onward the minor number. It is always called out in the changelog under its own heading and never happens silently in a compatible release. (Earlier wording said “minor version bump, never patch-level”, which is unsatisfiable while the release number only moves in the patch position — the v0.0.16 raise from 1.85 to 1.86 shipped exactly this way.)
  • A raise needs a reason recorded in the changelog entry: which API, and why the alternative was rejected. “It was more convenient” is not a reason; “the alternative allocates on a hot path” is.
  • The one raise made without a toolchain reason is the v0.0.58 move to 1.89.0, which puts the family on one floor with the maintainer’s other Rust crates. It is recorded as a decision in ADR-0014, not as a precedent.
  • Prefer writing the older form. Option::map_or over Option::is_none_or costs nothing and keeps the floor where it is.

Dev-dependencies are in scope for CI, not for the contract

A dev-dependency whose own rust-version exceeds ours fails the MSRV job even though no consumer of the library is affected. When that happens:

  1. Pin the dev-dependency back to the last version under our floor, if one exists that is not yanked.
  2. If no such version exists, that is a decision, not a mechanical fix. Raising the library’s MSRV to accommodate a test-only dependency narrows what consumers can use, and should be made explicitly rather than to make a red job green.

Deprecation

Process

  1. Mark with #[deprecated(since = "x.y.z", note = "...")]. The note must name the replacement, not just say the item is deprecated.
  2. Keep it for at least two minor releases after the marking release.
  3. Remove only in a release that documents the removal in the changelog under Removed.

What is not deprecated

Behaviour changes that fix a defect are not deprecations — they are fixes, and ship as such with the reasoning in the changelog. v0.0.19 changed bare nan / inf scalars from resolving as floats to staying strings; that was a correctness fix to spec-conformance, documented, not a deprecation cycle.


Why this exists

Both of these were hit inside one release cycle, which is what turned an implicit convention into a written one:

  • Option::is_none_or was used in the v0.0.21 no_std work. It is stable from 1.82; the MSRV was 1.86, so this one was fine, but the same pattern in a sibling crate with a 1.80 floor was caught only by clippy’s incompatible_msrv. The lint is the backstop; writing the older form is the habit.
  • A dev-dependency raised its own MSRV five releases past a sibling crate’s floor, with its predecessor version yanked. There was no mechanical fix, only a choice between pinning to an older major and raising a user-facing promise to accommodate a test tool. Without a written policy that choice gets made by whoever is trying to get CI green.

Enforcement

RuleEnforced by
Library builds on the declared MSRVmsrv-core, msrv-per-crate CI jobs
MSRV-incompatible API useclippy incompatible_msrv
Deprecations carry a replacementreview
Removal after two minor releasesreview, changelog

The first two are mechanical and should stay that way. The last two are judgement, and this document exists so the judgement is consistent.

OpenSSF Best Practices Badge — self-assessment

noyalib holds the passing-level badge: project 13057, 100% of the 65 passing criteria, achieved 2026-05-31. The badge is embedded in the workspace README.md header. This file is the maintenance record: each criterion mapped to the artefact that satisfies it, so answers can be re-verified when the project changes and the Silver application can start from it.

Passing-level criteria — prefilled answers

The 65 criteria are organised under six headings on the badge site. Each row below maps the criterion → the noyalib artefact that satisfies it.

Basics

CriterionSatisfied by
Project website / repository URLhttps://github.com/sebastienrousseau/noyalib
Description / what the project doesRepository description + README.md headline
Stable URL for the projectThe GitHub repo URL above
Discussion mechanismGitHub Issues + Discussions enabled
License is OSI-approvedMIT OR Apache-2.0 — see LICENSE-MIT, LICENSE-APACHE
Project provides documentationREADME.md, docs/USER-GUIDE.md, docs/ARCHITECTURE.md, docs/POLICIES.md, docs/BENCHMARKS.md
Documentation includes “Quick Start”README.md §“Quick Start”, crates/noyalib/README.md, docs/GETTING_STARTED.md
Documentation has a security policySECURITY.md
Maintainer-direct contactsebastian.rousseau@gmail.com (per SECURITY.md)
Public bug trackerGitHub Issues
Acknowledgement of contributionsCONTRIBUTING.md, GitHub PR / issue author attribution

Change control

CriterionSatisfied by
Source under version controlGit, GitHub-hosted
Unique version identifier per releaseSemVer tags v0.0.x
Release notes per versiondocs/release-notes/v0.0.X.md for each tagged release, indexed by docs/release-notes/README.md
Standardised file structureCargo workspace conventions; crates/, doc/, pkg/
Changelog keptCHANGELOG.md (Keep-a-Changelog format)

Reporting

CriterionSatisfied by
Bug reports trackedGitHub Issues, with templates in .github/ISSUE_TEMPLATE/
Bug report responses ≤ 14 daysIssue-response SLA documented in SECURITY.md (48 h initial response)
Vulnerability report channelSECURITY.md — disclosure via sebastian.rousseau@gmail.com, 48 h response
Security audit logAudit reports tracked in docs/POLICIES.md § “Audit pipeline”

Quality

CriterionSatisfied by
Working build systemcargo build --workspace --all-features
Working test systemcargo test --workspace --all-features (~5 400 tests)
Tests run on every change.github/workflows/ci.yml triggers on push + pull_request
Code-coverage measurement.github/workflows/ci.yml § Coverage gate (≥96%) — cargo llvm-cov
Coverage tool integrationSame
New features include testsRequired by review process; enforced by cargo-machete, strict-doc gate
Documented coding styleWorkspace-level lints in crates/noyalib/Cargo.toml; cargo fmt enforced by CI
Code review of every changemain ruleset requires PR + 1 approving review + code-owner review + last-push approval (post-this-commit)

Security

CriterionSatisfied by
Cryptographic best practicesReleases signed via cosign keyless + SLSA Build L2 provenance attestations on every artefact
Inputs validated before useParser enforces ParserConfig limits (max_depth, max_document_length, max_alias_expansions, …)
Hardened against vulnerabilities#![forbid(unsafe_code)] workspace-wide, fuzz suite (10 targets) + Miri soak runs in .github/workflows/security.yml
Vulnerability disclosure testedOne historical CVE-equivalent (issue #46 RecursionLimitExceeded false-positive) — patched in v0.0.6 within the same release cycle
Security expertise consultedAudit pipeline: cargo-deny, cargo-vet, cargo-audit, cargo-machete, CodeQL — see docs/POLICIES.md

Analysis

CriterionSatisfied by
Static analysis appliedcargo clippy --workspace --all-features -- -D warnings on every PR; CodeQL on .github/workflows/security.yml
Dynamic analysis appliedDifferential fuzz (10 s smoke per PR) + soak fuzz (1 h per target weekly); Miri (focused per PR + full weekly)
Coverage-guided fuzzingcargo-fuzz with libFuzzer, 10 targets: fuzz_borrowed_alias, fuzz_diff, fuzz_double_quoted, fuzz_from_value, fuzz_multi_doc, fuzz_no_span_loader, fuzz_parse, fuzz_roundtrip, fuzz_strict, fuzz_yaml_v1_1
Memory-safety analysis#![forbid(unsafe_code)] (the strongest possible static guarantee) + Miri runs to verify transitive unsafe blocks in dev-deps

Silver / Gold level (future)

The “Silver” badge adds 30+ more criteria around release process maturity and “Gold” 30 more around supply-chain transparency. The passing entry is complete; tracking Silver for the v0.1.0 milestone via https://www.bestpractices.dev/en/projects/13057/silver/edit.

noyafmt as a pre-commit hook

noyafmt --check exits 1 when any of its file arguments would be rewritten by formatting, printing one path per line on stdout. That shape matches what pre-commit, lefthook, and plain Git pre-commit hooks expect of a formatter gate.

Plain Git hook (.git/hooks/pre-commit)

#!/usr/bin/env sh
set -eu

# Only inspect staged YAML files. `--diff-filter=ACMR` excludes
# deletions and rename-source paths. `-z` + `xargs -0` is robust
# against paths containing spaces.
files=$(git diff --cached --diff-filter=ACMR --name-only -z -- '*.yaml' '*.yml')
[ -z "$files" ] && exit 0

# Run noyafmt --check on every staged YAML file. Any file that needs
# formatting is printed on stdout; we tee it so the developer sees it
# AND we use the exit status to fail the commit.
unformatted=$(printf '%s' "$files" | xargs -0 noyafmt --check || true)
if [ -n "$unformatted" ]; then
    echo "noyafmt: the following files are not formatted:"
    echo "$unformatted" | sed 's/^/    /'
    echo
    echo "fix them with: noyafmt --write \"\$file\""
    exit 1
fi

pre-commit framework (.pre-commit-config.yaml)

repos:
  - repo: local
    hooks:
      - id: noyafmt
        name: noyafmt
        entry: noyafmt --check
        language: system
        types: [yaml]

CI gate (GitHub Actions)

- name: noyafmt --check
  run: |
    cargo install noya-cli --version 0.0.29 --locked --no-default-features --features noyafmt
    git ls-files '*.yaml' '*.yml' | xargs noyafmt --check

Editor integration (VS Code, Helix, Zed)

noyafmt --stdin reads from stdin and writes to stdout, so any editor that supports “external formatter” hooks can use it directly:

EditorSetting
VS Code"yaml.format.command": "noyafmt --stdin"
Helix[language.formatter] command = "noyafmt", args = ["--stdin"] (in languages.toml)
Zedformatter: { external: { command: "noyafmt", arguments: ["--stdin"] } }

Packaging noyalib for a distribution

Written for downstream maintainers (Debian, Fedora, Arch, Nix, Homebrew). Everything here is CI-enforced upstream, so it stays true between releases.

Licensing

Dual MIT OR Apache-2.0, REUSE 3.3 compliant: every file carries machine-readable licensing (inline SPDX headers or REUSE.toml annotations), lintable with reuse lint. The vendored YAML test suite under crates/noyalib/tests/yaml-test-suite/ is MIT, copyright yaml-test-suite contributors.

Toolchain floor

MSRV is declared as rust-version in each crate’s Cargo.toml and CI-enforced (currently 1.89.0, identical across the family). Raises happen only on the breaking-change axis with the reason recorded in the CHANGELOG — policy and history in MSRV-AND-DEPRECATION.md. Dev-dependencies may need a newer toolchain than the library; the contract covers the library on default and all-features.

The version pin model

Six crates release in lockstep at the identical =0.0.X: noyalib (the library), noya-cli (the noyafmt/noyavalidate binaries), noyalib-lsp, noyalib-mcp, noyalib-wasm, and noyalib-serde-yaml (a package-rename drop-in for archived serde_yaml). Satellites pin the core exactly; packaging noya-cli 0.0.X requires noyalib 0.0.X. The pin is the compatibility contract — do not relax it.

Building and testing offline

make vendor          # cargo vendor the full dependency tree
cargo build --locked --offline
cargo test --locked --offline

Lockfiles are committed and CI builds --locked; a resolver run is never required. The official YAML test suite is vendored, so the full compliance suite (406 cases) runs hermetically.

Binaries, manpages, completions (noya-cli)

make install in the noya-cli repository honors PREFIX and DESTDIR and installs binaries, roff manpages, and bash/zsh/fish completions to FHS paths. The committed manpages and completions are regenerated from the clap definitions (make assets) and CI fails if they drift; [package.metadata.deb] and [package.metadata.generate-rpm] in its Cargo.toml describe the same layout for cargo deb / cargo generate-rpm. Release binaries are built with cargo auditable, so cargo audit bin can read the embedded dependency list.

Verifying upstream artefacts

Tags and commits are signed (KEYS.asc at the repo root); release artefacts carry sigstore bundles, SHA256/SHA512 checksum files, a CycloneDX SBOM, and SLSA Build L2 provenance attestations (gh attestation verify <artefact> --repo sebastienrousseau/noyalib --signer-workflow sebastienrousseau/noyalib/.github/workflows/release.yml). The full cookbook is pkg/VERIFY.md.

Questions

Open an issue with the packaging label, or see SUPPORT.md. Security contacts are in SECURITY.md — never a public issue for a vulnerability.

noyalib production-readiness plan

This document is the long-form roadmap for shipping noyalib as a distro-grade Rust library and CLI tool: ≥ 98 % unit-test, doc, example, and benchmark coverage on every package; a ripgrep-shaped repo layout; and a multi-platform release pipeline that produces signed artefacts ready for crates.io, npm, the VS Code Marketplace, Linux distros (deb / rpm / Arch / Nix), Homebrew, AUR, Scoop, and a container registry.

Superseded in part. Phase 2.2 below proposes a crates/xtask/ runner (cargo xtask completions, cargo xtask manpages, …). That crate was built, then retired with the workspace split (#134, closed): man pages and shell completions now belong to noya-cli and are generated in its repository, and what remains here runs from Makefile targets and scripts/. cargo xtask is not a command in this repo. The rest of the plan stands.

The plan is written so any maintainer can pick up where the last commit left off. Each phase is sized for a single self-contained PR; the order respects dependencies (gates first, restructure early, distro outreach last).

Working invariants

These rules apply to every change inside this plan. They are binding even when not restated in a section.

  • CI must always be green. Every push verifies the workflow status; any red job is fixed in the same session before declaring done. Never bypass with --no-verify, [skip ci], or if: false.
  • Conventional Commits with signed (-S) commits. Assisted-by: trailer is appended automatically by the commit-msg hook; the branding signature is appended manually per ~/.claude/CLAUDE.md.
  • #![forbid(unsafe_code)] at every crate root in the workspace. No exceptions.
  • No fabrication. API names, flag names, paths, version numbers, and CI matrix entries are checked against the source before committing.
  • Single zero-logic commit for the restructure (Phase 1). The Big Move changes only file paths and Cargo.toml members = entries. No comment edits, no formatting changes, no refactoring inside the moved files. This preserves git blame --follow accuracy for every line.

Locked decisions

DecisionChoiceRationale
Naming (E1)crates/noya-cli/ package, binaries stay noyafmt + noyavalidateTwo distinct tools (formatter vs. CI-gate validator) following Unix philosophy; binary names already shipped in v0.0.1 examples and integrations, so no breaking change for downstream consumers.
Asset generation (B)Hybrid build.rs + xtaskNOYA_GEN_ASSETS=1 cargo build --release produces man pages and completions for distro packagers; cargo xtask paths reuse the same generation code so docs and binaries never drift.
Restructure cadence (Phase 1)One zero-logic commit, file-moves onlyPreserves git blame --follow; surface area is large, code semantics are unchanged.
Universal macOS bottle (R1)Yes — lipo job after the two macos legsHomebrew bottle complexity drops; Intel-Rosetta users get the same artefact.
Debug-symbol packaging (R2)Yes — llvm-objcopy --only-keep-debug per Linux gnu leg, ship noyalib-<v>-<target>-debuginfo.tar.gz and -debuginfo sub-packagesEnterprise Linux deployments need separate symbol packages for telemetry / crash unwinders.
Vendoring (C)Yes — make vendor target, vendor-build CI job, documented in pkg/VERIFY.mdAir-gapped, FIPS-bound, and RHEL build chains require offline-buildable source trees.
CodeQL (D)Yes — new job in security.ymlCWE coverage complements cargo-deny (license/advisory) and cargo-vet (audit chain).

Status snapshot (cf2d877)

AxisCurrentTargetGap
Workspace unsafe_codeforbidforbid—
YAML 1.2 conformance406 / 406406 / 406—
Lib unit tests146 pass≥ 98 % line every packageneeds gap fill
Integration tests213+ pass——
Doc-tests337 pass——
cargo audit / deny / vetcleanclean—
Workspace coverage gate95 % fn / 92 % region / 93 % line98 % everywhere+3–6 pp
noyalib-wasm coverageexcludeddedicated 90 % gateneeds first run
noyalib-mcp coveragepartial exclusiondedicated 90 % gateneeds first run
noyalib-lsp coveragepartial exclusiondedicated 90 % gateneeds first run
Rustdoc coveragemissing_docs = warndeny, ≥ 98 % itemsneeds audit
Examples surface coverage56 examplesevery public module5 modules uncovered
Benchmark coverage10 benchesevery hot path4 paths uncovered
Release pipelinecrates.io + sigstorecrates.io + npm + VS Code + 14 binary targets + .deb + .rpm + .msi + Homebrew + AUR + containermajor rewrite
Repo layoutflatcrates/ + pkg/ + docs/ + ci/ + complete/restructure

Phase 0 — Pre-flight

GateStatus
CI fully green on feat/v0.0.1✓ at cf2d877
All 56 examples runnable✓
Coverage gate passing✓ (95/93/92)
Miri focused suite✓
cargo-vet, cargo-deny, REUSE✓
Branch protection on mainverify before Phase 1 — required reviews + status checks

Effort: 5 min. Branch protection check only.

Phase 1 — Repo restructure (one zero-logic commit)

The structural change. No semantic edits — only path moves and Cargo.toml members/path adjustments. Verified locally before push; CI is the post-push sanity check.

1.1 Target tree (final shape, including all later-phase additions)

noyalib/
├── Cargo.toml                # workspace manifest only
├── Cargo.lock
├── README.md, CHANGELOG.md, PLAN.md, CONTRIBUTING.md, SECURITY.md, FAQ.md (new)
├── LICENSE-MIT, LICENSE-APACHE, COPYING (new), NOTICE (generated)
├── REUSE.toml
├── ci/                       # CI helper scripts (new)
│   ├── docker/
│   ├── build-deb
│   ├── install-cross
│   ├── ubuntu-install-packages
│   └── macos-install-packages
├── complete/                 # source shell completions (new)
├── crates/
│   ├── noyalib/              # library — moved from root
│   ├── noya-cli/             # noyafmt + noyavalidate binaries (extracted from src/bin)
│   ├── noyalib-lsp/          # already exists, moved under crates/
│   ├── noyalib-mcp/          # already exists, moved under crates/
│   ├── noyalib-wasm/         # already exists, moved under crates/
│   └── xtask/                # cargo xtask runner (new)
├── docs/                      # documentation (new)
│   ├── noyafmt.1, noyafmt.1.adoc
│   ├── noyavalidate.1, noyavalidate.1.adoc
│   ├── USER-GUIDE.md, MIGRATION-FROM-SERDE-YAML.md, ARCHITECTURE.md
│   └── design/               # docs/design/ moves here
├── pkg/                      # distribution metadata (new)
│   ├── README.md, VERIFY.md
│   ├── debian/, rpm/, arch/, nix/, windows/, snap/, flatpak/, docker/
│   ├── homebrew/noyalib.rb.template
│   ├── vscode/               # VS Code extension source
│   ├── npm-mcp-wrapper/      # npx wrapper for noyalib-mcp
│   └── completions/          # generated completions (gitignored except .gitkeep)
├── benches/                  # workspace-level Criterion benches
├── examples/                 # workspace-level examples
├── fuzz/                     # cargo-fuzz harnesses (kept at root)
├── scripts/
│   ├── miri.sh
│   ├── coverage-gap-report.sh
│   ├── release-checklist.sh
│   └── msrv-per-crate.sh     # Phase 7.5 dynamic MSRV check
├── supply-chain/             # cargo-vet, unchanged
└── .github/workflows/
    ├── ci.yml, release.yml, docs.yml, security.yml, scorecard.yml, codspeed.yml
    ├── release-binaries.yml  # multi-platform binary release (new)
    ├── nightly.yml           # daily snapshot builds (new)
    └── promotional.yml       # Homebrew/AUR/Scoop bumps after release (new)

1.2 Migration sequence (to be executed inside the zero-logic commit)

# 1. Move library to crates/noyalib/
mkdir -p crates/noyalib
git mv src tests examples benches crates/noyalib/

# 2. Move satellite crates
git mv noyalib-wasm noyalib-mcp noyalib-lsp crates/

# 3. Extract CLI binaries (src/bin moved with the library above; now relocate)
mkdir -p crates/noya-cli/src/bin
git mv crates/noyalib/src/bin/*.rs crates/noya-cli/src/bin/
rmdir crates/noyalib/src/bin

# 4. Move design docs
git mv docs/design docs/design

# 5. Edit workspace Cargo.toml + crate manifests (members, paths, [[example]] paths,
#    workspace inheritance via [workspace.package]/[workspace.dependencies])

# 6. Edit REUSE.toml (every noyalib-{wasm,mcp,lsp}/Cargo.toml → crates/noyalib-{...}/)

# 7. Edit every `working-directory:` in .github/workflows/

# 8. Verify
cargo build --workspace --all-features
cargo test  --workspace --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
reuse lint
cargo vet
cargo deny check

1.3 Workspace Cargo.toml shape

Historical. This is the multi-crate shape as originally planned. It is not the current layout: ADR-0005 split the satellites into their own repositories (noyalib-wasm v0.0.12; noyalib-mcp, noyalib-lsp, noya-cli v0.0.13), leaving a single-member workspace. The edition, floor and member list below are the v0.0.1-era values; the live values are edition 2024, rust-version = "1.89.0", members = ["crates/noyalib"]. See the root Cargo.toml and docs/POLICIES.md §1, which is the source of truth for the MSRV.

[workspace]
resolver = "2"
members = [
    "crates/noyalib",
    "crates/noya-cli",
    "crates/noyalib-lsp",
    "crates/noyalib-mcp",
    "crates/noyalib-wasm",
    "crates/xtask",
]
exclude = ["fuzz", "examples/wasm"]

[workspace.package]
edition      = "2021"   # historical — now 2024
rust-version = "1.75.0" # historical, now 1.89.0
authors      = ["Sebastien Rousseau <sebastian.rousseau@gmail.com>"]
license      = "MIT OR Apache-2.0"
repository   = "https://github.com/sebastienrousseau/noyalib"
homepage     = "https://github.com/sebastienrousseau/noyalib"

[workspace.dependencies]
# (every shared dep declared once, used via `<dep>.workspace = true` per crate)

[workspace.lints.rust]
unsafe_code     = "forbid"
unreachable_pub = "forbid"
missing_docs    = "deny"   # ratchet-up — see Phase 1.5

[profile.release]
codegen-units    = 1
lto              = true
opt-level        = "s"
panic            = "abort"
strip            = "symbols"

[profile.dist]
inherits = "release"
debug    = "limited"
strip    = false

1.4 Naming locked-in

  • Library crate: noyalib (unchanged, public API surface preserved).
  • CLI crate: noya-cli (new package name; binaries noyafmt and noyavalidate keep their names so apt install noyalib && noyafmt … continues to work).
  • Satellites: noyalib-lsp, noyalib-mcp, noyalib-wasm.

1.5 Workspace-wide missing_docs = "deny"

All crates inherit via:

[lints]
workspace = true

Audit pass: ~30 public items currently lack doc comments; each gets a one-line ///. CI’s cargo doc step uses RUSTDOCFLAGS="-D missing_docs" so undocumented items fail the build.

Phase 1 deliverables: 1 commit, ~250 file moves, ~12 manifest edits, no behavioural change. Effort: 1 day code + 1 review.

Risks & mitigations:

  • git blame --follow confusion. Single move-only commit preserves rename detection. BLAME-MOVES.md records the SHA for archaeologists.
  • External users bookmarking src/... URLs. README links updated; redirects added in any old-path docs.

Phase 2 — Tooling (build.rs + xtask hybrid)

Goal: Every distro packaging artefact (man pages, shell completions) produced reproducibly from source via either cargo build (for distros) or cargo xtask (for developers iterating).

2.1 build.rs in crates/noya-cli/

// A sketch of `noya-cli`'s build.rs — that crate is not a dependency
// of noyalib, so this block is not compiled here.
//! Generates man pages and shell completions during `cargo build` so
//! distro packagers (rpmbuild / dpkg-buildpackage) get the artefacts
//! without needing a separate `cargo xtask` invocation.
//!
//! Off by default — enabled by `NOYA_GEN_ASSETS=1`. Generation is
//! deterministic (no clock reads, no env reads beyond the trigger
//! var), so output is bit-reproducible.

fn main() {
    println!("cargo:rerun-if-env-changed=NOYA_GEN_ASSETS");
    if std::env::var("NOYA_GEN_ASSETS").as_deref() != Ok("1") {
        return;
    }
    let out_dir: PathBuf = std::env::var_os("OUT_DIR").unwrap().into();
    noya_cli::codegen::generate_completions(&out_dir);
    noya_cli::codegen::generate_manpages(&out_dir);
}

Distro packagers run:

NOYA_GEN_ASSETS=1 cargo build --release --locked
# Artefacts at target/release/build/noya-cli-<hash>/out/{noyafmt.1, noyafmt.bash, …}

A short shim ci/extract-assets.sh finds and copies them to a stable location.

2.2 crates/xtask/ for developer ergonomic

cargo xtask completions   # writes complete/*.{bash,fish,zsh,ps1}
cargo xtask manpages      # writes docs/*.1
cargo xtask sbom          # writes SBOM.txt
cargo xtask notice        # writes NOTICE

Both entry points call into the same noya_cli::codegen::* functions, so output is byte-identical regardless of which path produced it. CI proves this with:

- run: NOYA_GEN_ASSETS=1 cargo build --release --bin noyafmt --bin noyavalidate
- run: cargo xtask completions
- run: diff -r target/release/build/noya-cli-*/out/ complete/

2.3 Asciidoc man pages

docs/noyafmt.1.adoc (hand-written prose) + the clap-derived flag dump (auto-generated) → docs/noyafmt.1 (committed). Generated via asciidoctor invoked from cargo xtask manpages.

Phase 2 deliverables: 1 PR. ~6 new files. Effort: 1.5 days.

Phase 3 — Distro packaging metadata

pkg/ populated with templates. No CI workflow changes (those land in Phase 4). All templates are inert text with __VERSION__ / __SHA256__ placeholders.

3.1 — 3.7 Standard Linux + macOS + Windows targets

SubdirFilesPurpose
pkg/debian/control, copyright, changelog, compat, rules, README.mdSource-package metadata for upstream Debian
pkg/rpm/noyalib.specFedora / RHEL .spec
pkg/arch/PKGBUILD.template, PKGBUILD-source.templateAUR noyalib-bin and noyalib
pkg/homebrew/noyalib.rb.templatePersonal tap (sebastienrousseau/homebrew-tap) → eventually homebrew-core
pkg/nix/flake.nix, package.nixnix run github:sebastienrousseau/noyalib works for any Nix user
pkg/windows/wix/noyalib.wxsMSI installer source
pkg/windows/scoop/noyalib.jsonScoop bucket manifest
pkg/snap/snapcraft.yamlSnap classic confinement
pkg/flatpak/io.noyalib.noyafmt.yamlFlathub manifest
pkg/docker/Dockerfile, Dockerfile.full, Dockerfile.mcpDistroless container, full-feature image, MCP server image

3.10 VS Code extension (pkg/vscode/)

pkg/vscode/
├── package.json
├── README.md
├── CHANGELOG.md
├── icon.png
├── language-configuration.json
├── syntaxes/yaml.tmLanguage.json
└── src/extension.ts          # spawns noyalib-lsp over stdio

Built and packaged by a new vscode-extension job in release-binaries.yml. Published to:

  • VS Code Marketplace (vsce publish, secret VSCODE_MARKETPLACE_PAT).
  • Open VSX (ovsx publish, secret OPEN_VSX_PAT).
  • Uploaded as noyalib-<version>.vsix to the GitHub Release.

The extension auto-detects a bundled noyalib-lsp binary; falls back to which noyalib-lsp for system installs.

3.11 npm via wasm-pack (crates/noyalib-wasm/)

New npm-publish job in release-binaries.yml:

- run: |
    cd crates/noyalib-wasm
    wasm-pack build --release --target bundler --scope noyalib
- run: |
    cd crates/noyalib-wasm/pkg
    npm publish --provenance --access public

Package name: @noyalib/noyalib-wasm. Secret: NPM_TOKEN. The --provenance flag produces an npm-native SLSA attestation.

3.12 MCP server distribution

Two channels for noyalib-mcp:

  1. Docker image (pkg/docker/Dockerfile.mcp) → ghcr.io/sebastienrousseau/noyalib-mcp:<v> via the existing container-publish job.
  2. npx wrapper (pkg/npm-mcp-wrapper/): a small Node bootstrap that downloads the platform-appropriate noyalib-mcp binary from GitHub Releases on first run, caches in ~/.cache/noyalib-mcp/. Published as noyalib-mcp on npm. Lets AI agents and users invoke npx noyalib-mcp without a Rust toolchain — removes the #1 barrier to MCP-tool adoption.

A Docker Desktop Extension is feasible but disproportionately costly to maintain — defer to v0.1.x.

Phase 3 deliverables: 1 PR. ~30 new template files. No CI workflow changes. Effort: 3 days.

Phase 4 — Release pipeline (release-binaries.yml)

A single workflow that on every v* tag produces signed binaries for 14 targets, .deb + .rpm packages for the Linux gnu legs, MSIs for Windows, container images for GHCR, the VS Code .vsix, the npm @noyalib/noyalib-wasm package, and updates Homebrew + AUR + Scoop manifests automatically.

4.1 Triggers (two-phase rollout)

# Phase 4.1.a — initial introduction
on:
  workflow_dispatch:
    inputs:
      tag:      { description: 'Tag to package', required: true }
      dry_run:  { type: boolean, default: true }

# Phase 4.1.b — after a clean dry-run end-to-end
on:
  push:
    tags: ['v[0-9]+.[0-9]+.[0-9]+', 'v[0-9]+.[0-9]+.[0-9]+-*']
  workflow_dispatch:
    inputs:
      dry_run: { type: boolean, default: false }

4.2 Job DAG

                   ┌─ create-release ─┐
                   │                  │
                   v                  v
            ┌─ build-release  ─┐  build-source-tarball
            │  (matrix × 14)    │
            ├──► .deb (gnu legs)┤
            ├──► .rpm (gnu legs)┤
            ├──► .msi (win legs)┤
            │                   │
            v                   v
       lipo-universal     verify-reproducible
            │                   │
            └──────► github-release ─────────┐
                       │                     │
       ┌───────┬───────┼───────┬─────────┐   │
       v       v       v       v         v   │
    homebrew  aur   scoop   vscode-      npm-publish
     bump    bump    bump   extension       │
                              │             │
                              v             v
                      ovsx publish   container-publish

4.3 Build matrix (14 targets)

targetrunnertoolchaincross?.deb.rpm
x86_64-unknown-linux-gnuubuntu-lateststableno✓✓
x86_64-unknown-linux-muslubuntu-lateststableyes——
aarch64-unknown-linux-gnuubuntu-24.04-armstableno✓✓
aarch64-unknown-linux-muslubuntu-24.04-armstableno——
armv7-unknown-linux-gnueabihfubuntu-lateststableyes✓—
arm-unknown-linux-gnueabihfubuntu-lateststableyes✓—
powerpc64le-unknown-linux-gnuubuntu-lateststableyes✓—
s390x-unknown-linux-gnuubuntu-lateststableyes✓—
riscv64gc-unknown-linux-gnuubuntu-lateststableyes✓—
x86_64-apple-darwinmacos-13stableno——
aarch64-apple-darwinmacos-14stableno——
x86_64-pc-windows-msvcwindows-lateststableno——
i686-pc-windows-msvcwindows-lateststableno——
aarch64-pc-windows-msvcwindows-lateststableno——

fail-fast: false so one target failure doesn’t abort the rest.

4.4 Per-target steps

  1. Checkout at the resolved tag.
  2. Install toolchain (dtolnay/rust-toolchain@master + rust-src for musl sysroot).
  3. Cache (Swatinem/rust-cache@v2, key includes target triple).
  4. Install cross if needed.
  5. Build: cargo build --release --locked --target $TARGET --bin noyafmt --bin noyavalidate.
  6. Strip + debug-symbol split (Linux gnu only):
    for bin in noyafmt noyavalidate; do
      BIN="target/${TARGET}/release/${bin}"
      llvm-objcopy --only-keep-debug "${BIN}" "${BIN}.debug"
      llvm-objcopy --strip-debug --strip-unneeded "${BIN}"
      llvm-objcopy --add-gnu-debuglink="${BIN}.debug" "${BIN}"
    done
    
  7. Verify binary type via file.
  8. Stage tarball (or zip on Windows) including README.md, CHANGELOG.md, LICENSE-*, COPYING, docs/*.1, complete/*, NOTICE.
  9. Generate SHA256 + SHA512 sidecars.
  10. Build .deb via cargo deb --target $TARGET --no-build.
  11. Build .rpm via cargo generate-rpm (x86_64-gnu + aarch64-gnu).
  12. Build .msi via cargo wix (windows legs).
  13. Sign every artefact with cosign keyless.
  14. Attest SLSA Build L2 via actions/attest-build-provenance@v2.
  15. Upload to the GitHub Release.

4.5 Reproducible builds

Every build leg exports:

export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct "$GITHUB_REF")
export RUSTFLAGS="--remap-path-prefix=$HOME=~ --remap-path-prefix=$(pwd)=/build"

A verify-reproducible job rebuilds one target on a clean runner and diffs the SHA256 against the published artefact. Promotional jobs (Homebrew, AUR, Scoop, VS Code, npm) gate on this job’s success.

4.6 macOS Universal Binary (lipo-universal)

After both macos legs succeed:

for bin in noyafmt noyavalidate; do
  lipo -create \
    "noyalib-${VER}-x86_64-apple-darwin/${bin}" \
    "noyalib-${VER}-aarch64-apple-darwin/${bin}" \
    -output "${bin}"
  lipo -info "${bin}"   # verifies fat-binary structure
done
tar -czf "noyalib-${VER}-universal-apple-darwin.tar.gz" universal/

Homebrew can then bottle :all_macos against the universal tarball.

4.7 Promotional jobs (post github-release)

JobActionSecret
homebrew-bumpmislav/bump-homebrew-formula-action@v3 against sebastienrousseau/homebrew-tapHOMEBREW_TAP_TOKEN
aur-bumpKSXGitHub/github-actions-deploy-aur@v3 for noyalib-binAUR_SSH_PRIVATE_KEY
scoop-bumpScoopInstaller/GithubActions@v2 against sebastienrousseau/scoop-bucketSCOOP_BUCKET_TOKEN
vscode-extensionvsce publish + ovsx publishVSCODE_MARKETPLACE_PAT, OPEN_VSX_PAT
npm-publishnpm publish --provenance for @noyalib/noyalib-wasmNPM_TOKEN
container-publishdocker/build-push-action@v6 to GHCR (multi-arch amd64+arm64)GITHUB_TOKEN (or optional DOCKER_PASSWORD for non-GHCR registries)

4.8 Required repo secrets summary

SecretPurpose
CARGO_REGISTRY_TOKENcrates.io publish (existing)
HOMEBREW_TAP_TOKENPR against personal tap
AUR_SSH_PRIVATE_KEYpush to AUR
SCOOP_BUCKET_TOKENpush to Scoop bucket
VSCODE_MARKETPLACE_PATvsce publish
OPEN_VSX_PATovsx publish
NPM_TOKENnpm publish (with --provenance)
DOCKER_PASSWORDoptional; only if pushing to non-GHCR registries
GITHUB_TOKENprovided automatically (GHCR push, release upload)

Documented in pkg/README.md for new maintainers.

Phase 4 deliverables: 1 PR adding release-binaries.yml (~700 lines), simplifying release.yml to crates.io-only, pkg/Dockerfile+pkg/Dockerfile.full+pkg/Dockerfile.mcp, secrets documentation. Effort: 5 days, including dry-run iteration.

Phase 5 — Distro publishing

ChannelSubmitEffortAuto-bumped after submission?
Personal Homebrew tapNew repo sebastienrousseau/homebrew-tap, seed Formula/noyalib.rb1 hyes (homebrew-bump)
AUR (noyalib-bin)AUR account + SSH key, push initial PKGBUILD1 hyes (aur-bump)
Personal Scoop bucketNew repo + initial noyalib.json1 hyes (scoop-bump)
VS Code Marketplace + Open VSXOne-time publisher account creation2 hyes (vscode-extension)
npm scope @noyalibOne-time org / scope creation30 minyes (npm-publish)
Homebrew corePR against Homebrew/homebrew-core once ≥ 75 stars + ≥ 30 days + stable APIhand-filedno — manual
DebianITP bug + DD sponsor + NEW queue (months)weeksno — manual
Fedora / RHELPackager account + sponsor + review request (months)weeksno — manual
openSUSE OBSAccount + initial submission2 hsemi-auto
nixpkgsPR against NixOS/nixpkgs2 hno — manual
Snapcraftsnapcraft register + snapcraft upload1 hsemi-auto via store
FlathubPR against flathub/io.noyalib.noyafmt2 hno — manual

Phase 5 deliverables: ad-hoc, multi-week. Each distro tracked separately as it lands. Effort: months of intermittent maintainer work.

Runbook: pkg/PUBLISH.md is the per-channel runbook covering bootstrap (account creation, secret population), first publish, and ongoing maintenance for every channel in the table above. The maintainer follows it sequentially as distros come online.

Phase 6 — Documentation

FileContentEffort
docs/USER-GUIDE.mdLong-form tutorial complementing README.1 d
docs/MIGRATION-FROM-SERDE-YAML.mdName-by-name mapping.0.5 d
docs/ARCHITECTURE.mdParser → AST → CST → output.1 d
docs/noyafmt.1.adoc, docs/noyavalidate.1.adocAsciidoc man pages.1 d
pkg/VERIFY.mdcosign + SLSA + vendoring cookbook.0.5 d
SECURITY.md extensionVerification commands.0.25 d
CONTRIBUTING.md extension“How to add a new packaging target”.0.5 d
README.md install tableAll distros.0.25 d

Effort: 1 week, intermittent.

Phase 7 — Hardening

7.1 Reproducible builds

SOURCE_DATE_EPOCH + --remap-path-prefix per release leg. verify-reproducible job in the release pipeline gates promotional jobs on byte-identity.

7.2 MUSL static linking

file check confirms statically linked on every musl leg. CI fails the release leg if a musl binary links dynamically.

7.3 Dependency vendoring (make vendor)

vendor:
	cargo vendor --versioned-dirs vendor
	@echo "Vendored. Configure cargo to use it via .cargo/config.toml [source.crates-io] replace-with = 'vendored'."

CI job vendor-build runs cargo vendor then cargo build --offline --workspace. Docs in pkg/VERIFY.md. A vendor-tarball release job ships noyalib-<version>-vendor.tar.xz with each release for cached offline builds.

7.4 CodeQL static analysis

New job in .github/workflows/security.yml:

codeql-analyze:
  permissions: { actions: read, contents: read, security-events: write }
  strategy: { matrix: { language: [rust] } }
  steps:
    - uses: actions/checkout@v5
    - uses: github/codeql-action/init@v3
      with: { languages: ${{ matrix.language }} }
    - run: cargo build --workspace --all-features
    - uses: github/codeql-action/analyze@v3

Findings land in the repo’s Security tab. Triage cadence: weekly during the launch program, monthly thereafter.

7.5 Dynamic per-crate MSRV check

scripts/msrv-per-crate.sh:

#!/usr/bin/env bash
# Run `cargo +<msrv> check` per workspace crate independently, so a
# satellite crate with a higher MSRV than the workspace floor is
# caught before it merges.
set -euo pipefail
for crate in crates/*/; do
  manifest="${crate}Cargo.toml"
  [[ -f "$manifest" ]] || continue
  msrv=$(grep -E '^rust-version' "$manifest" | head -1 | sed 's/.*"\(.*\)".*/\1/')
  [[ -n "$msrv" ]] || continue
  echo "→ Checking ${crate} against rust ${msrv}"
  rustup toolchain install "$msrv" --profile minimal --quiet
  cargo +"$msrv" check --manifest-path "$manifest" --locked
done

CI job msrv-per-crate runs this on every PR. It reads rust-version from each manifest rather than hard-coding a number, so it catches drift if e.g. noyalib-lsp adopts a feature requiring a newer rustc than the declared floor — which would silently break distros pinned to the lower rustc. The current floor is 1.86.0 across all five crates (see docs/POLICIES.md §1).

CI’s docs job:

- env:
    RUSTDOCFLAGS: >-
      -D warnings
      -D rustdoc::broken_intra_doc_links
      -D rustdoc::private_intra_doc_links
      -D rustdoc::invalid_codeblock_attributes
      -D rustdoc::invalid_html_tags
      -D rustdoc::bare_urls
      --cfg docsrs
      --extern-html-root-url serde=https://docs.rs/serde/latest
      --extern-html-root-url indexmap=https://docs.rs/indexmap/latest
  run: cargo +nightly doc --workspace --all-features --no-deps

Every broken [Type] reference in a doc comment fails CI.

7.7 License compliance

cargo about generate -c about.toml about.hbs > NOTICE in CI; NOTICE shipped in every release tarball, .deb, .rpm, MSI.

7.8 Cross-target test matrix

cargo test via cross on every release-binary target, not just host runners. New cross-test job in ci.yml, scheduled weekly (cost-aware — full matrix takes ~2 h).

7.9 Cosign root-of-trust pinning

pkg/VERIFY.md documents the Rekor + Fulcio public-key fingerprints + their expiry rotation cadence so verifiers don’t depend on sigstore-go’s defaults.

7.10 OpenSSF Best Practices badge

Apply at https://www.bestpractices.dev once Phases 1–4 land. Gold target.

Effort: 1.5 weeks.

Verification matrix

After every phase, the following must be true:

GatePhase 012347
cargo build --workspace --all-features✓✓✓✓✓✓
cargo test --workspace --all-features✓✓✓✓✓✓
cargo clippy --workspace --all-targets --all-features -- -D warnings✓✓✓✓✓✓
Coverage gate95/93/92ratchet to 9898989898
cargo doc (no warnings, no broken intra-doc links)warndenydenydenydenydeny
56+ examples runnable✓✓✓✓✓✓
reuse lint clean✓✓✓✓✓✓
cargo audit / deny / vet clean✓✓✓✓✓✓
Miri focused suite green✓✓✓✓✓✓
msrv-per-crate.sh clean—✓✓✓✓✓
CodeQL clean—————✓
cargo vendor + offline build clean—————✓
Release dry-run produces all 14 + universal tarballs————✓✓
Reproducible build verified————✓✓
npm publish --dry-run, vsce package clean————✓✓

Estimated timeline

PhaseEffortCalendar
0 — pre-flightdoneday 1
1 — restructure (one zero-logic commit)1 d code + 1 d reviewdays 2–3
2 — tooling (build.rs + xtask hybrid)1.5 ddays 4–5
3 — packaging templates + VS Code + npm + MCP wrappers3 ddays 6–8
4 — release pipeline + universal + debug-info + npm + .vsix5 ddays 9–13
5 — distro publishingweeks (per distro)weeks 3+
6 — documentation1 wk (intermittent)weeks 3+
7 — hardening (vendor + CodeQL + intra-doc + per-crate MSRV)1.5 wkweeks 3–4

Total focused engineering: ~4 working weeks before distro outreach kicks off.

Execution checklist

#PhaseLandsEstimated commits
10branch protection verification0 — config only
21restructure (single zero-logic commit)1
31.5missing_docs = "deny" + audit fixes1
42xtask + build.rs hybrid1
53pkg/ templates (deb/rpm/arch/nix/win/snap/flatpak/docker)1
63.10pkg/vscode/ extension scaffold1
73.11–12pkg/npm-mcp-wrapper/ + package.json.tmpl1
84release-binaries.yml workflow_dispatch + dry-run1
94.1.bflip release-binaries.yml to auto-on-tag1
104.6lipo-universal job1
114.7promotional jobs (Homebrew/AUR/Scoop/VS Code/npm/container)1
127.1reproducible builds + verify-reproducible job1
137.3cargo vendor + make vendor + vendor-build job1
147.4CodeQL job1
157.5msrv-per-crate.sh + CI job1
167.6strengthened RUSTDOCFLAGS in docs job1
177.7cargo about + NOTICE shipping1
186docs/USER-GUIDE.md, docs/MIGRATION-FROM-SERDE-YAML.md, docs/ARCHITECTURE.md3
196man-page asciidoc sources1
205distro PRs (per distro, ad-hoc)many

Total ~20 PRs over ~4 weeks for the engineering work; distro outreach then runs at distro maintainer pace.

Road to 1.0 — the long 0.x runway

1.0.0 is a contract, not a milestone date: it promises SemVer-strict stability and a commercial-grade support commitment. It is earned by field maturity, not scheduled. The entire 0.x line — all the way through 0.9.999 — is the maturation period: ship, gather real-world usage, break the API freely under 0.x rules, and harden it against actual workloads rather than against our own test suite. This is a deliberately long road. There is no target date, only gates.

Versioning posture during 0.x

Cargo’s SemVer rules let a 0.0.x patch break, and a 0.x.0 minor break. We use that latitude on purpose — a wrong early API is far cheaper to fix now than to carry past 1.0.

BandThemeAPI contract
0.0.xFoundation & hardening (Phases 0–7 above)Exploratory; may break per patch, noted in CHANGELOG.md.
0.1 → 0.8Capability maturation & API convergenceEach minor is a coherent capability milestone plus accumulated field feedback. Breaking changes allowed, increasingly rare, always with migration notes.
0.9.xAPI-freeze candidatesFeature-complete for the 1.0 scope; bugfixes and polish only. A public, written 1.0 API review opens here.
0.9.999Pre-1.0 soak / extended RCThe frozen 1.0 API, shipped as a long-lived, widely-deployed release. It runs in the wild until every gate below is demonstrably met. 1.0.0 is then a near-mechanical re-tag of a proven 0.9.999.

0.9.999 is intentional: it announces “this is 1.0, pending proof” and lets real deployments soak the final API for a long time, without prematurely spending the 1.0 stability promise.

1.0 gates (signal-gated, never date-gated)

1.0.0 ships only when all of the following are demonstrably true. Each is a measurable signal, not a vibe.

API & correctness

  • ≥ 4 consecutive minors with zero breaking changes to the public API.
  • cargo semver-checks clean across that window; no #[doc(hidden)] or __-prefixed escape hatches inside the 1.0 surface.
  • A written, reviewed 1.0 API guarantee enumerating the stable surface and the explicitly-unstable corners.
  • Fuzz targets accumulate ≥ N CPU-months with no new reproducers; coverage gate held at its ceiling.

Adoption & field testing

  • Real downstream users beyond CI/mirrors: a meaningful count of distinct crates.io reverse-dependencies and/or named production deployments.
  • Issues from real workloads triaged to zero open criticals — a steady state, not a launch spike.
  • Confirmed in the wild across the target matrix (OSes, architectures, no_std), not just in our runners.

Community & governance

  • ≥ 2 maintainers with merge rights (bus-factor retired).
  • CODE_OF_CONDUCT.md + a short GOVERNANCE.md (how decisions and releases are made); active Discussions.
  • The security-disclosure and release processes each exercised end-to-end by more than one person.

Ecosystem & distribution

  • Every distribution channel green and consumed: crates.io, docs.rs, Docker/GHCR, and the packaging channels (AUR / Scoop / Homebrew / npm / VS Code — see #99) actually publishing and used by real installs.
  • A stable, documented MSRV policy; the serde_yaml migration guide validated by external migrators.

Operational & supply chain

  • Reproducible builds reproduced independently by a third party.
  • OpenSSF Scorecard ≥ 8.0 and the OpenSSF Best Practices badge at passing (targeting silver); zero open advisories.
  • Signed releases verified by consumers (the pkg/VERIFY.md cookbook exercised externally).

Commercial readiness

  • A written support / stability / LTS policy and a security-response SLA.
  • Published performance baselines with regression gating in CI.

Explicit non-gates

1.0 is not triggered by a calendar date, a feature count, a star count, or “it’s been long enough.” A breaking change during 0.x is a tool we use deliberately, not a failure to apologise for.

After 1.0

  • Strict SemVer: a breaking change implies 2.0 and a deprecation cycle.
  • A 1.x LTS branch carrying backported security fixes.
  • The stable core becomes the foundation a commercial offering can layer on — never a reason to destabilise the open library.

noyalib ecosystem — road to 10/10

Last reconciled against the tree at v0.0.21. Every number in §1 was counted from the repository at that point, not carried forward; where a previous revision of this document was wrong, §0 says so.


0. What changed since the last revision

This document had drifted. Recorded plainly so the corrections are auditable:

Claim (previous)Reality at v0.0.21
467 public fns488
142 test files161
77 examples79
A1 comment mutation → M1 (v0.0.18–0.0.19)landed in v0.0.21
A2 reorder, A5 key spans → “planned”landed in v0.0.19
A3 extended remove → M2, unqualifiedpartly done — see EPIC A
D1 bare-metal → M3 (v0.0.21)landed in v0.0.20, closed #210
#210 openclosed
Coverage gate “≥95%”95% functions, deliberately rebaselined from 96

The coverage rebaseline deserves a note rather than a silent number: 96% left under three functions of slack, and the metric proved unstable across runs of the same commit (78 uncovered then 77). A threshold a no-op rename can breach measures LLVM’s instantiation accounting, not the test suite. Line (94%) and region (93%) floors were stable and are unchanged.


1. Current-state scorecard

CategoryNowEvidence (counted at v0.0.21)Gap to 10/10
API / functionality9488 public fns; lossless CST editors incl. set/insert/remove/rename_key/rename_anchor/swap_items/move_item/set_comment/remove_comment; streaming; async#221 closed in v0.0.23 — flow-member and sole-entry removal completed sub-ask 4; sub-ask 5 was resolved in v0.0.21 by a structural oracle rather than auto-quoting
Correctness / testing9.5161 test files, 5 961 tests, coverage gate (95 fn / 94 line / 93 region), Miri, differential fuzz vs saphyrFuzz is a PR smoke, not continuous; no structured fuzzers for the editors; property-test breadth uneven
Performance916 benches, SIMD, fast-int/fast-float, parallelNo published numbers; no CI regression gate; no criterion baselines
Security / supply-chain9cargo-vet, cargo-deny, cargo-audit, CodeQL, OSSF scorecard, REUSE 850/850, unsafe forbidden except simd; schema-validator hardening pinned by test (v0.0.21); SLSA Build L2 attestation + keyless sigstore in release.yml; build.rs contract CI-enforced (build-script-contract); CycloneDX SBOM signed + attested per release (v0.0.29); weekly cargo hack --feature-powerset --depth 2 sweep (v0.0.29, found the ariadne-without-std break on day one)No OpenSSF badge; the schema validator’s recursion bound is the jsonschema crate’s fixed 129 (pinned by test, not caller-configurable — upstream limitation; parser/serializer max_depth ARE caller-configurable). Earlier revisions of this row claimed no SBOM, no SLSA, unaudited build.rs, and an unconfigurable depth bound generally — all four were stale.
Documentation9rustdoc-strict + broken-intra-doc-link gate, USER-GUIDE.md, ADRs, 79 examplesNo docs.rs feature-matrix proof; no cookbook; no competitive comparison page
no_std / portability10no_std+alloc; wasm32 and bare-metal thumbv7em / riscv32imac / aarch64-unknown-none build (v0.0.20) and are gated in CI (v0.0.21)—
DX / ergonomics8.5miette/ariadne diagnostics, typed path API, recoveryNo derive helpers/builders; fix-hints not uniform across the error taxonomy
Interop / ecosystem9serde, compat-serde-yaml shim, figment, schemars, garde/validator, tokio, svalSuccessor position is earned but unclaimed — see EPIC F1
Satellites7.5wasm/mcp/lsp/cli all shippingEach v0.0.x; noyalib-mcp predates the 2026-07-28 MCP spec; no npm/registry packaging
Release / governance9.5ADR-0005 lockstep, signed commits, Keep-a-ChangelogMSRV/deprecation policy undocumented; release partly manual

Weighted read: core ≈ 9.2/10, satellites ≈ 7.5/10.


2. Gaps & issues (grounded)

2.1 Project-tracked

  • #221 — CST edit API. Sub-asks 1 (comment mutation), 2 (rename_key
    • key spans) and 3 (reorder) are done. Remaining:
    • (4) Extended remove — multi-line values, nested collections and nested sequence items do work. Sole entries and flow-collection members are refused; remove_subtree does not exist.
    • (5) Quoting-aware fragment emit — closed in v0.0.29. The _value inserters already quoted via Emit; set gained a structural oracle in v0.0.21; and v0.0.29 closed the remainder: insert_entry runs under the same oracle (its new-key splice was entirely unguarded — a lone U+000D in the fragment escaped, and the key half spliced verbatim), keys refuse <</non-printables and auto-quote like rename_key, and guarded_insert pins container growth to exactly one entry, so push_back("s", "v\n - w") can no longer append two items inside the elided container. Pinned by tests/cst_insert_containment.rs. The lesson from the v0.0.21 remove bug applied: every fast path now has an oracle, not an intuition.
  • #210 — bare-metal no_std. Closed in v0.0.20.

2.2 Analysis-surfaced

  • A remove data-loss bug, found and fixed in v0.0.21. The oracle guard only ran for multi-line edits; single-line entries took an unguarded path. In a flow collection an entry shares its line with its siblings and its parent, so remove("a.x") on a: {x: 1, y: 2} deleted the whole document and returned Ok. The lesson generalises: any fast path that skips the oracle needs a proof, not an intuition.
  • Schema validator hardening was untested, not absent. Measured at v0.0.21: external $ref is refused, and recursion is bounded (the jsonschema crate stops at depth 129). Both properties come from how the dependency is configured — default-features = false — rather than from anything this crate asserts, so a feature flag or a version bump could have removed either silently. Fixed in v0.0.21 by tests/schema_hardening.rs, which pins both. An earlier revision of this roadmap claimed the bounds were missing; they were merely unguarded.
  • build.rs unaudited — stale; closed before this revision. The build-script-contract CI job greps the script for network / filesystem / process capability and fails on any reach beyond its documented contract (declare cfgs, read $RUSTC --version, read one env var).
  • Fuzzing is a PR smoke. No corpus accretion, no continuous run, and no structured fuzzers for the editors — only the parser is differentially fuzzed against saphyr.
  • No performance regression gate, and no published numbers to anchor the SIMD / fast-float claims.
  • SIMD unsafe audit. simd/nightly-simd opt out of unsafe_code = forbid with no published soundness note.

3. Implementation plan — core crate

Format: Epic → tasks → acceptance → effort (S≤1d / M≤1wk / L>1wk) → risk → categories moved.

EPIC A — Finish the CST surgical-edit API (closes #221)

  • A1. Comment mutation — done, v0.0.21. set_comment(path, position, text) / remove_comment(path, position) with CommentPosition ∈ {Inline, Before}, leading blocks written at the node’s own indentation. Deferred: a separate insert_comment and a Trailing position — set_comment covers both intents today; add them only if a caller shows the distinction matters.
  • A2. Sequence reorder — done, v0.0.19 (swap_items, move_item).
  • A3. Extended remove — remaining: remove_subtree, sole-entry removal yielding a valid empty collection, and flow-collection members.
    • Accept: flow-member removal rewrites the flow collection rather than the line; a fuzz target removes a random path and asserts value == original − path; every refusal leaves the source byte-identical (already tested).
    • M · medium risk · API, correctness
  • A4. Fragment containment — done, v0.0.21. Investigation changed the shape of this task twice.
    • Routing set through Emit would have broken its documented contract: it splices verbatim on purpose, and set(p, "{x: 1}") turning a scalar into a mapping is legitimate use. The safe route already exists — set_value renders a typed value, and was verified to round-trip every hazardous input exactly ("true", "", "v # x", "v\nc: 3", "x: y", "- item").
    • The real defect was narrower and worse: a fragment could reach outside its path. set("a", "v\nc: 3") gave the document a new key c and returned Ok, because the result is valid YAML. Fixed by a structural oracle — the document’s shape outside the edited path must be unchanged.
    • The oracle compares shape, not values, and that distinction was found the hard way: a value-comparing first version wrongly rejected edits to anchored values, whose aliases legitimately change elsewhere. It also parses fallibly, because an invalid splice commits optimistically by design and surfaces via validate.
    • Completed, v0.0.21. push_back and insert_after had the same hole — push_back("s", "v\nqq: 7") appended to the sequence and gave the document a top-level qq. Both now run through guarded_insert, which elides the container being inserted into (its shape must change) and requires everything else to match; for insert_after("items[2]", ..) the container is the parent sequence. insert_entry was already covered, delegating to the guarded set.
    • Property tests at 512 cases each now generalise the round-trip claim beyond enumerated inputs: arbitrary scalars survive set_value at top level and nested, and set never silently corrupts — whatever it returns, siblings survive and the entry count holds.
  • A5. Read-only key spans — done, v0.0.19 (key_span).

EPIC B — Testing to a defensible 10

  • B1. Coverage floor. Keep 95/94/93 rather than chase 100. The v0.0.19 experience is the argument: the function metric moved between runs of an identical commit, so a higher floor buys flakiness, not assurance. Instead: B1′ — make the metric trustworthy by pinning the nightly used for coverage, so run-to-run variance is attributable. S · low · testing
  • B2. Editor fuzzing — structured arbitrary targets per mutator (set/insert/remove/rename/reorder/comment): apply a random edit, assert the oracle invariant or a clean refusal. The v0.0.21 data-loss bug is exactly what this catches. M · low · testing, correctness
  • B3. Continuous fuzzing — OSS-Fuzz, or a nightly ≥30 min job with a persisted corpus. M · low · testing, security
  • B4. Property-test parity — a checklist test that fails when a public codec entry point has no round-trip property. M · low · testing

EPIC C — Performance, measured and defended

  • C1. Published benchmarks — criterion baselines + BENCHMARKS.md with numbers against serde-saphyr, yaml-rust2 and (as a historical baseline) serde_yaml, on a stated corpus and machine. S–M · low · performance, docs
  • C2. CI regression gate — fail on >X% regression. Budget for runner noise: the CI duration monitor in this repo already demonstrates the failure mode, where a single slow run tripped a 1.1× threshold that the rolling average then absorbed. Use a median-of-N, not a single sample. M · medium · performance
  • C3. SIMD soundness note — document the unsafe invariants; Miri over the scalar-fallback equivalence tests; a simd == scalar differential property. M · medium · performance, security

EPIC D — no_std / portability

  • D1. Bare-metal build — done, v0.0.20. Four root causes, two more than #210 documented: dependencies pulling std unconditionally; FxHashMap/FxHashSet being std-only aliases; indexmap’s default hasher being std-only (worked around with a cfg-defaulted alias so no public signature changed); and core having no f64::fract/mul_add.
  • D2. Keep it built — done, v0.0.21. shared-no-std.yml gains a bare-metal matrix over thumbv7em-none-eabihf, riscv32imac-unknown-none-elf and aarch64-unknown-none. Recorded there: wasm32 passed throughout the #210 bug, because the target has std available and masked three dependencies pulling it in unconditionally — so wasm32 alone was never sufficient cover.
  • D3. alloc-free surface audit — document which APIs need alloc vs pure core. M · medium · no_std, docs

EPIC E — Security / supply-chain to 10

Updated against 2026 practice: SBOM alone is table stakes; provenance and VEX are what auditors now ask for.

  • E1. Per-release SBOM — CycloneDX and SPDX (cargo-cyclonedx / cargo-sbom) as release assets. S · low · security
  • E2. SLSA build provenance — GitHub’s attest-build-provenance reaches SLSA Build L2. Reaching L3 requires a hardened, isolated builder, which this repo does not yet use. Attest the release artifacts and the SBOM predicate. S–M · low · security, governance
  • E3. VEX statements — publish exploitability assessments for advisories that cannot be fixed upstream. The fleet has a live example: an advisory reachable only through a dependency the crate never called. VEX is how you say that formally instead of in a commit message. M · low · security
  • E4. OpenSSF Best Practices badge — passing → silver. S · low · security, governance
  • E5. build.rs contract — done, v0.0.21. The module comment states what it may do (declare cfgs, read $RUSTC --version, read one env var) and what it must never do; a build-script-contract CI job asserts the latter by grepping for network and filesystem capability, a subprocess count above one, and any [build-dependencies]. Grepping for capability means a future edit has to defeat CI rather than a reader’s attention.
  • E6. MSRV & deprecation policy — done, v0.0.21. docs/MSRV-AND-DEPRECATION.md. The clause that mattered: a dev-dependency outrunning the floor is a decision, not a mechanical fix — raising a user-facing promise to accommodate a test tool should be deliberate, not a way to turn a job green.

EPIC F — Documentation & interop leadership

  • F1. Claim the successor position. serde_yaml is archived and serde_yml now ships a shim pointing here; noyalib is already named alongside serde-saphyr and yaml-rust2 in community “what should I use now” threads. Convert that into MIGRATING-FROM-SERDE-YAML.md with a shim parity table, a one-line Cargo.toml swap and a compile-tested example. Highest adoption ROI on this list. M · low · interop, docs
  • F2. Honest competitive comparison — a page stating where each library wins. serde-saphyr deliberately has no Value DOM and deserialises straight into typed structs, which is faster and leaner for from_str::<T> and unable to do what a CST does. noyalib’s differentiator is the lossless editing surface plus schema validation. Say so, including where a reader should pick the other one. S · low · docs, interop
  • F3. The Norway problem, stated. serde-saphyr markets its typed-schema approach as the answer to NO → false. Document noyalib’s resolution rules and how to get strict behaviour, rather than leaving readers to infer it. S · low · docs, correctness
  • F4. Task-oriented cookbook — runnable, doctested recipes. M · low · docs, DX
  • F5. docs.rs feature-matrix proof — CI builds docs under each major feature combination. S · low · docs

EPIC G — Agent-era readiness (new)

The 2026-07-28 MCP specification is the largest revision since launch: a stateless protocol core, an Extensions framework, Tasks, MCP Apps, authorization hardening and a formal deprecation policy. It also lifts tool inputSchema/outputSchema to full JSON Schema 2020-12 — oneOf/anyOf/allOf, conditionals, $ref/$defs — and requires that implementations not auto-dereference external $ref URIs and bound schema depth and validation time.

noyalib already ships a JSON Schema 2020-12 validator. That makes this an opportunity rather than a chore — but the hardening the spec requires is currently absent.

  • G1. Pin the schema-validator hardening — done, v0.0.21. Measurement changed the shape of this task: the protections already existed, undocumented and untested. tests/schema_hardening.rs now pins six properties — external $ref refused, that refusal being fast enough to rule out a network attempt, recursion bounded at depth 500/2 000/10 000 without stack exhaustion, the bound naming itself in the error, local $ref/$defs still resolving, and ordinary schemas unaffected — and schema_validate.rs documents them as contract.
    • Still open: a configurable depth bound and an explicit validation-time budget. Today’s limit is jsonschema’s, not ours, so a caller cannot tighten it. S–M · low · security, interop
  • G2. noyalib-mcp → 2026-07-28 conformance. Stateless core, Tasks for long edits, Extensions, OAuth 2.1. Add an MCP Inspector conformance job. L · medium · satellites, ecosystem
  • G3. Tool-schema generation. noyalib already has schemars; expose a helper that emits an MCP-shaped tool schema from a Rust type, so agent authors get validated tool definitions without hand-writing JSON Schema. M · low · interop, DX
  • G4. Agent-safe editing profile. Agents edit configuration files unattended. Package the existing guarantees — oracle-guarded mutators, byte-faithful round-trip, refusal over silent corruption — as a documented “safe for unattended edits” profile, with the failure modes stated. The v0.0.21 data-loss bug is precisely the class this profile must exclude. S–M · low · docs, DX, correctness

4. Implementation plan — satellites

4.1 noya-cli (noyafmt, noyavalidate)

  • CLI-1. noyaedit exposing the CST mutators as a jq-style path CLI.
  • CLI-2. Shell completions (clap_complete) + man pages (clap_mangen).
  • CLI-3. Prebuilt binaries (cargo-dist), cargo-binstall metadata, Homebrew tap, official pre-commit-hooks.yaml.
  • CLI-4. --format json|sarif for noyavalidate so it lands in code scanning.

4.2 noyalib-mcp

Now gated on G2 above — conformance to the 2026-07-28 spec comes before feature breadth.

  • MCP-1. Cover the complete CST edit API as it lands.
  • MCP-2. Resources + prompts, not just tools.
  • MCP-3. Publish to the MCP registry with a signed release.

4.3 noyalib-lsp

  • LSP-1. Duplicate-key diagnostics on key_span; code actions backed by the mutators — including the new comment mutators.
  • LSP-2. Semantic tokens, symbols, folding, schema-aware hover.
  • LSP-3. VS Code extension (Marketplace + OpenVSX); Neovim/Helix/Emacs.

4.4 noyalib-wasm

  • WASM-1. npm package with TypeScript types, ESM + CJS, size-tracked.
  • WASM-2. Expose the full lossless-edit API; a live playground.
  • WASM-3. wasm32-wasi + Deno/Bun smoke tests.

5. New feature proposals

Ranked value-to-effort; each semver-additive and feature-gated.

  1. Document::diff / structural patch — a minimal comment-preserving patch between two documents. Powers CLI/LSP/MCP “apply change” flows and 3-way merges, and is the natural substrate for agent edits (G4).
  2. JSONPath / JMESPath-style query over Value and Document.
  3. Deterministic canonical form (to_canonical) for hashing and signing payloads — increasingly relevant as config gets signed rather than trusted.
  4. YAML↔JSON lossless-where-possible converters, with anchor/merge behaviour documented.
  5. Schema inference — emit a JSON Schema or struct skeleton from a sample document. Pairs with G3: infer, then serve as a tool schema.
  6. Merge-key (<<) round-trip policy as a documented, tested surface.
  7. Editor-grade recovery surface — expose partial parses as a public best-effort API.

6. Sequencing & milestones

Releasable under the lockstep contract (core + satellites move together).

  • M1 — “Finish the editors” (v0.0.18–0.0.19). ✅ A2 reorder, A5 key spans, B1 coverage gate, F5 docs-matrix groundwork. A1 slipped to v0.0.21.
  • M2 — “Reach everywhere” (v0.0.20). ✅ D1 bare-metal (closed #210).
  • M3 — “Safe to automate” (v0.0.21). ✅ A1 comment mutation; the remove data-loss fix. Open: A4 quoting-aware Emit — the last correctness hazard — and G1 validator hardening.
  • M4 — “Provably trustworthy” (v0.0.22). A3 remainder, B2 editor fuzzing, E1–E6 (SBOM, SLSA provenance, VEX, badge, build.rs contract, MSRV policy), D2 embedded CI targets.
  • M5 — “Own the niche” (v0.0.23 → 0.1.0). F1 succession guide, F2 comparison, F3 Norway stance, G2–G4 agent readiness, C1–C2 published and gated performance, the satellite build-outs. Cut 0.1.0 when the edit API is complete, the successor story is documented, and MCP conformance is demonstrated.

Category → milestone it reaches 10

CategoryReaches 10 at
no_std / portabilityM2 ✅ (M4 to keep it there)
API / functionalityM3–M4 (needs A3 + A4)
Correctness / testingM4
Security / supply-chainM4
Interop / ecosystemM5
DocumentationM5
PerformanceM5
SatellitesM5

7. Effort summary

Roughly 6–9 focused weeks to M4, and M5 is dominated by satellites rather than core work. The two items with the best ratio of value to effort are F1 (the successor guide — adoption, near-zero risk) and G1 (validator hardening — small, and a prerequisite for any MCP claim).

The single highest-risk item remains A4: it touches every mutator, and the failure it prevents is silent.


8. What I’d not do

  • Chase a 100% coverage floor. Demonstrated flaky at 96; the marginal 5% is mostly unreachable error arms, and a flaky gate teaches people to ignore gates.
  • Re-implement a CSS/JS/general minifier, or any large third-party surface, to avoid a dependency — unless the dependency carries an unfixable advisory and the replacement is small enough to test exhaustively. (The sibling html-generator case met that bar; most will not.)
  • Chase serde-saphyr on no-DOM deserialisation throughput. Different design centre. Compete on lossless editing, schema validation and portability, and say so honestly.
  • Add a plugin/scripting surface. A YAML library that executes user code inherits an attack surface disproportionate to the benefit.
  • Support YAML 1.1 semantics by default. Legacy behaviours stay behind explicit flags; the default should be predictable.

Repository standard compliance grade

This assessment applies the eight-category rubric in /Users/seb/Code/REPO-STANDARD.md to the repository state on 2026-09-20. Scores measure checked-in evidence and CI enforcement. They do not treat plans as completed work.

Result

The portfolio auditor independently records 20/24 signals (83%), template status pass, and strict candidate tier L1 for this working tree. The weighted assessment below adds qualitative evidence that the static signal scanner cannot establish by filename matching alone.

CategoryScoreEvidenceRemaining gap
Identity and README9/10Canonical README structure, verified version snippets, security and stability sections, license and MSRV badgesAdd a dedicated CI assertion for the canonical heading sequence
Documentation10/10docs/, mdBook deployment, DEVELOPMENT.md, architecture, ADRs, migrations, strict link checksNone against the current rubric
Build and install UX9/10Native Cargo flow, discoverable Makefile, examples, docs, SBOM, and offline tasksThe binary install contract belongs to noya-cli; this library has no GNUmakefile
Releases and binaries10/10Tag release workflow, mandatory signed-tag verification, trusted registry publication, checksums, Sigstore, SLSA, and CycloneDX SBOMNone against the current rubric
Packaging and distribution7/10Maintainer packaging guide, Debian and Fedora submission records, plus a fresh-runner byte-for-byte .crate reproduction gateNo completed multi-distribution tracking
Quality gates in CI10/10OS and toolchain matrices, strict lint/docs, coverage floors, feature powerset, fuzz replay, examples, benchmarks, and API checksNone against the current rubric
Supply chain and security9/10Private disclosure, dependency review, audit/deny/vet, pinned actions, Scorecard, keys, REUSE, SLSA, and SBOMRecord a live Scorecard result of at least 9 as release evidence
Community and governance9/10Governance files, templates, citation, agent rules, editor and pre-commit policy, docs lint, devcontainer, family scorecardMeasure and gate the sub-minute devcontainer boot requirement

Total: 73/80 (9.1/10). Candidate tier: L1, with substantial L2 and L3 evidence.

The repository cannot claim L2 or L3 because the tier rubric is cumulative and requires every category. Packaging and distribution currently block L2; a recorded Scorecard floor and measured devcontainer startup remain L3 gaps. The engineering and supply-chain gates already exceed many individual L2 and L3 requirements, but partial evidence does not unlock a tier.

Priority actions

  1. Add a cheap README-template conformance check to the documentation CI job.
  2. Record Repology or equivalent distribution coverage once two distributions track the relevant package.
  3. Measure devcontainer startup in CI and gate it below 60 seconds.

Re-run this grade only after the associated CI evidence exists. A document or workflow name by itself does not close a requirement.

Detailed README reference

This document preserves the extended technical material from the primary README before the workspace-wide canonical README template was adopted. The concise README.md is the project entry point. This reference retains the deeper rationale, feature catalogue, security discussion, and examples so the template migration does not discard documentation.


Noyalib logo

noyalib

A YAML 1.2 parser and serialiser for Rust, with full serde integration and zero unsafe code.

Build Crates.io Docs.rs lib.rs OpenSSF Scorecard OpenSSF Best Practices License: Apache-2.0 OR MIT MSRV 1.89.0


Contents

Getting started

The noyalib ecosystem (library + five satellite crates)

  • The noyalib ecosystem — noyalib, noya-cli, noyalib-lsp, noyalib-mcp, noyalib-wasm, noyalib-serde-yaml at a glance

Library reference

Operational


Install

As a Rust library (crates.io)

[dependencies]
noyalib = "0.0.58"

As a CLI tool

The noyafmt and noyavalidate binaries ship from the noya-cli companion crate (the noyalib library crate itself contains no binaries — the split keeps clap + miette + validate-schema out of the library’s dependency graph for downstream embedders).

ChannelInstall
Cargo (crates.io)cargo install noya-cli --locked
Cargo (from source)git clone https://github.com/sebastienrousseau/noya-cli && cargo install --locked --path noya-cli
Container (GHCR)docker run --rm ghcr.io/sebastienrousseau/noyafmt:latest --version
npm (WASM)npm install @sebastienrousseau/noyalib-wasm
npm (MCP)npx @sebastienrousseau/noyalib-mcp (no Rust toolchain needed)

cargo install noya-cli --locked builds both binaries by default (via the noyavalidate Cargo feature). To install only the formatter and skip the schema-validation toolchain, use cargo install noya-cli --locked --no-default-features --features noyafmt.

GitHub Releases additionally publish pre-built tarballs for Linux (gnu + musl), macOS (Intel + Apple Silicon + universal), and Windows (x86_64, i686, aarch64). Each archive ships with the binaries, man pages, shell completions, license bundle, and a cosign keyless signature + SLSA Build L2 attestation.

See pkg/VERIFY.md for verification commands and pkg/PUBLISH.md for the per-channel maintainer runbook.

no_std support

[dependencies]
noyalib = { version = "0.0.58", default-features = false }

Requires alloc. Core data binding (from_str, to_string, Value, schemas) and the streaming deserializer all compile and run without the standard library. from_reader, to_writer, the Spanned<T> deserialization helper (which uses thread-local storage), and the CST module require the std feature, which is enabled by default.

Build from source

git clone https://github.com/sebastienrousseau/noyalib.git
cd noyalib
make          # check + clippy + test

Cargo features

All optional integrations are off by default. Enable only what the application needs.

FeaturePulls inAddsDocumented in
std (default)—from_reader, to_writer, Spanned<T>, CST moduleInstall
miettemiette 7Rich terminal diagnostics with source spansLibrary Usage, examples/diagnostic.rs
ariadneariadneAlternative ariadne-rendered diagnosticsexamples/ariadne_diagnostic.rs
include—$include directive resolution via an in-memory resolverexamples/include_directive.rs
include_fsinclude + stdFilesystem include resolver (SafeFileResolver)examples/include_directive.rs
schemaschemars, serde_jsonJsonSchema derive + schema_for::<T>(). Downstream callers that derive JsonSchema must add schemars = "1.2" to their own Cargo.toml — the proc-macro emits ::schemars::* paths that need to resolve in the call-site dep graph.Capabilities at a glance
validate-schemaschema + jsonschemavalidate_against_schema, coerce_to_schemaGovernance: schema-driven autofix
figmentfigment 0.10noyalib::figment::Yaml providerexamples/figment.rs
gardegarde 0.23Validated<T> wrapperexamples/validation_garde.rs
validatorvalidator 0.21ValidatedValidator<T> wrapperexamples/validation_validator.rs
lossless-float—LosslessFloat — refuse-to-lose-precision float, the floating-point sibling of lossless-u64—
parallelrayon 1.10parallel::parse<T>, parse_with_config<T>, and parse_with_config_in_pool<T> for bounded ----separated streamsBenchmarks
recovery—noyalib::recovery::parse_lenient — best-effort tree + error list for LSP / IDE half-typed documentsexamples/recovery_lenient.rs, benches/v006_features.rs
arbitraryarbitrary 1arbitrary::Arbitrary for Value, Number, Tag, TaggedValue, Mapping: structure-aware fuzz targets and property tests build valid trees from one generatorfuzz/fuzz_targets/fuzz_value_roundtrip.rs
svalsval 2impl sval::Value for Value / Number / Mapping / MappingAny / TaggedValue, noyalib::sval_adapter::to_sval_writerexamples/sval_streaming.rs, benches/v006_features.rs
tokiotokio, tokio-util, bytesBounded async readers, backpressured AsyncYamlStream, and the lower-level YamlDecoder codecexamples/tokio_async_reader.rs, benches/v006_features.rs
simd—Forward-compat no-op — noyalib::simd::* is always available and the parser hot path uses it unconditionallyBenchmarks
nightly-simdsimd (nightly toolchain)core::simd-backed StructuralIter (32-byte chunks)Benchmarks
compat-serde-yaml—behavioural serde_yaml 0.9 shim (values, error text, and locations pinned by the live-captured 18-case contract suite); noyalib-serde-yaml packages it as a Cargo package-rename drop-inWhen not to use noyalib
lossless-u64—Number::Unsigned(u64) plus opt-in parser/serializer config for scalars in (i64::MAX, u64::MAX]docs/adr/0004-lossless-u64-integers.md, examples/lossless_u64.rs, benches/lossless_u64.rs
compare-saphyrserde-saphyr (dev only)Cross-library bench comparison armsbenches/comparison.rs
wasm-opt—Build-time flag opting the noyalib-wasm bundle into wasm-opt size passes (no API surface)sebastienrousseau/noyalib-wasm
noyavalidatestd + miette + validate-schemaEnables the schema-validation surface the noyavalidate CLI is built ondocs/USER-GUIDE.md §11
# Example: rich diagnostics + schema validation
[dependencies]
noyalib = { version = "0.0.58", features = ["miette", "validate-schema"] }

Optional features: lossless-u64 preserves YAML integer scalars above i64::MAX as Number::Unsigned(u64) instead of lossy f64 widening — useful for distributed-system IDs, content hashes, and timestamp fields. Enable the Cargo feature, then opt in at runtime with ParserConfig::lossless_u64_integers(true) and SerializerConfig::lossless_u64_integers(true). See docs/adr/0004-lossless-u64-integers.md for rationale and migration notes.


Requirements

  • Rust 1.89.0 or newer. Every crate manifest carries rust-version, and CI enforces the floor on every push: msrv-core builds the core surface on 1.89.0, and msrv-per-crate checks each satellite against its own declared floor.
  • Any tier-1 platform. The test matrix runs on Linux, macOS, and Windows with the stable, beta, and nightly toolchains; stable is the gate, beta and nightly are early warning.
  • std is optional. With default-features = false the core builds on core + alloc; CI checks wasm32-unknown-unknown, thumbv7em-none-eabihf, riscv32imac-unknown-none-elf, and aarch64-unknown-none on every push.

MSRV by crate. Each workspace crate carries its own rust-version; CI’s msrv-per-crate job (Phase 7) gates each crate independently so a satellite never silently breaks downstream users pinned to the core’s floor.

CrateMSRVWhy
noyalib (core lib)1.89.0One floor shared with the maintainer’s other Rust crates since v0.0.58 (ADR-0014); the toolchain-driven floor is 1.86 (criterion 0.8, a dev-dependency). Enforced by the dedicated MSRV CI job.
noyalib-mcp1.89.0Lockstep with the core floor; the MCP SDK alone needs 1.88.
noya-cli (binaries)1.89.0Lockstep with the core floor; clap_builder 4.6 (a transitive of clap = "4.5") ships in edition 2024.
noyalib-lsp1.89.0Lockstep with the core floor; LSP transport-stack transitives (litemap, uuid) require recent stables.
noyalib-wasm1.89.0Lockstep with the core floor; the wasm-bindgen 0.2 ecosystem floors at 1.86.
noyalib-serde-yaml1.89.0Lockstep with the core floor; a pure re-export crate with no extra dependencies.

As of v0.0.58 the whole lockstep set shares one floor, 1.89.0, the same floor as the maintainer’s other Rust crates. The historical split (core at a lower floor than the satellites) has been gone since v0.0.16.

The number is the floor we verify, not the floor the library happens to reach. At the old 1.86.0 floor, cargo +1.85.0 check --lib succeeded, but cargo +1.85.0 check --all-targets fails because criterion 0.8 requires 1.86, so no test, bench or coverage run can execute there. We do not advertise an MSRV that CI cannot exercise. We also guarantee never to require a rustc newer than 12 months old at release time — see docs/POLICIES.md §1.

The opt-in, bench-only compare-saphyr feature needs rustc 1.88+ (serde-saphyr uses let-chains), which the 1.89.0 floor covers. It is still not built by the MSRV gate, which checks the core feature sets.

rust-toolchain.toml itself selects stable for local development; the 1.89.0 floor on the core surface is enforced by the dedicated msrv-core CI job (Ubuntu, no-default-features + default-features build paths).


Quick Start

use noyalib::{from_str, to_string};

#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
struct Config {
    name: String,
    port: u16,
    features: Vec<String>,
}

fn main() -> Result<(), noyalib::Error> {
    let yaml = "
name: myapp
port: 8080
features:
  - auth
  - api
";

    let config: Config = from_str(yaml)?;
    let output = to_string(&config)?;
    let roundtrip: Config = from_str(&output)?;
    assert_eq!(config, roundtrip);

    Ok(())
}

The noyalib ecosystem

Six crates make up the family. The library is the core; five satellites wrap it for specific delivery surfaces, each in its own repository, all releasing in lockstep at the identical =0.0.X (ADR-0005) — the version number is the compatibility contract.

CrateWhat it isUse case
noyalibLibrary — YAML 1.2 parser, serializer, lossless CST, JSON Schema validatorEmbed YAML support in any Rust binary or library.
noya-cli (own repo)Two binaries: noyafmt (formatter), noyavalidate (schema validator + autofixer)CI gates, pre-commit hooks, ad-hoc command-line use.
noyalib-lsp (own repo)Language Server Protocol serverEditor integration — VS Code, Neovim, Helix, Emacs, Zed, Sublime, IntelliJ.
noyalib-mcp (own repo)Model Context Protocol serverLLM agent tooling — Claude Desktop, Cursor, Continue.dev, Zed assistant, mcp.run.
noyalib-wasm (own repo)wasm-bindgen wrapper around the libraryBrowser, Node, Cloudflare Workers, Deno, any WASM-capable host.
noyalib-serde-yaml (own repo)Drop-in serde_yaml replacement — a Cargo package rename over the behavioural shimMigrate off archived serde_yaml 0.9 by changing one manifest line and zero source lines.

Install the pieces

# CLI tools (noyafmt + noyavalidate)
cargo install noya-cli

# LSP server
cargo install noyalib-lsp

# MCP server
cargo install noyalib-mcp

# WASM bundle
npm install @sebastienrousseau/noyalib-wasm
# serde_yaml drop-in — the whole migration is this one line:
serde_yaml = { package = "noyalib-serde-yaml", version = "=0.0.58" }

Per-crate READMEs cover the surface specific to each artifact:

If you use…Drop-in config
VS Code / JetBrains / Neovim / Helix / Emacs / Zed / Sublimeeditor configs in noyalib-lsp/examples/
Claude Desktop / Cursor / Continue.dev / Zed assistant / hosted MCPclient configs (noyalib-mcp repo)
GitHub Actions / pre-commit / Helm / Compose / pyproject-adjacent YAMLvalidation gates in noya-cli/examples/
Vite / Webpack / Next.js / Cloudflare Workers / Deno / Bunbundling guide (noyalib-wasm repo)

How good is this, really?

The suite carries a rating, and the rating is a program.

scripts/ecosystem-scorecard.sh --network

It runs ~30 probes across the five repos — tests, spec conformance, clippy, rustdoc under -D warnings, cargo audit/deny/vet, REUSE, dependency-closure size, SHA-pinning of GitHub Actions, fuzz targets, release signatures, OpenSSF Scorecard — and prints, for every one, the command that produced the number next to the number itself.

Three properties make the score worth quoting:

  • Falsifiable. Every metric names its command and its threshold. Disagree with a row? Run the command in that row.
  • No credit for unmeasured work. A probe that cannot run (tool absent, no network, opt-in gate not passed) scores N/A and leaves the denominator. It is never silently counted as a pass. The report states what fraction of the rubric actually executed.
  • Reproducible. The header records rustc/cargo versions, host triple, and each repo’s commit SHA and dirty flag.

--json emits the same data for CI. The script exits non-zero below SCORE_FLOOR (default 0.90), so the rating can gate a merge instead of decorating a README.

Full methodology, the competitive landscape, and the measured gaps — including the ones that do not flatter this project — are in docs/ECOSYSTEM.md.

The rest of this README covers the library surface (noyalib itself). For the satellite crates, jump straight to their READMEs above.


One-minute migration from serde_yaml (and the wider ecosystem)

The one-minute version is a package rename. Change zero source lines:

[dependencies]
serde_yaml = { package = "noyalib-serde-yaml", version = "=0.0.58" }

noyalib-serde-yaml re-exports noyalib’s serde_yaml 0.9 shim and releases in lockstep with the core, so the exact pin is the compatibility contract. The shim is behavioural since v0.0.29: << stays a literal key, 0123 stays a string, u64::MAX keeps precision, alias bombs fail with upstream’s repetition limit exceeded, and the pinned error classes carry libyaml’s phrasing and locations, verified by an 18-case contract suite captured live from serde_yaml 0.9.34. Every type is noyalib-native; there is no transitive dependency on the archived upstream. The same shim is available without the rename: depend on noyalib with features = ["compat-serde-yaml"] and replace use serde_yaml with use noyalib::compat::serde_yaml.

Moving to the native API

Take this route when you want what the shim does not expose: from_str_strict, Spanned<T>, the Tagged variant, and the lossless CST. Most call sites are mechanical to update. The full guide, covering serde_yaml 0.9 plus every actively published fork and adjacent crate, is docs/MIGRATION-FROM-SERDE-YAML.md. The headline mapping for serde_yaml 0.9 is below; the same guide has per-crate sections for serde_yml, yaml_serde, serde-yaml-ng, serde-norway, serde-yaml-bw, serde-saphyr, and yaml-spanned with verified function tables for each.

-[dependencies]
-serde_yaml = "0.9"
+[dependencies]
+noyalib = "0.0.58"
-use serde_yaml::Value;
-let v: Value = serde_yaml::from_str(input)?;
-let s        = serde_yaml::to_string(&v)?;
+use noyalib::Value;
+let v: Value = noyalib::from_str(input)?;
+let s        = noyalib::to_string(&v)?;
serde_yaml 0.9noyalib
serde_yaml::from_str::<T>noyalib::from_str::<T>
serde_yaml::from_slice::<T>noyalib::from_slice::<T>
serde_yaml::from_reader::<R, T>noyalib::from_reader::<R, T>
serde_yaml::to_stringnoyalib::to_string
serde_yaml::to_writernoyalib::to_writer
serde_yaml::to_valuenoyalib::to_value
serde_yaml::Valuenoyalib::Value (adds a 7th Tagged variant)
serde_yaml::Mappingnoyalib::Mapping
serde_yaml::Numbernoyalib::Number
serde_yaml::Errornoyalib::Error
serde_yaml::with::singleton_map*noyalib::with::singleton_map*
(n/a)noyalib::from_str_strict::<T> — error on unknown keys
(n/a)noyalib::Spanned<T> — source-location wrapper
(n/a)noyalib::cst::Document — lossless byte-faithful edits

Coming from a different YAML crate?

Each crate has a standalone migration guide with TL;DR diff, function-mapping table, behavioural notes, and a checklist. Crates.io state verified 2026-09-05:

CrateVersionDrop-in for serde_yaml?Migration guide
serde_yml0.0.13 (repo archived)mostlyMIGRATION-FROM-SERDE-YML.md
yaml_serde0.10.7yes (Cargo package = rename)MIGRATION-FROM-YAML-SERDE.md
serde-yaml-ng0.10.0yesMIGRATION-FROM-SERDE-YAML-NG.md
serde-norway0.9.42yesMIGRATION-FROM-SERDE-NORWAY.md
serde-yaml-bw2.5.7no (breaking 2.x; 8-variant Value with Alias)MIGRATION-FROM-SERDE-YAML-BW.md
serde-saphyr1.2.0no (no Value DOM, streaming-only)MIGRATION-FROM-SERDE-SAPHYR.md
yaml-spanned0.0.3no (parser-only, no to_string)MIGRATION-FROM-YAML-SPANNED.md

The umbrella index is docs/MIGRATION.md — start there if you’re not sure which guide applies, or pick the row above.

The three behavioural differences worth knowing about (YAML 1.2 strict booleans, Tagged variant, multi-doc API): MIGRATION-FROM-SERDE-YAML.md covers each in detail.


Why this approach?

noyalib targets the niche serde_yaml / serde_yml / libyml occupy — read YAML into typed Rust structs, write Rust structs back as YAML — and is written from scratch against the YAML 1.2 spec. The implementation runs the official YAML test suite to 100% strict compliance: 406/406 attempted cases pass, 0 failures, 0 skips, on every push and with no skip list. Every companion crate runs the same vendored suite through its own entry point in its own CI (see Conformance), and since v0.0.36 every valid case also runs as a multi-document stream to pin error positions. It is not a fork of serde_yaml; the parser, scanner, serialiser, and CST are independent code.

Two architectural choices motivate the rewrite:

  1. Streaming-first deserialise. The default from_str<T> path walks parser events directly into the typed target. The serde_yaml-shaped pattern is parse → Value → T::deserialize(&Value), which allocates every key, scalar, and nested mapping into an intermediate AST that the typed target then throws away. noyalib bypasses that AST when the caller asked for a typed T. The dynamic Value tree is still available when callers want it, but it is no longer in the hot path.

  2. #![forbid(unsafe_code)] at the workspace root. There is no FFI to a C library, no raw-pointer dereferences, and no unsafe blocks in the parser, scanner, formatter, or CST. CI enforces the attribute on every push. Most popular Rust YAML crates wrap libyaml via C-FFI; the unsafe blocks involved are usually well-vetted, but their existence makes a security-conscious downstream audit meaningfully harder.

A few features built on top of those choices:

  • SIMD-accelerated structural discovery. Stable Rust dispatches to memchr (SSE2 / NEON) for arity-1/2/3 needles and SWAR for arity-4+. With nightly-simd on, the structural-bitmask scanner widens to a 32-byte Simd<u8, 32> chunk and walks delimiters via mask.trailing_zeros() — the same shape that powers simdjson.
  • SWAR decimal parsing. The plain-scalar integer resolver folds 8 ASCII digits per u64 cycle via three pair-wise multiply-add phases. ~2× faster than <i64 as FromStr>::from_str on big numbers.
  • Lossless CST. A side-table green tree (noyalib::cst::Document) reproduces the source byte-for-byte and supports surgical edits. doc.set("version", "0.0.2") rewrites only the touched span; comments, indentation, and sibling entries are left alone.

The default profile compiles eight crates: five unconditional (serde, indexmap, rustc-hash, memchr, smallvec) plus three default-on but optional (itoa via fast-int, ryu via fast-float, serde_ignored via strict-deserialise). Disabling the default features drops the graph to the five unconditional crates. No archived or unmaintained crate appears in the graph — serde_yaml 0.9 (archived), libyaml (C-FFI), and thiserror are all absent. cargo audit, cargo deny, and cargo vet are CI gates on every push.


Capabilities at a glance

The library has covered a complete YAML 1.2 stack since its first release. See CHANGELOG.md for the per-release inventory; the table below groups the current surface by theme.

ThemeHeadline deliverables
Spec complianceYAML 1.2 official test suite at 100% strict (406/406 attempted, 0 failures, 0 skips — verified by tests/yaml_compliance_report.rs); YAML 1.1 opt-in compatibility for the “Norway problem”; multi-document streams
Migration from serde_yamlbehavioural compat-serde-yaml shim since v0.0.29 — parses under ParserConfig::serde_yaml_compat() and renders upstream-style errors, pinned by the live-captured 18-case contract suite; noyalib-serde-yaml packages it as a one-line package-rename drop-in; From/TryFrom parity for Value/Mapping/Number
Binary scalarsFirst-class !!binary tag; RFC 4648 base64 round-trip with serde_bytes::ByteBuf/Bytes; non-UTF-8 payloads supported
Flatten guardSpanned<Value> in #[serde(flatten)] returns an actionable error pointing at the working alternative
Lossless editingSide-table CST with byte-faithful round-trip; Document::entry(path) chainable mutable handle (19 methods); rename_key / rename_anchor / swap_items / move_item and the auto-formatting *_value inserts; set_comment / remove_comment for inline and leading comments; automatic indent detection (2/3/4-space)
Anchor managementDocument::anchors() / aliases() / aliases_of(name); materialise_alias_at(byte_pos) and materialise_aliases_of(name) for breaking aliases
Schema codegenschema feature: JsonSchema derive re-export; schema_for::<T>() and schema_for_yaml::<T>(); honours #[doc], #[serde(default)], #[serde(rename)]
Schema validationvalidate-schema feature: validate_against_schema(value, schema); aggregated violations with RFC 6901 paths
Toolingnoyavalidate (with --schema and --fix), noyafmt, noyalib-lsp, noyalib-mcp, noyalib-wasm, noyalib-serde-yaml
Performancenoyalib::simd primitives — find_any_of, clean_prefix_len, ByteBitmap; parser hot path integrated; ~58× and ~5.4× over byte-by-byte at arities 3 and 8
Supply chainSLSA Build L2 provenance, sigstore signing, OpenSSF Scorecard, REUSE.software 3.3 compliance, signed commits, cargo-deny / cargo-vet / cargo-semver-checks gates, differential and soak fuzz

Two APIs, one parser

noyalib exposes two complementary surfaces over the same scanner and strictness rules:

  • Data binding — from_str, to_string, Value, StreamingDeserializer, BorrowedValue. Read YAML into typed Rust data, write Rust data back out. The round-trip travels through a Value/struct, so comments and exact whitespace are not preserved. Use this for config loaders, RPC payloads, and the 95% of YAML workloads that just want data.
  • Tooling / automation — noyalib::cst::parse_document, parse_stream, and the Document handle. Read YAML into a side-table CST that reproduces the source byte-for-byte, then run targeted edits like doc.set("version", "0.0.2") — only the touched span is rewritten, every comment and the original indentation is left alone. Use this for Renovate-style version bumps, manifest patchers, formatters, and schema-driven linters. See examples/lossless_edit.rs.

The CST also lets a tool break aliases by inlining the anchored content at every reference site — useful when a manifest needs to become self-contained before being shipped to a system that does not resolve YAML aliases:

#![allow(unused)]
fn main() {
use noyalib::cst::parse_document;

let yaml = "a: &shared 7\nb: *shared\nc: *shared\n";
let mut doc = parse_document(yaml).unwrap();

// Replace every `*shared` reference with the bytes of the
// anchored value. The `&shared` declaration stays in place.
let n = doc.materialise_aliases_of("shared").unwrap();
assert_eq!(n, 2);
assert!(!doc.to_string().contains('*'));
}

Document::materialise_alias_at(byte_pos) is the single-site variant for callers that already know the alias’s source position.


Ecosystem comparison

noyalib is the only Rust YAML implementation that passes all 406 active YAML 1.2 Test Suite cases under strict comparison and ships a lossless CST, native LSP, MCP server, WASM bundle, JSON Schema validator, and schema-driven autofix in one workspace.

The full feature matrix — every row, every column, with the reading-the-table notes — lives at docs/COMPARISON.md so the README stays fast to scan.

Quick orientation:

CrateDrop-in for serde_yaml?Key gap vs noyalib
serde_yaml 0.9(archived 2024-03)unmaintained
serde_yml, serde-yaml-ng, serde-norway, yaml_serdeyes (path rename)no streaming deser, no CST, no LSP / MCP / WASM, no JSON Schema
serde-yaml-bwno (breaking 2.x)no LSP / MCP / WASM, no JSON Schema
serde-saphyrno (no Value DOM)no Value DOM, no CST, no LSP / MCP / WASM
yaml-spannedno (read-only)no serializer, no CST, no LSP / MCP / WASM

Per-crate migration guides at docs/MIGRATION.md.


Benchmarks

Headline numbers (Apple M4 / aarch64, Rust 1.95 stable, --release with LTO=fat, codegen-units=1, panic=abort, criterion --warm-up-time 2 --measurement-time 4):

Fixturenoyalibvs serde_yaml_ngvs yaml-rust2vs yaml-spannedvs serde-saphyr
Deserialise simple (3 fields)1.40 µs1.84×1.36×1.69×2.00×
Deserialise nested (20 fields)9.66 µs1.55×1.25×1.60×1.76×
Deserialise large_list (500 items)920 µs1.42×1.19×1.38×1.69×
Deserialise github_actions (deep + comments)46.4 µs1.66×1.25×1.74×1.73×
Deserialise k8s multi-document85.1 µs1.42×1.11×——
Typed deserialise simple (streaming)1.22 µs1.72×———
Typed deserialise nested (streaming)7.08 µs1.55×———
Serialise simple290 ns4.34×———
Serialise nested2.25 µs3.00×———
Round-trip nested12.0 µs1.83×———
Structural-discovery (1 MiB, nightly-SIMD)311 µs———9.2× over memchr loop
SWAR decimal parse (i64::MAX)9.75 ns———2.5× over stdlib
Lossless-u64 parser resolution (benches/lossless_u64.rs)see harness———knob on vs off for large unsigned scalars

(serde_yml was previously a comparison column — retired in v0.0.6 since RUSTSEC-2025-0068 flagged it unsound + unmaintained; the bench arm was dropped so the OpenSSF Scorecard Vulnerabilities check stays clean. The headline ratios above are unchanged for the remaining four competitors.)

noyalib is faster than every other pure-Rust YAML library on every deserialize fixture measured. Speedup ranges across the four competitors above: 1.69×–2.00× vs serde-saphyr, 1.42×–1.84× vs serde_yaml_ng, 1.38×–1.74× vs yaml-spanned, 1.11×–1.36× vs yaml-rust2. Serialize is 3.00×–4.34× ahead of serde_yaml_ng.

The narrowest gap is yaml-rust2, which doesn’t carry the Spanned<T> plumbing, the per-tag Cow<'a, str> propagation, or the Value::Tagged preservation that noyalib does — closing the remaining gap to ≥ 2× over yaml-rust2 is a separate effort because the levers needed (CompactString keys in Mapping, bump-arena event lifetimes, eliminating the Value AST on the typed path) require SemVer-breaking refactors.

./scripts/pgo.sh runs the LLVM profile-guided optimisation pipeline and adds 5–15% on top of the numbers above; recommended for production deployments.

The full breakdown — every workload, every comparison library, the SWAR pipeline explanation, parallel multi-doc scaling, and the project-metrics table — lives at docs/BENCHMARKS.md.

Algorithmic-complexity guarantees (O(n) parser, O(d) stack, etc.) live in POLICIES.md §4.


Features

Serdefrom_str, from_slice, from_reader, to_string, to_writer, to_fmt_writer – all with _with_config variants. to_value, from_value for Value conversion. Multi-document: load_all, load_all_as, to_string_multi. Streaming deserializer bypasses Value AST for typed targets.
Values7-variant Value enum: Null, Bool, Number, String, Sequence, Mapping, Tagged. Path traversal via get_path("server.host"). Path queries via query("items[*].name") with wildcards (*), recursive descent (..), and bracket-quoted keys (labels["app.kubernetes.io/name"]) for a key the grammar would otherwise read as structure; noyalib::path::join_keys spells such a path from literal keys. Deep merge via merge() and merge_concat(). MappingAny for non-string keys. Zero-copy BorrowedValue<'a> borrows strings from input (18% faster).
SpansSpanned<T> tracks line, column, and byte offset for every deserialized field. Serializes transparently as T. Span tracking is opt-in — disabled by default in from_str for zero overhead. For large documents, consider the memory impact of the span HashMap.
FormattingPer-value output control: FlowSeq<T>, FlowMap<T>, LitStr, FoldStr, Commented<T>, SpaceAfter<T>.
Enumssingleton_map, singleton_map_optional, singleton_map_recursive, singleton_map_with – custom key transforms (snake_case, kebab-case, lowercase).
SchemasValidate against YAML schema levels: validate_yaml_failsafe_schema, validate_yaml_json_schema, validate_yaml_core_schema.
AnchorsAnchors (&), aliases (*), and merge keys (<<), including composition: an entry written after a <<: may take an alias as its value and overrides the merged entry of the same name, and the result does not depend on whether the alias appears before or after the merge key. Only a plain << is a merge key — a quoted "<<", and an alias resolving to the string <<, are ordinary keys, matching the YAML merge type’s tag resolution. Smart pointer wrappers: RcAnchor, ArcAnchor, RcWeakAnchor, ArcWeakAnchor.
Security7 configurable limits in ParserConfig: depth, document size, alias expansions, mapping keys, sequence length, duplicate key policy, strict booleans. ParserConfig::strict() for untrusted input. Billion-laughs safe via max_alias_expansions with saturating_add overflow protection.
CompatYAML 1.1 legacy boolean mode (legacy_booleans): resolves yes/no/on/off/y/n as booleans for Docker Compose, GitHub Actions, and other YAML 1.1 tooling. Solves the “Norway problem”.
WASMCompiles to wasm32-unknown-unknown. wasm-bindgen bindings (camelCase per JS conventions): parse(), stringify(), getPath(), validateJson(), merge(), plus the WasmDocument class (toString(), get(), getSource(), set(), setValue(), spanAt(), commentsAt(), replaceSpan()). Browser demo included.
ErrorsSource locations on all parse errors. format_with_source() renders rustc-style diagnostics with --> pointer. #[track_caller] on all Index panics. miette::Diagnostic integration included (--features miette) for rich terminal reports with error codes, actionable help text, and source spans.
no_stdFull #![no_std] support with alloc. Use default-features = false. Core parsing (from_str, to_string, Value, schemas) works without std. I/O functions (from_reader, to_writer), Spanned<T> deserialization (TLS), and the CST module require the std feature. CI enforces cargo check --no-default-features on every push.
CST editingSide-table CST (noyalib::cst) for byte-faithful round-tripping. Document::set("server.port", "9090") rewrites only the touched bytes; comments, blank lines, and sibling formatting survive. Document::entry(path) is the chainable mutable handle (19 methods covering path / set / set_value / remove / insert / insert_value / push_back / push_back_value / insert_after / insert_after_value / and_modify / or_insert / or_insert_with / or_insert_value / get / span_at / comments / exists / nested entry, plus smart items[0] path composition). Document::indent_unit() detects 2-/3-/4-space conventions so inserts conform to the file’s existing style. The *_value insertion mutators auto-format through the cst::Emit trait — a string that would re-parse as a number, a boolean, or a nested collection is quoted — and every splice is checked against a typed oracle, rolling back rather than restructuring the document. remove takes an entry’s whole line — indentation and terminator — whenever the entry is alone on it, in flow collections wrapped one member per line as well as in block style, so an edit never leaves a whitespace-only line for git diff --check or yamllint to reject.
Anchors v2Document::anchors() / aliases() / aliases_of(name) enumerate every &name / *name lexeme in source order. Document::materialise_alias_at(byte_pos) and materialise_aliases_of(name) “break” an alias by inlining the anchored scalar’s source bytes — leaves the alias site independent of future anchor edits.
Schema codegenschema feature: derive JsonSchema (re-exported from schemars), then schema_for::<T>() -> Result<Value> or schema_for_yaml::<T>() -> Result<String> to emit the JSON Schema 2020-12 document. Honours #[doc], #[serde(default)], #[serde(rename)], integer bounds, nested types via $defs.
Schema validationvalidate-schema feature (implies schema): validate_against_schema(value, schema) -> Result<()> enforces a JSON Schema 2020-12 contract on parsed YAML. Multiple violations aggregated with RFC 6901 JSON-pointer paths. validate_against_schema_str is the raw-text convenience.
Binary scalarsFirst-class !!binary tag with RFC 4648 base64 round-trip. serde_bytes::ByteBuf / Bytes work end-to-end including non-UTF-8 payloads.
serde_yaml shimcompat-serde-yaml feature: name-for-name re-exports backed by noyalib-native types — the unmaintained serde_yaml 0.9 crate is intentionally not a dependency. Migrating in-flight ::serde_yaml::Value from un-migrated modules flows through the Serde bridge: noyalib::to_value(&upstream)?.
SIMD primitivesnoyalib::simd::find_any_of / clean_prefix_len / SimdScanner / StructuralIter / ByteBitmap / parse_decimal_{u64,i64}. Parser hot path routes through them for free; public for downstream scanner authors. StructuralIter delivers 4.2× stable / 9.2× nightly-simd vs the memchr loop on 1 MiB workloads.
Parallel parsingparallel feature: parallel::parse<T>, parse_with_config<T>, parse_with_config_in_pool<T>, and their dynamic-value variants deserialise multi-document streams across the global or a caller-owned Rayon pool. The bounded pre-scan is O(input_len); small streams stay sequential and larger per-document work parallelises naturally.
Pluggable policiesnoyalib::policy::Policy trait + ParserConfig::with_policy(p). Built-ins: DenyAnchors (rejects &name / *name — billion-laughs guard), DenyTags (rejects custom tags), MaxScalarLength(n) (caps individual scalar size). Custom policies implement the trait.
Schema autofixvalidate-schema feature: coerce_to_schema(value, schema) -> Result<usize> walks JSON Schema type-mismatch errors and rewrites string-shaped scalars into the schema’s expected type when the parse succeeds. Solves the port: "8080" quoting slip-up automatically. Library engine behind noyavalidate --fix.
Key internernoyalib::interner::KeyInterner — &str → Arc<str> deduplication for repeated-key workloads. Kubernetes-shaped streams with 20-byte keys × 10 000 records: footprint drops from ~200 KB of fresh allocations to ~20 B + Arc pointers.

Governance: schema-driven autofix

YAML written by hand often has type slips that strict validation catches but humans don’t notice — port: "8080" (string) where the schema declares port: integer. noyalib ships a one-call fix-up engine that rewrites these to match the schema:

#![allow(unused)]
fn main() {
use noyalib::{coerce_to_schema, from_str, schema_for, validate_against_schema, JsonSchema};

#[derive(serde::Serialize, serde::Deserialize, JsonSchema)]
struct ServerConfig {
    /// Port the server binds on.
    port: u16,
    /// Hostname or IP literal.
    host: String,
}

let schema = schema_for::<ServerConfig>().unwrap();

// Hand-written YAML with the port quoted by mistake.
let mut data = from_str("port: \"8080\"\nhost: api.example.com\n").unwrap();

// Strict pass — fails because `port` is a string.
assert!(validate_against_schema(&data, &schema).is_err());

// Apply schema-driven coercions.
let n = coerce_to_schema(&mut data, &schema).unwrap();
assert_eq!(n, 1, "one fix expected");

// Re-validation passes — port is now an integer.
validate_against_schema(&data, &schema).unwrap();
}

Library engine behind noyavalidate --fix. Handles String → Integer, String → Number, String → Boolean coercions; the fix-loop iterates until convergence. Unparseable inputs (e.g. port: "abc" against type: integer) are left in place so the caller can surface the residue via a follow-up validate_against_schema call.


Policy enforcement (Safe YAML)

The pluggable policy::Policy trait + ParserConfig::with_policy(p) lets you reject documents that violate organisational constraints at parse time, before any data flows to downstream code. Built-in policies cover the most common asks; custom policies implement the trait directly.

#![allow(unused)]
fn main() {
use noyalib::policy::DenyAnchors;
use noyalib::{from_str_with_config, ParserConfig, Value};

let cfg = ParserConfig::new().with_policy(DenyAnchors);

// Anchors / aliases are the classical billion-laughs vector and a
// readability hazard in audited configs. With `DenyAnchors`
// registered, any document that defines `&name` or dereferences
// `*name` is rejected before deserialise begins.
let res: Result<Value, _> =
    from_str_with_config("key: &x 1\nval: *x\n", &cfg);
assert!(res.is_err());

// Anchor-free input passes through unchanged.
let v: Value = from_str_with_config("key: 1\nval: 1\n", &cfg).unwrap();
assert!(matches!(v, Value::Mapping(_)));
}

Built-ins: DenyAnchors, DenyTags (rejects custom tags while preserving YAML 1.2 core tags), MaxScalarLength(n). Compose freely — cfg.with_policy(DenyAnchors).with_policy(DenyTags) runs both in registration order.


Strict deserialise (typo detection)

noyalib::from_str_strict<T> errors if the YAML carries any keys the target type T doesn’t declare — closing the silent-data-loss gap when a config-key typo (e.g. replicass: 3) deserialises into a struct whose replicas field stays at its Default:

#![allow(unused)]
fn main() {
#[derive(Debug, serde::Deserialize)]
struct Config {
    port: u16,
    host: String,
}

let yaml = "port: 8080\nhost: api.example.com\nporrt: 9090\n";

// Lenient path silently drops `porrt`.
let cfg: Config = noyalib::from_str(yaml).unwrap();
assert_eq!(cfg.port, 8080);

// Strict path surfaces the typo as a typed error.
let err = noyalib::from_str_strict::<Config>(yaml).unwrap_err();
assert!(err.to_string().contains("porrt"));
}

The strict path walks nested structs — a typo at server.unknown is reported with its parent path so the user sees exactly where the bad key lives.

The same check is available on every input shape:

Input shapeLenientStrict
&strfrom_strfrom_str_strict
&[u8]from_slicefrom_slice_strict
impl io::Readfrom_readerfrom_reader_strict

The slice and reader variants share from_str_strict’s semantics — they exist so callers that already hold bytes (a buffer, a network frame, a bytes::Bytes) don’t have to round-trip through String to opt in.


Diagnostics output

Every parse error carries an exact (line, column, byte offset) triple. Error::format_with_source(input) renders a rustc-style snippet without pulling in any extra dependency:

error: expected ',' or ']'
  --> input.yaml:2:7
   |
 1 | host: localhost
 2 | port: [broken
   |       ^^^^^^ here
 3 | db: postgres
   |

Enabling --features miette makes noyalib::Error implement miette::Diagnostic; wrapping the error in miette::Report gives terminals with ANSI support the same snippet with a colour-coded label, and lets a CLI surface error codes (noyalib::parse, noyalib::duplicate_key, …) and actionable help text from the same call site:

#![allow(unused)]
fn main() {
let cfg: Config = noyalib::from_str(yaml)
    .map_err(|e| miette::Report::new(e).with_source_code(yaml.to_owned()))?;
}

See examples/diagnostic.rs for the full integration pattern.

For sinks with hard length budgets — Slack messages, Sentry tags, structured-log fields — the same diagnostic is available capped at a caller-supplied character count:

#![allow(unused)]
fn main() {
use noyalib::{from_str, Value};
let source = "a: [unclosed";
let err = from_str::<Value>(source).unwrap_err();

// Cap at 60 chars; UTF-8-aligned cut; ASCII `...` ellipsis.
let short = err.format_with_source_truncated(source, 60);
assert!(short.len() <= 60);

// Multi-line context (rustc-style) capped at 200 chars:
let with_ctx = err.format_with_source_radius_truncated(source, 1, 200);
assert!(with_ctx.len() <= 200);
}

The truncation contract: UTF-8-aligned cut, ... appended on truncation (dropped only when max_chars < 3), no ANSI escapes, deterministic across builds.


Custom tags (“just data”)

YAML tags (!Custom, !!python/object, !Color) attach a type label to any node. noyalib surfaces them as Value::Tagged on the default from_str::<Value> path, never as code paths — they are pure data the caller dispatches on:

#![allow(unused)]
fn main() {
use noyalib::{from_str, Value};

let v: Value = from_str("!Color '#ff8800'\n").unwrap();
match &v {
    Value::Tagged(t) => {
        assert_eq!(t.tag().as_str(), "!Color");
        assert_eq!(t.value().as_str(), Some("#ff8800"));
    }
    _ => unreachable!(),
}
}

Three escape hatches when the wrapper isn’t what you want:

NeedAPI
Read the inner value directly, ignoring the tagValue::untag_ref / Value::untag
Strip a known tag inline on the streaming path (no AST detour)TagRegistry::with
Reject any document carrying a non-core tag at parse timepolicy::DenyTags

For lossless emit, use to_string_value / to_writer_value:

#![allow(unused)]
fn main() {
use noyalib::{from_str, to_string_value, Value};
let v: Value = from_str("!Color '#ff8800'\n").unwrap();
let yaml = to_string_value(&v).unwrap();           // "!Color '#ff8800'\n"
let back: Value = from_str(&yaml).unwrap();
assert!(matches!(back, Value::Tagged(_)));         // round-trips
}

The generic to_string (and the rest of the Serialize-trait family) routes Value::Tagged through serialize_map for serde-bridge interop with serde_json etc., which is lossy on the YAML-tag wire form. The dedicated *_value variants short- circuit the Serialize pipeline.

Typed targets (#[derive(serde::Deserialize)] struct Foo { ... }) see through tags transparently — from_str::<Foo>("!Foo {x: 1}") yields Foo { x: 1 } regardless of the tag. The tag wrapper is only surfaced when the deserialise target is Value itself, detected at the entry point via TypeId.


Compact list indentation

Two camps disagree on whether YAML lists under a mapping key should indent (the default) or align with the key column. noyalib ships both styles; opt into the compact form via SerializerConfig::compact_list_indent(true):

#![allow(unused)]
fn main() {
use noyalib::{to_string_with_config, SerializerConfig, Value};

let yaml = "items:\n  - a\n  - b\n";
let v: Value = noyalib::from_str(yaml).unwrap();

// Default — list items indented one level past the key.
let std = noyalib::to_string(&v).unwrap();
// Output:
//   items:
//     - a
//     - b

// Compact — list items align with the key column. Style preferred
// by Kubernetes manifests and GitHub Actions workflows.
let cfg = SerializerConfig::new().compact_list_indent(true);
let compact = to_string_with_config(&v, &cfg).unwrap();
// Output:
//   items:
//   - a
//   - b
}

Key interning (memory footprint)

For Kubernetes-shaped streams that repeat the same keys (metadata, labels, name, apiVersion, selector) thousands of times, noyalib::interner::KeyInterner deduplicates the heap allocations:

#![allow(unused)]
fn main() {
use noyalib::interner::KeyInterner;
use std::sync::Arc;

let mut interner = KeyInterner::new();
let a = interner.intern("metadata");
let b = interner.intern("metadata");
// Same `Arc` — second call returned a clone of the cached entry.
assert!(Arc::ptr_eq(&a, &b));
}
WorkloadWithout interningWith KeyInterner
20-byte key × 100 records2 KB heap20 B + 100 × 16 B (Arc clones) ≈ 1.6 KB
20-byte key × 10 000 records200 KB20 B + 160 KB ≈ 20 B for the strings
Distinct K8s key set × 1 000 manifests~520 KB13 distinct allocations + 13 000 × 16 B Arc clones

The Mapping public API stays String-keyed for v0.0.1 — the interner is the explicit opt-in primitive. A future major version may switch the internal storage to Arc<str> and use the interner transparently during parse.


The “Norway problem”

YAML 1.1 resolved the bare scalar no as the boolean false. The country code for Norway is NO, so a YAML 1.1 parser silently rewrites country: NO to country: false. Real-world data corruption.

YAML 1.2’s official fix is to drop those bare-word booleans — only true and false count. noyalib defaults to YAML 1.2 strict semantics, so country: NO round-trips as the string "NO".

For migrating from Docker Compose / GitHub Actions / pre-1.2 YAML toolchains that expect the legacy behaviour, opt back in via legacy_booleans:

#![allow(unused)]
fn main() {
use noyalib::{from_str_with_config, ParserConfig, Value};

// Default (YAML 1.2 strict): "NO" → string.
let v: Value = noyalib::from_str("country: NO\n").unwrap();
assert_eq!(v["country"].as_str(), Some("NO"));

// Opt-in legacy mode: "NO" → false. Use only when migrating
// from a toolchain that depended on the YAML 1.1 behaviour.
let cfg = ParserConfig::new().legacy_booleans(true);
let v: Value = from_str_with_config("country: NO\n", &cfg).unwrap();
assert_eq!(v["country"].as_bool(), Some(false));
}

Conversely, strict_booleans = true tightens the YAML 1.2 contract — only the lowercase forms (true / false) are recognised; True, TRUE, Yes, etc. all stay strings. Use this for schema-strict pipelines where boolean-shape ambiguity is a contract violation.


Library Usage

Deserialization
#![allow(unused)]
fn main() {
// API surface synopsis — substitute your own `Config`, `yaml`, `bytes`, etc.
use noyalib::{from_str, from_slice, from_reader, from_value, ParserConfig};

// From string, byte slice, reader, or Value
let config: Config = from_str(yaml)?;
let config: Config = from_slice(bytes)?;
let config: Config = from_reader(file)?;
let config: Config = from_value(&value)?;

// With security limits
let parser = ParserConfig::strict();
let config: Config = noyalib::from_str_with_config(yaml, &parser)?;
let config: Config = noyalib::from_slice_with_config(bytes, &parser)?;
let config: Config = noyalib::from_reader_with_config(reader, &parser)?;
}
Serialization
#![allow(unused)]
fn main() {
// API surface synopsis — substitute your own `config` value + `file` writer.
use noyalib::{to_string, to_writer, to_fmt_writer, to_value, SerializerConfig};

// To string, io::Write, or fmt::Write
let yaml: String = to_string(&config)?;
to_writer(&mut file, &config)?;
let mut buf = String::new();
to_fmt_writer(&mut buf, &config)?;

// To Value
let value: noyalib::Value = to_value(&config)?;

// With custom config
let ser_config = SerializerConfig::new()
    .indent(4)
    .quote_all(true)
    .document_start(true);
let yaml = noyalib::to_string_with_config(&config, &ser_config)?;
}
Dynamic values
#![allow(unused)]
fn main() {
use noyalib::{from_str, Value};

let value: Value = from_str("
name: test
items:
  - one
  - two
settings:
  debug: true
")?;

// Field access
let name = value.get("name").and_then(|v| v.as_str());

// Path-based traversal
let debug = value.get_path("settings.debug");

// Sequence indexing
let first = value.get("items").and_then(|v| v.get(0));

// Missing keys return None (never panic)
assert!(value.get("nonexistent").is_none());
assert!(value.get_path("a.b.c").is_none());

// Path queries: wildcards and recursive descent
let all_names = value.query("items[*]");       // all items
let deep = value.query("..debug");              // find at any depth
}
Zero-copy borrowed values
#![allow(unused)]
fn main() {
use noyalib::borrowed::from_str_borrowed;

let yaml = "host: localhost\nport: 8080\n";
let value = from_str_borrowed(yaml).unwrap();

// Strings borrow directly from input — zero heap allocation
assert_eq!(value.as_mapping().unwrap().get("host").unwrap().as_str(), Some("localhost"));

// Convert to owned Value when needed
let owned = value.into_owned();
}

18% faster than from_str::<Value> on typical payloads. Aliases not supported in borrowed mode.

Source spans
#![allow(unused)]
fn main() {
use noyalib::{from_str, Spanned};

#[derive(serde::Deserialize)]
struct Config {
    port: Spanned<u16>,
}

let config: Config = from_str("port: 8080")?;
assert_eq!(config.port.value, 8080);
assert_eq!(config.port.start.line(), 1);
assert_eq!(config.port.start.column(), 6);
}

Spanned<T> serializes transparently as T.

Merge keys
#![allow(unused)]
fn main() {
use noyalib::{from_str, Value};

let yaml = "
defaults: &defaults
  timeout: 30
  retries: 3

production:
  <<: *defaults
  timeout: 60
";

let mut value: Value = from_str(yaml)?;
value.apply_merge()?;
// production -> {timeout: 60, retries: 3}
}
Multi-document streams
#![allow(unused)]
fn main() {
// API surface synopsis — substitute your own `Config` type + `config1`/`config2` values.
use noyalib::{load_all, to_string_multi};

let docs = load_all("---\na: 1\n---\nb: 2\n")?;
for doc in docs {
    println!("{:?}", doc?);
}

let items: Vec<Config> = noyalib::load_all_as::<Config>(yaml)?;
let yaml = to_string_multi(&[config1, config2])?;
}
Enum serialization
#![allow(unused)]
fn main() {
#[derive(serde::Serialize, serde::Deserialize)]
enum Action { StartServer, StopServer }

#[derive(serde::Serialize, serde::Deserialize)]
struct Task {
    #[serde(with = "noyalib::with::singleton_map")]
    action: Action,
}
}
ModulePurpose
singleton_mapSerialize enums as {Variant: data}
singleton_map_optionalSame, for Option<Enum>
singleton_map_recursiveApply recursively to nested enums
singleton_map_withCustom key transforms (snake_case, kebab-case)
Formatting wrappers
WrapperEffect
FlowSeq<T>Inline sequence: [a, b, c]
FlowMap<T>Inline mapping: {a: 1, b: 2}
LitStr / LitStringLiteral block scalar (|)
FoldStr / FoldStringFolded block scalar (>)
Commented<T>Attach a YAML comment
SpaceAfter<T>Insert a blank line after the value

Configuration

Parser configuration
#![allow(unused)]
fn main() {
// API surface synopsis — substitute your own `input` string.
use noyalib::{from_str_with_config, ParserConfig, DuplicateKeyPolicy};

let config = ParserConfig::new()
    .max_depth(64)
    .max_document_length(1_000_000)
    .max_alias_expansions(1000)
    .max_mapping_keys(10_000)
    .max_sequence_length(10_000)
    .duplicate_key_policy(DuplicateKeyPolicy::Error)
    .strict_booleans(true);

let value: noyalib::Value = from_str_with_config(input, &config)?;
}

For untrusted input, use ParserConfig::profile(ParserProfile::Strict) or its concise alias, ParserConfig::strict(). Use ParserLimits with limits() and with_limits() to replace resource budgets without changing semantic policy or installed integrations.

Serializer configuration
#![allow(unused)]
fn main() {
// API surface synopsis — substitute your own `value` to serialise.
use noyalib::{to_string_with_config, SerializerConfig, FlowStyle, ScalarStyle};

let config = SerializerConfig::new()
    .indent(4)
    .flow_style(FlowStyle::Auto)
    .scalar_style(ScalarStyle::DoubleQuoted)
    .quote_all(true)
    .document_start(true)
    .document_end(true)
    .block_scalars(true)
    .block_scalar_threshold(3);

let yaml = to_string_with_config(&value, &config)?;
}

Examples

Run all examples:

cargo run --example all
All examples
CategoryExamplePurpose
CorehelloStruct roundtrip
stdVec, HashMap
variantsEnum strategies
deepNested structures
dynamicDynamic Value type
modifyto_value, from_value, get_path, MappingAny
tagsSingleton map enums
SpecaliasAnchors, aliases, merge keys
smartRcAnchor, ArcAnchor
overlayValue merging
inheritMerge key precedence
streamMulti-document streams
typesCustom YAML tags
binaryLarge ints, .inf, .nan, hex/octal
lossless_u64Opt-in Number::Unsigned for scalars above i64::MAX
Securitystrictstrict_booleans, DuplicateKeyPolicy
secureParserConfig limits
schemaSchema validation
env${VAR} expansion
DXerrorsError types, diagnostics
tracePath tracking
sourceSpanned<T> locations
styleFlowSeq, FlowMap, Commented
AdvancedemitSerializerConfig options
renameCustom key transforms
flattenserde flatten, untagged
bridgeJSON <-> YAML interop
pipesfrom_slice, from_reader, to_writer
globalConfiguration layering
FutureportableWASM portability proof
maskSecret<T> redaction
patchSurgical YAML patching
suggest“Did you mean?” typo detection
schema_extSelf-documenting config schemas
Deep RustuntaggedPolymorphic deserialization
borrowZero-copy patterns
transcodeValue-to-Value transcoding
commentsComment handling
async_ioAsync integration
recursiveSelf-referential types
Platformdiagnosticmiette::Diagnostic integration
nostd#![no_std] compatibility guide
preserveCST preservation foundations
lossless_editRenovate-style version bump (CST Document::set)
cst_wrapped_flow_editRemoving from a flow collection wrapped one member per line
merge_keys_with_aliasesMerge keys (<<:) and aliases (*name) in one mapping; which spellings of << are merges
Runtimeasync_ioAsync integration (spawn_blocking pattern)
recursiveSelf-referential types (trees, org charts)
BenchbenchPerformance overview
Ecosysteminclude$include-key modular config (Argo CD / JSON-Schema-style refs)
figmentLayered defaults / YAML / env via the figment Provider
validation_gardeDeclarative validation through garde + Validated<T>
validation_validatorDeclarative validation through validator + ValidatedValidator<T>
diagnostic_pathserde_path_to_error — pinpoint the offending nested key

When not to use noyalib

A few cases where another tool fits better, listed because the short answer is “we don’t do that yet” rather than because of a disagreement on priorities.

  • You need to round-trip comments through the data-binding API. The YAML data model excludes comments by spec. The lossless CST (noyalib::cst::Document) preserves them byte-for-byte for the tooling path, but from_str::<T> → to_string(&T) does not. No Rust YAML library currently round-trips comments through a typed deserialise / serialise pair.
  • You need a different YAML 1.1 resolver behaviour than the three forms the spec actually disagrees with 1.2 on. ParserConfig::version(YamlVersion::V1_1) flips the bundle of resolver-table differences (yes / no / on / off booleans, bare-0 octal 0644, sexagesimal 60:00) on as a single preset; fine-grained legacy_* flags remain for mix and match. Other 1.1-isms (mandatory !! tag prefix, broader timestamp parsing) are not version-gated in either direction.
  • You have a hard dependency budget that cannot tolerate a Grisu / Ryu float formatter and a hash-randomised lookup table. Default profile carries 8 runtime deps. noyalib = { version = "0.0.58", default-features = false, features = ["std"] } (or the equivalent features = ["minimal"]) drops to 5 — itoa, ryu, and serde_ignored become opt-in via the fast-int / fast-float / strict-deserialise features. Numeric formatting falls back to core::fmt (slower; output remains valid YAML); the from_str_strict typo-detection helpers go away.

If you hit a case that should be on this list, please open an issue — that’s how it gets fixed or moved into the supported set.


Development

make              # check + clippy + test
make test         # run all tests
make clippy       # lint with Clippy
make fmt          # check formatting
make examples     # run all examples
make doc          # build API documentation
make deny         # supply-chain audit
make miri         # Miri memory checking (requires nightly)
make sbom         # generate software bill of materials
make clean        # remove build artifacts

Fuzzing

Thirteen cargo-fuzz targets ship under fuzz/fuzz_targets/, including differential targets that compare noyalib against other ecosystem parsers and a structure-aware target that builds Value trees with Arbitrary and proves round-trip, CST agreement, and alias-budget invariants rather than only surviving input. Two more under fuzz-nostd/ drive the core + alloc build, so the no_std code shape is fuzzed and not only compiled. Every push replays the seed corpus plus the minimized crash inputs of previously-fixed findings (fuzz/regressions/), so a fixed crash cannot silently return; onboarding to Google’s OSS-Fuzz is in review (google/oss-fuzz#16093).

# Generic surface
cargo +nightly fuzz run fuzz_parse              # arbitrary YAML parsing
cargo +nightly fuzz run fuzz_roundtrip          # parse → serialize → re-parse
cargo +nightly fuzz run fuzz_value_roundtrip    # Arbitrary Value → emit → parse == Value; alias budget oracle
cargo +nightly fuzz run fuzz_from_value         # Value → typed deserialise
cargo +nightly fuzz run fuzz_multi_doc          # multi-document streams
cargo +nightly fuzz run fuzz_strict             # tight security limits

# Targeted regression coverage
cargo +nightly fuzz run fuzz_borrowed_alias     # BorrowedValue + alias resolution
cargo +nightly fuzz run fuzz_diff               # owned-vs-borrowed parity
cargo +nightly fuzz run fuzz_double_quoted      # double-quoted scalar escapes
cargo +nightly fuzz run fuzz_yaml_v1_1          # YAML 1.1 resolver toggle

Seed corpus included in fuzz/corpus/seed/. The alloc-only crate:

cargo +nightly fuzz run --fuzz-dir fuzz-nostd fuzz_roundtrip_alloc_only

Miri (UB / aliasing / leak verification)

noyalib is #![forbid(unsafe_code)] so Miri does not police noyalib’s own code — every byte is checked at compile time. The reason a Miri job exists is to verify the interaction with the runtime dependencies (indexmap, rustc-hash, ryu, itoa, memchr, smallvec — all of which use unsafe internally) is sound, plus to validate that the SWAR decimal parser and the structural-bitmask iterator behave correctly under simulated big-endian targets.

make miri              # focused suite — parser, scanner, value, interner, simd
make miri-full         # full lib test suite under Miri (slow)
make miri-bigendian    # focused suite simulated on mips64 big-endian

# Or invoke the script directly:
./scripts/miri.sh                    # full focused suite
./scripts/miri.sh simd               # subset
MIRI_TARGET=mips64-unknown-linux-gnuabi64 ./scripts/miri.sh
MIRI_MODEL=tree ./scripts/miri.sh    # Tree Borrows (weekly in CI)
MIRI_ALIGN=1 ./scripts/miri.sh simd  # symbolic alignment check on the SWAR module (weekly in CI)

The CI matrix runs the focused suite on every PR (miri-focused) and the full + big-endian sweep on a weekly schedule (miri-full).

CI

WorkflowTriggerPurpose
ci.ymlpush, PRClippy, fmt, test (3 OS x 3 toolchains), coverage, audit, cargo-deny
docs.ymlpush to mainBuild and deploy API docs to GitHub Pages
release.ymltag v*Validate, cross-verify, checksums, SBOM, GitHub Release, crates.io
security.ymlpush, PR, weeklyDependency review, CodeQL analysis

See CONTRIBUTING.md for signed commits and PR guidelines.


Security

Reporting: never open a public issue for a vulnerability — see SECURITY.md for the private reporting channel and disclosure policy.

YAML parsers are notorious attack surface — libyaml-based wrappers have shipped multiple critical CVEs over the years (deserialisation RCE in PyYAML, billion-laughs amplification in dozens of language ecosystems, code-execution-via-tag in legacy Ruby YAML). noyalib’s posture is built around closing each of those vectors at the architectural level, not via opt-in flags.

RCE prevention (no arbitrary object instantiation)

noyalib does not instantiate arbitrary objects via tags. YAML’s custom-tag mechanism (!Foo, !!python/object) is the historical RCE vector — a malicious tag can load arbitrary code in legacy parsers. noyalib only deserialises into Rust types you’ve defined at compile time, with #[derive(serde::Deserialize)].

Custom tags surface as pure data through the Value tree, never as code paths. Three options, in order of decreasing strictness:

  • Tagged data is fully preserved. from_str::<Value>("!Custom 'hello'\n") returns Value::Tagged(t) where t.tag() == "!Custom" and t.value() == Value::String("hello") — the same shape that already covered tagged sequences and tagged mappings. Downstream code can read the tag via Value::Tagged(t) pattern matching, dispatch on it, or step through it via Value::untag_ref for transparent reads. No global type registry, no runtime code lookup, no attacker-controlled instantiation — just data.
  • Typed targets see through tags. A #[derive(serde::Deserialize)] struct Foo { x: u8 } against !Foo {x: 1} yields Foo { x: 1 }. The typed visitor never observes the tag string, so an attacker forging a tag to trigger a different Deserialize impl is impossible.
  • TagRegistry for opt-in routing. Register the specific tags your application understands; unregistered custom tags still surface as Value::Tagged(...) data. See TagRegistry.
  • policy::DenyTags for hard rejection. Reject any document carrying a non-core tag at parse time, before any data flows downstream. See Policy enforcement.

There is no path from a parsed YAML document to running attacker- chosen code. Period.

Configurable resource budgets

The ParserConfig::strict() preset enforces explicit resource budgets that cap every dimension of input size — designed to make billion-laughs and similar memory-amplification attacks mathematically impossible:

BudgetDefaultstrict()Protects against
max_depth12864Stack-blowing nested structures
max_document_length64 MiB1 MiBOversized payloads
max_stream_bytes256 MiB16 MiBOversized multi-document streams
max_nodes250 K25 KAST node-count floods (empty-collection bombs)
max_alias_expansions1024100Billion-laughs amplification
max_mapping_keys64 K1024Hash-collision DoS
max_sequence_length64 K1024Memory-spike DoS
duplicate_key_policyLastErrorSilent data loss
strict_booleansoffonThe “Norway problem”

Alias-byte accumulation uses saturating_add so a crafted input that overflows a usize counter still triggers the limit cleanly — no integer-wrap escape hatch.

Secret redaction (${VAR} interpolation)

Value::interpolate_properties (see examples/env.rs) substitutes ${name} references inside string scalars from a property map. By default it is strict: an unknown placeholder returns Error::Custom so a misconfigured deployment surfaces immediately rather than silently degrading. Two opt-in variants change that contract:

MethodUnknown placeholderError message echoes name
interpolate_properties (default)Err(Error::Custom)yes
interpolate_properties_redactedErr(Error::Custom)replaced with <redacted>
interpolate_properties_lossysubstituted with "", never errorsn/a

For secrets, pair the strict path with secrecy::Secret<T> on the target field to keep the substituted value out of Debug / log output.

#![allow(unused)]
fn main() {
use noyalib::{from_str, Value};
use std::collections::HashMap;

let yaml = "db_url: ${DATABASE_URL}\napi_key: ${API_KEY}\n";
let mut map: HashMap<String, String> = HashMap::new();
map.insert("DATABASE_URL".into(), "postgres://...".into());
map.insert("API_KEY".into(), "redacted-token".into());

let mut value: Value = from_str(yaml).unwrap();
// Strict by default — every placeholder must resolve.
value.interpolate_properties(&map).unwrap();
// `api_key` now holds the substituted secret — wrap downstream
// in `secrecy::Secret<String>` to keep it out of Debug formatting.

// Hide the placeholder name in error output (audit-trail safe).
let mut value: Value = from_str(yaml).unwrap();
let _ = value.interpolate_properties_redacted(&map);

// Treat unknown placeholders as empty strings (env-var style).
let mut value: Value = from_str(yaml).unwrap();
value.interpolate_properties_lossy(&map);
}

Compile- and runtime-safety guarantees

  • #![forbid(unsafe_code)] across the entire workspace — no FFI, no raw-pointer dereferences, no unsafe blocks anywhere.
  • #[non_exhaustive] on ParserConfig, SerializerConfig, FlowStyle, ScalarStyle, Error — adding a variant is not a semver break.
  • #[must_use] on 83 query methods — silent result-discarding bugs caught at compile time.
  • #[track_caller] on 13 Index/IndexMut panic paths — panic diagnostics point at your call site, not noyalib internals.
  • Pluggable parser policies (DenyAnchors, DenyTags, MaxScalarLength) — opt into “Safe YAML” enforcement at parse time. See noyalib::policy.

Supply chain

  • cargo audit clean — zero advisories.
  • cargo deny clean — license / advisory / ban / source checks.
  • cargo vet clean — every dependency in the graph has a local audit or imported coverage from Mozilla / Google / Bytecode Alliance / Embark / ISRG audit sets.
  • OpenSSF Scorecard tracked, badge in the header — v0.0.6 lifts the score from 6.5/10 to ~9/10 by demoting workflow tokens to least privilege, pinning every GitHub Action by SHA, wiring Dependabot, and retiring the unmaintained serde_yml / libyml bench dev-deps. See docs/POLICIES.md § OpenSSF Scorecard posture for the per-check breakdown.
  • SLSA Build L2 provenance + sigstore signing on every release.
  • REUSE.software 3.3 compliant — every source file carries SPDX headers.
  • Signed commits (SSH ed25519) enforced via CI.

Notes

  • Comment round-tripping for the data-binding API is not supported (YAML spec excludes comments from the data model). Commented<T> provides write-only comment injection. The CST API (noyalib::cst::Document) preserves comments byte-for-faithfully for the lossless-tooling path.

Documentation

The four entry points, identical across every repo in the family:

  • User Manual — the rendered book: user guide, migrations, architecture, policies, ADRs
  • API reference — rustdoc on docs.rs
  • Developer docs — toolchain, task map, reproducing every CI gate locally
  • Ecosystem map — the six crates, the lockstep model, the scorecard
DocumentCovers
docs/POLICIES.mdMSRV, SemVer & API stability, security & audit pipeline, performance & algorithmic complexity, concurrency guarantees, platform support, the full feature-flag matrix, panic policy, error model, dependency policy, release & changelog policy. Single source of truth for engineering posture.
docs/MIGRATION.mdUmbrella index of migration guides for serde_yaml 0.9, serde_yml, yaml_serde, serde-yaml-ng, serde-norway, serde-yaml-bw, serde-saphyr, yaml-spanned. Each linked guide is a per-crate function-mapping table + behavioural-difference notes + checklist.
SECURITY.mdDisclosure policy, supported versions, contact, security design summary.
CONTRIBUTING.mdSigned-commit policy, PR guidelines, local-test recipe.
CHANGELOG.mdPer-release notes following Keep a Changelog 1.1.0. The complete record — every release appears here.
docs/release-notes/Narrative notes for v0.0.1–v0.0.17, one file per tag. Complements the changelog rather than duplicating it.
docs/USER-GUIDE.mdLong-form usage guide with worked examples for every major feature.
docs/ARCHITECTURE.mdModule map, hot-path notes, design decisions.
docs/COOKBOOK.mdTask-shaped recipes, each pointing at the runnable example it comes from.
docs/GLOSSARY.mdYAML / serde terminology reference.
docs/internals.mdLibrary internals (parser stages, loader frames, CST green tree).
docs/errors.mdError reference — every variant, when it fires, how to handle it.

The per-crate READMEs at crates/noyalib, noya-cli, noyalib-lsp, and the split-out satellite repos sebastienrousseau/noyalib-mcp and sebastienrousseau/noyalib-wasm document the surface specific to each artifact (binaries, MCP tools, LSP capabilities, WASM bindings).


Acknowledgements

Independent implementations find things a library’s own test suite cannot, because they exercise it against files nobody thought to write down.

@zoosky, author of yqr, has been noyalib’s most consistent source of that signal — eight merged commits across the CST edit API and the scanner, and the reports behind #221, #269, #280, #283, #285 and #288. The pattern is the same each time: adopt a published release in a real consumer, find where it produces or refuses something the library itself disagrees with, and arrive with a reproduction, a diagnosis naming the responsible function, a fix, and the tests that must keep failing.

Two contributions are worth naming specifically. #269 was filed as a defect and then re-framed by its author once he read the tests — the behaviour was deliberate, so the argument became a design question about whether two mutators may disagree about who owns a comment, which is a better question than the one originally asked. And #261’s CRLF fix was proven by disabling yqr’s local workaround and showing the suite pass against the branch while failing against the released version — evidence rather than assertion.

Thanks also to @kshpytsya and @EdJoPaTo for reports and clippy follow-ups.

Stability guarantees

  • Versioning. Strict SemVer. Pre-1.0 the breaking-change axis is the patch number during the 0.0.x series; every breaking change is marked with ! in its commit and CHANGELOG entry, and cargo semver-checks gates every PR against the last release.
  • Output stability. For a parser, output is API: a change to how a document parses (scalar resolution, error-vs-accept, the resulting Value shape) is treated as a breaking change even when no Rust signature moves — spec-alignment fixes ship as fix(spec)!: on the breaking axis, never in a compatible release.
  • MSRV. Raised only on the breaking axis with the reason recorded in the CHANGELOG, never silently — policy and history in doc/MSRV-AND-DEPRECATION.md.
  • Deprecations live for at least two releases with a #[deprecated] note naming the replacement before removal.

License

Dual-licensed under Apache 2.0 or MIT, at your option.

See CHANGELOG.md for release history.

Back to Top

The noyalib ecosystem

This document does two things. It describes what the ecosystem is today, and it states — with numbers anyone can reproduce — how good it actually is. The second part is the harder one, and the reason scripts/ecosystem-scorecard.sh exists.

Why a harness instead of a claim

A rating is worth something only if it can be shown to be wrong. Most “production-ready / battle-tested / enterprise-grade” language in this corner of the industry cannot be, because nothing is attached to it. So every number below is produced by a command, and the command is printed next to the number.

Three rules govern the harness:

  1. Falsifiable. Each metric names the exact command and the exact threshold. “Docs are good” is not a metric. “rustdoc emits 0 warnings under -D warnings” is — run it and see.
  2. No credit for unmeasured work. A probe that cannot run scores N/A and is removed from the denominator. It never scores 0, and it never silently scores 1. The report states what fraction of the rubric actually executed, because a perfect score over a third of the rubric is not a perfect score.
  3. Reproducible. The header records rustc/cargo versions, host triple, each repo’s commit SHA, and whether the worktree was dirty.

Run it:

scripts/ecosystem-scorecard.sh                  # local probes
scripts/ecosystem-scorecard.sh --network        # + crates.io, GitHub, OpenSSF
scripts/ecosystem-scorecard.sh --deep           # + all-features build probes
scripts/ecosystem-scorecard.sh --with-coverage  # + cargo-llvm-cov
scripts/ecosystem-scorecard.sh --json out.json

CI runs the same harness weekly and on every change to it or to this page (.github/workflows/ecosystem-scorecard.yml), against fresh checkouts of all six repos; the workflow fails below the 0.90 floor, and the JSON report is attached to the run.

It exits non-zero below SCORE_FLOOR (default 0.90), so the rating can be a CI gate rather than a boast.

What it deliberately does not measure

  • Adoption. Downloads are recorded as context below, not scored. Popularity is not quality, and scoring it would let the rating drift with marketing rather than engineering.
  • Benchmark numbers. The harness checks that docs/BENCHMARKS.md discloses host, toolchain and repro command — the things that make a speed claim falsifiable — but does not re-run criterion. Timing a laptop under variable load and calling the delta a score would be measurement theatre.
  • Code review. OpenSSF scores this 0 across every repo in the family, correctly: these are single-maintainer repos with squash merges. That is a real limitation and is left visible rather than excluded.

The competitive landscape, 2026

YAML tooling is not one market. It is five, and each has a different incumbent in a different language.

SurfaceIncumbentLanguagenoyalib’s answer
Libraryserde_yaml (archived 2024)Rustnoyalib
CLI query / transformyqGonothing
Style linteryamllintPythonnothing
Language serveryaml-language-server (Red Hat)TypeScriptnoyalib-lsp
Agent tooling (MCP)no incumbent—noyalib-mcp
Browser / JSyaml (eemeli), js-yamlJavaScriptnoyalib-wasm

The Rust library tier alone has fragmented since serde_yaml was archived. Recent-download counts from the crates.io API:

CrateRecent downloadsStatus
serde_yaml87.1Marchived upstream
yaml-rust213.9Mmaintained, no serde layer
serde_yml6.4Mdescription begins “DEPRECATED”
serde_yaml_ng5.0Mmaintained fork
serde-saphyr3.6Mmaintained, newer
serde_norway2.7Mmaintained fork
noyalib0.247Mmaintained

The honest reading: noyalib is roughly 0.3% of the deprecated incumbent and 6.8% of serde-saphyr. On measured capability it leads; on measured reach it does not. The constraint is distribution, not engineering — which is exactly where the gaps below sit.

For a feature-by-feature comparison of the Rust libraries — and instructions for verifying any row of it yourself — see COMPARISON.md. This document is about the suite, not the library.

The structural advantage is that noyalib is the only one of these that spans all five surfaces from a single core. yq, yamllint and yaml-language-server are three unrelated codebases in three languages with three different YAML parsers and three different sets of edge-case behaviour. A team that adopts them gets three answers to “is this document valid?”.

Measured gaps

Every row below was produced by a filesystem or API probe on 2026-08-20, not by judgement.

Capability the suite cannot express today

MissingEvidenceIncumbent
Query / transform toolnoya-cli declares exactly two [[bin]]: noyafmt, noyavalidateyq
Style linternoyavalidate is schema validation; no line-length, key-duplication, indentation or quote-consistency rulesyamllint

Both are surfacing work, not engine work. The lossless CST already tracks every span these rules need, and already backs remove/set.

Distribution

MissingEvidence
GitHub Actionno action.yml in any of the five repos
Hosted pre-commit hookno .pre-commit-hooks.yaml in noya-cli — docs/pre-commit.md documents only repo: local, which needs noyafmt already installed
VS Code extensionno editors/vscode in noyalib-lsp — the server exists but nobody can install it from the marketplace
Homebrew formulashipped: homebrew-tap/Formula/noya-cli.rb, with Scoop and AUR (noyalib-bin) beside it
CLI container imageshipped: noya-cli/pkg/docker/Dockerfile publishes ghcr.io/sebastienrousseau/noya-cli from its release workflow (first image with v0.0.34); the core’s dead monorepo-era Dockerfile is gone
Python bindingsno bindings/python
Native Node bindingswasm only; no napi

Supply-chain posture

OpenSSF Scorecard, scans taken the same day at the current HEAD of each repo:

RepoScore
noyalib8.0
noya-cli6.5
noyalib-lsp6.5
noyalib-mcp6.4
noyalib-wasm6.4

The satellite deficit is concentrated in four checks:

  • Fuzzing 10 -> 0. No satellite has a fuzz/ directory. Worth about a point each and cheap to fix.
  • CII-Best-Practices 5 -> 0. The badge is registered for the core only.
  • Contributors 3 -> 0, Signed-Releases 3 -> 1.

Code-Review is 0 on all five, including the core — single-maintainer squash merges. Honest, and not something a script can fix.

The credibility gap

Three crates in the same portfolio still depend on YAML libraries their own authors have marked dead:

RepoDependencyUpstream status
libmakeserde_yml 0.0.13crates.io description begins “DEPRECATED”
nucleusflowserde_yml 0.0.13same
wiseroneserde_yaml 0.9.31archived
metadata-gennoyalib and yaml-rust2half-migrated

Already migrated: frontmatter-gen, html-generator, static-site-generator, and the five satellites.

Shipping a YAML library while three of your own crates depend on an archived one is the most falsifiable credibility claim in the set, and the cheapest to close.

Proposed additions

Ranked by measured gap times incumbent strength.

Tier 1 — capability

  1. noyaq (new bin in noya-cli) — query and in-place transform with a yq-compatible expression subset.
  2. noyalint (new bin in noya-cli) — yamllint’s rule set over the CST.
  3. noyalib-rules (new crate) — the rule engine behind noyalint, reused by the LSP for diagnostics and by the MCP server as an agent-facing lint tool, so three surfaces do not re-implement the same checks.

Tier 2 — distribution

  1. First-party GitHub Action. CI is where YAML tooling is actually consumed; this is the highest-leverage single artefact on the list.
  2. .pre-commit-hooks.yaml — one file, unlocks the entire pre-commit user base.
  3. VS Code extension wrapping noyalib-lsp.
  4. Homebrew tap (shipped, with Scoop and AUR) and a CLI container image.

Tier 3 — reach beyond Rust

  1. noyalib-py (PyO3) — the linting and round-trip audience is Python-native.
  2. noyalib-node (napi-rs) — native throughput where WASM loses.

Tier 4 — cheap credibility

  1. Fuzz targets in each satellite.
  2. OpenSSF Best Practices badge for the five satellites.
  3. Migrate libmake, nucleusflow, wiserone; finish metadata-gen.

Rating

Rating: A- (92.5% weighted) — 134 executed probes, 12 scored N/A.

Measured 2026-10-15T20:47:55Z on aarch64-apple-darwin, rustc 1.99.0 (b940084d7 2026-09-28). Rubric executed: 92%.

RepoMetricMeasuredScoreProbe
noyalibtests6635 passed / 0 failed1.00cargo test --workspace --locked
noyalibclippy0 diagnostics (rc=0)1.00cargo clippy --workspace --all-targets --locked -- -D warnings
noyalibrustfmtclean1.00cargo fmt --all --check
noyalibunsafe_forbidden2/2 crate roots1.00grep -l 'forbid(unsafe_code)' on every lib.rs/main.rs
noyalibmsrv_declared1.89.01.00grep rust-version Cargo.toml
noyalibrustdoc_strict0 warnings1.00RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features (matches CI + docs.rs)
noyalibmissing_docs_lint2/2 roots deny missing_docs1.00grep for deny/warn(missing_docs) in crate roots
noyalibreadme_examplescompile1.00scripts/check-readme-examples.sh
noyalibbench_methodology3/3 disclosed (host, toolchain, command)1.00grep host/toolchain/repro-command in docs/BENCHMARKS.md
noyalibaudit_vulnerabilities0 advisories1.00cargo-audit audit --json | .vulnerabilities.count
noyalibdeny_checkpass1.00cargo deny check (advisories, bans, licenses, sources)
noyalibvet_auditedpass1.00cargo vet --locked
noyalibreuse_compliancecompliant1.00reuse lint (REUSE 3.3)
noyalibdependency_closure12 unique runtime crates1.00cargo tree -e normal --prefix none --no-dedupe | sort -u (library budget 60; propagates to consumers)
noyalibactions_sha_pinned160/160 external uses pinned1.00grep 'uses:.*@<40-hex>' .github/workflows
noyalibfuzz_targets18 libFuzzer targets1.00find fuzz/fuzz_targets -name '*.rs' (floor 2)
noyalibline_coveragenot runn/apass --with-coverage to measure
noyalibrelease_gpg_signed3 .asc assets1.00gh release view --json assets | grep '\.asc$'
noyalibrelease_sigstore3 .bundle assets1.00gh release view --json assets | grep '\.bundle$'
noyalibrelease_sbom6 sbom assets1.00gh release view --json assets | grep -i sbom
noyalibdependabot_open0 open alerts1.00gh api /repos/sebastienrousseau/noyalib/dependabot/alerts?state=open
noyalibci_main_greenfailure0.00gh run list --branch main --workflow CI --status completed --limit 1 --json conclusion
noyalibcrates_io_currenttree 0.0.58 / crates.io 0.0.570.00crates.io/api/v1/crates/noyalib .max_stable_version
noyalibopenssf_scorecard8.3/100.83api.securityscorecards.dev/projects/github.com/sebastienrousseau/noyalib .score
noya-clitests122 passed / 0 failed1.00cargo test --workspace --locked
noya-cliclippy0 diagnostics (rc=0)1.00cargo clippy --workspace --all-targets --locked -- -D warnings
noya-clirustfmtclean1.00cargo fmt --all --check
noya-cliunsafe_forbidden1/1 crate roots1.00grep -l 'forbid(unsafe_code)' on every lib.rs/main.rs
noya-climsrv_declared1.89.01.00grep rust-version Cargo.toml
noya-clirustdoc_strict0 warnings1.00RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features (matches CI + docs.rs)
noya-climissing_docs_lint1/1 roots deny missing_docs1.00grep for deny/warn(missing_docs) in crate roots
noya-clireadme_examplescompile1.00scripts/check-readme-examples.sh
noya-clibench_methodology1/3 disclosed (host, toolchain, command)0.33grep host/toolchain/repro-command in docs/BENCHMARKS.md
noya-cliaudit_vulnerabilities0 advisories1.00cargo-audit audit --json | .vulnerabilities.count
noya-clideny_checkpass1.00cargo deny check (advisories, bans, licenses, sources)
noya-clivet_auditedpass1.00cargo vet --locked
noya-clireuse_compliancecompliant1.00reuse lint (REUSE 3.3)
noya-clidependency_closure105 unique runtime crates (leaf, not scored)n/acargo tree -e normal --prefix none --no-dedupe | sort -u — recorded, not scored: a leaf binary's tree is not inherited
noya-cliactions_sha_pinned77/77 external uses pinned1.00grep 'uses:.*@<40-hex>' .github/workflows
noya-clifuzz_targets2 libFuzzer targets1.00find fuzz/fuzz_targets -name '*.rs' (floor 2)
noya-cliline_coveragenot runn/apass --with-coverage to measure
noya-clirelease_gpg_signed3 .asc assets1.00gh release view --json assets | grep '\.asc$'
noya-clirelease_sigstore3 .bundle assets1.00gh release view --json assets | grep '\.bundle$'
noya-clirelease_sbom6 sbom assets1.00gh release view --json assets | grep -i sbom
noya-clidependabot_open0 open alerts1.00gh api /repos/sebastienrousseau/noya-cli/dependabot/alerts?state=open
noya-clici_main_greensuccess1.00gh run list --branch main --workflow CI --status completed --limit 1 --json conclusion
noya-clicrates_io_currenttree 0.0.58 / crates.io 0.0.570.00crates.io/api/v1/crates/noya-cli .max_stable_version
noya-cliopenssf_scorecard8.4/100.84api.securityscorecards.dev/projects/github.com/sebastienrousseau/noya-cli .score
noyalib-lsptests75 passed / 0 failed1.00cargo test --workspace --locked
noyalib-lspclippy0 diagnostics (rc=0)1.00cargo clippy --workspace --all-targets --locked -- -D warnings
noyalib-lsprustfmtclean1.00cargo fmt --all --check
noyalib-lspunsafe_forbidden2/2 crate roots1.00grep -l 'forbid(unsafe_code)' on every lib.rs/main.rs
noyalib-lspmsrv_declared1.89.01.00grep rust-version Cargo.toml
noyalib-lsprustdoc_strict0 warnings1.00RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features (matches CI + docs.rs)
noyalib-lspmissing_docs_lint2/2 roots deny missing_docs1.00grep for deny/warn(missing_docs) in crate roots
noyalib-lspreadme_examplescompile1.00scripts/check-readme-examples.sh
noyalib-lspbench_methodology1/3 disclosed (host, toolchain, command)0.33grep host/toolchain/repro-command in docs/BENCHMARKS.md
noyalib-lspaudit_vulnerabilities0 advisories1.00cargo-audit audit --json | .vulnerabilities.count
noyalib-lspdeny_checkpass1.00cargo deny check (advisories, bans, licenses, sources)
noyalib-lspvet_auditedpass1.00cargo vet --locked
noyalib-lspreuse_compliancecompliant1.00reuse lint (REUSE 3.3)
noyalib-lspdependency_closure71 unique runtime crates (leaf, not scored)n/acargo tree -e normal --prefix none --no-dedupe | sort -u — recorded, not scored: a leaf binary's tree is not inherited
noyalib-lspactions_sha_pinned62/62 external uses pinned1.00grep 'uses:.*@<40-hex>' .github/workflows
noyalib-lspfuzz_targets2 libFuzzer targets1.00find fuzz/fuzz_targets -name '*.rs' (floor 2)
noyalib-lspline_coveragenot runn/apass --with-coverage to measure
noyalib-lsprelease_gpg_signed3 .asc assets1.00gh release view --json assets | grep '\.asc$'
noyalib-lsprelease_sigstore3 .bundle assets1.00gh release view --json assets | grep '\.bundle$'
noyalib-lsprelease_sbom6 sbom assets1.00gh release view --json assets | grep -i sbom
noyalib-lspdependabot_open0 open alerts1.00gh api /repos/sebastienrousseau/noyalib-lsp/dependabot/alerts?state=open
noyalib-lspci_main_greensuccess1.00gh run list --branch main --workflow CI --status completed --limit 1 --json conclusion
noyalib-lspcrates_io_currenttree 0.0.58 / crates.io 0.0.570.00crates.io/api/v1/crates/noyalib-lsp .max_stable_version
noyalib-lspopenssf_scorecard8.4/100.84api.securityscorecards.dev/projects/github.com/sebastienrousseau/noyalib-lsp .score
noyalib-mcptests124 passed / 0 failed1.00cargo test --workspace --locked
noyalib-mcpclippy0 diagnostics (rc=0)1.00cargo clippy --workspace --all-targets --locked -- -D warnings
noyalib-mcprustfmtclean1.00cargo fmt --all --check
noyalib-mcpunsafe_forbidden2/2 crate roots1.00grep -l 'forbid(unsafe_code)' on every lib.rs/main.rs
noyalib-mcpmsrv_declared1.89.01.00grep rust-version Cargo.toml
noyalib-mcprustdoc_strict0 warnings1.00RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features (matches CI + docs.rs)
noyalib-mcpmissing_docs_lint2/2 roots deny missing_docs1.00grep for deny/warn(missing_docs) in crate roots
noyalib-mcpreadme_examplescompile1.00scripts/check-readme-examples.sh
noyalib-mcpbench_methodology1/3 disclosed (host, toolchain, command)0.33grep host/toolchain/repro-command in docs/BENCHMARKS.md
noyalib-mcpaudit_vulnerabilities0 advisories1.00cargo-audit audit --json | .vulnerabilities.count
noyalib-mcpdeny_checkpass1.00cargo deny check (advisories, bans, licenses, sources)
noyalib-mcpvet_auditedpass1.00cargo vet --locked
noyalib-mcpreuse_compliancecompliant1.00reuse lint (REUSE 3.3)
noyalib-mcpdependency_closure134 unique runtime crates (leaf, not scored)n/acargo tree -e normal --prefix none --no-dedupe | sort -u — recorded, not scored: a leaf binary's tree is not inherited
noyalib-mcpactions_sha_pinned74/74 external uses pinned1.00grep 'uses:.*@<40-hex>' .github/workflows
noyalib-mcpfuzz_targets1 libFuzzer targets0.50find fuzz/fuzz_targets -name '*.rs' (floor 2)
noyalib-mcpline_coveragenot runn/apass --with-coverage to measure
noyalib-mcprelease_gpg_signed3 .asc assets1.00gh release view --json assets | grep '\.asc$'
noyalib-mcprelease_sigstore3 .bundle assets1.00gh release view --json assets | grep '\.bundle$'
noyalib-mcprelease_sbom6 sbom assets1.00gh release view --json assets | grep -i sbom
noyalib-mcpdependabot_open0 open alerts1.00gh api /repos/sebastienrousseau/noyalib-mcp/dependabot/alerts?state=open
noyalib-mcpci_main_greensuccess1.00gh run list --branch main --workflow CI --status completed --limit 1 --json conclusion
noyalib-mcpcrates_io_currenttree 0.0.58 / crates.io 0.0.570.00crates.io/api/v1/crates/noyalib-mcp .max_stable_version
noyalib-mcpopenssf_scorecard8.4/100.84api.securityscorecards.dev/projects/github.com/sebastienrousseau/noyalib-mcp .score
noyalib-wasmtests57 passed / 0 failed1.00cargo test --workspace --locked
noyalib-wasmclippy0 diagnostics (rc=0)1.00cargo clippy --workspace --all-targets --locked -- -D warnings
noyalib-wasmrustfmtclean1.00cargo fmt --all --check
noyalib-wasmunsafe_forbidden1/1 crate roots1.00grep -l 'forbid(unsafe_code)' on every lib.rs/main.rs
noyalib-wasmmsrv_declared1.89.01.00grep rust-version Cargo.toml
noyalib-wasmrustdoc_strict0 warnings1.00RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features (matches CI + docs.rs)
noyalib-wasmmissing_docs_lint1/1 roots deny missing_docs1.00grep for deny/warn(missing_docs) in crate roots
noyalib-wasmreadme_examplescompile1.00scripts/check-readme-examples.sh
noyalib-wasmbench_methodology0/3 disclosed (host, toolchain, command)0.00grep host/toolchain/repro-command in docs/BENCHMARKS.md
noyalib-wasmaudit_vulnerabilities0 advisories1.00cargo-audit audit --json | .vulnerabilities.count
noyalib-wasmdeny_checkpass1.00cargo deny check (advisories, bans, licenses, sources)
noyalib-wasmvet_auditedpass1.00cargo vet --locked
noyalib-wasmreuse_compliancecompliant1.00reuse lint (REUSE 3.3)
noyalib-wasmdependency_closure33 unique runtime crates (leaf, not scored)n/acargo tree -e normal --prefix none --no-dedupe | sort -u — recorded, not scored: a leaf binary's tree is not inherited
noyalib-wasmactions_sha_pinned65/65 external uses pinned1.00grep 'uses:.*@<40-hex>' .github/workflows
noyalib-wasmfuzz_targets2 libFuzzer targets1.00find fuzz/fuzz_targets -name '*.rs' (floor 2)
noyalib-wasmline_coveragenot runn/apass --with-coverage to measure
noyalib-wasmrelease_gpg_signed3 .asc assets1.00gh release view --json assets | grep '\.asc$'
noyalib-wasmrelease_sigstore3 .bundle assets1.00gh release view --json assets | grep '\.bundle$'
noyalib-wasmrelease_sbom6 sbom assets1.00gh release view --json assets | grep -i sbom
noyalib-wasmdependabot_open0 open alerts1.00gh api /repos/sebastienrousseau/noyalib-wasm/dependabot/alerts?state=open
noyalib-wasmci_main_greensuccess1.00gh run list --branch main --workflow CI --status completed --limit 1 --json conclusion
noyalib-wasmcrates_io_currenttree 0.0.58 / crates.io 0.0.570.00crates.io/api/v1/crates/noyalib-wasm .max_stable_version
noyalib-wasmopenssf_scorecard8.4/100.84api.securityscorecards.dev/projects/github.com/sebastienrousseau/noyalib-wasm .score
noyalib-serde-yamltests6 passed / 0 failed1.00cargo test --workspace --locked
noyalib-serde-yamlclippy0 diagnostics (rc=0)1.00cargo clippy --workspace --all-targets --locked -- -D warnings
noyalib-serde-yamlrustfmtclean1.00cargo fmt --all --check
noyalib-serde-yamlunsafe_forbidden1/1 crate roots1.00grep -l 'forbid(unsafe_code)' on every lib.rs/main.rs
noyalib-serde-yamlmsrv_declared1.89.01.00grep rust-version Cargo.toml
noyalib-serde-yamlrustdoc_strict0 warnings1.00RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features (matches CI + docs.rs)
noyalib-serde-yamlmissing_docs_lint1/1 roots deny missing_docs1.00grep for deny/warn(missing_docs) in crate roots
noyalib-serde-yamlreadme_examplescompile1.00scripts/check-readme-examples.sh
noyalib-serde-yamlbench_methodology1/3 disclosed (host, toolchain, command)0.33grep host/toolchain/repro-command in docs/BENCHMARKS.md
noyalib-serde-yamlaudit_vulnerabilities0 advisories1.00cargo-audit audit --json | .vulnerabilities.count
noyalib-serde-yamldeny_checkpass1.00cargo deny check (advisories, bans, licenses, sources)
noyalib-serde-yamlvet_auditedpass1.00cargo vet --locked
noyalib-serde-yamlreuse_compliancecompliant1.00reuse lint (REUSE 3.3)
noyalib-serde-yamldependency_closure10 unique runtime crates (leaf, not scored)n/acargo tree -e normal --prefix none --no-dedupe | sort -u — recorded, not scored: a leaf binary's tree is not inherited
noyalib-serde-yamlactions_sha_pinned52/52 external uses pinned1.00grep 'uses:.*@<40-hex>' .github/workflows
noyalib-serde-yamlfuzz_targets2 libFuzzer targets1.00find fuzz/fuzz_targets -name '*.rs' (floor 2)
noyalib-serde-yamlline_coveragenot runn/apass --with-coverage to measure
noyalib-serde-yamlrelease_gpg_signed3 .asc assets1.00gh release view --json assets | grep '\.asc$'
noyalib-serde-yamlrelease_sigstore3 .bundle assets1.00gh release view --json assets | grep '\.bundle$'
noyalib-serde-yamlrelease_sbom6 sbom assets1.00gh release view --json assets | grep -i sbom
noyalib-serde-yamldependabot_open0 open alerts1.00gh api /repos/sebastienrousseau/noyalib-serde-yaml/dependabot/alerts?state=open
noyalib-serde-yamlci_main_greensuccess1.00gh run list --branch main --workflow CI --status completed --limit 1 --json conclusion
noyalib-serde-yamlcrates_io_currenttree 0.0.58 / crates.io 0.0.570.00crates.io/api/v1/crates/noyalib-serde-yaml .max_stable_version
noyalib-serde-yamlopenssf_scorecard7.3/100.73api.securityscorecards.dev/projects/github.com/sebastienrousseau/noyalib-serde-yaml .score
ecosystemversion_lockstepcore 0.0.58 not tagged yetn/agit tag -l v0.0.58 in noyalib (pending release; satellites pin only published versions)
ecosystemhost_coverage6/6 hosts present1.00directory presence for noyalib noya-cli noyalib-lsp noyalib-mcp noyalib-wasm noyalib-serde-yaml

Generated by scripts/ecosystem-scorecard.sh v1.0.0. Regenerate rather than edit.

How to disagree with this rating

Pick the row you doubt and run its probe. If the number differs, the harness is wrong and should be fixed — open an issue with the command output. That is the entire contract.

Two caveats the harness prints about itself and this document repeats, because a score that hides its own limits is a marketing number:

  • Rubric coverage. The percentage of probes that actually executed is reported alongside the score. Network and coverage probes are opt-in; with them off, a large slice of the rubric is N/A, and the headline is correspondingly less meaningful.
  • Worktree state. The header records whether each repo was dirty. A score measured against uncommitted changes describes those changes, not the published artefact.

Known weaknesses of the harness itself

  • A timed-out probe used to score 0, asserting a failure it had not observed. The run recorded above shows noyalib · tests · timeout after 3600s · 0.00, which dragged correctness to 83.3%. That is an artefact of the ceiling firing on a cold target dir, not a test failure: the same suite run directly on the same commit passed 5739 / 0, and spec_conformance in this very run reports 406/406. It also contradicted rule 2 above — an unmeasured probe is supposed to score N/A and never 0. Fixed: a timeout now records N/A, which keeps it visible (N/A rows print, and they lower the reported rubric coverage) without claiming a failure that did not happen. The next regeneration will show N/A here rather than a false ✗.

  • It measures the presence of fuzz targets, not fuzzing effort. Two targets that have never found anything score the same as two that have.

  • dependency_closure used one flat budget of 60 for every repo. That is why noya-cli scored 0 at 130 crates. The obvious fix — a per-repo budget — turned out to be the wrong one: any number picked now would be a number picked so the current value clears it. The rule that survived scrutiny is about propagation. A library’s dependencies are inherited by every downstream consumer, so their size is a cost imposed on other people and belongs in the score; noyalib sits at 12. A leaf binary’s are not inherited, and 130 of noya-cli’s crates are miette’s fancy renderer giving noyavalidate source excerpts and carets — which for a validator is the job, not bloat. Leaves are therefore recorded and not scored: the count stays in the table and the JSON, so a leaf that doubles its tree is obvious, but it is not judged against a line nobody can defend.

  • Code-Review cannot be fixed by any script, and no probe pretends otherwise.

  • Test pass ratio is scored, but not test quality. A suite of 4,000 trivial assertions scores the same as 4,000 sharp ones. Coverage (--with-coverage) partly compensates and is off by default because it is slow.

Family layout

Every repository in the family carries the same skeleton, enforced per push by the Family layout contract step of shared-docs-lint.yml — a repo that drops one of these files fails its own CI:

FileRole
README.mdidentity, install, quick start, the four documentation links
CHANGELOG.mdKeep-a-Changelog, one heading per lockstep release
SECURITY.mdprivate reporting channel
CONTRIBUTING.mdfamily process (satellites point at the core’s)
DEVELOPMENT.mddeveloper entry point (satellites point at the core’s)
REUSE.tomlmachine-readable licensing for headerless files
CODE_OF_CONDUCT.md, GOVERNANCE.md, SUPPORT.mdcommunity files; satellites point at the core’s governance model
AGENTS.mdthe invariants an AI-assisted contributor must respect (lockstep, signing, CI green)
CITATION.cffone citation record per crate, at the crate’s version
.pre-commit-config.yaml, .devcontainer/pre-commit hooks; a container that boots to a working make
docs/documentation root (never doc/), with ARCHITECTURE.md and an mdBook (book.toml, SUMMARY.md) deployed to Pages
scripts/verify-release-versions.shthe version gate, run before every tag
fuzz/corpus/seeda seed corpus replayed by CI on every push
.editorconfig, .markdownlint.yaml, .codespellrcshared editor + docs-lint configs

Satellites additionally carry supply-chain/; the core additionally carries the shared-*.yml workflows the family consumes by SHA and the manual for the library itself. Until 2026-09-05 the citation record, AGENTS.md, and the devcontainer lived in the core only; every satellite now carries its own, so the contract lists them.

Where the family is published

Cratecrates.ionpmghcr.io
noyalibnoyalib
noya-clinoya-clisebastienrousseau/noya-cli (from v0.0.34)
noyalib-lspnoyalib-lsp
noyalib-mcpnoyalib-mcp@sebastienrousseau/noyalib-mcpsebastienrousseau/noyalib-mcp
noyalib-wasmnoyalib-wasm@sebastienrousseau/noyalib-wasm
noyalib-serde-yamlnoyalib-serde-yaml

All six crates release in lockstep at the identical =0.0.X; the npm packages and container images carry the same version. Homebrew, Scoop, and AUR carry noya-cli (see its pkg/). A repository’s GitHub Packages sidebar counts only GitHub-hosted registries, so it is not the map; this table is.

Architecture Decision Records

This directory holds the architectural decisions that shape noyalib — the choices that would be expensive to reverse and that contributors should understand before proposing structural changes.

Format

Every ADR uses Michael Nygard’s format — see TEMPLATE.md. Sections are:

  • Status: proposed / accepted / superseded / deprecated
  • Context: what forces are at play
  • Decision: what we’re doing
  • Consequences: what becomes easier and harder

ADRs are immutable once accepted — if a decision changes, the old ADR moves to “superseded” and a new ADR is added with a reference back. Nothing is silently rewritten.

Index

#TitleStatus
0001CST shape: parallel green tree, not unifiedaccepted
0002YAML 1.2 strict semantics by default; 1.1 opt-inaccepted
0003#![forbid(unsafe_code)] workspace-wideaccepted
0004Opt in to lossless u64 integersproposed
0005Split workspace into 4 satellite repositories with strict-lockstep versioningaccepted
0006Opt in to literal text for a String/char target reading a plain scalarproposed
0007Add prefer_single_quotes as an opt-in serializer config flagaccepted
0008CompiledSchema: compile a JSON Schema once, validate manyaccepted
0009Document::set_path: parent-creating writes in the CST editoraccepted
0010set_value accepts collections, emitted in the target node’s styleaccepted
0011Flow-collection inserts, flow renames, and one anchored-node policyaccepted
0012Bracket-quoted key segments in the query-path grammaraccepted
0013Located duplicate-key and key-collision errors as sibling variantsaccepted
0014Share one MSRV floor, 1.89.0, with the maintainer’s other cratesproposed

When to add an ADR

Add one when you’re about to make a decision that:

  • Is hard to reverse (changes the data model, public API surface, dependency floor, or core invariants like the unsafe policy)
  • Will surprise a future contributor reading the code
  • Has plausible alternatives that someone might propose later

Don’t add ADRs for routine implementation choices — those go in commit messages and code comments. The bar is “would I want a new contributor to read this before proposing the opposite?”

When not to add an ADR

  • The decision is captured by the type system or a test
  • The decision is implicit in well-known Rust idiom
  • The decision is genuinely reversible without consequence

0001. CST shape: parallel green tree, not unified AST

  • Status: accepted
  • Date: 2026-04-30
  • Authors: Sebastien Rousseau

Context

noyalib has two competing demands on its parse output:

  1. Data binding for application code. from_str::<MyConfig>(s) should be fast, allocate little, and surface a strongly-typed value. The user does not care about whitespace, comments, or indentation choices — they want their struct.
  2. Lossless tooling. noyafmt, noyavalidate --fix, the LSP server, and the MCP server all need to mutate one logical value in a YAML file without disturbing comments, indent style, blank lines, or sibling formatting. A round-trip through from_str / to_string will erase all of that — by design, since the YAML data model excludes formatting.

A naïve unified CST (one tree carrying both data and trivia) would tax every consumer in case 1 with the cost of case 2 — extra allocation, extra walk depth, extra match arms. That’s the design mistake serde_yaml 0.9 avoided by simply not having a CST; it’s also what made retrofitting one impossible.

rust-analyzer’s rowan and taplo’s TOML implementation both solved this by splitting the parse output: a green tree (immutable, byte-faithful, structurally shared) with a thin red tree layer for node identity. Application code that only wants typed data never touches the green tree; tooling code does.

Decision

noyalib parses to two parallel outputs:

  • Value / BorrowedValue / T via Deserializer — the data binding path. Fast, lean, allocates only what the data demands. Used by 95%+ of consumers. No formatting, no comments, no spans unless explicitly opted in via Spanned<T>.
  • noyalib::cst::Document — the lossless CST path. Backed by a rowan-shaped green tree that retains every byte of the source. Mutation rewrites the tree structurally and re-emits byte-for-byte where untouched, minimally where edited.

Both paths share the same scanner token stream — there is no second parser. The CST is constructed from the token stream lazily when cst::parse_document is called; ordinary from_str users never pay for it.

Consequences

Positive:

  • Application code stays the size it is — from_str::<T> benchmarks unchanged from the no-CST baseline.
  • Tooling code gets a real CST instead of a YAML-as-strings parser fork; comments, indent style, and blank lines are all preserved byte-for-byte.
  • One source of truth for parsing. The scanner runs once; the green tree and the value tree both consume its output.
  • The CST is a stable foundation for ongoing scanner strictness work — fixes to indentation rigor land once, both consumers benefit.

Negative:

  • Two trees instead of one means two construction paths to maintain. Drift risk: a scanner change that affects token boundaries must be reflected in both the loader (Value path) and the green-tree builder (CST path). Mitigated by a per-PR cst_round_trip integration test that asserts byte equality on every spec-suite case.
  • The CST API is more complex than a pure data-binding surface. Users who try to use cst::Document for read-only access get a more verbose surface than Value; the docs steer them away from this.
  • Memory: the green tree is allocated structurally per source byte. For 1 MB inputs that’s roughly 2× the input size. The mutation API uses structural sharing so edits don’t double again, but raw parse-and-hold cost is real.

Neutral:

  • The CST module sits behind the std feature (uses thread-local storage for span attachment). no_std users get the data path only — which is what they wanted anyway.

Alternatives considered

Unified CST

One tree carries both typed data and trivia. Application code walks the tree to reach values; tooling code walks the same tree to reach formatting. Rejected because it taxes every from_str caller with the trivia overhead, and because most YAML use is data-binding — the heavy users would be paying for the tooling users’ feature.

String-rewriting tooling without a CST

Tooling could re-implement a partial parser that finds the byte range of a value, swaps the bytes in place, and skips full parsing. This is what yq does. Rejected because it duplicates the parsing logic across two implementations, drifts in subtle ways, and can’t handle structural edits (insert / delete a sibling, change a value’s quoting style) without reproducing the full scanner anyway. We’re already the parser — we should be the editor too.

Span-augmented Value (no separate tree)

Add (start, end) byte ranges to every Value node and call that a “CST”. This is what serde_path_to_error does for error reporting. Rejected because spans alone don’t preserve trivia — no comments, no blank lines, no indent choices — so it doesn’t solve the lossless-tooling case. We do offer this in a lighter form via Spanned<T> for diagnostic use cases that don’t need full fidelity.

References

  • Detailed design note: docs/design/green-tree.md (~480 lines, written 2026-04-30)
  • rowan source-of-truth implementation: https://github.com/rust-analyzer/rowan
  • taplo TOML CST: https://github.com/tamasfe/taplo
  • noyalib::cst::Document API: crates/noyalib/src/cst/document.rs
  • Round-trip lock test: crates/noyalib/tests/cst_round_trip.rs

0002. YAML 1.2 strict semantics by default; 1.1 opt-in

  • Status: accepted
  • Date: 2026-05-06
  • Authors: Sebastien Rousseau

Context

YAML 1.1 (2005) and YAML 1.2 (2009) disagree on plain-scalar resolution in three places:

FormYAML 1.1YAML 1.2 (core schema)
yes / no / on / offboolstring
0644 (bare zero prefix)int 420 (octal)int 644 (decimal)
60:00 (colon-separated)int 3600 (sexagesimal)string

The 1.1 lexicon is responsible for the Norway problem: the country code for Norway is NO, so YAML 1.1 silently coerces country: NO to country: false. This has been a real-world data-loss bug in production for over a decade.

YAML 1.2 narrowed the boolean lexicon specifically to fix this. But existing 1.1 documents — Ruby YAML configs, pyyaml output, crontab-style time-of-day notation — exist in the wild and cannot be unilaterally re-parsed under 1.2.

The library has to default to one of these. Defaulting to 1.1 preserves quiet bug-compatibility with old configs but ships a known correctness hazard. Defaulting to 1.2 surfaces the correctness hazard at parse time but breaks documents that genuinely intended 1.1 semantics.

Decision

noyalib defaults to YAML 1.2 strict semantics. Plain scalars follow the core schema:

  • yes / no / on / off → string
  • 0644 → integer 644 (decimal)
  • 60:00 → string

YAML 1.1 semantics are available as an opt-in via ParserConfig::version(YamlVersion::V1_1), which flips three fine-grained flags as a bundle:

#![allow(unused)]
fn main() {
cfg.legacy_booleans      = true;  // yes / no / on / off
cfg.legacy_octal_numbers = true;  // 0644 → octal
cfg.legacy_sexagesimal   = true;  // 60:00 → 3600
}

The fine-grained flags remain available so callers can mix and match — e.g. “1.1 booleans but reject octal 0644”.

%YAML 1.1 directives in the document body are recorded but do not auto-enable 1.1 mode. Honouring them silently would be the exact silent-coercion failure mode 1.2 was created to fix; users who want directive-driven version selection can layer it on top of ParserConfig.

Consequences

Positive:

  • The Norway problem cannot bite a noyalib user by default. country: NO is a string, and stays a string.
  • The 1.2 spec is the active spec — defaulting to it aligns noyalib with the YAML committee’s stated direction.
  • Migration from 1.1-era libraries is a single version() call, not a full rewrite. The opt-in is loud but cheap.

Negative:

  • Documents that genuinely use 1.1 sexagesimal (crontab: 1:30) silently parse as strings under default settings. Users who expect 1.1 must explicitly opt in. This is the trade-off the decision makes — surfacing the discrepancy beats coercing it.
  • Double documentation: the README has to explain both modes, and the per-feature flags exist independently of the version preset, so the surface is slightly larger.

Neutral:

  • noyalib accepts certain 1.1-tolerant forms regardless of version (e.g. tab-relaxed indentation in flow contexts) where doing so does not introduce silent data loss. The boundary is “would this cause a Norway-style coercion?” — if no, accept; if yes, gate behind the version flag.

Alternatives considered

Default to 1.1 for compatibility

Match serde_yaml 0.9’s behaviour and accept the Norway problem as a documented quirk. Rejected — serde_yaml’s archival in 2024 was partly driven by the inability to fix this without breaking its existing user base. noyalib gets the freedom to fix it; declining would be an own goal.

Auto-detect from %YAML 1.1 directive

Read the first directive in the stream and auto-enable 1.1 mode when present. Rejected because documents commonly carry stale directives — a %YAML 1.1 from 2008 in a file that now expects 1.2 behaviour is worse than the manual opt-in. The directive is recorded for inspection; auto-application would be silent coercion in disguise.

Per-call version override only (no default)

Force every caller to specify a version explicitly. Rejected because the 99% case is “I have a YAML file, parse it” — adding mandatory version selection is friction that yields no correctness benefit for the ~95% of users on 1.2-compatible documents.

Strict 1.2 only, no 1.1 mode at all

Drop legacy_* flags entirely; force all users to migrate. Rejected because YAML 1.1 documents exist and noyalib’s selling point is being able to parse them losslessly. Refusing to handle them would push users back to serde_yaml’s archived fork.

References

  • YAML 1.2 spec, §10.2: https://yaml.org/spec/1.2.2/#102-tags
  • Norway problem write-up: https://hitchdev.com/strictyaml/why/implicit-typing-removed/
  • Implementation: crates/noyalib/src/de.rs — YamlVersion, ParserConfig::version
  • Test coverage: crates/noyalib/tests/yaml_version.rs (11 tests)
  • Commit that introduced version(): 1cffa2c

0003. #![forbid(unsafe_code)] workspace-wide

  • Status: accepted
  • Date: 2026-04-30
  • Authors: Sebastien Rousseau

Context

YAML parsing is a fertile ground for memory-safety bugs. The C reference implementation libyaml has had multiple CVEs over the years involving heap overruns, use-after-frees, and integer overflows in the scanner state machine. Even pure-Rust implementations have shipped subtle UB bugs — in 2023, a widely-used Rust YAML parser carried an unsafe block that violated stacked-borrow rules and only surfaced under Miri.

noyalib is positioned as the safe replacement. Allowing even one unsafe block — anywhere in the workspace — would compromise the core promise. Worse, it would invite “just one small unsafe for performance” arguments later, and the policy would erode by a thousand exceptions.

The cost of forbidding unsafe is real but bounded: parser hot paths can still use SIMD via memchr (which contains audited unsafe internally but exposes a safe API), and the SWAR numeric parser uses pure-safe u64 packing rather than unaligned-load tricks.

Decision

Every crate in the workspace begins with:

#![allow(unused)]
#![forbid(unsafe_code)]
fn main() {
}

This is enforced at the lint level (forbid, not deny) so the attribute cannot be locally disabled with #[allow]. Any PR that introduces an unsafe block — or even a deny-level attribute that could be locally bypassed — will fail to compile on every target.

Performance-critical code reaches for SIMD and unaligned operations through memchr and well-vetted dependencies whose own unsafe is audited externally. noyalib’s own code stays provably safe.

The policy applies workspace-wide:

  • noyalib (core library) — forbidden
  • noyalib-lsp — forbidden
  • noyalib-mcp — forbidden
  • noyalib-wasm — forbidden (the JsValue glue uses wasm_bindgen’s safe wrappers)
  • noya-cli — forbidden
  • xtask — forbidden

No exceptions, no gates, no profiles where it’s relaxed.

Consequences

Positive:

  • The headline compliance claim (“zero unsafe”) is mechanically enforced. A reviewer or auditor running rg "unsafe" against the workspace will find no matches in non-test code, period.
  • Memory-safety bugs in parser code become impossible-by-construction. noyalib could still have logic bugs (off-by-ones, infinite loops, panics on malformed input), but it cannot have use-after-free, double-free, data races, or buffer overruns in its own code.
  • Audit cost evaporates. A FIPS / DoD / supply-chain reviewer who would otherwise have to vet every unsafe block can short-circuit on the forbid(unsafe_code) attribute.
  • cargo geiger reports zero unsafe lines for noyalib, improving its score on supply-chain dashboards.

Negative:

  • Some optimisations are off the table. A handwritten unaligned SIMD load could shave ~10% off the structural-bitmask scan; we don’t get that. Mitigation: the SWAR fallback and memchr cover the realistic upper bound; the perf bench numbers versus serde_yaml_ng show we’re not leaving meaningful throughput on the floor.
  • Some idioms require workarounds. Self-referential structs are out; recursive descent uses heap allocation where some C parsers use stack tricks. We pay 1× recursion overhead per level for this; in practice it’s noise.
  • Dependency choices are constrained. We cannot pull in a crate whose public API is unsafe fn even if its internals are sound, because the wrapper would force noyalib’s surface to relax. Mitigation: every direct dep we need (memchr, indexmap, rustc-hash, itoa, ryu, smallvec) has a safe public API.

Neutral:

  • The Miri test pass becomes evidence about dependencies, not about noyalib itself. UB found by Miri must be in a dep — which is still useful to know but reframes what a Miri failure means.

Alternatives considered

deny(unsafe_code) instead of forbid

deny allows local override via #[allow(unsafe_code)]. Rejected because the whole point is mechanical enforcement that can’t be silently bypassed in a single file. forbid makes the attribute a hard compile-time gate.

Allow unsafe in performance-critical paths only

Carve out parser/scanner.rs for SIMD intrinsics and forbid elsewhere. Rejected because (a) the SIMD perf wins via unsafe_code are bounded, (b) the audit story degrades from “zero unsafe” to “12 unsafe blocks, here’s why each one is sound” — a worse pitch and a worse maintenance burden, (c) the exception would invite scope creep.

Allow unsafe only in xtask / build tooling

These crates don’t ship in user binaries, so an unsafe exception would be invisible to the security-conscious user. Rejected because the contributor experience matters too — every contributor reading the codebase should see forbid(unsafe_code) and understand it’s universal.

Use cap-std or sandboxed toolchains for runtime safety

These help against bugs in dependencies, not in noyalib itself. Orthogonal to the policy. Considered for the CLI / LSP deployment story but not for the core library.

References

  • The lint: https://doc.rust-lang.org/rustc/lints/listing/allowed-by-default.html#unsafe-code
  • Forbid vs deny: https://doc.rust-lang.org/rustc/lints/levels.html#forbid
  • cargo-geiger: https://github.com/rust-secure-code/cargo-geiger
  • Workspace lint config: each crate’s [lints.rust] table sets unsafe_code = "forbid" so cargo clippy --all-targets enforces it across tests, examples, and benches as well as src/.

0004. Opt in to lossless u64 integers

  • Status: proposed
  • Date: 2026-06-22
  • Authors: Noyalib contributors

Context

YAML 1.2 integer scalars are not limited to Rust’s i64 range. noyalib’s public Number model is currently:

#![allow(unused)]
fn main() {
pub enum Number {
    Integer(i64),
    Float(f64),
}
}

That shape leaves no lossless representation for unsigned integer scalars in (i64::MAX, u64::MAX]. The gap appears in several paths:

  • Serializer::serialize_u64 rejects u64 values above i64::MAX.
  • Number::from(u64) stores large values as Float(v as f64), losing precision above 2^53.
  • Plain scalar resolution tries i64 first and then falls through to f64, so 18446744073709551615 becomes a float-shaped value instead of a YAML integer.
  • Value’s serde bridge casts visit_u64 through i64, which wraps large unsigned values.

The repository already has a safe, tested parse_decimal_u64 implementation in crates/noyalib/src/simd.rs, so the parser does not need new dependencies or unsafe code. The hard part is the public API: Number is exported directly and through compatibility modules, and migration docs currently describe the model as the serde-yaml-like Integer / Float split.

Decision

We will add lossless unsigned integer support as an opt-in surface.

When the lossless-u64 Cargo feature is enabled, Number gains:

#![allow(unused)]
fn main() {
#[cfg(feature = "lossless-u64")]
Unsigned(u64)
}

The existing Integer(i64) variant stays in place. We will not rename it to Signed, because keeping the variant name minimizes source churn for callers that already construct or match signed integers.

The feature is dependency-free and is not enabled by default. Runtime behavior is controlled by explicit configuration so default parser and compatibility behavior remain stable. The compatibility shim for serde_yaml keeps the legacy Integer / Float contract and does not opt into Unsigned values.

When both compile-time and runtime opt-ins are active:

  • u64 values in (i64::MAX, u64::MAX] serialize as plain YAML integer scalars.
  • Plain and explicitly tagged !!int scalars in that range parse as unsigned integers rather than floats or strings.
  • Typed u64 deserialization round-trips those values losslessly.
  • Values above u64::MAX remain outside the integer model and must not be rounded into u64.

Consequences

  • Positive: identifiers, counters, and other legitimate unsigned YAML integers can round-trip without application-level string workarounds.
  • Positive: the implementation can reuse existing safe integer parsing primitives and preserves the workspace-wide zero-unsafe invariant.
  • Positive: the default API and compat-serde-yaml migration story remain stable for callers that do not opt in.
  • Negative: --all-features builds expose a third Number variant. Because Number is not #[non_exhaustive], exhaustive downstream matches that enable this feature must add an Unsigned arm.
  • Negative: parser, loader, streaming deserializer, borrowed AST, serde bridge, schema helpers, and tests all need coordinated updates to avoid reintroducing a lossy fallback.
  • Neutral: migration and architecture documentation must describe the default Integer / Float model separately from the opt-in Unsigned model.

Alternatives considered

Always add Unsigned(u64)

This would make all valid u64 scalars lossless by default. Rejected for this release because it changes parser output and the public Number enum shape for every user, contradicting the current migration docs and SemVer policy.

Rename Integer(i64) to Signed(i64)

This is more explicit, but it breaks every caller that constructs or matches Number::Integer. Rejected in favour of adding Unsigned(u64) while keeping the established signed variant name.

Store large unsigned integers as strings

This avoids a public enum change, but changes the YAML type from !!int to !!str and forces callers to recover numeric intent out of band. Rejected because the goal is lossless integer serialization, not lossless text preservation.

Continue using Float(f64) for overflow

This preserves the current API, but it is lossy above 2^53 and makes valid YAML integer scalars indistinguishable from floating-point data. Rejected because it is the bug this ADR addresses.

References

  • YAML 1.2.2 core schema: https://yaml.org/spec/1.2.2/#102-tags
  • docs/POLICIES.md — SemVer, feature, and zero-unsafe policies.
  • docs/adr/0002-yaml-1.2-default.md — precedent for explicit runtime opt-in when compatibility and correctness trade off.
  • docs/adr/0003-zero-unsafe-policy.md — safe numeric parsing requirement.

0005. Split the noyalib workspace into 4 satellite repositories with strict-lockstep versioning

  • Status: proposed
  • Date: 2026-07-01
  • Authors: Sebastien Rousseau

Context

Since v0.0.1 (2026-05-04) noyalib has shipped as a Cargo workspace containing five publishable crates:

  • noyalib — the core YAML 1.2 library
  • noya-cli — the noyafmt + noyavalidate CLI binaries
  • noyalib-lsp — the Language Server Protocol implementation
  • noyalib-mcp — the Model Context Protocol server
  • noyalib-wasm — the browser / WebAssembly bindings

The monorepo shape has served the launch phase well. Every cross-cutting hardening pass (CI cache-poisoning guard in v0.0.11, supply-chain refresh in v0.0.9, no_std fix in v0.0.11) lands as one PR touching every crate that needs to change. Every release is a single tag that publishes five crates in lockstep. Every issue and pull request lands in one tracker with one label taxonomy.

The forces now pushing back on the monorepo shape:

  • Contributor surface confusion. A contributor filing a bug against the LSP has to know that the LSP lives under crates/noyalib-lsp/ in a bigger repo whose root README talks primarily about the core library. Different downstream audiences (LSP consumers via editor plugin marketplaces, MCP consumers via AI-agent frameworks, WASM consumers via npm, CLI consumers via Homebrew / apt / snap) each want their own repo home for issue triage and release cadence visibility.
  • Downstream dependency surface. Even though noyalib’s packaged .crate archive on crates.io does not include the satellite source (per include = [...] in crates/noyalib/Cargo.toml), a downstream user cloning the git repo to audit or contribute pulls in the whole workspace. For downstream security scanners that trace repo provenance as part of the supply-chain graph, the repo-level footprint matters.
  • Independent release visibility. All five crates ship at the same version because that’s the shape lockstep in a workspace enforces. A downstream user who consumes only noyalib-wasm from npm has no visible signal that a v0.0.11 release happened because the wasm crate itself changed; every noyalib patch bumps their wasm dep even when the wasm source was untouched.
  • Repository-level metrics. Per-satellite stars, watchers, forks, Scorecard scores, and issue-triage SLAs are useful signals to satellite maintainers (once the satellites have co-maintainers) and to downstream consumers evaluating the health of the specific component they depend on. The monorepo aggregates all of these under one number.

Against those forces sit the counter-arguments that were weighed and rejected (see Alternatives):

  • The technical “footprint” argument for downstream users of noyalib is already addressed by include = [...]. Splitting does not shrink the packaged .crate archive.
  • Cross-cutting hardening becomes N-PR coordination unless it is factored into shared reusable workflows before the first split.
  • Lockstep coordination becomes N-repo release orchestration unless it is factored into a shared release workflow.

The forces balance in favour of splitting only if the shared-workflow scaffolding lands before the pilot, and only if the versioning contract is chosen to preserve the audit clarity that lockstep provides today. The remainder of this ADR formalises both.

Decision

We WILL split the noyalib workspace into 5 repositories under the sebastienrousseau/ org:

  • sebastienrousseau/noyalib — the core library (this repo, kept as-is after the split, with the satellite crates/*/ directories removed)
  • sebastienrousseau/noyalib-wasm — WebAssembly bindings
  • sebastienrousseau/noyalib-mcp — Model Context Protocol server
  • sebastienrousseau/noyalib-lsp — Language Server Protocol
  • sebastienrousseau/noya-cli — CLI binaries (noyafmt, noyavalidate)

The split proceeds as a staged pilot over four release windows to catch failure modes early:

  • v0.0.12 — pilot: noyalib-wasm. Smallest satellite, no intra-workspace dependents on it, cleanest split.
  • v0.0.13 — noyalib-mcp. Same playbook.
  • v0.0.14 — noyalib-lsp.
  • v0.0.15 — noya-cli (bundled with xtask). Largest satellite. Core is officially single-crate after this ships.

Each split window includes a 14-day soak review at its close. No subsequent split begins until the prior window records a GO signal. If any window records a NO-GO, the rollback recipe (see below) restores the monorepo shape and the split project is paused pending a full retrospective.

Versioning contract

We WILL use strict lockstep versioning across all 5 repositories. Every satellite version equals noyalib’s version at the moment of release. All 5 crates release simultaneously with a single coordinated tag push in the release-orchestration workflow.

Cross-repo dependencies MUST use exact-match syntax:

# In noyalib-lsp/Cargo.toml — required
noyalib = "=0.0.42"

A regression check MUST fail CI if a caret (^), tilde (~), or floating-range (>=, <) spec appears on any published branch of any satellite.

Cross-repo dependency posture

Satellites MUST NOT use path = "../noyalib" overrides on any published branch. Local development against an unpublished noyalib requires a [patch.crates-io] block in the satellite’s Cargo.toml, gated by a [cfg(local-dev)] convention that is never merged to main.

Shared reusable workflows

Every satellite’s .github/workflows/ MUST consume the parent repo’s shared workflows via uses: references — never by copy. Shared workflows are added to this repo under .github/workflows/shared-<name>.yml with a workflow_call trigger and pinned by SHA in each satellite. A hardening pass lands as one PR in this repo plus N (small, Dependabot-managed) SHA-bump PRs in the satellites.

Caller-side permissions must be at least as broad as every consumed workflow. When the callee declares a permissions: scope the caller does not, GitHub Actions rejects the entire workflow with a 0s startup_failure — no jobs are scheduled and no annotation is emitted. The parent repo’s own ci.yml sets permissions: read-all and never hits this. Satellites narrow to the specific scopes their consumed workflows demand:

Consumed workflowRequired caller permissions
shared-cargo-deny.ymlcontents: read
shared-cargo-vet.ymlcontents: read
shared-cargo-machete.ymlcontents: read
shared-reuse.ymlcontents: read
shared-rustdoc-strict.ymlcontents: read
shared-test-matrix.ymlcontents: read
shared-verify-signatures.ymlcontents: read and pull-requests: read

The pull-requests: read gap was hit by the v0.0.12 pilot (noyalib-wasm PR #1); root-cause + fix documented there. New satellites MUST copy the union of required permissions into their top-level ci.yml permissions: block or they will startup-fail.

Rollback recipe

The rollback recipe MUST be executable in ≤60 minutes of maintainer time from a clean scratch clone. Concretely:

  1. git clone this repo at the pre-split tag (v0.0.11 or the most recent monorepo tag)
  2. git remote add satellite-<name> <split-repo-url> for each split satellite
  3. git fetch --tags satellite-<name> for each
  4. git subtree add --prefix=crates/<name> satellite-<name> main for each satellite that had accepted commits post-split
  5. Restore workspace membership in the root Cargo.toml from the pre-split state
  6. cargo test --workspace to confirm the restored shape is green
  7. Cut a v0.0.16 release from the restored workspace
  8. Mark each satellite crate on crates.io as yanked at every version >= the rollback point (yank is not deletion; the monorepo re-takes precedence)
  9. Update the parent repo README to note the rollback and cite the failing acceptance criterion

The pre-flight dry-run of this recipe MUST be exercised at least once (in a scratch clone against a mock satellite) as part of the pilot’s pre-work, and the wall-clock time MUST be recorded in the ADR update section below.

Naming reservations

noyalib-wasm@0.0.0 has been published as a namespace reservation (2026-07-01) to close the pre-split race window for the only unclaimed name. noyalib, noyalib-mcp, noyalib-lsp, and noya-cli are already owned by sebastienrousseau from the v0.0.11 release. Ownership drift is monitored daily via crates-io-ownership.yml.

Consequences

Positive

  • Downstream contributors reach the right repo for the component they care about; issue triage lives at the correct level of granularity.
  • Each satellite carries its own OpenSSF Scorecard score, its own supply-chain gates, its own security policy — a security reviewer auditing noyalib-mcp sees only the MCP surface, not the LSP or CLI surface they will never use.
  • Per-satellite release history is separately auditable — a downstream user chasing “when did this LSP behaviour change” reads only the noyalib-lsp git log, not a 10x-noisier monorepo log.
  • The v0.0.11 CI cache-poisoning guard, docs-strict gate, and README-examples gate propagate to all 5 repos via reusable workflows — a security-first invariant enforced by one authority.
  • Each satellite has its own Renovate / Dependabot config, scoped to its own dep tree — downstream security scanners observe a tighter blast radius per component.

Negative

  • Every hardening pass now touches 5 repos: 1 PR in the parent (the shared workflow bump) + 4 Dependabot PRs in satellites. For maintainer time this is roughly break-even with the monorepo shape (single-repo PR touching 5 crates), but the wall-clock latency is higher because each satellite’s Dependabot has its own cycle.
  • Every release cuts 5 tags across 5 repos in a coordinated ceremony. A shared release-orchestration workflow makes this one command, but the failure surface is 5x per release: any single satellite failing to publish requires the whole cohort to be re-tagged.
  • Cross-repo issues (an LSP bug caused by a scanner change in noyalib) require cross-linking across repos, which GitHub’s issue-linking model does not enforce.
  • Contributors mid-split (e.g. #91 @EdJoPaTo, #117 @canardleteer) need clear guidance on which repo their commits belong in. Migration guides in MIGRATION.md handle this but require active maintainer bandwidth during the split window.
  • Rollback is possible (≤60min) but costs credibility if exercised — announcing a rollback for a shipped release is a publicly visible admission of a bad decision. Mitigation is the strict pilot-then-observe cadence with a 14-day soak gate between each split.

Neutral

  • Repo count 1 → 5. Star count / watcher count / community metrics fragment; the sum stays the same but no single number represents the project any more.
  • CODEOWNERS, SECURITY.md, deny.toml, REUSE.toml, osv-scanner.toml duplicate across all 5 repos. This is a shared-workflow problem to solve; documented under #127.
  • Downstream cargo add noyalib-lsp behaviour is unchanged: the crate name is stable, the API surface is stable, the use noyalib::... paths are unchanged.

Alternatives considered

Keep the monorepo

Would have kept the current shape indefinitely. Rejected because:

  • Contributor-surface confusion accumulates as the project’s audience diversifies (browser, MCP, LSP, CLI) — the monorepo optimises for maintainer clarity at the cost of downstream clarity.
  • Per-satellite Scorecard, security-policy, and issue-triage granularity is a hard requirement for the components that ship to security-conscious downstreams (LSP, MCP).
  • Not splitting means the security-review surface grows without bound as the workspace accepts more satellites.

Independent SemVer (posture B from #128)

Would have let each satellite version on its own cadence, pinning noyalib to a caret range (noyalib = "^0.0"). Rejected because:

  • Introduces a compatibility matrix — the downstream question “which noyalib × noyalib-lsp combo actually works” becomes a table to consult on every upgrade.
  • Slower security-patch propagation: a CVE in noyalib does not force a coupled satellite re-release, so downstreams pinning noyalib-lsp = "0.0.9" might not pick up the noyalib patch until they Dependabot-bump the LSP separately.
  • Contradicts the security-first / audit-clarity posture of the project (documented in the security-first-posture memory).

Hybrid (posture C from #128)

Would have made noyalib patches transparent to satellites but required a coupled satellite patch on every noyalib minor. Rejected because:

  • Adds a decision-point (“is this noyalib change patch-shaped or minor-shaped?”) that requires an active call at every release. Lockstep and independent-semver both remove this decision-point; hybrid re-introduces it.
  • The edge-case surface is real: a noyalib patch that subtly breaks a satellite’s assumptions is possible under any posture, but hybrid papers over it by refusing to bump the satellite. Under strict lockstep the same patch forces a satellite re-release that gets soak-tested end-to-end.

One repo per satellite, but keep noyalib in this repo

Would have moved noyalib-wasm, noyalib-mcp, noyalib-lsp, noya-cli, and xtask to their own repos while leaving the core library in sebastienrousseau/noyalib. This is the shape we ARE adopting; it’s noted here only to distinguish it from “one repo per crate including a fresh repo for noyalib core” which would have added migration cost for downstream users of noyalib itself without any offsetting benefit.

References

  • Pre-work issues: #125, #126, #127, #128
  • Split issues: #129 (v0.0.12 pilot), #130 (v0.0.13), #131 (v0.0.14), #132 (v0.0.15)
  • Post-split cleanup: #133 (MIGRATION.md), #134 (retire monorepo tooling)
  • Related PR: #135 — crates.io ownership-drift regression harness closing #125 AC5
  • noyalib-wasm@0.0.0 namespace reservation: https://crates.io/crates/noyalib-wasm
  • Prior art: tokio uses a workspace for the reasons this ADR chose to move away from; serde uses a workspace but ships serde_derive and serde_json separately at independent semver, which was the posture we rejected.

Post-implementation update

Rollback-recipe dry-run — 2026-07-02

Executed the 9-step rollback recipe against a scratch clone mocking a v0.0.12-pilot-shipped state:

  1. git clone git@github.com:sebastienrousseau/noyalib.git at tag v0.0.11 (the pre-split monorepo tip on main).
  2. Mocked the split by extracting crates/noyalib-wasm/’s history via git subtree split --prefix=crates/noyalib-wasm --branch=mock-satellite-wasm — 11 commits pulled out.
  3. Pushed the mock satellite to a local bare repo at file:///tmp/rollback-dry-run/mock-satellite-wasm.git — that stands in for git@github.com:sebastienrousseau/noyalib-wasm.git at pilot completion.
  4. Simulated the post-split working tree: git rm -rq crates/noyalib-wasm + removed the corresponding "crates/noyalib-wasm" line from the root Cargo.toml workspace member list, then committed.
  5. Executed the rollback recipe verbatim from the scratch clone: git remote add satellite-wasm … → git fetch --tags satellite-wasm → git subtree add --prefix=crates/noyalib-wasm satellite-wasm main → restore workspace membership → cargo test --workspace --no-run → smoke cargo test -p noyalib-wasm --tests native.

Wall-clock time from git subtree add through cargo-test-green:

1 min 42 sec (102 s)

Well within the AC target of ≤ 60 minutes. The vast majority of the elapsed time was the cargo test --workspace --no-run compilation on a fresh target dir — the git-level restoration steps (subtree add + Cargo.toml edit + commit) completed in under 5 seconds.

Steps 7-9 of the recipe (cut a rollback release, yank the satellite crate versions on crates.io, update the parent-repo README to note the rollback) were not exercised in this dry-run — they are irreversible publish-time actions covered by the existing release + cargo yank flows and would produce public artefacts inappropriate for a rehearsal.

Assessment: recipe is executable. The bottleneck is cargo compilation, not git plumbing; a real rollback on a runner with a warm cache would land inside 5 minutes.

v0.0.12 pilot — noyalib-wasm split (2026-07-02)

Status: pilot infrastructure landed in sebastienrousseau/noyalib-wasm PR #1. Awaits parent v0.0.12 publish before final CI green.

Concrete results from the pilot:

  • History extraction via git subtree split --prefix=crates/noyalib-wasm produced 11 commits on the new repo’s main. Authorship (per-commit Author: line) preserved intact; commit signatures do not carry through subtree, which matches the ADR’s expectation.
  • Strict-lockstep versioning is mechanically enforced: satellite Cargo.toml pins noyalib = { version = "=0.0.12", features = ["std"] }; Dependabot config explicitly ignores the noyalib dep (see satellite .github/dependabot.yml).
  • Shared reusable workflows are consumed by SHA. First run hit a startup_failure with 0 scheduled jobs because shared-verify-signatures.yml declares permissions: pull-requests: read and the caller only granted contents: read. GitHub Actions rejects the reusable-workflow invocation when the callee’s permission scope exceeds the caller’s, with no annotation surfaced. Documented in the permissions table above; new satellites (v0.0.13 mcp, v0.0.14 lsp, v0.0.15 cli) must copy the union of required permissions.
  • Repository ruleset (signed commits, linear history, 1-approver PR + CODEOWNERS review, required status checks) applied to the new repo’s default branch via API. Matches the parent noyalib ruleset.

v0.0.13 pilot — noyalib-mcp split (2026-07-05)

Status: pilot infrastructure landed in sebastienrousseau/noyalib-mcp PR #1. Awaits parent v0.0.13 publish before final CI green.

Concrete results from the pilot:

  • Playbook applied line-for-line from v0.0.12; the only new work was the multi-channel release workflow (crates.io + npm wrapper + GHCR + MCP Registry) which extends the v0.0.12 crates-only+npm pattern with two additional channels.
  • Subtree extraction via git subtree split --prefix=crates/noyalib-mcp produced 14 commits on the new repo’s main. Authorship preserved.
  • Caller-side pull-requests: read permission included in the satellite’s ci.yml from day one (v0.0.12 pilot’s hard-earned lesson, applied preemptively — no startup_failure this time).
  • Registry manifests (server.json, glama.json) moved from parent root to satellite root, with URLs rewritten to point at the satellite repo. Version-locked to the crate via the release workflow’s validate job.
  • npm wrapper renamed to @sebastienrousseau/noyalib-mcp matching the v0.0.12 pilot’s scope decision (no @noyalib npm org exists).

Soak review signals

The 14-day soak reviews at each split window will record their GO / NO-GO signal here as an inline update rather than moving the ADR to superseded; this ADR remains the source of truth for the whole split project. If the project rolls back mid-flight, this ADR moves to superseded by [next ADR] and a fresh ADR describes the rollback rationale.

0006. Opt in to literal text for a String/char target reading a plain scalar

  • Status: proposed
  • Date: 2026-08-30
  • Authors: Noyalib contributors

Context

A typed String field asks serde for a string. A plain YAML scalar’s source text is always one, but noyalib’s implicit-typing resolver runs ahead of the target type: password: 123456 against struct Smtp { password: String } resolves 123456 to Scalar::Int/Value::Number first, then both deserializers (streaming.rs’s deserialize_str and de/deserializer.rs’s deserialize_str) refuse it as a “non-string scalar” type mismatch. The same refusal hits ~, null, true, and an empty value. serde_yaml 0.9.34 (measured) does not refuse any of these — a String target receives the scalar’s literal text ("123456", "~", "", …) regardless of what an untyped deserialize_any would have inferred.

Implicit typing is a fallback for untyped targets, not a constraint on an explicitly-typed field, so the refusal is arguably a bug (issue #344). But String fields silently accepting any scalar shape is also an observable behaviour change for every existing caller — a config loader that relied on the refusal to catch a misquoted key (timeout: 30 where "30" was intended) would stop erroring. Two call sites need the same decision: the streaming deserializer’s deserialize_str/deserialize_string/deserialize_char, and the Value-AST Deserializer’s equivalents (reached whenever the streaming fast path is bypassed, or via from_value).

Decision

We will add the behaviour as an opt-in ParserConfig flag, plain_scalar_strings (default false), rather than changing the default contract.

When enabled, a plain scalar deserializes into a String (or char) target as its source text even where the YAML 1.2 schema resolves it to a number, boolean, or null. Off by default, both deserializers keep refusing a non-string plain scalar for a String target — the existing, tested contract. Quoted scalars are strings either way, unaffected by the flag.

The flag threads through parser::ParseConfig (so the streaming deserializer’s self.config.plain_scalar_strings sees it directly, without going through the AST) and through Deserializer’s per-call options (alongside the existing ignore_binary_tag_for_string toggle), propagated through every descent site the latter already covers (descend, ValueSeqAccess, ValueMapAccess). It does not join stream_eligible’s exclusion list in de.rs — unlike ignore_binary_tag_for_string, which only ever mattered on the AST path and therefore forces the AST loader, plain_scalar_strings is consulted directly inside the streaming deserializer, so a document opting into it stays on the fast path.

Consequences

  • Positive: callers migrating from serde_yaml-shaped configs (numeric-looking secrets, environment-style values) get an explicit, documented escape hatch instead of pre-quoting every field or patching the crate.
  • Positive: the default contract — and every existing test in the crate — is untouched. git diff v0.0.28 -- crates/noyalib/tests shows only the new test file.
  • Positive: the flag reaches the streaming fast path directly (via ParseConfig), so opting in does not cost the AST-loader detour the way ignore_binary_tag_for_string does.
  • Negative: another boolean on an already-large ParserConfig; contributors adding a new scalar-resolution edge case must now check two call sites (streaming and AST) plus the propagation structs on the AST side.
  • Negative: the two deserializers cannot round-trip identically under the flag for every input — the AST path loses a null scalar’s original spelling (~/null/empty all format back as "", since Value::Null carries no text) and an integer literal’s non-decimal spelling (0x1F becomes "31", since Number::Integer only stores the parsed value). The streaming path is exact for both because it reads the source text directly. Documented on the AST helper (scalar_as_text) rather than hidden.
  • Neutral: the flag is a scalar-shape decision only; it does not interact with deserialize_any, Option<T>’s null short-circuit, numeric/bool targets, or untagged-enum dispatch, all of which keep routing through deserialize_any unchanged.

Alternatives considered

Change the default contract directly

This is what issue #344 describes as the fix and is closer to serde_yaml’s behaviour out of the box. Rejected for this release: it is an observable breaking change for every caller relying on the refusal as an implicit type check, and CI has no way to distinguish “a config typo now silently stringifies” from “the fix worked.”

Gate the option on stream_eligible (force the AST path)

This would have reused the existing ignore_binary_tag_for_string pattern verbatim — no new code path in the streaming deserializer. Rejected because it taxes every document that opts in with an unnecessary AST-loader parse, and because the two paths already disagree on null/hex text preservation; forcing everything through the AST path would silently make that disagreement the only observable behaviour, hiding the streaming path’s better fidelity.

A separate StrictString newtype wrapper

Callers could wrap fields needing the loose behaviour (StrictString(String)) with a custom Deserialize impl instead of a global config flag. Rejected: it requires touching every affected struct field rather than one parser-config toggle, and does not help callers who don’t control the target type (e.g. deserializing into a third-party struct).

References

  • Issue #344.
  • docs/adr/0004-lossless-u64-integers.md — precedent for an opt-in runtime flag that keeps the default model stable.
  • crates/noyalib/tests/string_target_plain_scalar.rs — the acceptance/guard test suite for this flag.

0007. Add prefer_single_quotes as an opt-in serializer config flag

  • Status: accepted
  • Date: 2026-08-30
  • Authors: Noyalib contributors

Context

write_string quotes a scalar only when its content forces it (a reserved word, a leading -, an embedded :, and so on — see NEEDS_QUOTE_BYTE / FIRST_CHAR_QUOTE), and once quoting is needed it always double-quotes. Several downstream users want single-quoted output for anything a single-quoted scalar can represent — it is easier to read when the content itself contains backslashes or double quotes, and it matches the house style a number of hand-written YAML files already use. There is currently no way to get that without a manual post-process pass over the emitted string.

SerializerConfig already carries several opt-in, purely additive style flags (quote_all, compact_list_indent); a quote-style preference is the same shape of decision.

Decision

We add SerializerConfig::prefer_single_quotes(bool), default false. Output is byte-for-byte unchanged unless a caller sets it.

When set, and write_string has already decided a scalar needs quoting, it single-quotes the scalar (doubling any embedded ') instead of double-quoting it — unless the scalar contains a character single-quoted style cannot carry at all: a control character (tab, CR, LF, the rest of the C0/C1 ranges) or any other non-printable code point. Single-quoted style has no escape mechanism beyond doubling the quote character itself, so such a scalar still double-quotes regardless of the setting. The empty string quotes as '' rather than "" when the option is set.

The flag does not change whether a scalar gets quoted, only which quote style is used once quoting is already required — a scalar that stays plain today (e.g. it's) stays plain either way.

Consequences

  • Positive: callers who want single-quoted output for readability or house-style reasons get it without a post-process string pass.
  • Positive: purely additive — the field defaults to false and no existing caller’s output changes.
  • Negative: one more flag on an already-large SerializerConfig; contributors touching write_string’s quoting decision must keep the single-quote-safety check (single_quote_safe) in sync with write_double_quoted’s escape set.
  • Neutral: quote_all is unaffected — it already always single-quotes regardless of content, which is a separate, pre-existing behavior this ADR does not change.

Alternatives considered

A ScalarStyle::SingleQuoted variant instead of a new flag

SerializerConfig::scalar_style already has a SingleQuoted variant, but it is not consulted anywhere in write_string today (a pre-existing gap, not something this change should paper over by quietly wiring in a different, narrower meaning). Piggy-backing the “prefer single quotes when quoting is needed” behavior onto that variant would conflate two different decisions (“force single-quoted style unconditionally” vs. “prefer it only when quoting was already necessary”) under one flag. A dedicated boolean keeps the new behavior’s scope explicit and reversible without touching the unrelated scalar_style gap.

Always prefer single quotes when safe (no opt-in)

Simpler, but it is a silent output-format change for every existing caller of to_string/to_string_with_config with a default config. Rejected because “default output does not change” is the norm every other SerializerConfig flag added so far has followed.

References

0008. CompiledSchema: compile a JSON Schema once, validate many

  • Status: accepted
  • Date: 2026-08-30
  • Authors: Noyalib contributors

Context

validate_against_schema(value, schema) converts both arguments to JSON and compiles the schema with jsonschema::validator_for on every call. For a one-off check that is the right trade-off; for a content pipeline that validates thousands of documents against a handful of schemas (the reporter validates every page’s frontmatter on every rebuild — #329), it compiles the same schema thousands of times.

Two adjacent gaps compound it:

  • jsonschema is not re-exported, so a caller who wants to compile once must depend on the crate directly and keep its version in step with noyalib’s.
  • Under Draft 2020-12 format is an annotation unless should_validate_formats(true) is set at build time; validate_against_schema exposes no way to set it, so format: date silently accepts 01/15/2024.

Decision

Under the validate-schema feature we add:

#![allow(unused)]
fn main() {
pub struct CompiledSchema { /* jsonschema::Validator */ }
impl CompiledSchema {
    pub fn compile(schema: &Value) -> Result<Self>;
    pub fn builder(schema: &Value) -> CompiledSchemaBuilder;
    pub fn validate(&self, value: &Value) -> Result<()>;
    pub fn iter_errors(&self, value: &Value) -> Result<Vec<SchemaViolation>>;
}
}

CompiledSchemaBuilder carries validate_formats(bool) and with_format(name, impl Fn(&str) -> bool). SchemaViolation is a plain struct: instance path (RFC 6901), the schema keyword that raised the violation, and the message.

validate_against_schema is reimplemented as CompiledSchema::compile(schema)?.validate(value), so the one-shot path and the compiled path are the same code. The hardening pinned by tests/schema_hardening.rs — external $ref refused, recursion bounded — therefore covers the compiled path by construction, and tests/schema_compiled.rs pins it again directly.

We do not re-export the jsonschema crate. The compiled type is the API; a re-export would freeze a third-party crate’s whole surface into ours and make every jsonschema major bump a noyalib breaking change.

Consequences

  • Positive: N validations against one schema cost one compile.
  • Positive: format assertion and custom formats become reachable, opt-in, without touching the default behaviour of validate_against_schema.
  • Positive: structured violations (iter_errors) replace string-parsing the aggregated error message.
  • Negative: three new public types to keep stable (CompiledSchema, CompiledSchemaBuilder, SchemaViolation).
  • Neutral: coerce_to_schema still compiles per call; it walks its own error stream and is not on the hot path this ADR addresses.

Alternatives considered

pub use jsonschema;

Smaller diff, and the issue offered it as acceptable. Rejected: it welds noyalib’s semver to jsonschema’s entire public API, and the builder we expose covers the two configuration points consumers actually asked for.

A schema cache inside validate_against_schema

A keyed global cache would speed up existing callers with no API change, but needs an eviction policy, hashing of arbitrary Value trees on every call, and shared-state reasoning in a crate that otherwise has none. An explicit handle is simpler and faster.

0009. Document::set_path: parent-creating writes in the CST editor

  • Status: accepted
  • Date: 2026-08-30
  • Authors: Noyalib contributors

Context

A comment-preserving frontmatter editor needs to set a nested key whose parents do not exist yet — the writers it replaces create them (#327). Against v0.0.28:

  • set("menu.visible", …) on title: x errors path not found;
  • insert_entry("menu", "visible", …) errors path not found: menu and additionally requires the parent mapping to be non-empty;
  • on parse_document("") both error, so an empty document can never receive its first key through the typed mutators.

Callers can splice a verbatim fragment with set at the deepest existing ancestor, but then every consumer re-derives indentation, style, and quoting that Emit already owns — and the empty-document case has no ancestor to splice at.

Decision

We add Document::set_path(&mut self, path: &str, value: &Value):

  • whole path exists — delegates to set_value (upsert, equal-value no-op, scalar-only replacement);
  • a block-mapping ancestor exists — wraps value in one mapping per missing level and inserts the chain through insert_entry_value, which owns indentation (indent_unit()), quoting (Emit), and the typed-oracle rollback;
  • the document is empty (comments, blank lines, or a bare --- only) — renders the chain with the serializer at the document’s indent unit and appends it after the existing bytes, so a comment header survives its document’s first key. The candidate is parsed and compared against the expected value before the splice; an explicit null root fails that check and is refused byte-identical.

Refusals, all leaving the source byte-identical:

  • an existing segment resolving to a scalar (title.x where title is a string) or a non-root null (filling an implicit null with a mapping stays a set fragment edit for now);
  • a missing segment that is a sequence index — set_path creates mappings, never sequence items;
  • flow or empty-{} ancestors — the flow inserters are #338’s scope; insert_entry_value’s existing guards produce the clean error.

The composition is deliberate: set_path adds no new splice logic. Every byte it writes goes through a mutator that already carries the re-parse guard and the typed-value oracle, so the correctness surface does not grow with the feature.

Consequences

  • Positive: the missing write shape lands (Renovate-style “ensure this nested key”) without callers re-implementing style.
  • Positive: the empty-document dead end is gone.
  • Negative: one more path-addressed mutator whose error catalogue must stay in step with set_value / insert_entry_value.
  • Neutral: flow ancestors and implicit-null parents refuse today; when #338 lands, set_path inherits flow support through insert_entry_value with no signature change.

Alternatives considered

insert_entry_value(.., create_parents: true)

A flag instead of a function. Works, but the common call site reads worse (set_path("menu.visible", v) states the intent), and the empty-document arm does not fit insert_entry_value’s contract of addressing an existing mapping.

Callers splice fragments at the deepest existing ancestor

The status quo. Rejected for the reasons in Context: every consumer re-derives what Emit owns, and parse_document("") has no splice site at all.

0010. set_value accepts collections, emitted in the target node’s style

  • Status: accepted
  • Date: 2026-08-31
  • Authors: Noyalib contributors

Context

set_value was documented scalar-only, so replacing a collection-valued key (tags, menu, media) went through set(path, fragment) with verbatim text — and the caller had to detect the target’s style first, because noyalib::to_string renders block style and splicing - a\n- c over tags: [a, b] fails the re-parse guard (block sequence entries are not allowed in this context, #328). Every consumer re-derived what the emitter owns.

docs/ROADMAP-TO-10.md A4 deliberately keeps set verbatim; this is the typed path, not a change to set.

Decision

set_value accepts Value::Sequence / Value::Mapping when the target node is itself a collection, rendering in the target’s own style:

  • a flow node ([…] / {…}, or any node inside a flow collection) is replaced by the serializer’s single-line flow rendering;
  • a block node is replaced by a block rendering at the document’s indent_unit(), with every continuation line shifted to the old value’s own column.

The span comes from the loader’s span tree (the green resolver mis-spans an indentless tags:\n- a sequence), trimmed of the leading indent it sweeps up. Before any byte moves, the candidate document is parsed and compared against the expected typed value — the document with exactly this path replaced — so a rendering the site cannot hold refuses byte-identically.

Replacing a scalar with a collection stays refused: growing a value onto its own lines is a layout decision set expresses with a fragment. Equal-value writes stay byte no-ops (#337’s rule, already in place, applies unchanged).

Replacing a block collection with a scalar is accepted, and the scalar goes where the collection’s content sat rather than where its span begins. The resolver widens a block collection’s span to its first line so that a read slice is uniformly indented; a scalar spliced over that span would start at the key’s own column, which this parser reads back and other implementations reject. When the collection sat at the key’s own column, which a block sequence is allowed to do, the scalar goes one indent step past the key instead. That is the column remove already picks when it empties a sole entry, and it keeps set_value consistent with what it already does for a scalar that was on its own line: the new value goes where the old value’s content began.

Consequences

  • Positive: doc.set_value("tags", &value) now covers the collection-valued frontmatter keys downstream editors replace, with no style detection in the caller.
  • Positive: the oracle-first ordering means the failure mode is a clean refusal, never a corrupted document.
  • Negative: one more rendering path (replace_collection_value) whose style decisions must stay in step with the serializer’s.
  • Neutral: comments inside the replaced collection go with it — they document bytes that are being replaced; bytes outside the span are untouched and pinned by test.

Alternatives considered

A separate set_collection_value

The issue offered it. One method with a documented collection arm reads better at the call site; the scalar/collection split is a fact about the argument, not two different intents.

Callers render the fragment themselves

The status quo; rejected for the duplication described in Context.

0011. Flow-collection inserts, flow renames, and one anchored-node policy

  • Status: accepted
  • Date: 2026-08-31
  • Authors: Noyalib contributors

Context

Frontmatter corpora write lists as tags: [a, b] and small mappings as menu: {visible: false} — in the reporting consumer’s corpus, 36,113 sequence sites are flow-style — yet every insertion mutator refused flow targets, empty collections had “no entry to anchor indentation on”, a root flow mapping was “stray content”, and rename_key called flow renames “a follow-up” (#338).

Separately, the mutators disagreed about writes inside anchored values: rename_key and the inserters refused (naming the anchor and pointing at materialise_aliases_of), while set_value and remove silently changed every *name and << merge site along with the addressed path.

Decision

Flow splices. Single-line flow collections accept inserts:

  • insert_entry_value on a flow mapping — {a: 1}, {}, or a whole document spelled as one — splices , key: value (no comma into an empty body) before the closing };
  • push_back_value on a flow sequence splices , value before ] ([] receives its first member);
  • insert_after_value splices , value after the addressed item’s span;
  • rename_key renames flow-mapping keys; a new key whose plain spelling would read as flow structure (, [ ] { }) is double-quoted.

Members are rendered flow-safe: strings through the flow-context speller (so b, c and multi-line strings double-quote), collections through the serializer’s flow style. A flow collection spanning more than one line refuses — its separators cannot be located from the span alone, the stance remove already takes. Every flow splice runs under the same snapshot → re-parse → typed-oracle guard as the block paths.

One anchored-node policy: refuse, with the escape hatch. A write into a value that live *name sites share lands at every one of them. All mutators now refuse it the same way — naming the anchor and pointing at materialise_aliases_of — set_value and remove included. An equal-value set_value stays a byte no-op wherever it points: a no-op is harmless.

Refusing was chosen over “accept, alias sites follow” because it is the option that loses no information: a caller who wants the propagation can materialise or edit the anchor deliberately, while a caller who didn’t know about the anchor is protected. It also matches what most of the surface already did — only set_value and remove changed behaviour.

Consequences

  • Positive: the dominant real-world layouts stop being dead ends for typed edits.
  • Positive: the mutators give one answer about anchors instead of three.
  • Breaking (behavioural): set_value / remove inside an anchored value now refuse where they used to silently edit every alias site. Recorded in the CHANGELOG; callers get the materialise_aliases_of guidance in the error.
  • Negative: multi-line flow collections still refuse inserts; the member-per-line layout needs line-ownership rules (#294) to splice well.

Alternatives considered

Accept everywhere, alias sites follow

YAML’s own semantics, and one less refusal — but a silent multi-site edit from a single-path call is exactly the surprise the integrity oracles exist to prevent, and rename_key + the inserters had already picked refusal. Consistency with the safer half won.

Docs-only (leave the disagreement, document it)

The issue floated it as a fallback. Rejected: a policy that differs by method is not a policy.

0012. Bracket-quoted key segments in the query-path grammar

  • Status: accepted
  • Date: 2026-09-04
  • Authors: Noyalib contributors

Context

One grammar, parse_query_path in crates/noyalib/src/path.rs, is behind every path string the crate reads: Value::get_path and query, the borrowed reads, and every path-taking cst::Document method (set, set_value, set_path, remove, rename_key, span_at, key_span, the comment editors, and Entry). It reads . as a segment separator, [n] as a sequence index, * and [*] as wildcards, and .. as recursive descent, and it has no way to say “this text is a key”. A mapping key that contains one of those characters therefore cannot be named at all.

Such keys are common: Kubernetes labels and annotations (app.kubernetes.io/name), version buckets (v0.26-dev), URL paths with an extension, glob patterns. At v0.0.32 the gap is not only a missing feature but a source of silent wrong writes, because the grammar is lenient: remove("a[x]") drops the bracket segment and removes a; set_path("a.") drops the trailing separator and writes a; insert_entry_value on an existing * appends a second * entry, because its existing-key guard covered . and [ only.

Issue #288 met this from the insert side. It fixed the insert anchor by reading the span tree directly and explicitly deferred the rest: “that is the wider question of whether the path grammar should grow an escape or quoting form”. Issue #388 asks that question.

Decision

The grammar gains a bracket-quoted key segment: ["key"] or ['key'] is one mapping key, whatever the text between the quotes contains, and inside either quote style \ escapes the next character. It composes with the rest of the grammar without a separator, the way an index does: items[0]["a.b"].c. The JSONPath convention, so the spelling is already familiar.

Because the change is in the one shared parser, every path-taking API gains it at once, and no method grows a segment-list twin.

Three spelling helpers make the public noyalib::path module, so a caller never composes a quoted segment by hand: quote_key spells one key as a quoted segment, push_key appends a key to a path in whichever form reads back as that key (plain with a . when the grammar would read it as itself, quoted otherwise), and join_keys builds a path from literal keys.

Inside the crate the same helpers replace the two places that composed "{path}.{key}" by hand (insert_entry, insert_entry_value) and the prefix re-renderer set_path uses when it creates a missing level. The ./[ refusal in the two inserters goes: an existing key of any spelling is now an upsert. rename_key’s stricter bracket check accepts a quoted segment and still refuses an unquoted non-index one.

The grammar rejects malformed syntax atomically. An unquoted bracket segment that is neither an index nor *, an unterminated quoted key, a dangling separator, or an unmatched bracket can never be interpreted as a valid prefix. Existing string-based APIs report their ordinary not-found error and leave mutable inputs unchanged.

Consequences

  • Positive: every key is addressable through every API, and the three silent wrong writes above become writes to the named key.
  • Positive: push_key keeps plain keys plain, so paths the crate composes for itself and shows in error messages do not change for ordinary keys.
  • Positive: malformed input cannot silently target an ancestor node.
  • Negative: callers that relied on partial-path recovery must correct the path before querying or mutating a document.
  • Negative: one more form in a grammar that is documented by example rather than by a spec; Path::Display (the diagnostics location type) still prints a key plain, so its output is not guaranteed to parse back as a query path. Making it do so is a separate decision.
  • Neutral: path is now a public module. Path stays re-exported at the crate root; QuerySegment and parse_query_path stay crate-private.

Alternatives considered

A segment-list API beside the string one

set_value_at(&[Segment], value) and twins for every mutator, with a Segment enum of literal key and index. Rejected: it doubles the surface of a dozen methods and leaves Value::get_path, Entry, and the comment editors either untouched or doubled too. A quoting form in the shared parser reaches all of them with one change.

Backslash escapes in dot notation

a\.b for a key holding a dot. Rejected: \ is legal key text today, so the change would not be additive, and a key holding [, ], or * would need the same treatment for each character. Bracket quoting escapes only inside the quotes.

Refuse such keys at the mutators

Detect the misread and error instead of writing. Rejected as the only measure: it stops the wrong writes but leaves the keys unreachable, and callers with such keys on disk (a frontmatter editor, a Kubernetes manifest tool) still have to fall back to a whole-document rewrite.

References

  • #288 – the insert-side fix that deferred this question.
  • #388 – the ask this ADR answers.
  • crates/noyalib/src/path.rs – parse_query_path, quote_key, push_key, join_keys.
  • crates/noyalib/tests/cst_quoted_path_segments.rs.
  • JSONPath bracket notation (RFC 9535 §2.3.1): https://www.rfc-editor.org/rfc/rfc9535#name-name-selector

0013. Located duplicate-key and key-collision errors as sibling variants

  • Status: accepted
  • Date: 2026-09-05
  • Authors: Noyalib contributors

Context

Under DuplicateKeyPolicy::Error a repeated mapping key is refused with Error::DuplicateKey(String), which carries the key’s text and nothing else: duplicate key: name. In a long configuration with many name: or enabled: keys the message does not say which one, while the typed rejections have said where they are since v0.0.30 (site.name: type mismatch: expected string, found sequence at line 2, column 9) and serde_yaml reported the same input as site: duplicate entry with key "name" at line 3 column 3. Error::KeyCollision(String), raised when two distinct-typed keys collapse to one string, has the same gap.

The information is at hand where the refusals fire. The span-aware loader holds the key’s span; the span-less loader sees the key’s byte offset when it pushes the key; both keep the frame stack that frames_path already turns into a dotted path for IntegerOverflow. The streaming deserializer refuses at the key event and has neither, but a typed read that fails there without a location is re-run through the span-aware loader (locate_streaming_error), so it can defer to that loader’s report. Issue #378 asked which shape the located error should take, since either touches the public error enum.

Decision

Two new variants, siblings of the existing ones rather than changes to them:

#![allow(unused)]
fn main() {
Error::DuplicateKeyAt { key: String, path: String, location: Location }
Error::KeyCollisionAt { key: String, path: String, location: Location }
}

path is the dotted path of the entry, key included (site.name, items.0.name); location is where the second key begins. They display as site.name: duplicate key "name" at line 3, column 3 and m.1: distinct mapping keys collide after string conversion: 1 (...) at line 3, column 3.

kind(), code(), the help text, and the i18n summary treat each pair alike, and location() returns the position, so a caller that dispatches on ErrorKind or reads the location sees no difference between the two forms beyond the extra information. The parsers that know the position – the span-aware and span-less loaders, and through them every from_str entry point and cst::parse_document – raise the located forms. locate_streaming_error keeps a located duplicate-key or key-collision refusal from the AST retry whole instead of folding the streaming twin’s message into a DeserializeWithLocation, which would have changed the kind.

The location-less variants stay: they are constructible by callers, documented, and the streaming walker still raises them on the paths that have no AST retry.

This is the precedent UnknownAnchor / UnknownAnchorAt set.

Consequences

  • Positive: an operator reading the error knows which key and where; the message has the shape the typed errors already use.
  • Positive: additive. Error is #[non_exhaustive], so a match outside the crate already has a wildcard arm, and every accessor answers the same for the new forms.
  • Negative: a caller that matched Error::DuplicateKey(_) to detect the refusal and did not also check kind() now misses the located form; kind() was the documented way and stays it.
  • Negative: two more variants on a large enum, and two rules to keep in sync (kind, code, help, i18n) – the sibling pattern’s known cost.
  • Neutral: the span-less loader now keeps one byte offset per open mapping key.

Alternatives considered

Add the fields to DuplicateKey itself

DuplicateKey { key, path, location } would be one variant instead of two, but changes a variant that callers construct and match today: a breaking change for a release line at 0.0.x that has not broken the error enum before. Rejected for now; folding the pairs into one variant is the natural move at the next deliberate break, and this ADR is the record for it.

Wrap the refusal in ParseWithLocation

Reuses an existing located variant with no new surface, but the error would then report ErrorKind::Syntax, and a caller dispatching on ErrorKind::DuplicateKey – the documented way to detect the policy refusal – would stop seeing it. Rejected.

Give the streaming walker its own location and path

Possible (the key event carries a span and the walker holds the input), but the walker tracks no path, and the typed read already retries through the span-aware loader on a location-less error, which reports both. Rejected as duplicate work; the walker’s location-less refusal is superseded by the retry’s.

References

  • #378 – the ask.
  • #353 – the typed rejections’ path prefix, whose shape this follows.
  • crates/noyalib/src/parser/loader.rs – duplicate_key_at, key_collision_at, Loader::frames_path, NoSpanLoader::frames_path.
  • crates/noyalib/src/de.rs – locate_streaming_error.
  • crates/noyalib/tests/duplicate_key_location.rs.

0014. Share one MSRV floor, 1.89.0, with the maintainer’s other crates

  • Status: proposed
  • Date: 2026-10-13
  • Authors: Noyalib contributors

Context

The lockstep family (noyalib, noya-cli, noyalib-lsp, noyalib-mcp, noyalib-serde-yaml, noyalib-wasm) declared 1.86.0, except noyalib-mcp, whose MCP SDK needs 1.88.0. The maintainer’s other Rust crates sit at several floors between 1.56 and 1.92, which makes the support promise different for every crate a user combines.

docs/POLICIES.md §1 says the MSRV moves only when the toolchain the project builds and tests at moves, never for tidiness. Nothing in the family needs 1.89: criterion 0.8 (a dev-dependency) sets the toolchain-driven floor at 1.86, and the MCP SDK sets noyalib-mcp’s at 1.88.

Decision

Every crate in the family declares rust-version = "1.89.0", the floor the maintainer chose for all of his Rust crates. This is a maintainer decision, made explicitly, and is the only raise recorded without a toolchain reason. The rule in docs/POLICIES.md §1 stands for every later raise.

Consequences

  • Positive: one floor across the family and the maintainer’s other crates; noyalib-mcp no longer differs from the rest; the opt-in compare-saphyr feature (1.88+) is now inside the floor.
  • Negative: users on 1.86, 1.87 or 1.88 lose new releases with no technical gain. They can stay on v0.0.57.
  • Neutral: the 12-month guarantee holds (1.89.0 shipped 2025-08-07).

Alternatives considered

Keep 1.86.0 (1.88.0 for noyalib-mcp)

Would have followed the written policy. Rejected by the maintainer in favour of one floor across all of his crates.

References

  • docs/POLICIES.md §1, docs/MSRV-AND-DEPRECATION.md
  • The v0.0.16 raise from 1.85 to 1.86 (its CHANGELOG entry)

Release notes

Per-release narrative notes, newest first. One file per tag, named exactly for the tag it documents — v0.0.17.md is v0.0.17 — so a version is enough to find its notes without consulting this index.

These complement CHANGELOG.md rather than duplicating it. The changelog is the complete, structured record of every release under Keep a Changelog; these files carry the narrative for the releases that had one — why a change was made, what it broke, what to do about it.

Notes exist for v0.0.1 – v0.0.17. From v0.0.18 to v0.0.53 the changelog entries carry that narrative themselves, so there are no separate files; see the changelog for those releases.

From v0.0.54 onward every release has a file again, in a fixed shape: it opens with the ## Highlights ⭐️ bullets the GitHub release page is composed from at release time (the rest of the page, What’s Changed, Checksums and the Full Changelog link, is generated). The release workflow refuses a tag whose Highlights file is missing, and scripts/verify-release-versions.sh checks for it before that.

VersionDateNotes
v0.0.55unreleasedSecurity hardening from the v0.0.54 family audit.
v0.0.542026-10-07Reader entry points bounded at max_document_length before buffering.
v0.0.172026-07-25Lockstep-only release; no code change in the core crate.
v0.0.162026-07-24Build fix, MSRV raise, and dependency refresh — main had been left unbuildable under cargo check --all-targets.
v0.0.152026-07-12Completes loader parity and hardens coverage.
v0.0.142026-07-09The loader-parity cut; closes a fast-path silent-collapse bug.
v0.0.132026-07-05Splits noyalib-mcp, noyalib-lsp and noya-cli into their own repositories.
v0.0.122026-07-03The workspace-split pilot; noyalib-wasm leaves the monorepo first.
v0.0.112026-07-01CI-integrity housekeeping.
v0.0.102026-06-30Scanner correctness for UTF-8 BOM-prefixed multi-node documents.
v0.0.9unreleasedSupply-chain refresh batching eight Dependabot updates. Never tagged.
v0.0.82026-06-17The FlowStyle fix.
v0.0.72026-06-02Supply-chain hardening.
v0.0.62026-05-30Ecosystem integration — the four remaining v0.0.6 milestone issues.
v0.0.52026-05-11Polish and stabilisation: edition 2024 and MSRV 1.85.
v0.0.42026-05-11Opens the v0.0.4 cycle with the !include directive.
v0.0.32026-05-11Surgical patch widening the rustc-hash dependency range.
v0.0.22026-05-10Zero-copy borrowed deserialisation and lazy parsing.
v0.0.12026-05-10First publishable release — a pure-Rust YAML 1.2 implementation with full serde integration.

Conventions

  • Filename equals tag. docs/release-notes/<tag>.md. The mapping is one-to-one with git tags and GitHub releases, so a link can be constructed from a version alone.
  • Notes are immutable. A published file records what was true at that release. Corrections belong in a later release’s notes or in the changelog, not in edits to history.
  • The changelog is the index of record. Every release appears there; only some have a narrative file here.

v0.0.9 was never tagged. Its notes are kept for continuity of the record; the published sequence goes v0.0.8 → v0.0.10.