Skip to content

v0.4.3

Latest

Choose a tag to compare

@github-actions github-actions released this 17 Sep 12:22
86c9a40

[0.4.3] — 2026-09-17

Security

  • Frontmatter YAML parsing is bounded (#162). Adversarial frontmatter can no
    longer exhaust the host through the YAML parser (an abort the JS/Python FFI
    boundary cannot catch). A block over 1 MiB, or one that would expand to more
    than 200,000 YAML nodes (an &anchor referenced by many *aliases), or one
    nesting flow collections deeper than 1024 levels, is rejected with
    mds::resource_limit before the parser materialises or deep-scans it — on the
    CLI, the Rust API, napi, wasm and Python. The deep-nesting guard is checked
    before the parser runs because the parser's own depth limits are reported only
    after an O(depth²) scan is already paid. mds::compile_str/check_str/lint_str_with
    now also reject sources over MAX_FILE_SIZE (10 MiB) with mds::resource_limit;
    the bindings and the CLI already did. mds lint and scan_imports no longer
    swallow these errors (and scan_imports now reports more than 256 frontmatter
    imports entries as mds::resource_limit instead of silently omitting them);
    plain YAML syntax errors in those two paths stay best-effort as before.
    Duplicate-key and syntax-error messages are unchanged.

Changed

  • BREAKING (CLI): mds build, mds check, mds fmt and mds lint now exit
    non-zero on a directory that contains no .mds files (#204).
    build/check/fmt
    exit 1 and lint exits 2 (its usage-error code), printing `no .mds files found in ; nothing was built` (`…checked` / `…formatted` / `…linted`) on stderr — even under `--quiet`, exactly like the existing all-under-excluded-directories diagnostic. Previously an empty tree exited 0 with `No .mds files found in ` (silent under `--quiet`), so a mistyped or not-yet-populated directory passed CI green. Scripts that relied on exit 0 for an empty directory must create at least one `.mds` file or skip the call. `mds watch ` is unchanged: it starts on an empty tree and compiles files created later. The bare auto-detect form (`mds build` with no argument in a directory holding no `.mds` file) already exited non-zero; this aligns the explicit directory form.
  • BREAKING (CLI): mds build <dir> and mds check <dir> now exit 1 when every
    .mds file in the tree is a _-prefixed partial (#387).
    The walker collects
    partials (they are inputs for watch, fmt and lint) but build/check never
    compile them, so a partials-only tree used to end 0 built, 0 failed with exit 0 —
    the same silent green pass #204 closed for the empty tree. It now prints <n> .mds file(s) found in <dir> but all are _-prefixed partials; nothing was built
    (…checked) on stderr, even under --quiet, and exits 1. mds fmt <dir> and
    mds lint <dir> are unchanged: they format and lint partials, so a partials-only
    library is real work for them. mds watch <dir> is unchanged and still starts. A
    partials library that is only ever imported from elsewhere should not be passed to
    build/check on its own.
  • mds watch --debounce is now a quiet period with a hard cap (#379).
    Each content event restarts the window instead of the window expiring at a fixed
    offset from the first event, so a save burst longer than the window coalesces into
    one rebuild; the window is bounded by max(10 x window, 1 s) and 10 000 events so a
    file written to continuously still rebuilds and the idle-tick liveness probe cannot
    be starved; raw values are clamped to 60 s (--debounce 18446744073709551615
    previously watched forever without ever rebuilding); --debounce 0 still means no
    coalescing. No new output. Known cost, in both modes: an event that is not the edit
    you care about can still extend an open window, because relevance is not re-derived
    per message inside it — in directory mode every event also opens one (events under
    excluded directories are filtered only afterwards), while in file mode the entry's
    parent directory is watched non-recursively, so a sibling scratch write by an editor
    extends a window a real edit has already opened. Either way npm install churn or a
    noisy editor can delay a real edit and the idle tick by up to the cap.
  • TextEdit::new, FixLineSpan::range_inclusive and FixLineSpan::range_exclusive
    now panic on a reversed range in every build profile (#220).
    The start <= end /
    from <= to precondition was a debug-only assertion; a release caller passing a
    reversed range got a value the fix planner later skipped in silence. The rustdoc
    # Panics sections say so. No in-tree caller constructs a reversed range.

Fixed

  • Warn on duplicate keys in --vars JSON files, at every depth, on every mds watch rebuild that writes output (#326).
    mds build|check|lint|watch --vars f.json with a repeated JSON object key (e.g.
    {"x": 1, "x": 2}) previously compiled silently with the last value winning —
    serde_json's map deserializer discards the earlier value with no signal. Now every
    subcommand prints warning: key '<path>' is set more than once in vars file <file>; the last value wins for each duplicate, exit 0, suppressed by --quiet (parity with
    the existing --set/--set-string duplicate-key warning, #200). Duplicates are
    detected at every depth and reported with a dotted/bracketed key path mirroring
    {{a.b}} interpolation syntax — x, x.a, x[2].a (the array-root form [0].a
    is produced only by the internal scanner and pinned by a unit test; both load
    functions reject a non-object root before the scan runs, so a caller of
    mds::load_vars_file/load_vars_str never sees it). At most 1 000 distinct
    duplicate key paths are listed; beyond that a single tail line reports how many
    more were omitted: warning: {n} more duplicate keys in vars file <file> are not listed. mds watch reloads the vars file from disk on every rebuild,
    so its duplicate keys are re-reported on every rebuild that writes output too —
    including a duplicate introduced mid-session by editing the vars file — while
    --set/--set-string duplicate warnings keep their existing once-at-startup
    behaviour. New public mds-core API:
    mds::VarsLoad { vars, duplicate_keys, duplicate_keys_omitted } (#[non_exhaustive])
    and mds::load_vars_file_reporting_duplicates /
    mds::load_vars_str_reporting_duplicates, implemented as a second, value-free parse
    pass over the same JSON text so the already-parsed vars map is never re-derived and
    stays byte-for-byte what the existing parser produced. load_vars_file/load_vars_str
    are unchanged in every observable way (same signature, same return type, same errors)
    and now delegate to the reporting variants. Known limitations: a key containing a
    literal ., [, or ] renders ambiguously in its reported path, and an
    empty-string key renders as an empty segment; the file-load and string-load
    error codes (mds::invalid_vars vs mds::json) remain deliberately
    un-unified (pre-existing split, unchanged).
  • mds build and mds watch outputs are written atomically (#227). Compiled
    artifacts, directory-mode outputs and .map sidecars are now written to a temporary
    file in the destination directory and renamed into place — the same primitive
    mds fmt and mds lint --fix already use — so a crash, kill or write error can never
    leave a truncated file; the previous output survives until the rename. Source rewrites
    keep their fsync-before-rename; compiled outputs and sidecars use rename only
    (regenerable; an unconditional fsync made directory-mode watch startup ~3× slower on
    macOS). A first build into a new file keeps the umask default mode (typically 0644); an
    existing output keeps its mode. Consequences: an output path that is a symlink (live or
    dangling) is refused instead of written through; a destination directory that is not
    writable fails the build even when the file itself is writable; a read-only (0444)
    existing output is replaced, mode preserved; an output path that is not a regular file
    in a writable directory — for example -o /dev/null or /dev/stdout — is no longer
    accepted (use stdout, i.e. omit -o, or mds check instead); a FIFO or other special
    file at the output path is replaced by a regular file rather than opened. The spurious
    cannot get metadata message that #240 emitted on every first write of a
    not-yet-existing file is gone; a stat failure other than "not found" is now a hard
    error rather than a warning (#225). A new test, write_funnel.rs, fails CI on any raw
    fs::write/File::create in the CLI outside the one justified site (the test-only
    readiness marker in watch.rs; mds init joined the funnel in #386).
  • Fix stale lint_str rustdoc and lint-rule Tier tables (#329). mds-core's
    lint_str rustdoc said "applies the 9 lint rules" after a 10th rule
    (legacy-interpolation) had shipped; the Tier tables in lint/tier.rs and
    lint/fix.rs both omitted legacy-interpolation from Tier A. Fixed all three, and
    added a mechanised test (module_doc_tier_table_matches_rule_tier) that extracts
    every rule name and tier from both module-doc tables and asserts they match
    rule_tier for all 10 known rules, so the tables can't drift again silently.
    crates/mds-python/tests/test_parity.py:201 still says "9 lint rules" — deliberately
    left as-is here since fixing it would touch the release-surface Python test path;
    tracked for a later step.
  • Release builds no longer panic on a non-boundary import/extends span offset, and a
    missing skeleton block is an internal error instead of silently rendering the base
    default (#220).
    attach_import_span and the @extends child-only-blocks check now
    compute the underline length through the existing char-boundary-safe helper: a byte
    offset that does not land on a UTF-8 character boundary (a compiler defect, not
    something a template can cause) yields a zero-length span with the numeric offset and
    no source snippet, instead of a byte index … is not a char boundary panic in
    release. The skeleton-splice walk asserts in every build profile that each @block
    placeholder has an effective-blocks entry; release builds used to splice the base
    default in silence — dropping a child's override — where debug builds panicked. The
    three source-map cursor checks in the evaluator and the byte-length check in
    neutralize_source_for_render are enforced in release now as well; none can be
    triggered by template input, only by a defect, and the messages carry no source text.
    On the CLI a tripped invariant is a Rust panic (exit code 101); the napi, WASM and
    Python bindings convert it to mds::internal as before.
  • mds init no longer writes through a symlink at the target path (#386). The
    starter file is written by the same replace-by-rename primitive as every other CLI
    write (atomic_write_file): a symlink at the target — live under --force, or
    dangling without it — is refused with cannot write <path>: refusing to replace a symlink (exit 1) and neither the link nor its target is touched. Previously a
    dangling link was written through (creating its target) and --force truncated a
    live link's target in place. --force on a regular file still replaces it, now
    atomically with its permission bits preserved; a new file keeps the umask default
    mode. The init entry is gone from the write_funnel.rs allow-list, so the CLI's
    only remaining raw write is the test-only readiness marker in watch.rs.
  • mds lint <dir> fails closed on a path it cannot name (#217). The directory-mode
    files[].file key and the sort key are the entry's path relative to the lint root. A
    path that is not valid UTF-8, or that is not under the lint root, previously produced
    a lossy (U+FFFD) or absolute key silently; it is now mds::io (path is not valid UTF-8: … / path escapes lint root …) reported before any file is linted — exit 2,
    with the analysis-failure envelope under --format json. Such a file already exited 2
    as a per-file error; the difference is that the rest of the tree is no longer linted
    around it and no lossy key is ever emitted.
  • mds fmt <file> on a path that is not valid UTF-8 exits 2 (#217). It was a generic
    error (exit 1); it is an I/O error (mds::io) like mds lint and mds build.
    Directory mode is unchanged (per-file failure, exit 1 with the summary).
  • Source-map sources[] anchors fail closed (#217). An empty project root, or one
    that is not valid UTF-8, can no longer make the containment check vacuous:
    NativeFs::source_root() reports no root for a non-UTF-8 root directory, and the
    relativization choke-point treats an unusable root as "not contained" (basename) and
    an unusable source_map_base as "anchor on the root" — never as an empty prefix every
    path matches. No change for any UTF-8 root; not reachable from the CLI or the public
    API today (non-UTF-8 entry paths are rejected before compilation) — closed as a latent
    hazard.
  • mds build/mds watch say so when a source is written flat instead of mirrored
    (#217).
    In --out-dir mode a source that is not under the build root is written to
    <out>/<stem>.<ext> (contained, unchanged) and now prints warning: <source> is outside the build root <root>; its output is written flat as <out> (…), not suppressed
    by --quiet. No walked source can trigger it; it is a tripwire for a future caller.
    Two degenerate fallbacks that could have joined an absolute path into the output
    directory now use a fixed relative name. mds watch no longer probes output paths for
    a vanished out-of-root dependency, which could drop the write-dedup entry of an in-root
    source with the same file name.

Internal

  • Docs bundle (#327, #328, #313). spec.md gains a "Filesystem constraints" subsection under §4.6 (relative form, NUL bytes, symlink rejection, root containment, path encoding — with error codes and the tests that pin them; SECURITY.md points at it), an "Error Codes" registry under §5 (26 mds-core codes and 4 binding-only codes with their CLI exit class and surfaces; §7.9 cross-references it), and a fmt.sort_frontmatter_keys row in §7.8 marked reserved and inert. SECURITY.md's debug-panics roster names the crates that declare it (mds-napi, mds-wasm, mds-python — not mds-core) and its release-build claim is scoped to the three build sites it rests on. RELEASING.md gains a partial-failure recovery runbook (failed job → what is already irreversible → recovery command). The bug-report template lists the Python package and the Rspack loader. package-lock.json was verified consistent (npm ci, npm ls --all) and left untouched.
  • atomic_write_file replace-by-rename contract documented (#226). The temp-file-then-rename write used by mds fmt, mds lint --fix and (with #227) mds build/mds watch outputs and .map sidecars gives the target a new inode, so hard links, ACLs, xattrs and owner/group of a pre-existing target are not preserved (permission bits are, on Unix). Stated in spec §7.2 "Output writing", SECURITY.md, the helper's rustdoc and RELEASING.md.
  • Cargo dependency sweep: napi 3.9.0 → 3.12.2, napi-derive 3.5.6 → 3.6.3, napi-build 2.3.2 → 2.4.1 (napi-sys 3.3.0, napi-derive-backend 6.1.2), pyo3 0.29.0 → 0.29.2, clap 4.6.1 → 4.6.6, similar 3.1.1 → 3.2.0, wasm-bindgen 0.2.121 → 0.2.126 (js-sys 0.3.103, wasm-bindgen-futures 0.4.76, wasm-bindgen-test 0.3.76), serde 1.0.228 → 1.0.229, serde_json 1.0.150 → 1.0.151, thiserror 2.0.18 → 2.0.20, libc 0.2.186 → 0.2.189. Supersedes Dependabot #354 #360 #359 #358 #280 #251 #249 #246 #243.
  • npm dependency sweep: relaxed the three phantom floor pins to caret ranges — fast-uri 3.1.5 → ^3.1.6 (oldest release patching GHSA-5jgf-p345-68v8, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc, GHSA-jqff-g426-hqxp), nanoid 3.3.18 → ^3.3.18, js-yaml 4.3.1 → ^4.3.1 (#336); @napi-rs/cli ^3.0.0 → ^3.8.6 (lock 3.7.0 → 3.8.6); vite lock 8.1.5 → 8.2.2; Dependabot ignore rules for semver-major bumps of the three phantom pins. Supersedes Dependabot #315 #332 #346 #362 #355 #357 #279.
  • GitHub Actions sweep: actions/checkout v6 → v7 (16 call sites: 9 ci.yml + 7 release.yml), actions/setup-node v6 → v7 (6 sites), actions/setup-python v5 → v7 (5 sites, ci.yml only; action runtime node20 → node24), PyO3/maturin-action pin normalized from the v1.51.0 annotated-tag object (3e2bdf6) to the commit it points to (e83996d1), same version; Dependabot ignore for typescript semver-major version updates pending the TS 7 migration (#364). Supersedes Dependabot #111, #189, #241, #356; replaces #169.
  • Release-surface PR gate: release.yml now triggers on pull_request events touching .github/workflows/release.yml, .github/actions/**, crates/mds-napi/**, crates/mds-python/**, and scripts/verify-napi-names.mjs; adds an unguarded rehearse-publish-python job that rehearses publish-python without uploading — pin shape (both the annotated-tag-object SHA a892a5a6 and the commit SHA dc37677b are rejected as positive controls), GHCR manifest (404 + MANIFEST_UNKNOWN body required), bounded docker pull (3-attempt loop), and twine check from the pinned image with --network none, each with a positive control — plus a publish-testpypi opt-in leg (dispatch-guarded); the rehearsal never invokes pypa/gh-action-pypi-publish (the action has no dry-run mode) and is denied id-token, both pinned by specs S14/S15; credential and OIDC probes run on pull_request events and fail closed on fork/Dependabot PRs (no secrets, no id-token: write) with an actionable error; verify-pr-checks.mjs requires Version gate, Stage + verify platform packages and Rehearse PyPI publish (no upload) on release-surface PRs and fails closed when the changed-file list cannot be read (#342, #350).
  • verify-pr-checks.mjs suite keying (D-PR8): the skipped-publish allowance and D-PR7 context attribution are now keyed on the check-run's check_suite.id mapping to a release.yml workflow run (one bounded GET /actions/runs?head_sha= call); the verifier exits 2 when it cannot enumerate the head's workflow runs; 2026-09 branch-protection and check fixtures added (checks-pr366-e02bcf2.json, runs-pr366-e02bcf2.json, protection-main-2026-09.json) (#341).
  • build-napi per-leg rust-cache key: adds key: ${{ matrix.settings.target }} to the Swatinem/rust-cache step so each cross-compile leg's target artifacts stay isolated (without the key all four ubuntu legs and both macOS legs restored one shared blob, confirmed live in run 34065573775); build-python's existing key: matrix.target-matrix.manylinux (#347) unchanged; spec S20 in release-auth-probe.spec.mjs pins both and fails Version gate if a key is dropped; spec S3 extended to pin the -z CARGO_REG_TOKEN guard in executable code; #345 verified that crates.io GET /api/v1/me is AuthCheck::only_cookie() (HTTP 403 for any API token) and the only token-accepting read route rejects scoped tokens — non-empty guard is the strongest check available, durable fix tracked in #368; #345 closed won't-fix-as-filed (#345 #352).
  • Alpine node:22-alpine load tests for both musl napi addons gate publish-crates: x64 (linux-x64-musl) as the last step of stage-and-verify-napi (after the staged artifact upload, so the artifact is never suppressed by an x64 failure), arm64 (linux-arm64-musl) in a new unguarded load-test-musl-arm64 job on a native ubuntu-24.04-arm runner using the napi-staged artifact; both use scripts/musl-load-probe.cjs in a docker run --network none step with a positive control; publish-crates blocks on both via needs: AND its if: conjunct; spec S21 in release-auth-probe.spec.mjs pins job existence, runner, guard shape, wiring, step order, and run-block byte-equality (#340); the first CI run surfaced #371 (string compile fails when the base directory is a filesystem root — node:22-alpine has no WORKDIR so the default container cwd is /); the gate now runs the container from /w (docker run -w /w) and the probe asserts its cwd so a dropped flag fails loudly.
  • Both musl napi legs (x86_64-unknown-linux-musl, aarch64-unknown-linux-musl) now cross-compile with napi build … -x / cargo-zigbuild 0.23.0: the SHA-pinned taiki-e/install-action (v2.85.10, fallback: none) installs cargo-zigbuild before Swatinem/rust-cache (rust-cache deletes ~/.cargo/bin on save; napi's detector is presence-only and would cargo install an unpinned copy mid-build otherwise); the hand-written zig cc wrappers, fake-zig self-check, and both CARGO_TARGET_*_MUSL_LINKER exports are deleted; three new steps assert the pinned version (before and after the build) and the no-op detector reads both musl linker vars inside [ -z ] guards to confirm none is set; the readelf gate adds ALLOWED_NEEDED='libc\.so|libgcc_s\.so\.1' with a planted libunwind.so.1 control; mlugg/setup-zig SHA-pinned (v2.2.1) in the same step; spec S22 in release-auth-probe.spec.mjs pins all of the above (#339).
  • manual watch-soak.yml Linux soak instrument for the cli_watch flake family (#129 #318 #320); workflow_dispatch only, not a gate, not a required context, not release-surface
  • cli_watch harness: every post-spawn write to a watched path goes through common::write_atomic (temp + rename, one FS event instead of the truncate-then-write pair whose 0-byte intermediate was compiled at --debounce 0); 45 sites converted by a mechanical rule stated in the file's doc comment, with two // DELIBERATE: plain-write exceptions whose subject IS the truncate+write pair (#318).
  • cli_watch harness: the pipe drain thread is now joinable — PipeTap::finish/finish_text reap the child and then JOIN the drain, so the final stderr read carries a happens-before edge to the child's last write; 13 of 13 post-kill flush sleeps deleted and ChildGuard moved to tests/common so finish can name it (#320).
  • cli_watch harness: a piped stdout is drained before the readiness wait, not after (spawn_watch_ready returns the tap as a third element; spawn_ready_piped_stdout hands it to the caller). mds watch -o - publishes its startup output before it writes the readiness marker, so an undrained pipe filled and blocked the child while the poller waited for a marker that could never arrive — reproduced locally as a deterministic 10s READY_TIMEOUT failure on 512 KiB of stdout (#320).
  • cli_watch harness: the i16–i20 duplicate-vars-warning family waits for the expected warning count with a bounded wait_for_stderr_count before asserting it. In directory mode the warning is emitted after the output write, so sampling stderr the instant the artifact appeared could read one warning short (CI runs 34366009518, 34404318888) (#326 #320).
  • cli_watch: watch_readiness_handshake_makes_ctrl_c_exit_deterministic is a two-arm control (20 iterations) proving the MDS_TEST_READY handshake, not luck, is what makes a post-SIGINT status.success() deterministic — unsynchronized spawn signalled on the Watching … line dies by SIGINT; a spawn signalled after the handshake exits 0 and prints Stopped watching. (#129).
  • cli_build: watch_bare_filename_from_cwd_succeeds is synchronised on the readiness handshake and reads hello.md once, instead of polling the output artifact for up to 10s; the private ChildGuard copy is replaced by common::ChildGuard and stderr is drained rather than discarded (#318).

What's Changed

  • chore(deps): add 30-day Dependabot cooldown and napi group by @dean0x in #353
  • chore(deps): cargo dependency sweep (napi 3.12.2, pyo3 0.29.2, clap 4.6.6, wasm-bindgen 0.2.126, +5) by @dean0x in #361
  • chore(deps): npm dependency sweep — relax phantom floor pins, @napi-rs/cli 3.8.6, vite 8.2.2 by @dean0x in #363
  • chore(ci): bump checkout v7, setup-node v7, setup-python v7; normalize maturin-action pin by @dean0x in #365
  • ci(release): run release.yml on release-surface PRs; rehearse the PyPI publish; add opt-in TestPyPI leg (#342, #350) by @dean0x in #366
  • ci(release): per-leg rust-cache keys in build-napi; suite-keyed Tier-B allowance in verify-pr-checks; re-scope #345 (#352, #341, #345) by @dean0x in #369
  • ci(release): load-test both musl addons on node:22-alpine before publish (#340) by @dean0x in #370
  • ci(release): build both musl legs with cargo-zigbuild via napi -x; retire the hand-written zig cc wrappers (#339) by @dean0x in #373
  • fix(cli): warn on duplicate keys in --vars JSON files at every depth; fix stale lint_str rustdoc (#326, #329) by @dean0x in #374
  • fix(watch): capped quiet-period debounce; harness atomic writes, joined drains, stdout drain; #129 handshake control; #318 site (#129, #318, #320, #379) by @dean0x in #382
  • fix(core): bound frontmatter YAML parsing — 1 MiB cap, 200k node budget, 1024 flow-depth guard, string-API MAX_FILE_SIZE; lint/scan_imports propagate resource_limit (#162) by @dean0x in #384
  • fix(cli): atomic mds build/watch outputs and .map sidecars via atomic_write_file (removes the spurious metadata warning); empty directory exits non-zero; atomic_write_file contract documented (#227, #225, #204, #226) by @dean0x in #385
  • fix(core,cli): enforce internal invariants in release builds (degrade non-boundary spans, assert skeleton/width/range/cursor invariants); path helpers fail closed on strip_prefix and non-UTF-8 paths; fmt non-UTF-8 exits 2 (#220, #217) by @dean0x in #388
  • fix(cli): mds init writes through atomic_write_file — a symlink at the target is refused, --force replaces by rename (#386) by @dean0x in #391
  • fix(cli): mds build/check exit 1 on a directory whose only .mds files are _-prefixed partials (#387) by @dean0x in #392
  • chore: spell out legacy decision-log citations inline and add the ledger-citation gate (comment-only) by @dean0x in #393
  • docs: v0.4.3 docs bundle — spec §4.6 filesystem constraints, §5 error-code registry, §7.8 reserved field; SECURITY.md debug-panics roster; RELEASING.md partial-failure runbook; bug template; KB refresh (#327, #328, #313) by @dean0x in #394
  • chore(release): v0.4.3 by @dean0x in #395

Full Changelog: v0.4.2...v0.4.3