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 to | Read |
|---|---|
| Parse or write YAML from Rust | the User guide |
| Find the recipe for a task | the Cookbook |
Leave serde_yaml behind | the migration guides — for the one-line drop-in, see noyalib-serde-yaml |
| Edit YAML without destroying formatting | the User guide’s CST sections, then Architecture |
| Judge the library before adopting it | Choosing a YAML library and Testing and verification |
| Contribute | DEVELOPMENT.md and CONTRIBUTING.md at the repository root |
The rest of the documentation
- API reference — docs.rs/noyalib (also rendered alongside this manual)
- Getting started —
GETTING_STARTED.md - Per-crate guides — each satellite repository’s README and
docs/directory
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.9 | docs/MIGRATION-FROM-SERDE-YAML.md |
| Understand the architecture | docs/ARCHITECTURE.md |
| Edit YAML lossy-free (preserving comments and layout) | crates/noyalib/README.md § CST and noyalib::cst::Document |
| Validate YAML against a JSON Schema | noyavalidate --schema schema.yaml input.yaml |
| Format YAML | noyafmt --write file.yaml |
| Use the LSP server in your editor | crates/noyalib-lsp/README.md |
| Drive noyalib from an AI agent (MCP) | crates/noyalib-mcp/README.md |
| Run noyalib in the browser | crates/noyalib-wasm/README.md |
| Look up a domain term | GLOSSARY.md |
| Contribute code | CONTRIBUTING.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
- Bug reports / feature requests: GitHub Issues
- Security issues: see SECURITY.md for the disclosure process — do not file public issues for vulnerabilities
- Migration questions: docs/MIGRATION-FROM-SERDE-YAML.md covers the common gotchas
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
- Reading YAML
- Writing YAML
- The dynamic
Valuetree - Source spans (
Spanned<T>) - Strict deserialise (typo detection)
- Parser policies (defence in depth)
- Structured diagnostics
- Lossless edits (
cst::Document) - Schema validation and autofix
- Multi-document streams + parallel parse
- The CLI tools (
noyafmt,noyavalidate) - 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:
| Source | API | Returns |
|---|---|---|
&str | from_str::<T>(s) | Result<T> |
&[u8] | from_slice::<T>(b) | Result<T> |
impl io::Read | from_reader::<R, T>(r) | Result<T> |
&noyalib::Value | from_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
}
| Sink | API |
|---|---|
String | to_string(&v) |
impl io::Write | to_writer(&mut w, &v) |
impl fmt::Write | to_fmt_writer(&mut w, &v) |
noyalib::Value | to_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:
| Input | Strict variant |
|---|---|
&str | from_str_strict::<T>(s) |
&[u8] | from_slice_strict::<T>(b) |
impl io::Read | from_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.
| Dial | Default | strict() | Protects against |
|---|---|---|---|
max_depth | 128 | 64 | Stack-blowing nested structures |
max_document_length | 64 MiB | 1 MiB | Oversized payloads |
max_nodes | 250 K | 25 K | AST node-count floods (empty-collection bombs) |
max_alias_expansions | 1024 | 100 | Billion-laughs amplification |
max_mapping_keys | 64 K | 1024 | Hash-collision DoS |
max_sequence_length | 64 K | 1024 | Memory-spike DoS |
duplicate_key_policy | Last | Error | Silent data loss |
strict_booleans | off | on | Norway 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:
| API | Use |
|---|---|
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:
| YAML | Resulting 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 42 | Value::String("42") (core tag — resolves) |
!!int 42 | Value::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-packoutput 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, …) callparse,format,get,set,validatetools without needing a Rust toolchain. Available asnpx @sebastienrousseau/noyalib-mcp(the wrapper bootstraps the binary) and as a GHCR container imageghcr.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
- Source spans + miette diagnostics:
crates/noyalib/examples/diagnostic.rs - Anchor manipulation:
crates/noyalib/examples/anchor_shared.rs - Schema codegen:
crates/noyalib/examples/schema_validation.rs - Figment / garde / validator integration:
crates/noyalib/examples/figment.rs,crates/noyalib/examples/validation_garde.rs,crates/noyalib/examples/validation_validator.rs - CST architecture:
ARCHITECTURE.md - Migrating from
serde_yaml:MIGRATION-FROM-SERDE-YAML.md
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.
| noyalib | serde_yml | serde_yaml_ng | saphyr | yaml-rust2 | rust-yaml | |
|---|---|---|---|---|---|---|
| YAML Test Suite | 100% strict (406/406) | — | — | — | — | — |
| Pure Rust | Yes | No (C-FFI) | No (C-FFI) | Yes | Yes | Yes |
Zero unsafe | Yes | No | No | Yes | Yes | Yes |
| Serde integration | Yes | Yes | Yes | Yes | No | Yes |
| Streaming deser | Yes | No | No | No | No | No |
#![no_std] | Yes | No | No | No | No | No |
| Zero-copy scalars | Yes | No | No | No | No | Yes |
| SIMD scanning | Yes (memchr + bitmask) | No | No | No | No | No |
| SWAR numeric parse | Yes | No | No | No | No | No |
| Parallel multi-doc | Yes (parallel::parse) | No | No | No | No | No |
| DoS hardened | ~12 budgets | Basic | Basic | Yes | No | Yes |
| Pluggable policies | Yes (policy::Policy) | No | No | No | No | No |
| Secret interpolation | Yes (${VAR}) | No | No | Yes | No | No |
| CST manipulation | Yes (cst::Document) | No | No | No | No | No |
| Native LSP | Yes (noyalib-lsp) | No | No | No | No | No |
| MCP server | Yes (noyalib-mcp) | No | No | No | No | No |
| JSON Schema codegen | Yes (schema_for) | No | No | No | No | No |
| JSON Schema validate | Yes (validate_against_schema) | No | No | No | No | No |
| Schema-driven autofix | Yes (coerce_to_schema) | No | No | No | No | No |
miette diagnostics | Yes | No | No | No | No | No |
| WASM | 338 KB | No | No | No | No | No |
| Source spans | Yes | No | No | Yes | No | No |
| YAML 1.1 compat | Yes | Yes | Yes | No | Yes | No |
| Serialization | Yes | Yes | Yes | Yes | No | No |
| Path queries | query("..name") | No | No | No | No | No |
| Zero-copy AST | BorrowedValue<'a> | No | No | No | No | Partial |
Reading the table
- YAML Test Suite (100% strict) —
noyalibis 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_ymlandserde_yaml_ng(the active forks ofdtolnay/serde-yaml) wraplibyamlviaunsafe-libyamlin 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 intermediateValueAST. 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. SeePOLICIES.mdfor 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.shruns 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)
| Fixture | noyalib | vs serde_yaml_ng | vs yaml-rust2 | vs serde_yml | vs yaml-spanned | vs serde-saphyr |
|---|---|---|---|---|---|---|
| simple (3 fields) | 1.40 µs | 1.84× | 1.36× | 1.96× | 1.69× | 2.00× |
| nested (20 fields) | 9.66 µs | 1.55× | 1.25× | 1.63× | 1.60× | 1.76× |
| large_list (500 items) | 920 µs | 1.42× | 1.19× | 1.48× | 1.38× | 1.69× |
| github_actions (deep + comments) | 46.4 µs | 1.66× | 1.25× | 1.72× | 1.74× | 1.73× |
| k8s multi-document | 85.1 µs | 1.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)
| Fixture | noyalib | vs serde_yaml_ng |
|---|---|---|
| simple (3 fields) | 290 ns | 4.34× |
| nested (20 fields) | 2.25 µs | 3.00× |
Round-trip (deserialize + serialize)
| Fixture | noyalib | vs serde_yaml_ng |
|---|---|---|
| nested (20 fields) | 12.0 µs | 1.83× |
Headline numbers
noyalib is faster than every Rust YAML library on every
fixture measured. Speedup ranges:
| Competitor | Speedup range (deserialize) |
|---|---|
serde-saphyr | 1.69×–2.00× |
serde_yml | 1.48×–1.96× |
serde_yaml_ng | 1.42×–1.84× |
yaml-spanned | 1.38×–1.74× |
yaml-rust2 | 1.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).
| Library | Simple struct | Nested struct |
|---|---|---|
| noyalib | 1.22 µs | 7.08 µs |
| serde_yaml_ng | 2.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)
| Library | Nested (20 fields) |
|---|---|
| noyalib | 12.7 us |
| serde_yaml_ng | 25.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)
| Path | 4 KiB | 64 KiB | 1 MiB | vs memchr loop |
|---|---|---|---|---|
| scalar (byte-by-byte baseline) | 13.0 us | 206 us | 3.33 ms | 0.86x |
memchr + find_any_of loop | 11.3 us | 179 us | 2.89 ms | 1.0x |
StructuralIter (stable) | 2.7 us | 42.3 us | 681 us | 4.2x |
StructuralIter (nightly-simd) | 1.20 us | 19.7 us | 311 us | 9.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)
| Width | stdlib from_str | SWAR | speedup |
|---|---|---|---|
| 8 digits | 8.12 ns | 3.74 ns | 2.17x |
| 19 digits | 22.0 ns | 9.25 ns | 2.38x |
i64::MAX | 24.6 ns | 9.75 ns | 2.52x |
| Bulk parse 1000 ints | 7.93 us | 5.38 us | 1.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:
- Load 8 bytes into a
u64register (one alignedmov). - Subtract
b'0'lane-wise (sub_packed). - Validate every byte is
< 10via a single masked compare. - 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
| Capability | Measured 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 keys | Faster key insertion and lookup |
SIMD scanning (memchr) | Faster delimiter search on large inputs |
| Path queries | value.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.
| Fixture | noyalib | serde-saphyr 1.2.0 | ratio | serde_yaml_ng | yaml-rust2 | yaml-spanned |
|---|---|---|---|---|---|---|
| Deserialise simple | 2.15 µs | 5.49 µs | 2.55× | 3.76 µs (1.75×) | 2.70 µs (1.25×) | 3.73 µs (1.73×) |
| Deserialise nested | 13.32 µs | 38.34 µs | 2.88× | 19.26 µs (1.45×) | 16.13 µs (1.21×) | 21.81 µs (1.64×) |
| Deserialise large_list | 1.09 ms | 3.10 ms | 2.84× | 2.12 ms (1.94×) | 1.79 ms (1.64×) | 2.12 ms (1.94×) |
| Deserialise github_actions | 76.72 µs | 250.41 µs | 3.26× | 211.06 µs (2.75×) | 253.59 µs (3.31×) | 438.86 µs (5.72×) |
| Deserialise k8s_multidoc | 134.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
| Group | Compares | Interpretation |
|---|---|---|
recovery_strict_vs_lenient | from_str vs recovery::parse_lenient on valid input | The lenient wrapper should not regress strict throughput by more than rounding noise (recovery retries only execute on Err). |
sval_vs_serde_value | from_str → Value (serde) vs streaming a pre-built Value through a no-op sval::Stream | Isolates the per-event cost of the sval surface — useful when deciding whether the serde-free route is worth the extra dep. |
tokio_async_drain | from_slice (sync) vs tokio_async::from_async_reader against an in-memory BufReader | Quantifies the async fixed-overhead cost when I/O latency is zero; real network latency dwarfs this. |
Project metrics
| Metric | Value |
|---|---|
| Source | 26,000+ lines across the workspace |
| Test suite | 6,900+ test entries + 580+ doctest entries + CLI smoke + stress/load suites; enumerate in CI for the exact revision |
| YAML Test Suite | 100% strict compliance: 406/406 attempted cases pass, 0 failures, 0 deliberate skips |
| Examples | 76 runnable examples |
| Coverage | 96%+ function coverage / 94%+ line coverage / 93%+ region coverage (CI-gated) |
| Dependencies | 5 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 binary | 338 KB (release, LTO) |
| MSRV | Rust 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-clipath 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 runscripts/pgo.shto 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:
- Builds an instrumented binary with
RUSTFLAGS=-Cprofile-generate=…. - Runs the instrumented binary against the training corpus
(default: every YAML under
tests/yaml-test-suite/plusbenches/fixtures/). - Merges the per-process
*.profrawfiles viallvm-profdata mergeinto a singlemerged.profdata. - 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-profdataon$PATH. Install viarustup component add llvm-tools-preview. The script auto-discovers the rustup-shipped binary if$PATHlookup 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:
| Workload | Baseline | PGO | Speedup |
|---|---|---|---|
noyafmt --check (parse + canonical compare) | 5.2 ms | 4.4 ms | 1.18× |
noyavalidate --schema (parse + JSON Schema check) | 9.1 ms | 7.9 ms | 1.15× |
from_str::<Value> micro-bench | 2.69 ms | 2.31 ms | 1.16× |
cst::parse_document micro-bench | 4.59 ms | 4.04 ms | 1.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:
- The two-pass build doubles the wall-clock cost.
- PGO artefacts are target-specific (per architecture).
- A small per-PR perf regression isn’t best caught via PGO —
CodSpeedalready 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.profdataon Linux x86_64. - Windows: works with
llvm-profdata.exefrom the rustupllvm-tools-previewcomponent. 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
.profrawfiles. Verify the binary actually executed against the training corpus (the scripttees 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
- The Rust Reference — profile-guided optimization.
- LLVM Profile-Guided Optimization.
- The
cargo-pgocrate provides a higher-level driver alternative to the shell script — drop-in replacement forscripts/pgo.shif your team prefers a Cargo-native UX.
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 from | Drop-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.13 | mostly (repo archived) | MIGRATION-FROM-SERDE-YML.md |
yaml_serde 0.10.7 | yes (Cargo package = rename) | MIGRATION-FROM-YAML-SERDE.md |
serde-yaml-ng 0.10.0 | yes | MIGRATION-FROM-SERDE-YAML-NG.md |
serde-norway 0.9.42 | yes | MIGRATION-FROM-SERDE-NORWAY.md |
serde-yaml-bw 2.5.7 | no (breaking 2.x; 8-variant Value with Alias) | MIGRATION-FROM-SERDE-YAML-BW.md |
serde-saphyr 1.2.0 | no (no Value DOM, streaming-only) | MIGRATION-FROM-SERDE-SAPHYR.md |
yaml-spanned 0.0.3 | no (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.
Value::Taggedis preserved through theValuedata path.from_str::<Value>("!Custom 'hi'\n")returnsValue::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 dynamicValuepath.- YAML 1.2 strict booleans by default.
country: NOparses asValue::String("NO"), notValue::Bool(false). Opt into YAML 1.1 boolean recognition viaParserConfig::new().legacy_booleans(true)if you need the old behaviour for round-trip compatibility with legacy files. - Multi-doc streams use
load_all_as.noyalib::load_all_as::<T>(input)?returns aVec<T>directly. TheDeserializer::from_str(...).map(...)pattern fromserde_yamland 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-spannedcovers 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-flightuse serde_yaml::*;pattern, with no transitiveserde_yaml0.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
Deserializeryou cannext()event-by-event from user code —noyalib::Deserializeris a serde-trait driver, not an event iterator. - No equivalent to
serde-yaml-bw’sValue::Aliasvariant — 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_yaml | noyalib | Notes |
|---|---|---|
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::Value | noyalib::Value | Identical 7-variant enum (Null, Bool, Number, String, Sequence, Mapping, Tagged). |
serde_yaml::Mapping | noyalib::Mapping | Wraps IndexMap<String, Value>; iteration order preserved. |
serde_yaml::Number | noyalib::Number | Default mode keeps the same Integer / Float split. Enabling lossless-u64 adds an opt-in Unsigned(u64) variant; see ADR 0004. |
serde_yaml::Error | noyalib::Error | Different variant set, same Display shape. See “Error handling” below. |
serde_yaml::with::singleton_map | noyalib::with::singleton_map | Identical. |
serde_yaml::with::singleton_map_optional | noyalib::with::singleton_map_optional | Identical. |
serde_yaml::with::singleton_map_recursive | noyalib::with::singleton_map_recursive | Identical. |
serde_yaml::Index | noyalib::ValueIndex | Same trait surface (get, get_mut). |
serde_yaml::value::Mapping::get_mut | noyalib::Mapping::get_mut | Identical. |
serde_yaml::Deserializer::from_str | noyalib::from_str | To 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::new | noyalib::Serializer::new | Identical. |
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.
noyalib | What 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::Document | Lossless 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_properties | Substitute ${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::Documentpreserves them byte-for-byte for the lossless tooling path, butfrom_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;noyalibfollows 1.2 by default but ships an opt-inMergeKeyPolicy::Autofor backwards compatibility. - Custom-tag dispatch via
serde(rename).noyalibsurfaces non-core tags asValue::Tagged(...)rather than routing them to a typed enum variant by tag-string. Adoptnoyalib::TagRegistryif 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()(orvalue.untag_ref()for borrow-friendly reads) before anyas_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}")yieldsFoo { x: 1 }regardless of the tag. - Registering the tag with
TagRegistry::withfor 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"(ornoyalib = { version = "0.0", features = ["compat-serde-yaml"] }for the shim path). - Replace
use serde_yaml::*;→use noyalib::*;(oruse noyalib::compat::serde_yaml::*;). - If you exhaustively match
Value, add aTagged(_)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. TheRUSTSEC-2024-0320advisory should no longer match anything. - Run your existing test suite. The
noyalib-shapedfrom_stris 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:
MIGRATION-FROM-SERDE-YML.md—serde_yml0.0 (archived 2025-09)MIGRATION-FROM-YAML-SERDE.md—yaml_serde0.10 (active fork)MIGRATION-FROM-SERDE-YAML-NG.md—serde-yaml-ng0.10 (active drop-in fork)MIGRATION-FROM-SERDE-NORWAY.md—serde-norway0.9 (hard-fork)MIGRATION-FROM-SERDE-YAML-BW.md—serde-yaml-bw2.5 (non-drop-in 2.x)MIGRATION-FROM-SERDE-SAPHYR.md—serde-saphyr0.0 (noValueDOM)MIGRATION-FROM-YAML-SPANNED.md—yaml-spanned0.0 (parser-only, span-aware)
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.10 | noyalib |
|---|---|
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_string | noyalib::to_string |
serde_yaml_ng::to_writer | noyalib::to_writer |
serde_yaml_ng::to_value | noyalib::to_value |
serde_yaml_ng::Value | noyalib::Value (same 7 variants) |
serde_yaml_ng::Mapping | noyalib::Mapping |
serde_yaml_ng::Number | noyalib::Number (default Integer / Float; opt-in Unsigned(u64) behind lossless-u64) |
serde_yaml_ng::Error | noyalib::Error |
serde_yaml_ng::Deserializer | noyalib::Deserializer |
serde_yaml_ng::Serializer | noyalib::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:
Value::Taggedis preserved.from_str::<Value>("!Custom 'hi'\n")returnsValue::Tagged(t)(t.tag() == "!Custom",t.value() == Value::String("hi")).serde_yaml_ngmatchedserde_yaml’s pre-Tagged behaviour; noyalib preserves the wrapper. Call.untag()to get the inner.- YAML 1.2 strict booleans by default.
country: NOstaysValue::String("NO"). Opt into YAML 1.1 boolean recognition viaParserConfig::new().legacy_booleans(true). - Multi-doc streams — use
noyalib::load_all_as::<T>(input)forVec<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"(ornoyalib = { version = "0.0", features = ["compat-serde-yaml"] }for the shim path). - Replace
use serde_yaml_ng::*;→use noyalib::*;(oruse 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.0 | noyalib |
|---|---|
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_string | noyalib::to_string |
serde_yml::to_writer | noyalib::to_writer |
serde_yml::to_value | noyalib::to_value |
serde_yml::Value | noyalib::Value (same 7 variants) |
serde_yml::Mapping | noyalib::Mapping |
serde_yml::Number | noyalib::Number |
serde_yml::Error | noyalib::Error |
serde_yml::Deserializer | noyalib::Deserializer |
serde_yml::Serializer | noyalib::Serializer |
serde_yml::with::singleton_map | noyalib::with::singleton_map |
serde_yml::with::singleton_map_recursive | noyalib::with::singleton_map_recursive |
serde_yml::with::singleton_map_optional | noyalib::with::singleton_map_optional |
serde_yml::with::singleton_map_with | noyalib::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:
Value::Taggedis preserved through theValuedata path.noyalib::from_str::<Value>("!Custom 'hi'\n")returnsValue::Tagged(t)(t.tag() == "!Custom",t.value() == Value::String("hi")), not the transparent-unwrappedValue::String("hi").serde_yml’s behaviour matchedserde_yaml’s pre-Tagged behaviour. To get the unwrapped string, callvalue.untag().as_str().- YAML 1.2 strict booleans by default.
country: NOparses asValue::String("NO"), notValue::Bool(false). Opt into YAML 1.1 boolean recognition viaParserConfig::new().legacy_booleans(true)if you need the old behaviour for round-trip compatibility with legacy files. - Multi-doc streams use
load_all_as.serde_yml’sDeserializer::from_str(...).map(...)pattern maps tonoyalib::load_all_as::<T>(input)?returningVec<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"(ornoyalib = { version = "0.0", features = ["compat-serde-yaml"] }for the shim path). - Replace
use serde_yml::*;→use noyalib::*;(oruse 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 transitiveserde_yaml0.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.0 | noyalib |
|---|---|
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_options | noyalib::from_slice_with_config |
serde_saphyr::from_reader_with_options | noyalib::from_reader_with_config |
serde_saphyr::from_multiple_with_options | noyalib::load_all_with_config (untyped DocumentIterator; deserialize each item, or load_all_as::<T> without a config) |
serde_saphyr::to_string | noyalib::to_string |
serde_saphyr::to_io_writer | noyalib::to_writer |
serde_saphyr::to_fmt_writer | noyalib::to_fmt_writer |
serde_saphyr::to_string_multiple | noyalib::to_string_multi |
serde_saphyr::Options (parse-side builder) | noyalib::ParserConfig |
serde_saphyr::SerializerOptions | noyalib::SerializerConfig |
serde_saphyr::Deserializer (streaming type) | noyalib::Deserializer |
options! macro | ParserConfig::new()-with-builder-methods |
ser_options! macro | SerializerConfig::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
- noyalib has a
ValueDOM, but doesn’t require it. The defaultfrom_str::<T>path streams directly into typed targets — same shape asserde_saphyr, no AST allocated. TheValuetree is only built when the caller asks for it viafrom_str::<Value>. - 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). Value::Taggedexists in noyalib. Custom-tag scalars like!Custom 'hi'surface asValue::Tagged(t)(t.tag() == "!Custom",t.value() == Value::String("hi")). If you hit a tagged scalar inserde_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 theserde_saphyrtyped contract.- YAML 1.2 strict by default.
country: NOstays a string.serde-saphyris YAML 1.2 by design; the migration is neutral on this dimension. - Tag-driven instantiation rejected.
serde-saphyrrejects 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 theValuedata 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_optionscalls with*_with_config(input, &cfg). - Replace
to_io_writerwithto_writer,to_string_multiplewithto_string_multi. - If you routed dynamic inspection through
serde_json::Value, switch tonoyalib::Valuefor the YAML data model (numeric types distinct from JSON,Taggedfor 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.9 | noyalib |
|---|---|
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_string | noyalib::to_string |
serde_norway::to_writer | noyalib::to_writer |
serde_norway::to_value | noyalib::to_value |
serde_norway::Value | noyalib::Value (same 7 variants) |
serde_norway::Mapping | noyalib::Mapping |
serde_norway::Number | noyalib::Number |
serde_norway::Error | noyalib::Error |
serde_norway::Deserializer | noyalib::Deserializer |
serde_norway::Serializer | noyalib::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:
Value::Taggedis preserved through theValuedata path.from_str::<Value>("!Custom 'hi'\n")returnsValue::Tagged(t)(t.tag() == "!Custom",t.value() == Value::String("hi")). Call.untag()to get the inner if you used the unwrapped form.- YAML 1.2 strict booleans by default. Same default as
serde_norway— no behavioural change. - Multi-doc streams use
load_all_as.noyalib::load_all_as::<T>(input)returnsVec<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"(ornoyalib = { version = "0.0", features = ["compat-serde-yaml"] }for the shim path). - Replace
use serde_norway::*;→use noyalib::*;(oruse 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.x | noyalib |
|---|---|
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_preserve | noyalib::from_str::<Value> (Tagged preserved by default) |
serde_yaml_bw::from_str_multi | noyalib::load_all_as::<T> |
serde_yaml_bw::from_slice_multi | noyalib::load_all_as::<T> (after std::str::from_utf8) |
serde_yaml_bw::from_reader_multi | noyalib::load_all_as::<T> (after read_to_string) |
serde_yaml_bw::from_multiple | noyalib::load_all_as::<T> |
serde_yaml_bw::to_string | noyalib::to_string |
serde_yaml_bw::to_writer | noyalib::to_writer |
serde_yaml_bw::to_string_multi | noyalib::to_string_multi |
serde_yaml_bw::to_writer_multi | noyalib::to_writer_multi |
serde_yaml_bw::to_value | noyalib::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::Mapping | noyalib::Mapping |
serde_yaml_bw::Number | noyalib::Number (default Integer / Float; opt-in Unsigned(u64) behind lossless-u64) |
serde_yaml_bw::Error | noyalib::Error |
serde_yaml_bw::SerializerBuilder | noyalib::SerializerConfig + to_writer_with_config |
serde_yaml_bw::SerializerOptions | noyalib::SerializerConfig |
serde_yaml_bw::DeserializerOptions | noyalib::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, unresolvedTagged(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
- Alias resolution is eager. noyalib resolves
*anchor_nameto the anchor’s value at parse time (per YAML 1.2 §7.1).serde_yaml_bw’sValue::Aliasvariant has no equivalent in noyalib; if you need to detect an alias use site, work at the CST layer. - Billion-laughs DoS hardening. Both crates guard against
exponential alias amplification. noyalib’s defaults:
max_alias_expansions = 100,max_document_lengthbounded by the input size; configure viaParserConfig::new().max_alias_expansions(...). - Merge keys (
<<) — both crates support YAML 1.1 merge keys. noyalib’sMergeKeyPolicyenum lets you choose:Auto(default),AsOrdinary(treat<<as a literal key), orError(reject). Value::Taggedis preserved.from_str::<Value>("!Custom 'hi'\n")returnsValue::Tagged(t)wheret.tag() == "!Custom"andt.value() == Value::String("hi")— same asserde_yaml_bw.- YAML 1.2 strict booleans by default.
country: NOstaysValue::String("NO"). Opt into YAML 1.1 boolean recognition viaParserConfig::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/*_multiplecalls withnoyalib::load_all_as. - Replace
SerializerBuilder/*Optionsbuilders withnoyalib::SerializerConfig/noyalib::ParserConfig. - If you used the
_with_optionsoverload family, usenoyalib::from_str_with_config(and the matchingto_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
Aliasarm 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.10 | noyalib |
|---|---|
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_string | noyalib::to_string |
yaml_serde::to_writer | noyalib::to_writer |
yaml_serde::to_value | noyalib::to_value |
yaml_serde::Value | noyalib::Value (same 7 variants) |
yaml_serde::Mapping | noyalib::Mapping |
yaml_serde::Number | noyalib::Number |
yaml_serde::Error | noyalib::Error |
yaml_serde::Deserializer | noyalib::Deserializer |
yaml_serde::Serializer | noyalib::Serializer |
yaml_serde::with::singleton_map | noyalib::with::singleton_map |
yaml_serde::with::singleton_map_recursive | noyalib::with::singleton_map_recursive |
yaml_serde::with::singleton_map_optional | noyalib::with::singleton_map_optional |
yaml_serde::with::singleton_map_with | noyalib::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:
Value::Taggedis preserved through theValuedata path.noyalib::from_str::<Value>("!Custom 'hi'\n")returnsValue::Tagged(t)(t.tag() == "!Custom",t.value() == Value::String("hi")). To get the unwrapped inner, callvalue.untag().as_str().- YAML 1.2 strict booleans by default.
country: NOstaysValue::String("NO"). Opt into legacy YAML 1.1 boolean recognition viaParserConfig::new().legacy_booleans(true). - Multi-doc streams use
load_all_as.noyalib::load_all_as::<T>(input)returnsVec<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 underserde_yaml = { package = "yaml_serde" }) →noyalib = "0.0". - Replace
use yaml_serde::*;→use noyalib::*;(oruse 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:
yaml-spanneddoes not provideto_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 withserde_yaml(or another crate) for the emit side.yaml-spannedis built onlibyaml-safer, which usesunsafeinternally. 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.0 | noyalib |
|---|---|
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::Value | noyalib::Value (same 7 variants) |
yaml_spanned::TaggedValue | noyalib::TaggedValue |
yaml_spanned::Tag | noyalib::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
#![forbid(unsafe_code)].yaml-spannedtransitively usesunsafe(vialibyaml-safer). noyalib forbids it throughout the workspace; the migration tightens the safety claim.- 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 whichyaml-spannedeither inherits fromlibyaml-saferor doesn’t expose. - Spans on the typed path.
Spanned<T>is a serde wrapper, so it composes with any#[derive(serde::Deserialize)]target — not just the dynamicValue. - 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. - Built-in serialization.
yaml-spannedis parser-only; noyalib providesto_string/to_writer/to_string_multi/to_writer_multi/to_fmt_writerfor 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::*;plususe noyalib::Spanned;where you wrap span-bearing fields. - Where you used
Spanned<Value>for span+dynamic, prefer a typed config withSpanned<T>per field, or fall back tonoyalib::cst::parse_documentfor path-based span queries. - If you used
from_str_lossy*, replace with explicitload_all(input)?.filter_map(...)— silent drops are rarely what production code wants. - If you used
Builderfor programmatic construction, switch toValue::Mapping(...)/From<T>impls (orcst::Documentfor source-faithful edits). - Add
to_string/to_writercalls if the project previously pairedyaml-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 submoduleorpath = "…".
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
| Release | Split | New repo | Shipped |
|---|---|---|---|
| v0.0.12 | noyalib-wasm | sebastienrousseau/noyalib-wasm | 2026-07-02 |
| v0.0.13 | noyalib-mcp | sebastienrousseau/noyalib-mcp | 2026-07-05 |
| v0.0.13 | noyalib-lsp | sebastienrousseau/noyalib-lsp | 2026-07-05 |
| v0.0.13 | noya-cli | sebastienrousseau/noya-cli | 2026-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-wasmissues → https://github.com/sebastienrousseau/noyalib-wasm/issuesnoyalib-mcpissues → 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:
Option A — pull from crates.io (recommended)
[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
noyaliblibrary crate: same identity, same public API, same MSRV (1.86, raised in v0.0.16). Any patch fix here still lands here.noyalibdocs (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:
noya-cli(noyafmt + noyavalidate binaries) →sebastienrousseau/noya-cli(split at v0.0.13)noyalib-lsp(Language Server Protocol implementation) →sebastienrousseau/noyalib-lsp(split at v0.0.13)noyalib-mcp→sebastienrousseau/noyalib-mcp(split at v0.0.13)noyalib-wasm→sebastienrousseau/noyalib-wasm(split at v0.0.12)xtask(internal release tooling) → removed; PGO and release tasks now run fromscripts/
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
u64packing) 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 viamask.trailing_zeros(). Same shape as simdjson’s structural-character pass. Yields ~4× over the memchr loop on 1 MiB workloads (~9× on nightly undercore::simd’s 32-byte vector).parse_decimal_u64/parse_decimal_i64— SIMD-Within- A-Register decimal parser. Folds 8 ASCII digits peru64cycle via three pair-wise multiply-add phases; ~2× faster than<i64 as FromStr>::from_stron 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:
<<: *aliasmerges natively into the surrounding mapping.!!binarypropagates 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:
- Resolving the edit path (
server.port) to the byte span of the value to replace. - Replacing the bytes in that span only.
- 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 likenull/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 fromnoyalib::fmt. - Block scalars (
|,>) when string content would otherwise span multiple lines, gated byblock_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:
| Pattern | Resolves to |
|---|---|
null, ~, empty | Null |
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 else | String |
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.
| Module | Feature | Pipeline shape | Source |
|---|---|---|---|
recovery | recovery | Wraps 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_adapter | sval | impl 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_async | tokio | Bounded 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
| Suite | Where | What |
|---|---|---|
| Unit | inline #[cfg(test)] blocks | Per-module logic |
| Integration | crates/noyalib/tests/ | Cross-module surfaces (139 files) |
| Doc-tests | every /// block with a code-fence | Public-API examples |
| Property | crates/noyalib/tests/proptest_*.rs | Roundtrip invariants via proptest |
| Official YAML 1.2 suite | crates/noyalib/tests/yaml-test-suite/** | 406/406 strict-pass, 0 failures, 0 deliberate skips |
| CLI smoke | crates/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-lsprepos 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
| Target | Tested | Notes |
|---|---|---|
x86_64-unknown-linux-gnu | ✓ CI matrix | Primary dev target |
aarch64-apple-darwin | ✓ CI matrix | NEON SIMD path |
x86_64-apple-darwin | ✓ CI matrix | macOS Intel |
x86_64-pc-windows-msvc | ✓ CI matrix | Windows |
aarch64-unknown-linux-gnu | ✓ CI matrix | ARM Linux |
wasm32-unknown-unknown | ✓ via noyalib-wasm | 338 KB binary after LTO |
Big-endian (mips64) | ✓ via Miri big-endian | SWAR + structural-bitmask paths verified |
| Other targets in the release matrix | Built at release time | See 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.
Where to read next
- Public API:
USER-GUIDE.md - Migration:
MIGRATION-FROM-SERDE-YAML.md - Distribution / packaging:
../pkg/PUBLISH.md - Verifying release artefacts:
../pkg/VERIFY.md - Design notes (historical):
design/ - Releases / phase work plan:
PLAN.md
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
- 100% lossless round-trip when nothing is edited.
parse → emitis byte-identical for any well-formed input. - 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.
- Zero performance impact on the existing fast path. Users who
call
from_str::<T>orStreamingDeserializersee no change in compile-time size, runtime allocation, or throughput. - 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. - 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.Valueremains the data API for serde-style use. - Comment attachment semantics (which comment “belongs” to which
node). The capture surface in
src/comments.rsis sufficient for most consumers; node-attached comments can be a follow-up. - Cross-document editing.
Documentis single-document; multi-doc streams are aVec<Document>(see API). - Schema-aware editing (e.g. “set the value of
versionto 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:
| Source | What 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::ScannedComment | Comments are captured with byte spans and an inline flag. The capture API is in src/comments.rs (load_comments). |
src/parser/events.rs | Token stream is consumed into a typed Event stream (MappingStart, Scalar, …). Each event carries a Span. |
src/parser/loader.rs | Events are folded into Value. This is the current AST builder. |
src/span_context.rs::SpanTree | An 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) │
└──────────────────┘
ValueandGreenNodeare 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 onValuedirectly.
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 atdependencies.serdeto2.0.” - Span-based (
replace_span) is the formatter / linter API. Users who already operate on spans (e.g. consumers ofSpanned<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.seton an alias returnsEditError::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
Eventstream fromsrc/parser/events.rsplus the un-skipped trivia from the scanner (ScannedCommentplus a new internal trivia capture for whitespace and newlines, which are currently elided byskip_to_next_tokenbut whose spans are easy to retain). - New API:
parse_document(input) -> Result<Document>andparse_stream(input) -> Result<Vec<Document>>. Read-only initially: nosetorreplace_spanyet. - New test:
tests/cst/round_trip.rs— for a corpus of inputs (existingtests/yaml-test-suite/cases), assertparse_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 / removeand theAsPath/Emittraits.tests/cst/mutation.rs— for each of N curated cases, apply a named edit, check the resultingto_string()against a golden file undertests/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.EditErrortype 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
criterionbenches underbenches/(benchmarks,comparison,architecture,validation_overhead) gate at ±5% againstmain. CI fails on regression. - The
parse_documentpath is benchmarked separately. Its overhead vsfrom_str::<Value>should be ≤2× — comparable totaplo’s ratio. The streaming path stays the undisputed champion forfrom_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_stringpreserve the original%YAML 1.2directive byte-for-byte, or canonicalise it? Default proposal: preserve verbatim (consistent with the indentation policy). - Preserving non-canonical scalar styles. A plain
42and a quoted"42"deserialise to the sameValue, but their CST representations differ.set(path, 42)on a quoted-style site should emit"42", not42. Decision: yes —Emitcarries anEmitCtxthat knows the existing style at the target. - Does
Documentimplementserde::Deserialize? Probably not — forces consumers throughDocument::as_value()which is the intended boundary. ButDocument::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 -- --blessto 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_stringmust be a fixed point. - Anchor/alias scar tests. A dedicated test file in
tests/cst/anchors.rsfor the four scenarios in §4.
What this design explicitly avoids
rowanas a hard dependency. We replicate the green-node pattern directly.rowanis excellent but pulls intext-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_spanproduces invalid YAML, the edit is rejected withEditError.Documentnever 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)
| Crate | MSRV | Reason |
|---|---|---|
noyalib | 1.89.0 | Single 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-mcp | 1.89.0 | Lockstep with the core floor |
noyalib-wasm | 1.89.0 | Lockstep with the core floor; wasm-bindgen 0.2 floors at 1.86 |
noya-cli | 1.89.0 | Lockstep with the core floor; clap_builder 4.6 is edition-2024 |
noyalib-lsp | 1.89.0 | Lockstep with the core floor; LSP transport stack (litemap, uuid) is edition-2024 |
noyalib-serde-yaml | 1.89.0 | Lockstep 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:
| File | Tests | What it locks |
|---|---|---|
official_suite.rs | 406 | YAML 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.rs | 12 | P4 borrowed-path alias resolution + bomb defence |
yaml_version.rs | 11 | P2 version(YamlVersion::V1_1) toggle |
json_surrogate_escape.rs | 13 | JSON-style \uXXXX\uXXXX surrogate-pair pairing |
scanner_panic_regressions.rs | 2 | Fuzz-discovered scanner panics — no-DoS contract |
legacy_sexagesimal.rs | 19 | YAML 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/:
| Target | Coverage |
|---|---|
fuzz_parse | Top-level from_str::<Value> panic surface |
fuzz_roundtrip | parse(emit(parse(s))) differential |
fuzz_from_value | Value → typed deserialise |
fuzz_multi_doc | load_all_as document-boundary scanning |
fuzz_strict | from_str_strict<T> unknown-key surfacing |
fuzz_diff | Differential against yaml-rust2 / saphyr / serde_yaml_ng |
fuzz_borrowed_alias | P4 alias resolution + bomb cap |
fuzz_no_span_loader | NoSpanLoader path — three-loader parity |
fuzz_yaml_v1_1 | P2 1.1 resolver bundle + individual flag overrides |
fuzz_double_quoted | Surrogate 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:
| Crate | Harnesses | What they cover |
|---|---|---|
noyalib | 16 | core parser, comparison vs yaml-rust2 / yaml-spanned / saphyr, SIMD, incremental repair, validation overhead, large-doc soak, structural bitmask, numeric parse |
noya-cli (split repo) | 1 | clap argv parse + command-tree construction |
noyalib-lsp (split repo) | 1 | textDocument/formatting + parse-for-diagnostics |
noyalib-mcp (split repo) | 1 | the 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 class | Layer |
|---|---|
| API contract / public docstring drift | doctest |
| Single-function invariant | unit test |
| End-to-end flow | integration test in tests/*.rs |
| Universal property over inputs | proptest |
| Adversarial input panics | fuzz target + regression test in tests/scanner_panic_regressions.rs |
| Memory-safety / UB | already covered — Miri runs the full suite |
| Performance regression | Criterion bench |
| Spec compliance gap | add 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
- MSRV (Minimum Supported Rust Version)
- SemVer & API stability
- Security & audits
- Performance & algorithmic complexity
- Concurrency guarantees
- Platform support
- Feature-flag matrix
- Panic policy
- Error model
- Dependency policy
- Release & changelog policy
1. MSRV (Minimum Supported Rust Version)
| Crate | MSRV | Rationale |
|---|---|---|
noyalib (library core) | 1.89.0 | One 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-mcp | 1.89.0 | Lockstep 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.0 | Lockstep with the core floor; clap_builder 4.6 is edition-2024 and requires 1.86+. |
noyalib-lsp | 1.89.0 | Lockstep with the core floor. |
noyalib-wasm | 1.89.0 | Lockstep 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.0was the next planned cut; under the current0.0.xposture there is no minor slot to spend, andSUPPORT.mdalready warns that a patch may carry breaking changes in0.x. Treating MSRV bumps as patches keeps the version line monotonic and honest rather than reserving a semantic that the0.0.xscheme cannot express. This changes at 1.0: once the crate is1.x, an MSRV bump becomes a genuine minor-version event again, per the1.0gates inPLAN.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 incrates/. Verified byRust CI / Check & Teston every PR.- No C dependencies, no FFI calls. No
libyaml, nolibc, no transitively-unsafeparser. The runtime deps that do useunsafe(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
RUSTCandNOYALIB_COVERAGE). The library reads files only when asked to:SafeFileResolver(include_fs) under its root, and thefigmentprovider’sYaml::fileandYaml::file_with_config. JSON Schema$refnever resolves outside the schema document. Thenoya-clibinaries read and write files.
Resource-limit gates
The parser has explicit DoS guards. Defaults are conservative;
override via ParserConfig.
| Limit | Default | strict() | Purpose | Override |
|---|---|---|---|---|
max_depth | 128 | 64 | Stack-overflow guard on deeply-nested input | ParserConfig::max_depth(N) |
max_alias_expansions | 1024 | 100 | Billion-laughs amplification cap | ParserConfig::max_alias_expansions(N) |
max_document_length | 64 MiB | 1 MiB | Per-document size cap | ParserConfig::max_document_length(N) |
max_stream_bytes | 256 MiB | 16 MiB | Multi-document stream size cap | ParserConfig::max_stream_bytes(N) |
max_nodes | 250000 | 25000 | AST node-count flood cap (empty-collection bombs) | ParserConfig::max_nodes(N) |
max_sequence_length | 65536 | 1024 | Per-sequence item count cap | ParserConfig::max_sequence_length(N) |
max_mapping_keys | 65536 | 1024 | Per-mapping key count cap | ParserConfig::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_lenientrejects a----marker scan beyondParserConfig::max_documentsto defeat marker-spam inputs, and caps the cumulative line-truncation-retry cost atLenientConfig::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 beyondmax_document_lengthfor a single document ormax_stream_bytesfor 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::YamlDecoderexposesmax_frame_size(usize): when the inter-frameBytesMutholds an incomplete document beyond the cap, the nextdecodecall returnsError::Io(InvalidData)rather than letting an adversarial producer pin memory by streaming without---. Constructors derive the cap fromParserConfig::max_document_length; callers may tighten it, andmax_document_lengthcaps a frame whatevermax_frame_sizesays. 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 previousdecodestopped, so a frame arriving in many small reads is scanned once, not once per read.sval_adapterforwards non-finite floats verbatim by default; useto_sval_writer_with_configwithSvalConfig::coerce_non_finite_to_nullto emitNullinstead, required for downstreams likesval_jsonthat 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’sfinishedflag. Serde visitors that callednext_entryafterOk(None)—ValueVisitor::visit_mapdoes this — re-entered the iterator, read the next event from the parent mapping, and treated it as the inner exhausted child’s. The recursivedeserialize_anyon each spilled value inflatedself.depthby one per empty flow{}, so apnpm-lock.yaml-shaped input with N consecutive{}hit the limit at exactly N =max_depth. Fixed: explicitif self.finished { return Ok(None) }guards on the three iterators; balanced decrement onOkandErrindeserialize_any/_seq/_map. - Under-count, silent bypass. The
NoSpanLoaderpath (value-target fast path,no_stdmulti-document loading) incrementedself.depthonSequenceStart/MappingStartbut did not compare againstmax_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 viadeny.toml).cargo vet(vetted-deps register atsupply-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-rust2for divergent acceptance behaviour). - Miri (focused) — verifies the supply-chain
unsafeinteraction surface is sound.
Weekly (or on-demand) jobs:
- Soak fuzz (per-target).
- Soak Miri (full suite + big-endian via mips64 cross).
Supply-chain provenance
- All releases on crates.io carry npm-provenance-style attestations when published from CI (cosign keyless via the GitHub Actions OIDC issuer).
- Verification recipe in
pkg/VERIFY.md.
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:
| Check | Status | Action |
|---|---|---|
| Token-Permissions | ✓ fixed | Top-level workflow tokens demoted to contents: read; writes scoped per-job. |
| Pinned-Dependencies | ✓ fixed | Every 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 | ✓ fixed | serde_yml / libyml dropped from the bench dev-deps in v0.0.6 (RUSTSEC-2025-0067 + -0068); Cargo.lock is now clean. |
| CII-Best-Practices | external | Apply 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-Review | partial 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-Protection | external | Repo-admin UI configuration required: enable “require approvals”, “require codeowners review”, “last push approval”. |
| Contributors | external | Improves 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.
| Operation | Throughput | Big-O | Notes |
|---|---|---|---|
from_str::<Value> (parse + AST) | ~36 MB/s | O(n) in input bytes | streaming-first; AST walks the event stream in one pass |
from_str::<T> (typed) | ~50–80 MB/s | O(n) | bypasses the AST when the target is fully typed |
cst::parse_document (CST) | ~21 MB/s | O(n) | builds green tree alongside parse |
to_string (emit) | ~150 MB/s | O(n) in tree size | single-pass write, no re-parse |
Document::set(path, value) | sub-ms on 1 MB inputs | O(d + len(path)) | d = path depth |
Algorithmic guarantees
- Parser: single pass,
O(n)time,O(d)stack (d= nesting depth, capped atmax_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 bymax_document_lengthto defeat billion-laughs amplification. - Owned mapping:
IndexMap-backed with randomized hashing on hosted builds; key insertion isO(1)amortised and ordered iteration isO(n). Allocation-onlyno_stdtargets retain deterministic Fx hashing. - Path lookup (
Document::get(path)):O(d + len(path))worst case wheredis path-depth (no cross-cuts). - Schema validation:
O(n × s)wheren= document size ands= schema size; bounded byjsonschema’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 benchruns the criterion suite under[profile.release]withopt-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
| Type | Send | Sync | Notes |
|---|---|---|---|
Value, Mapping, Number, Tag, TaggedValue | yes | yes | All-owned, no interior mutability. |
Error | yes | yes | All-owned (boxed inner data). |
cst::Document | yes | yes | Immutable after construction; set takes &mut self. |
ParserConfig, SerializerConfig | yes | yes | Plain config structs. |
ParserConfig::policies (Arc<dyn Policy>) | yes | yes | The Policy trait inherits Send + Sync. |
Deserializer<'de> | yes ('de: 'static) | yes | Borrows the source; lifetimes propagate. |
Serializer<W> | depends on W | depends on W | Inherits 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 theparallelfeature). 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
Deserializeritself is notSync-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)
| Target | Toolchain | Notes |
|---|---|---|
aarch64-apple-darwin | stable, nightly | M-series Macs (developer host) |
x86_64-unknown-linux-gnu | stable, nightly | GitHub ubuntu-latest runners |
x86_64-pc-windows-msvc | stable, nightly | GitHub windows-latest runners |
Tier 2 (verified on demand / weekly)
| Target | Verification |
|---|---|
mips64-unknown-linux-gnuabi64 | Miri (big-endian sanity, scheduled job) |
wasm32-unknown-unknown | wasm-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.
| Target | Verification |
|---|---|
thumbv7em-none-eabihf | cargo check --no-default-features --lib --locked --target … (per-PR) |
riscv32imac-unknown-none-elf | same |
aarch64-unknown-none | same |
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 minimalwhen you wantno_std.minimalis defined asminimal = ["std"]— it is a dependency-budget alias that turnsstdback on while droppingitoa,ryuandserde_ignored(see §7, Feature compatibility). Combining it with--no-default-featuresyields astdbuild, 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:
indexmap,rustc-hashandmemchrare now all declareddefault-features = false, so nothing pullsstdtransitively.FxHashMap/FxHashSetarehashbrown::HashMap/HashSetwithFxBuildHasher(see theno_stdprelude inlib.rs) rather than therustc_hashaliases, which do not exist withoutstd.
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 needstd::io) are unavailable. Usefrom_slice/to_stringand route through your own writer impl.figmentintegration unavailable.mietteintegration unavailable.- The
noyavalidatebinary unavailable (it bundlesmiette/fancy).
Cross-platform behavioural notes
- Line endings: parse / emit normalize to
\nregardless 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 viastd::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, thenrename. 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.2displays as0.30000000000000004exactly the same on every platform.
7. Feature-flag matrix
noyalib (the library) — defaults: std, fast-int,
fast-float, strict-deserialise.
| Feature | Default? | What it adds | Cost (deps) |
|---|---|---|---|
std | yes | std-aware error trait, std::io reader/writer | (transitive only) |
fast-int | yes | branchless integer formatting via itoa | itoa |
fast-float | yes | branchless float formatting via ryu | ryu |
strict-deserialise | yes | from_str_strict::<T> rejects unknown keys | serde_ignored |
minimal | no | meta: std only, drops the three accelerators | (none) |
miette | no | Error::Diagnostic impl for rich CLI errors | miette |
validate-schema | no | JSON Schema 2020-12 validation | jsonschema, serde_json |
schema | no | derive helpers for schemars | schemars, serde_json |
garde | no | garde validator integration | garde |
validator | no | validator validator integration | validator |
figment | no | Figment provider implementation | figment |
parallel | no | parallel::parse::<T> on rayon | rayon |
simd | no | optional explicit SIMD acceleration | (none — uses portable_simd / std::simd via cfg) |
compat-serde-yaml | no | name-for-name shim under noyalib::compat::serde_yaml | (none) |
noyavalidate | no | meta — pulls validate-schema + miette/fancy | (transitive) |
wasm-opt | no | post-build wasm-opt pass marker (used by noyalib-wasm) | (none) |
nightly-simd | no | enables nightly-only std::simd paths in simd.rs; gracefully no-ops on stable | (none — gated by cfg(noyalib_nightly)) |
compare-saphyr | no | bench-only — pulls serde_saphyr into the comparison harness; never built into a release artefact | serde_saphyr (dev-only) |
Feature compatibility
minimalis not additive withfast-int/fast-float/strict-deserialise— usingminimalmeans you opted out of those three.cargo build --features minimalbuilds with onlystdenabled.validate-schemaandschemaboth pullserde_json; if you enable both, you get exactly one copy.parallelisstd-only — there’s no rayonno_std.compat-serde-yamlis independent and pulls no extra deps.nightly-simdrequires a nightly toolchain at build time; gracefully no-ops to portable scalar fallbacks when built on stable. Not enabled indefault— opt-in viacargo +nightly build --features nightly-simd.compare-saphyris not intended for production builds. It compiles inserde_saphyrpurely so the comparison benches incrates/noyalib/benches/comparison.rscan pitnoyalibhead-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:
unwrap/expectin tests and examples — never on user-facing code paths.- Internal invariants —
crate::error::invariant_violatedis 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. - Allocator OOM — Rust’s default behaviour. A user with a
custom global allocator that returns
nullwill see the process abort, not unwind, because we setpanic = "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 returnsResult<T, Error>, never panics on malformed input. - Every public
to_*function returnsResult<…, 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 intoanyhow/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, withderive+alloc)indexmap(>=2, <2.11) — ordered mappingsitoa(default-on optional viafast-int)ryu(default-on optional viafast-float)serde_ignored(default-on optional viastrict-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.tomlcomment block. - Every dep is checked against
cargo-vet(vetted-deps register) andcargo-deny(allow / deny / skip rules). - No wildcard version requirements (the
cargo-denyconfig 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 updateis 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 = falsemeans “deliberately parked” —cargo vet suggestwill 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 ofsuggest = falseis 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.mdfollows 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 thecargo-semver-checksgate 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-simdfeature, which requires nightly by definition.
Raising the MSRV
- A raise ships only on the breaking-change axis — during the
0.0.xseries that is the patch number (the same axis every breaking change uses; see the SemVer section of the crate docs), and from0.xonward 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_oroverOption::is_none_orcosts 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:
- Pin the dev-dependency back to the last version under our floor, if one exists that is not yanked.
- 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
- Mark with
#[deprecated(since = "x.y.z", note = "...")]. The note must name the replacement, not just say the item is deprecated. - Keep it for at least two minor releases after the marking release.
- 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_orwas 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’sincompatible_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
| Rule | Enforced by |
|---|---|
| Library builds on the declared MSRV | msrv-core, msrv-per-crate CI jobs |
| MSRV-incompatible API use | clippy incompatible_msrv |
| Deprecations carry a replacement | review |
| Removal after two minor releases | review, 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
| Criterion | Satisfied by |
|---|---|
| Project website / repository URL | https://github.com/sebastienrousseau/noyalib |
| Description / what the project does | Repository description + README.md headline |
| Stable URL for the project | The GitHub repo URL above |
| Discussion mechanism | GitHub Issues + Discussions enabled |
| License is OSI-approved | MIT OR Apache-2.0 — see LICENSE-MIT, LICENSE-APACHE |
| Project provides documentation | README.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 policy | SECURITY.md |
| Maintainer-direct contact | sebastian.rousseau@gmail.com (per SECURITY.md) |
| Public bug tracker | GitHub Issues |
| Acknowledgement of contributions | CONTRIBUTING.md, GitHub PR / issue author attribution |
Change control
| Criterion | Satisfied by |
|---|---|
| Source under version control | Git, GitHub-hosted |
| Unique version identifier per release | SemVer tags v0.0.x |
| Release notes per version | docs/release-notes/v0.0.X.md for each tagged release, indexed by docs/release-notes/README.md |
| Standardised file structure | Cargo workspace conventions; crates/, doc/, pkg/ |
| Changelog kept | CHANGELOG.md (Keep-a-Changelog format) |
Reporting
| Criterion | Satisfied by |
|---|---|
| Bug reports tracked | GitHub Issues, with templates in .github/ISSUE_TEMPLATE/ |
| Bug report responses ≤ 14 days | Issue-response SLA documented in SECURITY.md (48 h initial response) |
| Vulnerability report channel | SECURITY.md — disclosure via sebastian.rousseau@gmail.com, 48 h response |
| Security audit log | Audit reports tracked in docs/POLICIES.md § “Audit pipeline” |
Quality
| Criterion | Satisfied by |
|---|---|
| Working build system | cargo build --workspace --all-features |
| Working test system | cargo 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 integration | Same |
| New features include tests | Required by review process; enforced by cargo-machete, strict-doc gate |
| Documented coding style | Workspace-level lints in crates/noyalib/Cargo.toml; cargo fmt enforced by CI |
| Code review of every change | main ruleset requires PR + 1 approving review + code-owner review + last-push approval (post-this-commit) |
Security
| Criterion | Satisfied by |
|---|---|
| Cryptographic best practices | Releases signed via cosign keyless + SLSA Build L2 provenance attestations on every artefact |
| Inputs validated before use | Parser 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 tested | One historical CVE-equivalent (issue #46 RecursionLimitExceeded false-positive) — patched in v0.0.6 within the same release cycle |
| Security expertise consulted | Audit pipeline: cargo-deny, cargo-vet, cargo-audit, cargo-machete, CodeQL — see docs/POLICIES.md |
Analysis
| Criterion | Satisfied by |
|---|---|
| Static analysis applied | cargo clippy --workspace --all-features -- -D warnings on every PR; CodeQL on .github/workflows/security.yml |
| Dynamic analysis applied | Differential fuzz (10 s smoke per PR) + soak fuzz (1 h per target weekly); Miri (focused per PR + full weekly) |
| Coverage-guided fuzzing | cargo-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:
| Editor | Setting |
|---|---|
| VS Code | "yaml.format.command": "noyafmt --stdin" |
| Helix | [language.formatter] command = "noyafmt", args = ["--stdin"] (in languages.toml) |
| Zed | formatter: { 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 tonoya-cliand are generated in its repository, and what remains here runs fromMakefiletargets andscripts/.cargo xtaskis 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], orif: false. - Conventional Commits with signed (
-S) commits.Assisted-by:trailer is appended automatically by thecommit-msghook; 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.tomlmembers =entries. No comment edits, no formatting changes, no refactoring inside the moved files. This preservesgit blame --followaccuracy for every line.
Locked decisions
| Decision | Choice | Rationale |
|---|---|---|
| Naming (E1) | crates/noya-cli/ package, binaries stay noyafmt + noyavalidate | Two 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 + xtask | NOYA_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 only | Preserves git blame --follow; surface area is large, code semantics are unchanged. |
| Universal macOS bottle (R1) | Yes — lipo job after the two macos legs | Homebrew 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-packages | Enterprise Linux deployments need separate symbol packages for telemetry / crash unwinders. |
| Vendoring (C) | Yes — make vendor target, vendor-build CI job, documented in pkg/VERIFY.md | Air-gapped, FIPS-bound, and RHEL build chains require offline-buildable source trees. |
| CodeQL (D) | Yes — new job in security.yml | CWE coverage complements cargo-deny (license/advisory) and cargo-vet (audit chain). |
Status snapshot (cf2d877)
| Axis | Current | Target | Gap |
|---|---|---|---|
Workspace unsafe_code | forbid | forbid | — |
| YAML 1.2 conformance | 406 / 406 | 406 / 406 | — |
| Lib unit tests | 146 pass | ≥ 98 % line every package | needs gap fill |
| Integration tests | 213+ pass | — | — |
| Doc-tests | 337 pass | — | — |
cargo audit / deny / vet | clean | clean | — |
| Workspace coverage gate | 95 % fn / 92 % region / 93 % line | 98 % everywhere | +3–6 pp |
noyalib-wasm coverage | excluded | dedicated 90 % gate | needs first run |
noyalib-mcp coverage | partial exclusion | dedicated 90 % gate | needs first run |
noyalib-lsp coverage | partial exclusion | dedicated 90 % gate | needs first run |
| Rustdoc coverage | missing_docs = warn | deny, ≥ 98 % items | needs audit |
| Examples surface coverage | 56 examples | every public module | 5 modules uncovered |
| Benchmark coverage | 10 benches | every hot path | 4 paths uncovered |
| Release pipeline | crates.io + sigstore | crates.io + npm + VS Code + 14 binary targets + .deb + .rpm + .msi + Homebrew + AUR + container | major rewrite |
| Repo layout | flat | crates/ + pkg/ + docs/ + ci/ + complete/ | restructure |
Phase 0 — Pre-flight
| Gate | Status |
|---|---|
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 main | verify 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-wasmv0.0.12;noyalib-mcp,noyalib-lsp,noya-cliv0.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 rootCargo.tomlanddocs/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; binariesnoyafmtandnoyavalidatekeep their names soapt 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 --followconfusion. Single move-only commit preserves rename detection.BLAME-MOVES.mdrecords 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
| Subdir | Files | Purpose |
|---|---|---|
pkg/debian/ | control, copyright, changelog, compat, rules, README.md | Source-package metadata for upstream Debian |
pkg/rpm/ | noyalib.spec | Fedora / RHEL .spec |
pkg/arch/ | PKGBUILD.template, PKGBUILD-source.template | AUR noyalib-bin and noyalib |
pkg/homebrew/ | noyalib.rb.template | Personal tap (sebastienrousseau/homebrew-tap) → eventually homebrew-core |
pkg/nix/ | flake.nix, package.nix | nix run github:sebastienrousseau/noyalib works for any Nix user |
pkg/windows/wix/ | noyalib.wxs | MSI installer source |
pkg/windows/scoop/ | noyalib.json | Scoop bucket manifest |
pkg/snap/ | snapcraft.yaml | Snap classic confinement |
pkg/flatpak/ | io.noyalib.noyafmt.yaml | Flathub manifest |
pkg/docker/ | Dockerfile, Dockerfile.full, Dockerfile.mcp | Distroless 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, secretVSCODE_MARKETPLACE_PAT). - Open VSX (
ovsx publish, secretOPEN_VSX_PAT). - Uploaded as
noyalib-<version>.vsixto 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:
- Docker image (
pkg/docker/Dockerfile.mcp) →ghcr.io/sebastienrousseau/noyalib-mcp:<v>via the existingcontainer-publishjob. npxwrapper (pkg/npm-mcp-wrapper/): a small Node bootstrap that downloads the platform-appropriatenoyalib-mcpbinary from GitHub Releases on first run, caches in~/.cache/noyalib-mcp/. Published asnoyalib-mcpon npm. Lets AI agents and users invokenpx noyalib-mcpwithout 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)
| target | runner | toolchain | cross? | .deb | .rpm |
|---|---|---|---|---|---|
x86_64-unknown-linux-gnu | ubuntu-latest | stable | no | ✓ | ✓ |
x86_64-unknown-linux-musl | ubuntu-latest | stable | yes | — | — |
aarch64-unknown-linux-gnu | ubuntu-24.04-arm | stable | no | ✓ | ✓ |
aarch64-unknown-linux-musl | ubuntu-24.04-arm | stable | no | — | — |
armv7-unknown-linux-gnueabihf | ubuntu-latest | stable | yes | ✓ | — |
arm-unknown-linux-gnueabihf | ubuntu-latest | stable | yes | ✓ | — |
powerpc64le-unknown-linux-gnu | ubuntu-latest | stable | yes | ✓ | — |
s390x-unknown-linux-gnu | ubuntu-latest | stable | yes | ✓ | — |
riscv64gc-unknown-linux-gnu | ubuntu-latest | stable | yes | ✓ | — |
x86_64-apple-darwin | macos-13 | stable | no | — | — |
aarch64-apple-darwin | macos-14 | stable | no | — | — |
x86_64-pc-windows-msvc | windows-latest | stable | no | — | — |
i686-pc-windows-msvc | windows-latest | stable | no | — | — |
aarch64-pc-windows-msvc | windows-latest | stable | no | — | — |
fail-fast: false so one target failure doesn’t abort the rest.
4.4 Per-target steps
- Checkout at the resolved tag.
- Install toolchain (
dtolnay/rust-toolchain@master+rust-srcfor musl sysroot). - Cache (
Swatinem/rust-cache@v2, key includes target triple). - Install
crossif needed. - Build:
cargo build --release --locked --target $TARGET --bin noyafmt --bin noyavalidate. - 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 - Verify binary type via
file. - Stage tarball (or zip on Windows) including
README.md,CHANGELOG.md,LICENSE-*,COPYING,docs/*.1,complete/*,NOTICE. - Generate SHA256 + SHA512 sidecars.
- Build .deb via
cargo deb --target $TARGET --no-build. - Build .rpm via
cargo generate-rpm(x86_64-gnu + aarch64-gnu). - Build .msi via
cargo wix(windows legs). - Sign every artefact with cosign keyless.
- Attest SLSA Build L2 via
actions/attest-build-provenance@v2. - 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)
| Job | Action | Secret |
|---|---|---|
homebrew-bump | mislav/bump-homebrew-formula-action@v3 against sebastienrousseau/homebrew-tap | HOMEBREW_TAP_TOKEN |
aur-bump | KSXGitHub/github-actions-deploy-aur@v3 for noyalib-bin | AUR_SSH_PRIVATE_KEY |
scoop-bump | ScoopInstaller/GithubActions@v2 against sebastienrousseau/scoop-bucket | SCOOP_BUCKET_TOKEN |
vscode-extension | vsce publish + ovsx publish | VSCODE_MARKETPLACE_PAT, OPEN_VSX_PAT |
npm-publish | npm publish --provenance for @noyalib/noyalib-wasm | NPM_TOKEN |
container-publish | docker/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
| Secret | Purpose |
|---|---|
CARGO_REGISTRY_TOKEN | crates.io publish (existing) |
HOMEBREW_TAP_TOKEN | PR against personal tap |
AUR_SSH_PRIVATE_KEY | push to AUR |
SCOOP_BUCKET_TOKEN | push to Scoop bucket |
VSCODE_MARKETPLACE_PAT | vsce publish |
OPEN_VSX_PAT | ovsx publish |
NPM_TOKEN | npm publish (with --provenance) |
DOCKER_PASSWORD | optional; only if pushing to non-GHCR registries |
GITHUB_TOKEN | provided 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
| Channel | Submit | Effort | Auto-bumped after submission? |
|---|---|---|---|
| Personal Homebrew tap | New repo sebastienrousseau/homebrew-tap, seed Formula/noyalib.rb | 1 h | yes (homebrew-bump) |
AUR (noyalib-bin) | AUR account + SSH key, push initial PKGBUILD | 1 h | yes (aur-bump) |
| Personal Scoop bucket | New repo + initial noyalib.json | 1 h | yes (scoop-bump) |
| VS Code Marketplace + Open VSX | One-time publisher account creation | 2 h | yes (vscode-extension) |
npm scope @noyalib | One-time org / scope creation | 30 min | yes (npm-publish) |
| Homebrew core | PR against Homebrew/homebrew-core once ≥ 75 stars + ≥ 30 days + stable API | hand-filed | no — manual |
| Debian | ITP bug + DD sponsor + NEW queue (months) | weeks | no — manual |
| Fedora / RHEL | Packager account + sponsor + review request (months) | weeks | no — manual |
| openSUSE OBS | Account + initial submission | 2 h | semi-auto |
| nixpkgs | PR against NixOS/nixpkgs | 2 h | no — manual |
| Snapcraft | snapcraft register + snapcraft upload | 1 h | semi-auto via store |
| Flathub | PR against flathub/io.noyalib.noyafmt | 2 h | no — manual |
Phase 5 deliverables: ad-hoc, multi-week. Each distro tracked separately as it lands. Effort: months of intermittent maintainer work.
Runbook:
pkg/PUBLISH.mdis 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
| File | Content | Effort |
|---|---|---|
docs/USER-GUIDE.md | Long-form tutorial complementing README. | 1 d |
docs/MIGRATION-FROM-SERDE-YAML.md | Name-by-name mapping. | 0.5 d |
docs/ARCHITECTURE.md | Parser → AST → CST → output. | 1 d |
docs/noyafmt.1.adoc, docs/noyavalidate.1.adoc | Asciidoc man pages. | 1 d |
pkg/VERIFY.md | cosign + SLSA + vendoring cookbook. | 0.5 d |
SECURITY.md extension | Verification commands. | 0.25 d |
CONTRIBUTING.md extension | “How to add a new packaging target”. | 0.5 d |
README.md install table | All 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).
7.6 Intra-doc link strictness
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:
| Gate | Phase 0 | 1 | 2 | 3 | 4 | 7 |
|---|---|---|---|---|---|---|
cargo build --workspace --all-features | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
cargo test --workspace --all-features | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
cargo clippy --workspace --all-targets --all-features -- -D warnings | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Coverage gate | 95/93/92 | ratchet to 98 | 98 | 98 | 98 | 98 |
cargo doc (no warnings, no broken intra-doc links) | warn | deny | deny | deny | deny | deny |
| 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
| Phase | Effort | Calendar |
|---|---|---|
| 0 — pre-flight | done | day 1 |
| 1 — restructure (one zero-logic commit) | 1 d code + 1 d review | days 2–3 |
| 2 — tooling (build.rs + xtask hybrid) | 1.5 d | days 4–5 |
| 3 — packaging templates + VS Code + npm + MCP wrappers | 3 d | days 6–8 |
| 4 — release pipeline + universal + debug-info + npm + .vsix | 5 d | days 9–13 |
| 5 — distro publishing | weeks (per distro) | weeks 3+ |
| 6 — documentation | 1 wk (intermittent) | weeks 3+ |
| 7 — hardening (vendor + CodeQL + intra-doc + per-crate MSRV) | 1.5 wk | weeks 3–4 |
Total focused engineering: ~4 working weeks before distro outreach kicks off.
Execution checklist
| # | Phase | Lands | Estimated commits |
|---|---|---|---|
| 1 | 0 | branch protection verification | 0 — config only |
| 2 | 1 | restructure (single zero-logic commit) | 1 |
| 3 | 1.5 | missing_docs = "deny" + audit fixes | 1 |
| 4 | 2 | xtask + build.rs hybrid | 1 |
| 5 | 3 | pkg/ templates (deb/rpm/arch/nix/win/snap/flatpak/docker) | 1 |
| 6 | 3.10 | pkg/vscode/ extension scaffold | 1 |
| 7 | 3.11–12 | pkg/npm-mcp-wrapper/ + package.json.tmpl | 1 |
| 8 | 4 | release-binaries.yml workflow_dispatch + dry-run | 1 |
| 9 | 4.1.b | flip release-binaries.yml to auto-on-tag | 1 |
| 10 | 4.6 | lipo-universal job | 1 |
| 11 | 4.7 | promotional jobs (Homebrew/AUR/Scoop/VS Code/npm/container) | 1 |
| 12 | 7.1 | reproducible builds + verify-reproducible job | 1 |
| 13 | 7.3 | cargo vendor + make vendor + vendor-build job | 1 |
| 14 | 7.4 | CodeQL job | 1 |
| 15 | 7.5 | msrv-per-crate.sh + CI job | 1 |
| 16 | 7.6 | strengthened RUSTDOCFLAGS in docs job | 1 |
| 17 | 7.7 | cargo about + NOTICE shipping | 1 |
| 18 | 6 | docs/USER-GUIDE.md, docs/MIGRATION-FROM-SERDE-YAML.md, docs/ARCHITECTURE.md | 3 |
| 19 | 6 | man-page asciidoc sources | 1 |
| 20 | 5 | distro 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.
| Band | Theme | API contract |
|---|---|---|
0.0.x | Foundation & hardening (Phases 0–7 above) | Exploratory; may break per patch, noted in CHANGELOG.md. |
0.1 → 0.8 | Capability maturation & API convergence | Each minor is a coherent capability milestone plus accumulated field feedback. Breaking changes allowed, increasingly rare, always with migration notes. |
0.9.x | API-freeze candidates | Feature-complete for the 1.0 scope; bugfixes and polish only. A public, written 1.0 API review opens here. |
0.9.999 | Pre-1.0 soak / extended RC | The 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-checksclean across that window; no#[doc(hidden)]or__-prefixed escape hatches inside the1.0surface.- 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 shortGOVERNANCE.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.mdcookbook 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.0and a deprecation cycle. - A
1.xLTS 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 fns | 488 |
| 142 test files | 161 |
| 77 examples | 79 |
| 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, unqualified | partly done — see EPIC A |
| D1 bare-metal → M3 (v0.0.21) | landed in v0.0.20, closed #210 |
| #210 open | closed |
| 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
| Category | Now | Evidence (counted at v0.0.21) | Gap to 10/10 |
|---|---|---|---|
| API / functionality | 9 | 488 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 / testing | 9.5 | 161 test files, 5 961 tests, coverage gate (95 fn / 94 line / 93 region), Miri, differential fuzz vs saphyr | Fuzz is a PR smoke, not continuous; no structured fuzzers for the editors; property-test breadth uneven |
| Performance | 9 | 16 benches, SIMD, fast-int/fast-float, parallel | No published numbers; no CI regression gate; no criterion baselines |
| Security / supply-chain | 9 | cargo-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. |
| Documentation | 9 | rustdoc-strict + broken-intra-doc-link gate, USER-GUIDE.md, ADRs, 79 examples | No docs.rs feature-matrix proof; no cookbook; no competitive comparison page |
| no_std / portability | 10 | no_std+alloc; wasm32 and bare-metal thumbv7em / riscv32imac / aarch64-unknown-none build (v0.0.20) and are gated in CI (v0.0.21) | — |
| DX / ergonomics | 8.5 | miette/ariadne diagnostics, typed path API, recovery | No derive helpers/builders; fix-hints not uniform across the error taxonomy |
| Interop / ecosystem | 9 | serde, compat-serde-yaml shim, figment, schemars, garde/validator, tokio, sval | Successor position is earned but unclaimed — see EPIC F1 |
| Satellites | 7.5 | wasm/mcp/lsp/cli all shipping | Each v0.0.x; noyalib-mcp predates the 2026-07-28 MCP spec; no npm/registry packaging |
| Release / governance | 9.5 | ADR-0005 lockstep, signed commits, Keep-a-Changelog | MSRV/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_subtreedoes not exist. - (5) Quoting-aware fragment emit — closed in v0.0.29.
The
_valueinserters already quoted viaEmit;setgained a structural oracle in v0.0.21; and v0.0.29 closed the remainder:insert_entryruns under the same oracle (its new-key splice was entirely unguarded — a loneU+000Din the fragment escaped, and the key half spliced verbatim), keys refuse<</non-printables and auto-quote likerename_key, andguarded_insertpins container growth to exactly one entry, sopush_back("s", "v\n - w")can no longer append two items inside the elided container. Pinned bytests/cst_insert_containment.rs. The lesson from the v0.0.21removebug 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
removedata-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, soremove("a.x")ona: {x: 1, y: 2}deleted the whole document and returnedOk. 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
$refis refused, and recursion is bounded (thejsonschemacrate 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 bytests/schema_hardening.rs, which pins both. An earlier revision of this roadmap claimed the bounds were missing; they were merely unguarded. build.rsunaudited — stale; closed before this revision. Thebuild-script-contractCI 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-floatclaims. - SIMD
unsafeaudit.simd/nightly-simdopt out ofunsafe_code = forbidwith 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)withCommentPosition ∈ {Inline, Before}, leading blocks written at the node’s own indentation. Deferred: a separateinsert_commentand aTrailingposition —set_commentcovers 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
- Accept: flow-member removal rewrites the flow collection rather than
the line; a fuzz target removes a random path and asserts
A4. Fragment containment— done, v0.0.21. Investigation changed the shape of this task twice.- Routing
setthroughEmitwould have broken its documented contract: it splices verbatim on purpose, andset(p, "{x: 1}")turning a scalar into a mapping is legitimate use. The safe route already exists —set_valuerenders 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 keycand returnedOk, 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_backandinsert_afterhad the same hole —push_back("s", "v\nqq: 7")appended to the sequence and gave the document a top-levelqq. Both now run throughguarded_insert, which elides the container being inserted into (its shape must change) and requires everything else to match; forinsert_after("items[2]", ..)the container is the parent sequence.insert_entrywas already covered, delegating to the guardedset. - Property tests at 512 cases each now generalise the round-trip claim
beyond enumerated inputs: arbitrary scalars survive
set_valueat top level and nested, andsetnever silently corrupts — whatever it returns, siblings survive and the entry count holds.
- Routing
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
arbitrarytargets 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.mdwith numbers againstserde-saphyr,yaml-rust2and (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
unsafeinvariants; Miri over the scalar-fallback equivalence tests; asimd == scalardifferential 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 pullingstdunconditionally;FxHashMap/FxHashSetbeing std-only aliases;indexmap’s default hasher being std-only (worked around with a cfg-defaulted alias so no public signature changed); andcorehaving nof64::fract/mul_add.D2. Keep it built— done, v0.0.21.shared-no-std.ymlgains abare-metalmatrix overthumbv7em-none-eabihf,riscv32imac-unknown-none-elfandaarch64-unknown-none. Recorded there: wasm32 passed throughout the #210 bug, because the target hasstdavailable and masked three dependencies pulling it in unconditionally — so wasm32 alone was never sufficient cover.- D3.
alloc-free surface audit — document which APIs needallocvs purecore. 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-provenancereaches 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.— done, v0.0.21. The module comment states what it may do (declare cfgs, readbuild.rscontract$RUSTC --version, read one env var) and what it must never do; abuild-script-contractCI 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_yamlis archived andserde_ymlnow ships a shim pointing here; noyalib is already named alongsideserde-saphyrandyaml-rust2in community “what should I use now” threads. Convert that intoMIGRATING-FROM-SERDE-YAML.mdwith a shim parity table, a one-lineCargo.tomlswap 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-saphyrdeliberately has noValueDOM and deserialises straight into typed structs, which is faster and leaner forfrom_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-saphyrmarkets its typed-schema approach as the answer toNO → 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.rsnow pins six properties — external$refrefused, 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/$defsstill resolving, and ordinary schemas unaffected — andschema_validate.rsdocuments 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
- Still open: a configurable depth bound and an explicit
validation-time budget. Today’s limit is
- 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.
noyaeditexposing 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-binstallmetadata, Homebrew tap, officialpre-commit-hooks.yaml. - CLI-4.
--format json|sariffornoyavalidateso 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.
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).- JSONPath / JMESPath-style query over
ValueandDocument. - Deterministic canonical form (
to_canonical) for hashing and signing payloads — increasingly relevant as config gets signed rather than trusted. - YAML↔JSON lossless-where-possible converters, with anchor/merge behaviour documented.
- Schema inference — emit a JSON Schema or
structskeleton from a sample document. Pairs with G3: infer, then serve as a tool schema. - Merge-key (
<<) round-trip policy as a documented, tested surface. - 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
removedata-loss fix. Open: A4 quoting-awareEmit— 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.rscontract, 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
| Category | Reaches 10 at |
|---|---|
| no_std / portability | M2 ✅ (M4 to keep it there) |
| API / functionality | M3–M4 (needs A3 + A4) |
| Correctness / testing | M4 |
| Security / supply-chain | M4 |
| Interop / ecosystem | M5 |
| Documentation | M5 |
| Performance | M5 |
| Satellites | M5 |
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-generatorcase met that bar; most will not.) - Chase
serde-saphyron 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.
| Category | Score | Evidence | Remaining gap |
|---|---|---|---|
| Identity and README | 9/10 | Canonical README structure, verified version snippets, security and stability sections, license and MSRV badges | Add a dedicated CI assertion for the canonical heading sequence |
| Documentation | 10/10 | docs/, mdBook deployment, DEVELOPMENT.md, architecture, ADRs, migrations, strict link checks | None against the current rubric |
| Build and install UX | 9/10 | Native Cargo flow, discoverable Makefile, examples, docs, SBOM, and offline tasks | The binary install contract belongs to noya-cli; this library has no GNUmakefile |
| Releases and binaries | 10/10 | Tag release workflow, mandatory signed-tag verification, trusted registry publication, checksums, Sigstore, SLSA, and CycloneDX SBOM | None against the current rubric |
| Packaging and distribution | 7/10 | Maintainer packaging guide, Debian and Fedora submission records, plus a fresh-runner byte-for-byte .crate reproduction gate | No completed multi-distribution tracking |
| Quality gates in CI | 10/10 | OS and toolchain matrices, strict lint/docs, coverage floors, feature powerset, fuzz replay, examples, benchmarks, and API checks | None against the current rubric |
| Supply chain and security | 9/10 | Private disclosure, dependency review, audit/deny/vet, pinned actions, Scorecard, keys, REUSE, SLSA, and SBOM | Record a live Scorecard result of at least 9 as release evidence |
| Community and governance | 9/10 | Governance files, templates, citation, agent rules, editor and pre-commit policy, docs lint, devcontainer, family scorecard | Measure 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
- Add a cheap README-template conformance check to the documentation CI job.
- Record Repology or equivalent distribution coverage once two distributions track the relevant package.
- 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
A YAML 1.2 parser and serialiser for Rust, with full
serde integration and zero unsafe code.
Contents
Getting started
- Install — Cargo, source
- Requirements — toolchain floor, platforms,
no_std - Quick Start — parse and serialise in ten lines
The noyalib ecosystem (library + five satellite crates)
- The noyalib ecosystem —
noyalib,noya-cli,noyalib-lsp,noyalib-mcp,noyalib-wasm,noyalib-serde-yamlat a glance
Library reference
- One-minute migration from
serde_yaml(and the wider ecosystem) — name-for-name mapping forserde_yaml0.9,serde_yml,yaml_serde,serde-yaml-ng,serde-norway,serde-yaml-bw,serde-saphyr,yaml-spanned - Why this approach? — design rationale
- Capabilities at a glance — the current surface by theme
- Two APIs, one parser — data binding vs. tooling
- Ecosystem comparison — short matrix; full table at
docs/COMPARISON.md - Benchmarks — headline numbers; full table at
docs/BENCHMARKS.md - Features — module-level capability list
- Custom tags (“just data”) —
Value::Tagged, untag, registry - Library Usage — deserialise, serialise, values, spans
- Configuration — parser and serialiser options
- Examples — runnable example index
Operational
- When not to use noyalib — limitations
- Development — make targets, fuzzing, CI
- Security — guarantees and compliance
- Documentation — all reference docs
- Stability guarantees — SemVer axis, output stability, MSRV discipline
- License
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).
| Channel | Install |
|---|---|
| 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.
| Feature | Pulls in | Adds | Documented in |
|---|---|---|---|
std (default) | — | from_reader, to_writer, Spanned<T>, CST module | Install |
miette | miette 7 | Rich terminal diagnostics with source spans | Library Usage, examples/diagnostic.rs |
ariadne | ariadne | Alternative ariadne-rendered diagnostics | examples/ariadne_diagnostic.rs |
include | — | $include directive resolution via an in-memory resolver | examples/include_directive.rs |
include_fs | include + std | Filesystem include resolver (SafeFileResolver) | examples/include_directive.rs |
schema | schemars, serde_json | JsonSchema 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-schema | schema + jsonschema | validate_against_schema, coerce_to_schema | Governance: schema-driven autofix |
figment | figment 0.10 | noyalib::figment::Yaml provider | examples/figment.rs |
garde | garde 0.23 | Validated<T> wrapper | examples/validation_garde.rs |
validator | validator 0.21 | ValidatedValidator<T> wrapper | examples/validation_validator.rs |
lossless-float | — | LosslessFloat — refuse-to-lose-precision float, the floating-point sibling of lossless-u64 | — |
parallel | rayon 1.10 | parallel::parse<T>, parse_with_config<T>, and parse_with_config_in_pool<T> for bounded ----separated streams | Benchmarks |
recovery | — | noyalib::recovery::parse_lenient — best-effort tree + error list for LSP / IDE half-typed documents | examples/recovery_lenient.rs, benches/v006_features.rs |
arbitrary | arbitrary 1 | arbitrary::Arbitrary for Value, Number, Tag, TaggedValue, Mapping: structure-aware fuzz targets and property tests build valid trees from one generator | fuzz/fuzz_targets/fuzz_value_roundtrip.rs |
sval | sval 2 | impl sval::Value for Value / Number / Mapping / MappingAny / TaggedValue, noyalib::sval_adapter::to_sval_writer | examples/sval_streaming.rs, benches/v006_features.rs |
tokio | tokio, tokio-util, bytes | Bounded async readers, backpressured AsyncYamlStream, and the lower-level YamlDecoder codec | examples/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 unconditionally | Benchmarks |
nightly-simd | simd (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-in | When 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-saphyr | serde-saphyr (dev only) | Cross-library bench comparison arms | benches/comparison.rs |
wasm-opt | — | Build-time flag opting the noyalib-wasm bundle into wasm-opt size passes (no API surface) | sebastienrousseau/noyalib-wasm |
noyavalidate | std + miette + validate-schema | Enables the schema-validation surface the noyavalidate CLI is built on | docs/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-corebuilds the core surface on 1.89.0, andmsrv-per-cratechecks 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.
stdis optional. Withdefault-features = falsethe core builds oncore+alloc; CI checkswasm32-unknown-unknown,thumbv7em-none-eabihf,riscv32imac-unknown-none-elf, andaarch64-unknown-noneon 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.
| Crate | MSRV | Why |
|---|---|---|
noyalib (core lib) | 1.89.0 | One 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-mcp | 1.89.0 | Lockstep with the core floor; the MCP SDK alone needs 1.88. |
noya-cli (binaries) | 1.89.0 | Lockstep with the core floor; clap_builder 4.6 (a transitive of clap = "4.5") ships in edition 2024. |
noyalib-lsp | 1.89.0 | Lockstep with the core floor; LSP transport-stack transitives (litemap, uuid) require recent stables. |
noyalib-wasm | 1.89.0 | Lockstep with the core floor; the wasm-bindgen 0.2 ecosystem floors at 1.86. |
noyalib-serde-yaml | 1.89.0 | Lockstep 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.
| Crate | What it is | Use case |
|---|---|---|
noyalib | Library — YAML 1.2 parser, serializer, lossless CST, JSON Schema validator | Embed 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 server | Editor integration — VS Code, Neovim, Helix, Emacs, Zed, Sublime, IntelliJ. |
noyalib-mcp (own repo) | Model Context Protocol server | LLM agent tooling — Claude Desktop, Cursor, Continue.dev, Zed assistant, mcp.run. |
noyalib-wasm (own repo) | wasm-bindgen wrapper around the library | Browser, 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 shim | Migrate 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:
- CLI:
noya-cli— flags, exit codes, recipes. - LSP:
noyalib-lsp— capabilities, editor configs. - MCP:
sebastienrousseau/noyalib-mcp— tools, host configs (split repo since v0.0.13; strict lockstep per ADR-0005). - WASM:
sebastienrousseau/noyalib-wasm— JS API, bundling (split repo since v0.0.12).
Per-host quick links
| If you use… | Drop-in config |
|---|---|
| VS Code / JetBrains / Neovim / Helix / Emacs / Zed / Sublime | editor configs in noyalib-lsp/examples/ |
| Claude Desktop / Cursor / Continue.dev / Zed assistant / hosted MCP | client configs (noyalib-mcp repo) |
| GitHub Actions / pre-commit / Helm / Compose / pyproject-adjacent YAML | validation gates in noya-cli/examples/ |
| Vite / Webpack / Next.js / Cloudflare Workers / Deno / Bun | bundling 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/Aand 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.9 | noyalib |
|---|---|
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_string | noyalib::to_string |
serde_yaml::to_writer | noyalib::to_writer |
serde_yaml::to_value | noyalib::to_value |
serde_yaml::Value | noyalib::Value (adds a 7th Tagged variant) |
serde_yaml::Mapping | noyalib::Mapping |
serde_yaml::Number | noyalib::Number |
serde_yaml::Error | noyalib::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:
| Crate | Version | Drop-in for serde_yaml? | Migration guide |
|---|---|---|---|
serde_yml | 0.0.13 (repo archived) | mostly | MIGRATION-FROM-SERDE-YML.md |
yaml_serde | 0.10.7 | yes (Cargo package = rename) | MIGRATION-FROM-YAML-SERDE.md |
serde-yaml-ng | 0.10.0 | yes | MIGRATION-FROM-SERDE-YAML-NG.md |
serde-norway | 0.9.42 | yes | MIGRATION-FROM-SERDE-NORWAY.md |
serde-yaml-bw | 2.5.7 | no (breaking 2.x; 8-variant Value with Alias) | MIGRATION-FROM-SERDE-YAML-BW.md |
serde-saphyr | 1.2.0 | no (no Value DOM, streaming-only) | MIGRATION-FROM-SERDE-SAPHYR.md |
yaml-spanned | 0.0.3 | no (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:
-
Streaming-first deserialise. The default
from_str<T>path walks parser events directly into the typed target. Theserde_yaml-shaped pattern isparse → 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 typedT. The dynamicValuetree is still available when callers want it, but it is no longer in the hot path. -
#![forbid(unsafe_code)]at the workspace root. There is no FFI to a C library, no raw-pointer dereferences, and nounsafeblocks in the parser, scanner, formatter, or CST. CI enforces the attribute on every push. Most popular Rust YAML crates wraplibyamlvia C-FFI; theunsafeblocks 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+. Withnightly-simdon, the structural-bitmask scanner widens to a 32-byteSimd<u8, 32>chunk and walks delimiters viamask.trailing_zeros()— the same shape that powerssimdjson. - SWAR decimal parsing. The plain-scalar integer resolver folds
8 ASCII digits per
u64cycle via three pair-wise multiply-add phases. ~2× faster than<i64 as FromStr>::from_stron 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.
| Theme | Headline deliverables |
|---|---|
| Spec compliance | YAML 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_yaml | behavioural 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 scalars | First-class !!binary tag; RFC 4648 base64 round-trip with serde_bytes::ByteBuf/Bytes; non-UTF-8 payloads supported |
| Flatten guard | Spanned<Value> in #[serde(flatten)] returns an actionable error pointing at the working alternative |
| Lossless editing | Side-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 management | Document::anchors() / aliases() / aliases_of(name); materialise_alias_at(byte_pos) and materialise_aliases_of(name) for breaking aliases |
| Schema codegen | schema feature: JsonSchema derive re-export; schema_for::<T>() and schema_for_yaml::<T>(); honours #[doc], #[serde(default)], #[serde(rename)] |
| Schema validation | validate-schema feature: validate_against_schema(value, schema); aggregated violations with RFC 6901 paths |
| Tooling | noyavalidate (with --schema and --fix), noyafmt, noyalib-lsp, noyalib-mcp, noyalib-wasm, noyalib-serde-yaml |
| Performance | noyalib::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 chain | SLSA 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 aValue/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 theDocumenthandle. Read YAML into a side-table CST that reproduces the source byte-for-byte, then run targeted edits likedoc.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. Seeexamples/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:
| Crate | Drop-in for serde_yaml? | Key gap vs noyalib |
|---|---|---|
serde_yaml 0.9 | (archived 2024-03) | unmaintained |
serde_yml, serde-yaml-ng, serde-norway, yaml_serde | yes (path rename) | no streaming deser, no CST, no LSP / MCP / WASM, no JSON Schema |
serde-yaml-bw | no (breaking 2.x) | no LSP / MCP / WASM, no JSON Schema |
serde-saphyr | no (no Value DOM) | no Value DOM, no CST, no LSP / MCP / WASM |
yaml-spanned | no (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):
| Fixture | noyalib | vs serde_yaml_ng | vs yaml-rust2 | vs yaml-spanned | vs serde-saphyr |
|---|---|---|---|---|---|
| Deserialise simple (3 fields) | 1.40 µs | 1.84× | 1.36× | 1.69× | 2.00× |
| Deserialise nested (20 fields) | 9.66 µs | 1.55× | 1.25× | 1.60× | 1.76× |
| Deserialise large_list (500 items) | 920 µs | 1.42× | 1.19× | 1.38× | 1.69× |
| Deserialise github_actions (deep + comments) | 46.4 µs | 1.66× | 1.25× | 1.74× | 1.73× |
| Deserialise k8s multi-document | 85.1 µs | 1.42× | 1.11× | — | — |
| Typed deserialise simple (streaming) | 1.22 µs | 1.72× | — | — | — |
| Typed deserialise nested (streaming) | 7.08 µs | 1.55× | — | — | — |
| Serialise simple | 290 ns | 4.34× | — | — | — |
| Serialise nested | 2.25 µs | 3.00× | — | — | — |
| Round-trip nested | 12.0 µs | 1.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
| Serde | from_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. |
| Values | 7-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). |
| Spans | Spanned<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. |
| Formatting | Per-value output control: FlowSeq<T>, FlowMap<T>, LitStr, FoldStr, Commented<T>, SpaceAfter<T>. |
| Enums | singleton_map, singleton_map_optional, singleton_map_recursive, singleton_map_with – custom key transforms (snake_case, kebab-case, lowercase). |
| Schemas | Validate against YAML schema levels: validate_yaml_failsafe_schema, validate_yaml_json_schema, validate_yaml_core_schema. |
| Anchors | Anchors (&), 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. |
| Security | 7 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. |
| Compat | YAML 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”. |
| WASM | Compiles 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. |
| Errors | Source 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_std | Full #![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 editing | Side-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 v2 | Document::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 codegen | schema 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 validation | validate-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 scalars | First-class !!binary tag with RFC 4648 base64 round-trip. serde_bytes::ByteBuf / Bytes work end-to-end including non-UTF-8 payloads. |
serde_yaml shim | compat-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 primitives | noyalib::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 parsing | parallel 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 policies | noyalib::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 autofix | validate-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 interner | noyalib::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 shape | Lenient | Strict |
|---|---|---|
&str | from_str | from_str_strict |
&[u8] | from_slice | from_slice_strict |
impl io::Read | from_reader | from_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:
| Need | API |
|---|---|
| Read the inner value directly, ignoring the tag | Value::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 time | policy::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));
}
| Workload | Without interning | With KeyInterner |
|---|---|---|
| 20-byte key × 100 records | 2 KB heap | 20 B + 100 × 16 B (Arc clones) ≈ 1.6 KB |
| 20-byte key × 10 000 records | 200 KB | 20 B + 160 KB ≈ 20 B for the strings |
| Distinct K8s key set × 1 000 manifests | ~520 KB | 13 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,
}
}
| Module | Purpose |
|---|---|
singleton_map | Serialize enums as {Variant: data} |
singleton_map_optional | Same, for Option<Enum> |
singleton_map_recursive | Apply recursively to nested enums |
singleton_map_with | Custom key transforms (snake_case, kebab-case) |
Formatting wrappers
| Wrapper | Effect |
|---|---|
FlowSeq<T> | Inline sequence: [a, b, c] |
FlowMap<T> | Inline mapping: {a: 1, b: 2} |
LitStr / LitString | Literal block scalar (|) |
FoldStr / FoldString | Folded 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
| Category | Example | Purpose |
|---|---|---|
| Core | hello | Struct roundtrip |
std | Vec, HashMap | |
variants | Enum strategies | |
deep | Nested structures | |
dynamic | Dynamic Value type | |
modify | to_value, from_value, get_path, MappingAny | |
tags | Singleton map enums | |
| Spec | alias | Anchors, aliases, merge keys |
smart | RcAnchor, ArcAnchor | |
overlay | Value merging | |
inherit | Merge key precedence | |
stream | Multi-document streams | |
types | Custom YAML tags | |
binary | Large ints, .inf, .nan, hex/octal | |
lossless_u64 | Opt-in Number::Unsigned for scalars above i64::MAX | |
| Security | strict | strict_booleans, DuplicateKeyPolicy |
secure | ParserConfig limits | |
schema | Schema validation | |
env | ${VAR} expansion | |
| DX | errors | Error types, diagnostics |
trace | Path tracking | |
source | Spanned<T> locations | |
style | FlowSeq, FlowMap, Commented | |
| Advanced | emit | SerializerConfig options |
rename | Custom key transforms | |
flatten | serde flatten, untagged | |
bridge | JSON <-> YAML interop | |
pipes | from_slice, from_reader, to_writer | |
global | Configuration layering | |
| Future | portable | WASM portability proof |
mask | Secret<T> redaction | |
patch | Surgical YAML patching | |
suggest | “Did you mean?” typo detection | |
schema_ext | Self-documenting config schemas | |
| Deep Rust | untagged | Polymorphic deserialization |
borrow | Zero-copy patterns | |
transcode | Value-to-Value transcoding | |
comments | Comment handling | |
async_io | Async integration | |
recursive | Self-referential types | |
| Platform | diagnostic | miette::Diagnostic integration |
nostd | #![no_std] compatibility guide | |
preserve | CST preservation foundations | |
lossless_edit | Renovate-style version bump (CST Document::set) | |
cst_wrapped_flow_edit | Removing from a flow collection wrapped one member per line | |
merge_keys_with_aliases | Merge keys (<<:) and aliases (*name) in one mapping; which spellings of << are merges | |
| Runtime | async_io | Async integration (spawn_blocking pattern) |
recursive | Self-referential types (trees, org charts) | |
| Bench | bench | Performance overview |
| Ecosystem | include | $include-key modular config (Argo CD / JSON-Schema-style refs) |
figment | Layered defaults / YAML / env via the figment Provider | |
validation_garde | Declarative validation through garde + Validated<T> | |
validation_validator | Declarative validation through validator + ValidatedValidator<T> | |
diagnostic_path | serde_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, butfrom_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/offbooleans, bare-0octal0644, sexagesimal60:00) on as a single preset; fine-grainedlegacy_*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 equivalentfeatures = ["minimal"]) drops to 5 —itoa,ryu, andserde_ignoredbecome opt-in via thefast-int/fast-float/strict-deserialisefeatures. Numeric formatting falls back tocore::fmt(slower; output remains valid YAML); thefrom_str_stricttypo-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
| Workflow | Trigger | Purpose |
|---|---|---|
ci.yml | push, PR | Clippy, fmt, test (3 OS x 3 toolchains), coverage, audit, cargo-deny |
docs.yml | push to main | Build and deploy API docs to GitHub Pages |
release.yml | tag v* | Validate, cross-verify, checksums, SBOM, GitHub Release, crates.io |
security.yml | push, PR, weekly | Dependency 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")returnsValue::Tagged(t)wheret.tag() == "!Custom"andt.value() == Value::String("hello")— the same shape that already covered tagged sequences and tagged mappings. Downstream code can read the tag viaValue::Tagged(t)pattern matching, dispatch on it, or step through it viaValue::untag_reffor 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}yieldsFoo { x: 1 }. The typed visitor never observes the tag string, so an attacker forging a tag to trigger a differentDeserializeimpl is impossible. TagRegistryfor opt-in routing. Register the specific tags your application understands; unregistered custom tags still surface asValue::Tagged(...)data. SeeTagRegistry.policy::DenyTagsfor 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:
| Budget | Default | strict() | Protects against |
|---|---|---|---|
max_depth | 128 | 64 | Stack-blowing nested structures |
max_document_length | 64 MiB | 1 MiB | Oversized payloads |
max_stream_bytes | 256 MiB | 16 MiB | Oversized multi-document streams |
max_nodes | 250 K | 25 K | AST node-count floods (empty-collection bombs) |
max_alias_expansions | 1024 | 100 | Billion-laughs amplification |
max_mapping_keys | 64 K | 1024 | Hash-collision DoS |
max_sequence_length | 64 K | 1024 | Memory-spike DoS |
duplicate_key_policy | Last | Error | Silent data loss |
strict_booleans | off | on | The “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:
| Method | Unknown placeholder | Error message echoes name |
|---|---|---|
interpolate_properties (default) | Err(Error::Custom) | yes |
interpolate_properties_redacted | Err(Error::Custom) | replaced with <redacted> |
interpolate_properties_lossy | substituted with "", never errors | n/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, nounsafeblocks anywhere.#[non_exhaustive]onParserConfig,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. Seenoyalib::policy.
Supply chain
cargo auditclean — zero advisories.cargo denyclean — license / advisory / ban / source checks.cargo vetclean — every dependency in the graph has a local audit or imported coverage from Mozilla / Google / Bytecode Alliance / Embark / ISRG audit sets.OpenSSF Scorecardtracked, badge in the header — v0.0.6 lifts the score from6.5/10to~9/10by demoting workflow tokens to least privilege, pinning every GitHub Action by SHA, wiring Dependabot, and retiring the unmaintainedserde_yml/libymlbench dev-deps. Seedocs/POLICIES.md§ OpenSSF Scorecard posture for the per-check breakdown.SLSA Build L2provenance + sigstore signing on every release.REUSE.software3.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
| Document | Covers |
|---|---|
docs/POLICIES.md | MSRV, 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.md | Umbrella 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.md | Disclosure policy, supported versions, contact, security design summary. |
CONTRIBUTING.md | Signed-commit policy, PR guidelines, local-test recipe. |
CHANGELOG.md | Per-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.md | Long-form usage guide with worked examples for every major feature. |
docs/ARCHITECTURE.md | Module map, hot-path notes, design decisions. |
docs/COOKBOOK.md | Task-shaped recipes, each pointing at the runnable example it comes from. |
docs/GLOSSARY.md | YAML / serde terminology reference. |
docs/internals.md | Library internals (parser stages, loader frames, CST green tree). |
docs/errors.md | Error 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.xseries; every breaking change is marked with!in its commit and CHANGELOG entry, andcargo semver-checksgates 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
Valueshape) is treated as a breaking change even when no Rust signature moves — spec-alignment fixes ship asfix(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.
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:
- 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. - No credit for unmeasured work. A probe that cannot run scores
N/Aand 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. - 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.mddiscloses 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.
| Surface | Incumbent | Language | noyalib’s answer |
|---|---|---|---|
| Library | serde_yaml (archived 2024) | Rust | noyalib |
| CLI query / transform | yq | Go | nothing |
| Style linter | yamllint | Python | nothing |
| Language server | yaml-language-server (Red Hat) | TypeScript | noyalib-lsp |
| Agent tooling (MCP) | no incumbent | — | noyalib-mcp |
| Browser / JS | yaml (eemeli), js-yaml | JavaScript | noyalib-wasm |
The Rust library tier alone has fragmented since serde_yaml was
archived. Recent-download counts from the crates.io API:
| Crate | Recent downloads | Status |
|---|---|---|
serde_yaml | 87.1M | archived upstream |
yaml-rust2 | 13.9M | maintained, no serde layer |
serde_yml | 6.4M | description begins “DEPRECATED” |
serde_yaml_ng | 5.0M | maintained fork |
serde-saphyr | 3.6M | maintained, newer |
serde_norway | 2.7M | maintained fork |
noyalib | 0.247M | maintained |
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
| Missing | Evidence | Incumbent |
|---|---|---|
| Query / transform tool | noya-cli declares exactly two [[bin]]: noyafmt, noyavalidate | yq |
| Style linter | noyavalidate is schema validation; no line-length, key-duplication, indentation or quote-consistency rules | yamllint |
Both are surfacing work, not engine work. The lossless CST already
tracks every span these rules need, and already backs remove/set.
Distribution
| Missing | Evidence |
|---|---|
| GitHub Action | no action.yml in any of the five repos |
| Hosted pre-commit hook | no .pre-commit-hooks.yaml in noya-cli — docs/pre-commit.md documents only repo: local, which needs noyafmt already installed |
| VS Code extension | no editors/vscode in noyalib-lsp — the server exists but nobody can install it from the marketplace |
shipped: homebrew-tap/Formula/noya-cli.rb, with Scoop and AUR (noyalib-bin) beside it | |
shipped: 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 bindings | no bindings/python |
| Native Node bindings | wasm only; no napi |
Supply-chain posture
OpenSSF Scorecard, scans taken the same day at the current HEAD of each repo:
| Repo | Score |
|---|---|
noyalib | 8.0 |
noya-cli | 6.5 |
noyalib-lsp | 6.5 |
noyalib-mcp | 6.4 |
noyalib-wasm | 6.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:
| Repo | Dependency | Upstream status |
|---|---|---|
libmake | serde_yml 0.0.13 | crates.io description begins “DEPRECATED” |
nucleusflow | serde_yml 0.0.13 | same |
wiserone | serde_yaml 0.9.31 | archived |
metadata-gen | noyalib and yaml-rust2 | half-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
noyaq(new bin innoya-cli) — query and in-place transform with ayq-compatible expression subset.noyalint(new bin innoya-cli) —yamllint’s rule set over the CST.noyalib-rules(new crate) — the rule engine behindnoyalint, 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
- First-party GitHub Action. CI is where YAML tooling is actually consumed; this is the highest-leverage single artefact on the list.
.pre-commit-hooks.yaml— one file, unlocks the entire pre-commit user base.- VS Code extension wrapping
noyalib-lsp. Homebrew tap(shipped, with Scoop and AUR) and a CLI container image.
Tier 3 — reach beyond Rust
noyalib-py(PyO3) — the linting and round-trip audience is Python-native.noyalib-node(napi-rs) — native throughput where WASM loses.
Tier 4 — cheap credibility
- Fuzz targets in each satellite.
- OpenSSF Best Practices badge for the five satellites.
- Migrate
libmake,nucleusflow,wiserone; finishmetadata-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%.
| Repo | Metric | Measured | Score | Probe |
|---|---|---|---|---|
noyalib | tests | 6635 passed / 0 failed | 1.00 | cargo test --workspace --locked |
noyalib | clippy | 0 diagnostics (rc=0) | 1.00 | cargo clippy --workspace --all-targets --locked -- -D warnings |
noyalib | rustfmt | clean | 1.00 | cargo fmt --all --check |
noyalib | unsafe_forbidden | 2/2 crate roots | 1.00 | grep -l 'forbid(unsafe_code)' on every lib.rs/main.rs |
noyalib | msrv_declared | 1.89.0 | 1.00 | grep rust-version Cargo.toml |
noyalib | rustdoc_strict | 0 warnings | 1.00 | RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features (matches CI + docs.rs) |
noyalib | missing_docs_lint | 2/2 roots deny missing_docs | 1.00 | grep for deny/warn(missing_docs) in crate roots |
noyalib | readme_examples | compile | 1.00 | scripts/check-readme-examples.sh |
noyalib | bench_methodology | 3/3 disclosed (host, toolchain, command) | 1.00 | grep host/toolchain/repro-command in docs/BENCHMARKS.md |
noyalib | audit_vulnerabilities | 0 advisories | 1.00 | cargo-audit audit --json | .vulnerabilities.count |
noyalib | deny_check | pass | 1.00 | cargo deny check (advisories, bans, licenses, sources) |
noyalib | vet_audited | pass | 1.00 | cargo vet --locked |
noyalib | reuse_compliance | compliant | 1.00 | reuse lint (REUSE 3.3) |
noyalib | dependency_closure | 12 unique runtime crates | 1.00 | cargo tree -e normal --prefix none --no-dedupe | sort -u (library budget 60; propagates to consumers) |
noyalib | actions_sha_pinned | 160/160 external uses pinned | 1.00 | grep 'uses:.*@<40-hex>' .github/workflows |
noyalib | fuzz_targets | 18 libFuzzer targets | 1.00 | find fuzz/fuzz_targets -name '*.rs' (floor 2) |
noyalib | line_coverage | not run | n/a | pass --with-coverage to measure |
noyalib | release_gpg_signed | 3 .asc assets | 1.00 | gh release view --json assets | grep '\.asc$' |
noyalib | release_sigstore | 3 .bundle assets | 1.00 | gh release view --json assets | grep '\.bundle$' |
noyalib | release_sbom | 6 sbom assets | 1.00 | gh release view --json assets | grep -i sbom |
noyalib | dependabot_open | 0 open alerts | 1.00 | gh api /repos/sebastienrousseau/noyalib/dependabot/alerts?state=open |
noyalib | ci_main_green | failure | 0.00 | gh run list --branch main --workflow CI --status completed --limit 1 --json conclusion |
noyalib | crates_io_current | tree 0.0.58 / crates.io 0.0.57 | 0.00 | crates.io/api/v1/crates/noyalib .max_stable_version |
noyalib | openssf_scorecard | 8.3/10 | 0.83 | api.securityscorecards.dev/projects/github.com/sebastienrousseau/noyalib .score |
noya-cli | tests | 122 passed / 0 failed | 1.00 | cargo test --workspace --locked |
noya-cli | clippy | 0 diagnostics (rc=0) | 1.00 | cargo clippy --workspace --all-targets --locked -- -D warnings |
noya-cli | rustfmt | clean | 1.00 | cargo fmt --all --check |
noya-cli | unsafe_forbidden | 1/1 crate roots | 1.00 | grep -l 'forbid(unsafe_code)' on every lib.rs/main.rs |
noya-cli | msrv_declared | 1.89.0 | 1.00 | grep rust-version Cargo.toml |
noya-cli | rustdoc_strict | 0 warnings | 1.00 | RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features (matches CI + docs.rs) |
noya-cli | missing_docs_lint | 1/1 roots deny missing_docs | 1.00 | grep for deny/warn(missing_docs) in crate roots |
noya-cli | readme_examples | compile | 1.00 | scripts/check-readme-examples.sh |
noya-cli | bench_methodology | 1/3 disclosed (host, toolchain, command) | 0.33 | grep host/toolchain/repro-command in docs/BENCHMARKS.md |
noya-cli | audit_vulnerabilities | 0 advisories | 1.00 | cargo-audit audit --json | .vulnerabilities.count |
noya-cli | deny_check | pass | 1.00 | cargo deny check (advisories, bans, licenses, sources) |
noya-cli | vet_audited | pass | 1.00 | cargo vet --locked |
noya-cli | reuse_compliance | compliant | 1.00 | reuse lint (REUSE 3.3) |
noya-cli | dependency_closure | 105 unique runtime crates (leaf, not scored) | n/a | cargo tree -e normal --prefix none --no-dedupe | sort -u — recorded, not scored: a leaf binary's tree is not inherited |
noya-cli | actions_sha_pinned | 77/77 external uses pinned | 1.00 | grep 'uses:.*@<40-hex>' .github/workflows |
noya-cli | fuzz_targets | 2 libFuzzer targets | 1.00 | find fuzz/fuzz_targets -name '*.rs' (floor 2) |
noya-cli | line_coverage | not run | n/a | pass --with-coverage to measure |
noya-cli | release_gpg_signed | 3 .asc assets | 1.00 | gh release view --json assets | grep '\.asc$' |
noya-cli | release_sigstore | 3 .bundle assets | 1.00 | gh release view --json assets | grep '\.bundle$' |
noya-cli | release_sbom | 6 sbom assets | 1.00 | gh release view --json assets | grep -i sbom |
noya-cli | dependabot_open | 0 open alerts | 1.00 | gh api /repos/sebastienrousseau/noya-cli/dependabot/alerts?state=open |
noya-cli | ci_main_green | success | 1.00 | gh run list --branch main --workflow CI --status completed --limit 1 --json conclusion |
noya-cli | crates_io_current | tree 0.0.58 / crates.io 0.0.57 | 0.00 | crates.io/api/v1/crates/noya-cli .max_stable_version |
noya-cli | openssf_scorecard | 8.4/10 | 0.84 | api.securityscorecards.dev/projects/github.com/sebastienrousseau/noya-cli .score |
noyalib-lsp | tests | 75 passed / 0 failed | 1.00 | cargo test --workspace --locked |
noyalib-lsp | clippy | 0 diagnostics (rc=0) | 1.00 | cargo clippy --workspace --all-targets --locked -- -D warnings |
noyalib-lsp | rustfmt | clean | 1.00 | cargo fmt --all --check |
noyalib-lsp | unsafe_forbidden | 2/2 crate roots | 1.00 | grep -l 'forbid(unsafe_code)' on every lib.rs/main.rs |
noyalib-lsp | msrv_declared | 1.89.0 | 1.00 | grep rust-version Cargo.toml |
noyalib-lsp | rustdoc_strict | 0 warnings | 1.00 | RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features (matches CI + docs.rs) |
noyalib-lsp | missing_docs_lint | 2/2 roots deny missing_docs | 1.00 | grep for deny/warn(missing_docs) in crate roots |
noyalib-lsp | readme_examples | compile | 1.00 | scripts/check-readme-examples.sh |
noyalib-lsp | bench_methodology | 1/3 disclosed (host, toolchain, command) | 0.33 | grep host/toolchain/repro-command in docs/BENCHMARKS.md |
noyalib-lsp | audit_vulnerabilities | 0 advisories | 1.00 | cargo-audit audit --json | .vulnerabilities.count |
noyalib-lsp | deny_check | pass | 1.00 | cargo deny check (advisories, bans, licenses, sources) |
noyalib-lsp | vet_audited | pass | 1.00 | cargo vet --locked |
noyalib-lsp | reuse_compliance | compliant | 1.00 | reuse lint (REUSE 3.3) |
noyalib-lsp | dependency_closure | 71 unique runtime crates (leaf, not scored) | n/a | cargo tree -e normal --prefix none --no-dedupe | sort -u — recorded, not scored: a leaf binary's tree is not inherited |
noyalib-lsp | actions_sha_pinned | 62/62 external uses pinned | 1.00 | grep 'uses:.*@<40-hex>' .github/workflows |
noyalib-lsp | fuzz_targets | 2 libFuzzer targets | 1.00 | find fuzz/fuzz_targets -name '*.rs' (floor 2) |
noyalib-lsp | line_coverage | not run | n/a | pass --with-coverage to measure |
noyalib-lsp | release_gpg_signed | 3 .asc assets | 1.00 | gh release view --json assets | grep '\.asc$' |
noyalib-lsp | release_sigstore | 3 .bundle assets | 1.00 | gh release view --json assets | grep '\.bundle$' |
noyalib-lsp | release_sbom | 6 sbom assets | 1.00 | gh release view --json assets | grep -i sbom |
noyalib-lsp | dependabot_open | 0 open alerts | 1.00 | gh api /repos/sebastienrousseau/noyalib-lsp/dependabot/alerts?state=open |
noyalib-lsp | ci_main_green | success | 1.00 | gh run list --branch main --workflow CI --status completed --limit 1 --json conclusion |
noyalib-lsp | crates_io_current | tree 0.0.58 / crates.io 0.0.57 | 0.00 | crates.io/api/v1/crates/noyalib-lsp .max_stable_version |
noyalib-lsp | openssf_scorecard | 8.4/10 | 0.84 | api.securityscorecards.dev/projects/github.com/sebastienrousseau/noyalib-lsp .score |
noyalib-mcp | tests | 124 passed / 0 failed | 1.00 | cargo test --workspace --locked |
noyalib-mcp | clippy | 0 diagnostics (rc=0) | 1.00 | cargo clippy --workspace --all-targets --locked -- -D warnings |
noyalib-mcp | rustfmt | clean | 1.00 | cargo fmt --all --check |
noyalib-mcp | unsafe_forbidden | 2/2 crate roots | 1.00 | grep -l 'forbid(unsafe_code)' on every lib.rs/main.rs |
noyalib-mcp | msrv_declared | 1.89.0 | 1.00 | grep rust-version Cargo.toml |
noyalib-mcp | rustdoc_strict | 0 warnings | 1.00 | RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features (matches CI + docs.rs) |
noyalib-mcp | missing_docs_lint | 2/2 roots deny missing_docs | 1.00 | grep for deny/warn(missing_docs) in crate roots |
noyalib-mcp | readme_examples | compile | 1.00 | scripts/check-readme-examples.sh |
noyalib-mcp | bench_methodology | 1/3 disclosed (host, toolchain, command) | 0.33 | grep host/toolchain/repro-command in docs/BENCHMARKS.md |
noyalib-mcp | audit_vulnerabilities | 0 advisories | 1.00 | cargo-audit audit --json | .vulnerabilities.count |
noyalib-mcp | deny_check | pass | 1.00 | cargo deny check (advisories, bans, licenses, sources) |
noyalib-mcp | vet_audited | pass | 1.00 | cargo vet --locked |
noyalib-mcp | reuse_compliance | compliant | 1.00 | reuse lint (REUSE 3.3) |
noyalib-mcp | dependency_closure | 134 unique runtime crates (leaf, not scored) | n/a | cargo tree -e normal --prefix none --no-dedupe | sort -u — recorded, not scored: a leaf binary's tree is not inherited |
noyalib-mcp | actions_sha_pinned | 74/74 external uses pinned | 1.00 | grep 'uses:.*@<40-hex>' .github/workflows |
noyalib-mcp | fuzz_targets | 1 libFuzzer targets | 0.50 | find fuzz/fuzz_targets -name '*.rs' (floor 2) |
noyalib-mcp | line_coverage | not run | n/a | pass --with-coverage to measure |
noyalib-mcp | release_gpg_signed | 3 .asc assets | 1.00 | gh release view --json assets | grep '\.asc$' |
noyalib-mcp | release_sigstore | 3 .bundle assets | 1.00 | gh release view --json assets | grep '\.bundle$' |
noyalib-mcp | release_sbom | 6 sbom assets | 1.00 | gh release view --json assets | grep -i sbom |
noyalib-mcp | dependabot_open | 0 open alerts | 1.00 | gh api /repos/sebastienrousseau/noyalib-mcp/dependabot/alerts?state=open |
noyalib-mcp | ci_main_green | success | 1.00 | gh run list --branch main --workflow CI --status completed --limit 1 --json conclusion |
noyalib-mcp | crates_io_current | tree 0.0.58 / crates.io 0.0.57 | 0.00 | crates.io/api/v1/crates/noyalib-mcp .max_stable_version |
noyalib-mcp | openssf_scorecard | 8.4/10 | 0.84 | api.securityscorecards.dev/projects/github.com/sebastienrousseau/noyalib-mcp .score |
noyalib-wasm | tests | 57 passed / 0 failed | 1.00 | cargo test --workspace --locked |
noyalib-wasm | clippy | 0 diagnostics (rc=0) | 1.00 | cargo clippy --workspace --all-targets --locked -- -D warnings |
noyalib-wasm | rustfmt | clean | 1.00 | cargo fmt --all --check |
noyalib-wasm | unsafe_forbidden | 1/1 crate roots | 1.00 | grep -l 'forbid(unsafe_code)' on every lib.rs/main.rs |
noyalib-wasm | msrv_declared | 1.89.0 | 1.00 | grep rust-version Cargo.toml |
noyalib-wasm | rustdoc_strict | 0 warnings | 1.00 | RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features (matches CI + docs.rs) |
noyalib-wasm | missing_docs_lint | 1/1 roots deny missing_docs | 1.00 | grep for deny/warn(missing_docs) in crate roots |
noyalib-wasm | readme_examples | compile | 1.00 | scripts/check-readme-examples.sh |
noyalib-wasm | bench_methodology | 0/3 disclosed (host, toolchain, command) | 0.00 | grep host/toolchain/repro-command in docs/BENCHMARKS.md |
noyalib-wasm | audit_vulnerabilities | 0 advisories | 1.00 | cargo-audit audit --json | .vulnerabilities.count |
noyalib-wasm | deny_check | pass | 1.00 | cargo deny check (advisories, bans, licenses, sources) |
noyalib-wasm | vet_audited | pass | 1.00 | cargo vet --locked |
noyalib-wasm | reuse_compliance | compliant | 1.00 | reuse lint (REUSE 3.3) |
noyalib-wasm | dependency_closure | 33 unique runtime crates (leaf, not scored) | n/a | cargo tree -e normal --prefix none --no-dedupe | sort -u — recorded, not scored: a leaf binary's tree is not inherited |
noyalib-wasm | actions_sha_pinned | 65/65 external uses pinned | 1.00 | grep 'uses:.*@<40-hex>' .github/workflows |
noyalib-wasm | fuzz_targets | 2 libFuzzer targets | 1.00 | find fuzz/fuzz_targets -name '*.rs' (floor 2) |
noyalib-wasm | line_coverage | not run | n/a | pass --with-coverage to measure |
noyalib-wasm | release_gpg_signed | 3 .asc assets | 1.00 | gh release view --json assets | grep '\.asc$' |
noyalib-wasm | release_sigstore | 3 .bundle assets | 1.00 | gh release view --json assets | grep '\.bundle$' |
noyalib-wasm | release_sbom | 6 sbom assets | 1.00 | gh release view --json assets | grep -i sbom |
noyalib-wasm | dependabot_open | 0 open alerts | 1.00 | gh api /repos/sebastienrousseau/noyalib-wasm/dependabot/alerts?state=open |
noyalib-wasm | ci_main_green | success | 1.00 | gh run list --branch main --workflow CI --status completed --limit 1 --json conclusion |
noyalib-wasm | crates_io_current | tree 0.0.58 / crates.io 0.0.57 | 0.00 | crates.io/api/v1/crates/noyalib-wasm .max_stable_version |
noyalib-wasm | openssf_scorecard | 8.4/10 | 0.84 | api.securityscorecards.dev/projects/github.com/sebastienrousseau/noyalib-wasm .score |
noyalib-serde-yaml | tests | 6 passed / 0 failed | 1.00 | cargo test --workspace --locked |
noyalib-serde-yaml | clippy | 0 diagnostics (rc=0) | 1.00 | cargo clippy --workspace --all-targets --locked -- -D warnings |
noyalib-serde-yaml | rustfmt | clean | 1.00 | cargo fmt --all --check |
noyalib-serde-yaml | unsafe_forbidden | 1/1 crate roots | 1.00 | grep -l 'forbid(unsafe_code)' on every lib.rs/main.rs |
noyalib-serde-yaml | msrv_declared | 1.89.0 | 1.00 | grep rust-version Cargo.toml |
noyalib-serde-yaml | rustdoc_strict | 0 warnings | 1.00 | RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features (matches CI + docs.rs) |
noyalib-serde-yaml | missing_docs_lint | 1/1 roots deny missing_docs | 1.00 | grep for deny/warn(missing_docs) in crate roots |
noyalib-serde-yaml | readme_examples | compile | 1.00 | scripts/check-readme-examples.sh |
noyalib-serde-yaml | bench_methodology | 1/3 disclosed (host, toolchain, command) | 0.33 | grep host/toolchain/repro-command in docs/BENCHMARKS.md |
noyalib-serde-yaml | audit_vulnerabilities | 0 advisories | 1.00 | cargo-audit audit --json | .vulnerabilities.count |
noyalib-serde-yaml | deny_check | pass | 1.00 | cargo deny check (advisories, bans, licenses, sources) |
noyalib-serde-yaml | vet_audited | pass | 1.00 | cargo vet --locked |
noyalib-serde-yaml | reuse_compliance | compliant | 1.00 | reuse lint (REUSE 3.3) |
noyalib-serde-yaml | dependency_closure | 10 unique runtime crates (leaf, not scored) | n/a | cargo tree -e normal --prefix none --no-dedupe | sort -u — recorded, not scored: a leaf binary's tree is not inherited |
noyalib-serde-yaml | actions_sha_pinned | 52/52 external uses pinned | 1.00 | grep 'uses:.*@<40-hex>' .github/workflows |
noyalib-serde-yaml | fuzz_targets | 2 libFuzzer targets | 1.00 | find fuzz/fuzz_targets -name '*.rs' (floor 2) |
noyalib-serde-yaml | line_coverage | not run | n/a | pass --with-coverage to measure |
noyalib-serde-yaml | release_gpg_signed | 3 .asc assets | 1.00 | gh release view --json assets | grep '\.asc$' |
noyalib-serde-yaml | release_sigstore | 3 .bundle assets | 1.00 | gh release view --json assets | grep '\.bundle$' |
noyalib-serde-yaml | release_sbom | 6 sbom assets | 1.00 | gh release view --json assets | grep -i sbom |
noyalib-serde-yaml | dependabot_open | 0 open alerts | 1.00 | gh api /repos/sebastienrousseau/noyalib-serde-yaml/dependabot/alerts?state=open |
noyalib-serde-yaml | ci_main_green | success | 1.00 | gh run list --branch main --workflow CI --status completed --limit 1 --json conclusion |
noyalib-serde-yaml | crates_io_current | tree 0.0.58 / crates.io 0.0.57 | 0.00 | crates.io/api/v1/crates/noyalib-serde-yaml .max_stable_version |
noyalib-serde-yaml | openssf_scorecard | 7.3/10 | 0.73 | api.securityscorecards.dev/projects/github.com/sebastienrousseau/noyalib-serde-yaml .score |
ecosystem | version_lockstep | core 0.0.58 not tagged yet | n/a | git tag -l v0.0.58 in noyalib (pending release; satellites pin only published versions) |
ecosystem | host_coverage | 6/6 hosts present | 1.00 | directory 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 draggedcorrectnessto 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, andspec_conformancein 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_closureused one flat budget of 60 for every repo. That is whynoya-cliscored 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;noyalibsits at 12. A leaf binary’s are not inherited, and 130 ofnoya-cli’s crates aremiette’sfancyrenderer givingnoyavalidatesource 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-Reviewcannot 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:
| File | Role |
|---|---|
README.md | identity, install, quick start, the four documentation links |
CHANGELOG.md | Keep-a-Changelog, one heading per lockstep release |
SECURITY.md | private reporting channel |
CONTRIBUTING.md | family process (satellites point at the core’s) |
DEVELOPMENT.md | developer entry point (satellites point at the core’s) |
REUSE.toml | machine-readable licensing for headerless files |
CODE_OF_CONDUCT.md, GOVERNANCE.md, SUPPORT.md | community files; satellites point at the core’s governance model |
AGENTS.md | the invariants an AI-assisted contributor must respect (lockstep, signing, CI green) |
CITATION.cff | one 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.sh | the version gate, run before every tag |
fuzz/corpus/seed | a seed corpus replayed by CI on every push |
.editorconfig, .markdownlint.yaml, .codespellrc | shared 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
| Crate | crates.io | npm | ghcr.io |
|---|---|---|---|
noyalib | noyalib | ||
noya-cli | noya-cli | sebastienrousseau/noya-cli (from v0.0.34) | |
noyalib-lsp | noyalib-lsp | ||
noyalib-mcp | noyalib-mcp | @sebastienrousseau/noyalib-mcp | sebastienrousseau/noyalib-mcp |
noyalib-wasm | noyalib-wasm | @sebastienrousseau/noyalib-wasm | |
noyalib-serde-yaml | noyalib-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
| # | Title | Status |
|---|---|---|
| 0001 | CST shape: parallel green tree, not unified | accepted |
| 0002 | YAML 1.2 strict semantics by default; 1.1 opt-in | accepted |
| 0003 | #![forbid(unsafe_code)] workspace-wide | accepted |
| 0004 | Opt in to lossless u64 integers | proposed |
| 0005 | Split workspace into 4 satellite repositories with strict-lockstep versioning | accepted |
| 0006 | Opt in to literal text for a String/char target reading a plain scalar | proposed |
| 0007 | Add prefer_single_quotes as an opt-in serializer config flag | accepted |
| 0008 | CompiledSchema: compile a JSON Schema once, validate many | accepted |
| 0009 | Document::set_path: parent-creating writes in the CST editor | accepted |
| 0010 | set_value accepts collections, emitted in the target node’s style | accepted |
| 0011 | Flow-collection inserts, flow renames, and one anchored-node policy | accepted |
| 0012 | Bracket-quoted key segments in the query-path grammar | accepted |
| 0013 | Located duplicate-key and key-collision errors as sibling variants | accepted |
| 0014 | Share one MSRV floor, 1.89.0, with the maintainer’s other crates | proposed |
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:
- 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. - 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 throughfrom_str/to_stringwill 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/TviaDeserializer— 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 viaSpanned<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_tripintegration 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::Documentfor read-only access get a more verbose surface thanValue; 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
stdfeature (uses thread-local storage for span attachment).no_stdusers 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) rowansource-of-truth implementation: https://github.com/rust-analyzer/rowantaploTOML CST: https://github.com/tamasfe/taplonoyalib::cst::DocumentAPI: 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:
| Form | YAML 1.1 | YAML 1.2 (core schema) |
|---|---|---|
yes / no / on / off | bool | string |
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→ string0644→ 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: NOis 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) — forbiddennoyalib-lsp— forbiddennoyalib-mcp— forbiddennoyalib-wasm— forbidden (the JsValue glue useswasm_bindgen’s safe wrappers)noya-cli— forbiddenxtask— 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
unsafeblock can short-circuit on theforbid(unsafe_code)attribute. cargo geigerreports zerounsafelines 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
memchrcover the realistic upper bound; the perf bench numbers versusserde_yaml_ngshow 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 fneven 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 setsunsafe_code = "forbid"socargo clippy --all-targetsenforces it across tests, examples, and benches as well assrc/.
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_u64rejectsu64values abovei64::MAX.Number::from(u64)stores large values asFloat(v as f64), losing precision above2^53.- Plain scalar resolution tries
i64first and then falls through tof64, so18446744073709551615becomes a float-shaped value instead of a YAML integer. Value’s serde bridge castsvisit_u64throughi64, 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:
u64values in(i64::MAX, u64::MAX]serialize as plain YAML integer scalars.- Plain and explicitly tagged
!!intscalars in that range parse as unsigned integers rather than floats or strings. - Typed
u64deserialization round-trips those values losslessly. - Values above
u64::MAXremain outside the integer model and must not be rounded intou64.
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-yamlmigration story remain stable for callers that do not opt in. - Negative:
--all-featuresbuilds expose a thirdNumbervariant. BecauseNumberis not#[non_exhaustive], exhaustive downstream matches that enable this feature must add anUnsignedarm. - 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/Floatmodel separately from the opt-inUnsignedmodel.
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 librarynoya-cli— thenoyafmt+noyavalidateCLI binariesnoyalib-lsp— the Language Server Protocol implementationnoyalib-mcp— the Model Context Protocol servernoyalib-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
.cratearchive on crates.io does not include the satellite source (perinclude = [...]incrates/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-wasmfrom 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
noyalibis already addressed byinclude = [...]. Splitting does not shrink the packaged.cratearchive. - 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 satellitecrates/*/directories removed)sebastienrousseau/noyalib-wasm— WebAssembly bindingssebastienrousseau/noyalib-mcp— Model Context Protocol serversebastienrousseau/noyalib-lsp— Language Server Protocolsebastienrousseau/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 withxtask). 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 workflow | Required caller permissions |
|---|---|
shared-cargo-deny.yml | contents: read |
shared-cargo-vet.yml | contents: read |
shared-cargo-machete.yml | contents: read |
shared-reuse.yml | contents: read |
shared-rustdoc-strict.yml | contents: read |
shared-test-matrix.yml | contents: read |
shared-verify-signatures.yml | contents: 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:
git clonethis repo at the pre-split tag (v0.0.11or the most recent monorepo tag)git remote add satellite-<name> <split-repo-url>for each split satellitegit fetch --tags satellite-<name>for eachgit subtree add --prefix=crates/<name> satellite-<name> mainfor each satellite that had accepted commits post-split- Restore workspace membership in the root
Cargo.tomlfrom the pre-split state cargo test --workspaceto confirm the restored shape is green- Cut a
v0.0.16release from the restored workspace - Mark each satellite crate on crates.io as
yankedat every version >= the rollback point (yank is not deletion; the monorepo re-takes precedence) - 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-mcpsees 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-lspbehaviour is unchanged: the crate name is stable, the API surface is stable, theuse 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.0namespace 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_deriveandserde_jsonseparately 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:
git clone git@github.com:sebastienrousseau/noyalib.gitat tagv0.0.11(the pre-split monorepo tip onmain).- Mocked the split by extracting
crates/noyalib-wasm/’s history viagit subtree split --prefix=crates/noyalib-wasm --branch=mock-satellite-wasm— 11 commits pulled out. - Pushed the mock satellite to a local bare repo at
file:///tmp/rollback-dry-run/mock-satellite-wasm.git— that stands in forgit@github.com:sebastienrousseau/noyalib-wasm.gitat pilot completion. - Simulated the post-split working tree:
git rm -rq crates/noyalib-wasm+ removed the corresponding"crates/noyalib-wasm"line from the rootCargo.tomlworkspace member list, then committed. - 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→ smokecargo 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-wasmproduced 11 commits on the new repo’smain. Authorship (per-commitAuthor:line) preserved intact; commit signatures do not carry through subtree, which matches the ADR’s expectation. - Strict-lockstep versioning is mechanically enforced: satellite
Cargo.tomlpinsnoyalib = { version = "=0.0.12", features = ["std"] }; Dependabot config explicitly ignores thenoyalibdep (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.ymldeclarespermissions: pull-requests: readand the caller only grantedcontents: 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-mcpproduced 14 commits on the new repo’smain. Authorship preserved. - Caller-side
pull-requests: readpermission included in the satellite’sci.ymlfrom 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’svalidatejob. - npm wrapper renamed to
@sebastienrousseau/noyalib-mcpmatching the v0.0.12 pilot’s scope decision (no@noyalibnpm 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/testsshows 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 wayignore_binary_tag_for_stringdoes. - 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"", sinceValue::Nullcarries no text) and an integer literal’s non-decimal spelling (0x1Fbecomes"31", sinceNumber::Integeronly 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 throughdeserialize_anyunchanged.
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
falseand no existing caller’s output changes. - Negative: one more flag on an already-large
SerializerConfig; contributors touchingwrite_string’s quoting decision must keep the single-quote-safety check (single_quote_safe) in sync withwrite_double_quoted’s escape set. - Neutral:
quote_allis 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
crates/noyalib/src/ser.rs—write_string,single_quote_safe,write_single_quoted,write_double_quoted.- YAML 1.2.2 §7.3.1 (Single-Quoted Style) / §7.3.2 (Double-Quoted Style): https://yaml.org/spec/1.2.2/#731-single-quoted-style
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:
jsonschemais 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
formatis an annotation unlessshould_validate_formats(true)is set at build time;validate_against_schemaexposes no way to set it, soformat: datesilently accepts01/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_schemastill 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", …)ontitle: xerrorspath not found;insert_entry("menu", "visible", …)errorspath not found: menuand 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
valuein one mapping per missing level and inserts the chain throughinsert_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 explicitnullroot fails that check and is refused byte-identical.
Refusals, all leaving the source byte-identical:
- an existing segment resolving to a scalar (
title.xwheretitleis a string) or a non-root null (filling an implicit null with a mapping stays asetfragment edit for now); - a missing segment that is a sequence index —
set_pathcreates 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_pathinherits flow support throughinsert_entry_valuewith 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_valueon 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_valueon a flow sequence splices, valuebefore]([]receives its first member);insert_after_valuesplices, valueafter the addressed item’s span;rename_keyrenames 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/removeinside an anchored value now refuse where they used to silently edit every alias site. Recorded in the CHANGELOG; callers get thematerialise_aliases_ofguidance 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_keykeeps 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:
pathis now a public module.Pathstays re-exported at the crate root;QuerySegmentandparse_query_pathstay 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.
Erroris#[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 checkkind()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-saphyrfeature (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.
| Version | Date | Notes |
|---|---|---|
v0.0.55 | unreleased | Security hardening from the v0.0.54 family audit. |
v0.0.54 | 2026-10-07 | Reader entry points bounded at max_document_length before buffering. |
v0.0.17 | 2026-07-25 | Lockstep-only release; no code change in the core crate. |
v0.0.16 | 2026-07-24 | Build fix, MSRV raise, and dependency refresh — main had been left unbuildable under cargo check --all-targets. |
v0.0.15 | 2026-07-12 | Completes loader parity and hardens coverage. |
v0.0.14 | 2026-07-09 | The loader-parity cut; closes a fast-path silent-collapse bug. |
v0.0.13 | 2026-07-05 | Splits noyalib-mcp, noyalib-lsp and noya-cli into their own repositories. |
v0.0.12 | 2026-07-03 | The workspace-split pilot; noyalib-wasm leaves the monorepo first. |
v0.0.11 | 2026-07-01 | CI-integrity housekeeping. |
v0.0.10 | 2026-06-30 | Scanner correctness for UTF-8 BOM-prefixed multi-node documents. |
v0.0.9 | unreleased | Supply-chain refresh batching eight Dependabot updates. Never tagged. |
v0.0.8 | 2026-06-17 | The FlowStyle fix. |
v0.0.7 | 2026-06-02 | Supply-chain hardening. |
v0.0.6 | 2026-05-30 | Ecosystem integration — the four remaining v0.0.6 milestone issues. |
v0.0.5 | 2026-05-11 | Polish and stabilisation: edition 2024 and MSRV 1.85. |
v0.0.4 | 2026-05-11 | Opens the v0.0.4 cycle with the !include directive. |
v0.0.3 | 2026-05-11 | Surgical patch widening the rustc-hash dependency range. |
v0.0.2 | 2026-05-10 | Zero-copy borrowed deserialisation and lazy parsing. |
v0.0.1 | 2026-05-10 | First 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.9was never tagged. Its notes are kept for continuity of the record; the published sequence goesv0.0.8→v0.0.10.