Skip to content

v0.4.0 Wave 1: breaking changes, live-bug fixes, and release-safety gates - #308

Open
dean0x wants to merge 34 commits into
mainfrom
wave/v0.4.0-wave1
Open

v0.4.0 Wave 1: breaking changes, live-bug fixes, and release-safety gates#308
dean0x wants to merge 34 commits into
mainfrom
wave/v0.4.0-wave1

Conversation

@dean0x

@dean0x dean0x commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes #38
Closes #180
Closes #202
Closes #203
Closes #209
Closes #211
Closes #213
Closes #215
Closes #216
Closes #224
Closes #288
Closes #289


v0.4.0 Wave 1 — the complete pre-tag change set

10 commits · 123 files · +19,719 / −2,181wave/v0.4.0-wave1 @ e3f8c64main @ 113f472.

This is the entire v0.4.0 release content landing on main as one squash. It is the last gate before the v0.4.0 tag, which publishes to crates.io and npm irreversibly.

⚠️ The Closes lines above are load-bearing. Every squash commit on this branch has an empty body — GitHub dropped the PR descriptions on squash. If those 12 lines are removed from this description, none of the 12 issues auto-close and the milestone silently drifts. This repo lost 11 issues to exactly this failure once (PR #117).


Why this wave exists

The v0.4.0 tag gate was deliberately set to MEDIUM width on 2026-08-09. The rule admits only:

  • (a) semver-breaking changes — the pre-tag window is the last moment these are free, and
  • (b) bugs already live in a published artifact,

plus community hygiene and release-safety gates. Everything else was deferred to v0.5.0.

The rule explicitly decouples the tag gate from the milestone: "milestone membership is the plan of record for a release theme, not the ship list for the tag." Both edges were exercised with worked precedents — #180 was held in (a small additive fix, but the defect is already reachable by users of the published npm packages), and #155 was deferred (a genuinely breaking mds-core public-trait change, but design-quality debt whose replacement API is still open). The bounding principle: the window justifies breaks whose target shape is settled, not tech debt whose replacement is still being designed.

Why one branch of six PRs instead of six PRs to main

  1. Semver atomicity. These are the last free breaks before the tag. A wave branch lets the whole breaking set land against one CHANGELOG, so [Unreleased] is written once coherently rather than merged six times and reconciled after.
  2. Hard ordering dependencies. PR6 had to be first: it lands the control-byte gate protecting the three PRs behind it that each author control-character tests (PF-018), and it supplies the pre-merge check verifier the wave's own squash-merges depend on (PF-017). It builds the tool the wave uses to merge itself safely. PR3 follows PR2 so its larger types.ts rewrite absorbs PR2's rule-name narrowing instead of conflicting with it.
  3. A shared 850,000-byte WASM budget. Six PRs each adding a few KB to one global binary cannot be merged independently — whoever lands last eats the guard failure. The shared branch made the trajectory visible (821,662 → 833,502 → 844,172), which triggered the ruling to insert an unplanned optimization pass (perf(wasm): size optimization pass — flag tuning, sort_unstable, CI pin #300) rather than raise the guard. That decision is only possible with an integration branch.

What's in it

Ten commits. Two were not in the original plan: #300 (WASM size, forced by the budget trajectory above) and #306 (the Python rename, forced by an external discovery — see below).

Commit PR What Closes
282af17 #293 Code of Conduct, control-byte CI gate, PR-check verifier #38, #288, #289
cbb11d4 #294 Lint JSON wire contract — sort, stdin label, name spans #211, #202, #203
13cb2a5 #295 Warn but continue on unknown lint rule names #224
c9265b4 #300 WASM shrink 2,024 bytes via wasm-opt flags + sort_unstable
3372e49 #301 CLI quiet-mode and directory-summary contract #216
4c13ecb #302 Fix basePath plumbing, complete TS public surface #180, #215, #213
61fe58e #303 Deprecate apply_fixes for apply_fixes_incremental #209
e23834d #305 Correct pre-tag CHANGELOG and doc blockers
7072df0 #306 Rename Python dist to markdown-script (Refs #292 — deliberately not Closes)
e3f8c64 #307 Recover 21 unpushed release-gate commits + share feature KBs

The one live shipped bug — #180

basePath was accepted by the unknown-option validator and then discarded before reaching the backend, so compile(source, { basePath }) resolved @import/@extends against the wrong directory. It now propagates through one table-driven choke point, forwardOpts (util/options.ts:265), which is compile-bound to the public interfaces by keysOf<T>; the WASM backend rejects non-null basePath with mds::invalid_options instead of silently ignoring it. (Note: lint already forwarded basePath on main; only compile and check dropped it.) This is the defect that qualified for the medium gate.

Why the Python package was renamed

mdscript on PyPI is a genuine dormant project, not a squat — 5 releases in April 2021, real working code, ~16 downloads/month, and a summary ("scripting in markdown files with hot reload") that is a direct topical collision. PEP 541 offers no route against a reachable owner (active on GitHub 12 days prior).

The decisive constraint: their top_level.txt is exactly mdscript, so publishing under a different distribution name while keeping import mdscript would put two top-level mdscript/ directories into site-packageswhoever installs second silently overwrites the first, with no pip error. Both names had to change. markdown-script is confirmed available on PyPI (404 on both /pypi/…/json and /simple/…/, for both hyphen and underscore forms, which PyPI normalizes to one name).

This is not breaking for anyone: crates/mds-python/Cargo.toml is publish = false and release.yml has no PyPI publish step, so there are zero existing consumers — no compat shim, no Provides-Dist, no alias. Recorded as ADR-012.

Why #307 exists — a consolidation gap found pre-merge

PR #293 squash-merged a stale head. ticket/pr6-community-release-safety had 35 commits; only the first 14 were merged. The other 21 (+1,375/−180) were never pushed to any remote. Among them: verify-pr-checks.mjs was missing --admin from its emitted merge command, while RELEASING.md requires it — the tool's own output did not work as documented, and it is the tool this very merge depends on. Gate test coverage was also roughly halved (37→73 and 37→46). Recovered as the full 21-commit series; see #307 for the conflict-resolution detail.


BREAKING changes

TypeScript

  • basePath?: never on FileOptions (types.ts:164), CheckFileOptions (:183, new interface), LintFileOptions (:359). Source-breaking when a CompileOptions/CheckOptions/LintOptions-typed variable is passed to a file surface (TS2322). Inferred-object variables are rejected at compile time (TS2322: Type 'string' is not assignable to type 'undefined'), as are typed vars and spreads of typed vars. The three real, narrower holes are {basePath: undefined}, spread of a Record<string, unknown>, and index-signature-typed values; the last two are caught by the runtime guard, the first is not (a runtime error on file-surface calls), and that was fixed in this wave.
  • LintDiagnostic.help and .span widened to | null (types.ts:207, :214). A pure declaration widening — the wire has always emitted JSON null (the payload is hand-built via serde_json::json! at diagnostic.rs:808-816; these are always-present keys, never absent). Only the TS type was wrong. Downstream guards written as diag.span !== undefined were already broken at runtime and must become diag.span != null.
  • packages/mds/src/index.ts deleted. Low risk: the exports map resolves only ./dist/node.js and ./dist/browser.js; nothing reachable by a published consumer pointed at it. SourceMapV3 and the full lint type set are now exported from node.ts and browser.ts.

Runtime

  • compileFile(path, {basePath}) / checkFile(path, {basePath}) now throw synchronously with code: 'mds::invalid_options'. Synchronous — .catch() on the returned promise does not receive it; use try/catch.
  • WASM backend compile/check now throw on basePath instead of silently ignoring it. Migration: MDS_BACKEND=native.
  • basePath now actually works. Code that passed it and unknowingly depended on it being ignored will see different import resolution. That is the fix, but it changes observable behavior.

Wire / output contracts

These break downstream tooling with no compile error:

  • Lint JSON diagnostic order is now ascending span.offset within each file group (stable; no-span sorts last). Previously implementation-defined rule-insertion order. Note truncation is not offset-ranked — still the first 1,000 in rule-execution order, re-sorted afterward.
  • stdin files[].file is now <stdin>, was "input.mds". Deliberate asymmetry now shipping: the CLI emits <stdin>; binding surfaces (napi/WASM/Python) still emit input.mds. Documented at spec.md:1033 and :1071.
  • unused-import spans anchor at the unused name in selective imports; length is the name's length, not a constant 7. Alias imports unchanged.
  • Directory file-group ordering changed from component-wise Path::Ord to byte-wise comparison of the relative display path (api-utils.mds now sorts before api/x.mds). On Windows, separators are now normalized to forward slashes — a path that appeared as sub\c.mds now appears as sub/c.mds. A consumer splitting on \ will silently stop matching.
  • mds build --quiet <dir> no longer prints its summary on a fully-successful run (still prints when fail_count > 0, so a non-zero exit is never unexplained). Scripts grepping that stderr for N built, N failed break.
  • mds lint <dir> now always prints a stderr summary on clean runs (suppress with --quiet). Scripts asserting zero stderr break.
  • mds lint --quiet now suppresses fix rejected: and the diagnostic-cap notice. Scripts grepping for fix rejected must drop --quiet.
  • Fixed: and Would fix: now emit in JSON directory mode (lint.rs:1541, :1613), previously human-only output. A CI job asserting on empty stderr breaks silently — exit code unchanged.
  • JSON stdout envelope is unchanged — no summary key was added.

Rust API — deprecations, not removals

  • mds::fix::apply_fixes#[deprecated(since = "0.4.0")] (fix.rs:731-735), removal scheduled for v0.5.0 (v0.5.0 removal tracker: delete mds::fix::apply_fixes and migrate ADR-004 coverage #304). Two non-mechanical migration hazards: the reverify closure bound tightens FnOnceFn, and apply_fixes_incremental can return FixOutcome::PartiallyFixed, which apply_fixes never returned (wildcard match arms compile but silently discard partial results).
  • mds::LintConfig::from_rules#[deprecated(since = "0.4.0")] (config.rs:295-299), prefer from_rules_checked. No removal scheduled before v1.0.0.

Downstream crates with #![deny(warnings)] calling either will fail to build on 0.4.0. Neither is a removal, but the build break is real.

Not in this wave — a correction

The #[non_exhaustive] sweep (14 types, 4 signature redesigns) is already on main as d8766af (PR #286, closing #259). It must not be attributed to this wave. Exactly one new #[non_exhaustive] type ships here: UnknownRuleNames. The large non_exhaustive block that appears as added lines in the CHANGELOG diff is primarily a section relocation from e23834d's Keep-a-Changelog restructure; however, the block also carried 10 lines of genuinely new content — MdsError::source_name() and is_string_source(), new in this wave, which have since been moved to ### Added (commit 9f233cd).


What a reviewer should actually open

The wave is large; these are the files where the risk concentrates.

Area Files
Lint JSON wire contract crates/mds-core/src/lint/diagnostic.rs, crates/mds-core/src/lint/rules/unused_import.rs, crates/mds-cli/src/output.rs
TS public surface packages/mds/src/types.ts, packages/mds/src/util/options.ts, packages/mds/__test__/types/consumer-node.ts
CLI output contract crates/mds-cli/src/lint.rs:1380-1410 (summary emitter and its quiet gate)
Unknown rule names crates/mds-core/src/lint/config.rs, crates/mds-core/src/options.rs
Deprecation crates/mds-core/src/lint/fix.rs:725-740
Release safety scripts/verify-pr-checks.mjs (gates every future merge), .github/workflows/ci.yml
WASM crates/mds-wasm/Cargo.toml — and confirm the stable-sort sites are correct (diagnostic.rs:932 is new in this wave, added by commit cbb11d4; fix.rs:361, formatter.rs:262, sourcemap.rs:382 are unchanged), where stability is load-bearing for the AD-202-1 wire contract

Process caveats a reviewer should know

  • Four of six PRs merged with unresolved review findings, all attributed to the same infrastructure failure — every surviving finding carries "NEVER DISPATCHED — the fix agent for this finding's file-group died before reaching it." All gates were green at merge; this is a process defect, not a test failure.
  • The per-PR run reports partly describe pre-correction state and should not be read as a current defect list. Two confirmed examples: PR5's CRITICAL blocker (a CI job rename that would have broken required branch-protection contexts) did not ship — the job name is unchanged and the doc gate was added as a step inside the existing job; and PR3's README rejected-promise-vs-synchronous-throw mismatch was corrected by e23834d.
  • pr2-run-report.md is internally inconsistent (a "Fixed (92)" and an "Outstanding (92)" section describing the same items). PR2's true surviving-finding count could not be determined.
  • The two committed plan docs under .devflow/docs/design/v040-wave1/ (pr1-lint-json-plan.md and pr5-deprecate-apply-fixes-plan.md) are the originals (88,084 bytes / 580 lines and 70,053 bytes / 424 lines respectively, both with complete headers and well-formed final sections). The other four plan docs are not committed. These were force-added against the stated .gitignore:64 policy — verify this is intentional.

Remediation summary

This branch now carries approximately 20 remediation commits resolving the 100 review findings documented in .devflow/docs/reviews/wave-v0.4.0-wave1/2026-08-19_1216/resolution-summary.md.


Verification

All numbers from CI run 32154708359 at a tree verified byte-identical to the branch tip.

Gate Result
cargo nextest run --workspace 2,087 passed
cargo test --doc 52 passed (nextest skips doctests — this run is mandatory)
pytest crates/mds-python/tests 242 passed
JS (npm test --workspaces) 575 passed
Gate self-test (npm run test:gates) 122 passed / 0 failed
cargo clippy --workspace --all-targets -D warnings zero warnings
cargo fmt --all --check clean
Rustdoc intra-doc links (RUSTDOCFLAGS="-D warnings") clean
verify-versions.mjs / verify-no-control-bytes.mjs exit 0
WASM optimized 836,126 bytes raw / 336,469 gzipped vs the 850,000 budget — 13,874 bytes headroom (1.63%). Guard not raised.

The WASM figure was re-measured at the branch tip (run 32223495191), not carried forward from c9265b4pkg/ and pkg-web/ are both 836,126, unchanged, so the four commits after the optimization pass consumed none of the remaining headroom.

Stale figures that appear in mid-wave artifacts (~1983 nextest / 50 doctests) predate PR4 and PR5. 2,087 / 52 is current.


Risk register

Must verify before tagging

# Sev Risk
R1 HIGH publish-npm publishes 8 packages before it builds the TS/WASM artifacts (release.yml:279-297). If the build at :284 fails, crates.io already has 0.4.0 (irreversible), 8 npm packages are live, and @mdscript/mds — the package users actually install — never publishes. This wave elevates the risk: packages/mds/src/ is the most-rewritten area in the diff. The dry-run gives zero coverage of this step. Mitigation: move :284-287 above :279 — a three-line move that closes the window entirely.
R2 HIGH publish-crates does not depend on the napi build or the A3 gate (release.yml:168). crates.io publishes in minutes; the 7-target matrix takes far longer. If the A3 name↔loader gate then fails, crates.io holds 0.4.0 forever with no npm release. Mitigation: run the dry-run and confirm A3 green before tagging; structurally, add stage-and-verify-napi to needs.
R3 HIGH No registry-auth pre-check before the first irreversible publish. A missing NPM_TOKEN renders as an empty string, not an error. Last updated 2026-07-24; granular npm tokens commonly expire at 30/90 days. An expired token → crates.io publishes, npm 401s → the R1 partial state with no build failure to explain it. This is the single highest-value manual check before tagging, and the one failure class the dry-run structurally cannot detect.
R4 HIGH The wave's own new security gate is not a required status check. Branch protection requires exactly 6 contexts; Source hygiene, both Python jobs, and examples/ gitignore coverage are not among them. Combined with --admin merges, the control-byte gate this wave introduces cannot block a merge. Partly mitigated by verify-pr-checks.mjs (also new here), which evaluates non-required check-runs too — but only if it is actually run.
R5 MED The WASM toolchain pin landed on the measuring job (ci.yml:77, 1.96.0), not the publishing job (release.yml:262, @stable). ci.yml never runs on tags, so the wasm binary that actually ships is never size-checked — though ci.yml:164 (js job) does build and exercise the nodejs-target WASM via @stable on all three OSes, the size is never asserted on any shipping artifact. This is now fixed (commit 77e6973 pins the release toolchain and adds a size check on the publish path). crates/mds-wasm/Cargo.toml:48-50 documents a previously-experienced failure of exactly this shape (post-MVP WASM features that wasm-opt rejects).
R6 CLOSED. WASM size was re-measured at the branch tip (run 32223495191): 836,126 bytes, unchanged, 13,874 headroom. The commits after the optimization pass consumed none of it. Note for future checks: local numbers are not transferable — local wasm-opt v117 reads 2–3 KB higher than CI's Binaryen v129.
R8 MED No Rust test runs on Windows or macOScargo test --workspace is ubuntu-only. The new relative_display path normalization (lint.rs:320) is therefore Linux-proven only, and its sole regression test is Unix-by-name. Assessment: absence of proof, not presence of a bug — using components() rather than .replace('\\', "/") is the correct platform-aware choice and the rustdoc shows the separator-ordering case was reasoned about. It matters because PF-003/#133 bit this repo before.
R9 MED crates/mds-napi/index.d.ts is gitignored and regenerated at release time — the published napi type surface appears in no diff and is pinned by no test. Materially reduced this wave: the napi diff touches only internal helper signatures; no #[napi]-exported signature changed.
R10 MED No concurrency group on release.yml. A re-pushed tag can run two publish pipelines at once. crates.io steps are idempotent; npm steps are not.

🔴 The sharpest edge — publish-npm has no already-published guard

publish-crates explicitly tolerates "already published" (release.yml:190-199, 225-234). publish-npm does not:279-297 are bare npm publish calls, and republishing an existing version returns 403 EPUBLISHCONFLICT.

Consequence: if npm publishing fails partway, re-running the workflow fails immediately on the first already-published package and never reaches the missing ones. This is the highest-probability way to end up stranded. Recovery is usually not hand-publishing (a laptop publish succeeds without provenance, producing an inconsistent release) but cutting v0.4.1 and npm deprecate-ing the half-published set. Worth ticketing.

Feared risks that are actually already mitigated

Stated explicitly so they don't get re-raised in review:

  • basePath?: never — type and runtime agree, and ADR-011 is implemented as written. browser.ts:17-38 re-exports the shared option types (not narrowed variants); runtime rejection lives in backend/wasm.ts:427-430, 446-465. Both halves are pinned by @ts-expect-error compile-time tests that fail if the error ever disappears, now wired into packages/mds/package.json:42.
  • help/span genuinely serialize as null — the wire is hand-built, no skip_serializing_if anywhere on the path, and all surfaces share one to_canonical_json. The TS types are correct.
  • The A3 name-gate carries no risk from this wavegit diff main...wave -- crates/mds-napi/{index.js,package.json,Cargo.toml} is empty. The 7-target list matches across package.json, release.yml:59-87, and verify-napi-names.mjs:36.
  • #[deprecated(since = …)] is correct — exactly two hits repo-wide, both "0.4.0", both introduced in this release.
  • bump-version.mjs's CHANGELOG stamping will work despite e23834d rewriting ~1,987 lines: ## [Unreleased] is alone on CHANGELOG.md:8 and the compare link is intact.
  • Version-gate coverage is complete, including Pythonpyproject.toml uses dynamic = ["version"], so there is no second source of truth to drift.
  • Unknown-rule-name warning is new output, not a new failure. main's spec already said unknown rules are warn-and-ignored; there was simply no detection. Exit codes are unchanged — no added exit() anywhere in the CLI diff.
  • Source maps are not a wire risk — the entire sourcemap.rs diff is 21 lines, replacing a literal with a named constant.
  • Both new gate scripts underwent positive-control testing — planted ESC/RLO U+202E/C1 NEL U+0085/BOM/LS each drive exit 1 through the pipeline, and a golden-count test (HAZARD_RANGES.length === 21) blocks silent narrowing of the hazard class. The positive controls exercise scanBuffer/isHazardous, covering the main gate paths; verify-no-control-bytes.mjs:372 had a revision-parsing gap (:${path} syntax allowing git blob redirection) that this wave's positive controls did not cover. That gap has since been fixed (commit 34e4ea1, SHA-based blob addressing + a planted 0:payload.mds positive control on the index-read path).

After this merges

Merging this releases nothing. The tag is a separate, deliberate, manual step.

Milestone status: v0.4.0 goes to 0 open / 18 closed. The 12 issues this closes are exactly the 12 currently open. No tag blockers remain in the tracker.

The path to a published v0.4.0

# 1. Pre-flight, on main
cargo test --workspace && cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo publish -p mds-core --dry-run
#    `cargo publish -p mds-cli --dry-run` FAILS locally by design (path+version dep
#    on mds-core, not yet on crates.io at 0.4.0). CI publishes mds-core first. Do not "fix" it.
npm ci && npm run build -w @mdscript/mds-wasm && npm run build --workspaces --if-present
npm test --workspaces --if-present
node scripts/verify-versions.mjs && node scripts/verify-no-control-bytes.mjs && npm run test:gates

# 2. The deprecation check — DO NOT SKIP (ADR-009)
grep -rn 'since = ' crates/ --include='*.rs'
#    Expect exactly 2 hits, both "0.4.0": lint/config.rs:296 and lint/fix.rs:732.
#    bump-version.mjs NEVER rewrites .rs files — these strings are hand-written and
#    nothing else in the toolchain will catch a wrong one.
#    If this ever returns ZERO hits, that is NOT evidence of cleanliness: plant a
#    temporary `since = "9.9.9"`, confirm the grep finds it, then remove it.

# 3. Bump — rewrites 14 files (root + 4 crate Cargo.toml, 8 package.json, CHANGELOG)
node scripts/bump-version.mjs 0.4.0
node scripts/verify-versions.mjs

# 4. Land the bump on protected main (--admin required; PF-017 verifier mandatory first)
node scripts/verify-pr-checks.mjs <bump-pr>
#    exit 0 → run the emitted command VERBATIM (it now includes --admin, restored by #307)

# 5. Dry run — AFTER the bump lands, so the version-gate validates 0.4.0
gh workflow run release.yml --ref main
#    Validates the 7-target napi matrix + the A3 name-gate. Publishes NOTHING.
#    A3 is a HARD checkpoint — drift produces a published package that silently
#    cannot load its native binary at runtime. Do not proceed past a failure.

# 6. MANUAL: re-verify NPM_TOKEN and CARGO_REGISTRY_TOKEN in their registry UIs (R3).

# 7. Tag — THE POINT OF NO RETURN
git tag -a v0.4.0 -m v0.4.0 && git push origin v0.4.0

⚠️ ci.yml does not run on tag pushes, and release.yml runs no cargo test and no npm test. The only correctness assurance at tag time is that the tagged commit is the merged main commit whose CI you already verified. Tag the exact commit you verified — never an amended one.

One-time prerequisites are all verified done: the @mdscript scope holds all 15 packages at 0.3.0, NPM_TOKEN and CARGO_REGISTRY_TOKEN both exist, private vulnerability reporting is enabled. Note npm auth is via NPM_TOKEN, not the OIDC trusted publishing that RELEASING.md calls preferred.

Python — outstanding work

Nothing Python ships in v0.4.0 (publish = false, no PyPI step in release.yml), so none of this blocks the tag.

Not yet authored

Post-tag wave plans (W2 correctness, W3 infra, W4 deferred queue) do not exist in the repo — verified absent with a positive-control grep. The 23 open v0.5.0 issues plus unmilestoned #223 are the raw material. #223 (decompose resolver.rs 2,554L and build.rs 2,020L) is scheduled strictly last by its own body: it rewrites both files wholesale, so any behavior fix touching them would have to be rebased across the decomposition if it landed first.

Also worth clearing before the tag

The default branch carries 2 open high-severity Dependabot advisories, and there are 22 open dependabot PRs.

dean0x and others added 10 commits August 13, 2026 10:56
…er (#293)

Community-release safety hardening, three coordinated changes:

- Add CODE_OF_CONDUCT.md (Contributor Covenant 2.1) with an enforcement
  contact, wired into CONTRIBUTING.md, README.md, and the PR template.
- Add scripts/verify-no-control-bytes.mjs: CI gate that rejects control
  bytes and Unicode bidirectional-override characters in tracked sources,
  plus a pre-commit hook so the scan runs before code ever leaves a
  workstation.
- Add scripts/verify-pr-checks.mjs (PF-017): pre-merge verifier that treats
  only status=COMPLETED + conclusion=SUCCESS as a pass, so CANCELLED,
  SKIPPED, STALE, NEUTRAL, ACTION_REQUIRED, QUEUED, and IN_PROGRESS checks
  can no longer read as "not failed" and slip past an --admin merge.

Covered by unit tests against recorded API fixtures for both scanners.

Closes #38
Closes #288
Closes #289

Co-Authored-By: Claude <noreply@anthropic.com>
Batches three issues that all mutate the same `--format json` wire object.

- #211: uniform `<stdin>` sentinel across all CLI surfaces (lint/check/build),
  via a single `STDIN_DISPLAY_LABEL` constant and a conditional
  `StdinRelabeledError` guard that leaves imported-file errors untouched.
- #202: diagnostics sorted by byte offset within each file group; no-span
  diagnostics sort to the end of their group, no-file diagnostics to the end
  of the list. Ordering is now a defined part of the wire contract.
- #203: `unused-import` spans anchor at the unused NAME in selective imports
  rather than at `@import`, via `ImportDirective::Selective::name_offsets`.

BREAKING CHANGE: `--format json` output changes in three ways — diagnostic
order within a file group is now defined (ascending byte offset, previously
rule-insertion order); the stdin `files[].file` key is `<stdin>` instead of
`input.mds`; and `unused-import` `span.offset`/`span.length` point at the name
rather than the `@import` keyword. Binding surfaces (napi/WASM/Python) still
emit `input.mds` and are unaffected by the file-key change.

Closes #211
Closes #202
Closes #203

Co-Authored-By: Claude <noreply@anthropic.com>
Unknown rule names in `mds.json` and in the `rules` option on every binding
surface now emit a warning and lint continues, instead of silently no-opping.

- CLI: warning goes to stderr so `--format json` stdout stays parseable JSON;
  `--quiet` suppresses it
- mds-core: `ALL_RULE_NAMES`/`KNOWN_LINT_RULES` registry is the single source of
  truth; `find_unknown_rule_names` + `format_unknown_rule_names_warning` shared
  by all surfaces
- Bindings (napi, WASM, Python): return `lint_warnings: string[]` on the lint
  result when unknown names are present, absent otherwise
- TypeScript: `LintRuleName` union, `LINT_RULE_NAMES` const, and
  `LintResult.lint_warnings?: string[]`

No breaking changes — warn-and-continue is strictly additive. Unknown severity
values still hard-fail; the asymmetry is intentional.

Closes #224
Tune the wasm-opt pipeline (-Oz --flatten --rereloop -Oz --converge
--strip-producers) and switch four tie-free sort sites to sort_unstable
(value.rs, evaluator.rs, builtins.rs x2). Also pins the wasm job's Rust
toolchain to 1.96.0 so future size measurements are toolchain-stable.

CI-measured (Binaryen v129, nodejs + web targets, both identical):
  before (PR2 line, run 31945656437): 838,150 bytes
  after  (this PR, run 31946611856):  836,126 bytes
  delta:                              -2,024 bytes (-0.24%)

Headroom against the 850,000-byte guard: 13,874 bytes (1.63%).
The guard is NOT raised.

The realized saving is small. Pre-implementation analysis predicted
-7,527 bytes from flag tuning alone under Binaryen v129, plus a further
~4-6 KB from sort_unstable; the author's local measurement with the
bundled wasm-opt v117 showed -2,235 bytes. Reality (-2,024) lands close
to the local v117 figure and well short of the v129 prediction. The
delta also absorbs the toolchain change from unpinned `stable` to
pinned 1.96.0, so the portion attributable to the flag and sort changes
alone is not separately isolated.

Co-Authored-By: Claude <noreply@anthropic.com>
Establishes the CLI quiet-mode contract and adds a directory-mode summary
line for `mds lint`.

- `mds build --quiet <dir>` suppresses the summary line on fully-successful
  runs, matching `mds check` and `mds fmt` (D1-a contract). The line still
  prints when `fail_count > 0`, so a non-zero exit is never unexplained.
- `mds lint <dir>` emits a per-run summary on stderr:
  `N clean, N with warnings, N with errors, N resource-limited`.
- Under `--quiet` the lint summary is suppressed for clean and warn-only
  runs, but always prints when error- or resource-limited files are present.
- D4: the diagnostic truncation-cap notice and the "fix rejected" message
  are gated on `!quiet` in both the single-file and directory lint paths.
- The `{"files":...}` JSON envelope is unchanged; no `summary` key added.

Tests cover AC-Q01-Q30, including hostile-filename forgery resistance
(PF-018) and positive controls on every suppression assertion (PF-013).

BREAKING CHANGE: CI scripts grepping `mds build --quiet` stderr for the
"N built, N failed" line on success will no longer see it.

Closes #216
Completes the TypeScript public surface for @mdscript/mds across three fronts.

#180 (live bug): `basePath` was accepted by the unknown-option validator but
discarded before reaching the backend, so `compile(source, { basePath })`
resolved `@import`/`@extends` against the wrong directory. `basePath` now
propagates through four typed per-surface builders (`compileSrcOpt`,
`checkSrcOpt`, `fileCompileOpt`, `fileCheckOpt`) to the native backend, and the
WASM backend rejects non-null `basePath` with `mds::invalid_options` instead of
silently ignoring it.

#215: `lint()` and `lintVirtual()` are exported from `browser.ts`; all seven
lint types and `LINT_RULE_NAMES` are now available from both the Node and
browser entry points.

#213: deleted the `_CompileBackendOpts`/`_CheckBackendOpts` stubs, unified
option forwarding through `forwardOpts`/`METHOD_KEYS`, and added the
`BASEPATH_PASSTHROUGH` set so the file surfaces raise a purpose-built error
rather than the generic unknown-key message.

BREAKING CHANGE: `FileOptions`, `CheckFileOptions` and `LintFileOptions` now
declare `basePath?: never`. This narrows the published types: code that
previously type-checked while passing `basePath` to `compileFile`, `checkFile`
or `lintFile` will now fail to compile, and those surfaces reject `basePath` at
runtime. File-based APIs derive their resolution root from the file path, so
`basePath` was never honored there. `FileOptions` also no longer extends
`CompileOptions`, and `checkFile` takes `CheckFileOptions` rather than
`CheckOptions`.

Closes #180
Closes #215
Closes #213

Co-Authored-By: Claude <noreply@anthropic.com>
…ental (#303)

Marks `mds::fix::apply_fixes` as `#[deprecated(since = "0.4.0")]`, directing
callers to `apply_fixes_incremental`, which upholds the same ADR-004 three-tier
reverify-gate contract while salvaging the safe subset of a batch instead of
refusing the whole plan.

- `#[deprecated]` note names the replacement and the `Fn`-vs-`FnOnce` closure-bound delta
- 10 per-fn `#[expect(deprecated)]` suppressions on tests exercising the legacy path;
  `unfulfilled_lint_expectations` fires under `-D warnings` if the attribute is removed
- New F-API-3 test pins `apply_fixes` as still callable from an external-crate context
- Rustdoc corrections: ADR-001 -> ADR-004, `apply_fixes` -> `apply_fixes_incremental`
- CHANGELOG `### Deprecated` section and KNOWLEDGE.md v0.5.0 removal tracker

Not a breaking change: `apply_fixes` remains present and functional. Removal is
deferred to v0.5.0.

Closes #209

Co-Authored-By: Claude <noreply@anthropic.com>
Fixes release-note and documentation defects found in the pre-tag sweep:

- CHANGELOG.md: correct v0.4.0 entries and restore accurate release history
- packages/mds README + types.ts: fix stale API/type documentation
- crates/mds-napi README, examples/linting README: correct doc drift
- crates/mds-cli output.rs (+ cli_lint test): align emitted text with docs
- .github/workflows/ci.yml: doc-related CI adjustments

Co-Authored-By: Claude <noreply@anthropic.com>
if !quiet {
eprintln!("fix rejected: {}", safe_inline(&reason));
}
emit_result(format, &original, quiet, named_source);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING — mds lint <file> --fix --check discards every diagnostic and the entire JSON envelope — complexity, 97%, reproduced on the CLI then confirmed in source.

Anchored here because the defect lines (:1027-1044) are unchanged context. This arm is the sibling that gets it right.

// :1027-1032  (run_lint_file, preview path)
if check {
    if !quiet { eprintln!("Would fix: {}", safe_path(path)); }
    std::process::exit(1);   // exits BEFORE emit_result at :1044
}

Reproduced with one fixable legacy-interpolation + one error-severity duplicate-export:

$ mds lint a.mds --fix --check --format json ; echo "exit=$?"
exit=1
--stdout--            <- COMPLETELY EMPTY. Not "{}" — zero bytes.

$ mds lint d --fix --check --format json    # same file, dir mode
{"files":[{"diagnostics":[{...,"rule":"duplicate-export","severity":"error",...}]}],"truncated":false,"version":1}

Directory mode does the opposite and says why at :1374-1375: "Emit JSON envelope BEFORE any early exit so consumers always receive parseable output regardless of exit code (AC-F-14 / issue #36)." Human mode loses the same information — Would fix: a.mds is the entire output.

mds lint is under ## [Unreleased] (CHANGELOG.md:115) — it has never shipped. This tag freezes AC-F-14 as true for <dir> and false for <file>, permanently.

Fix (4 lines; preview_fixes returns owned Strings, so result is no longer borrowed and this compiles without restructuring):

if check {
    if !quiet { eprintln!("Would fix: {}", safe_path(path)); }
    emit_result(format, &result, quiet, named_source);  // ADD — mirrors dir mode
    std::process::exit(1);
}

Add the single-file twin of crates/mds-cli/tests/cli_lint.rs:2309 dir_fix_check_json_emits_parseable_json_before_exit_1 — the absence of that twin is exactly why this survived.

If the fix is judged too risky for the tag, the minimum acceptable alternative is a spec.md §lint-json entry stating AC-F-14 does not hold for single-file --fix --check. Shipping it undocumented is the option to reject.

Comment thread crates/mds-cli/src/lint.rs Outdated
Comment on lines +309 to +311
/// Error-only entries (`{"file": …, "error": …}`) push the raw display path
/// without a second sanitization pass — a pre-existing asymmetry; their array
/// position is still determined by the sanitized sort key.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale rustdoc contradicts code added in the same wave — reported independently by security (90%), regression R-03 (95%) and rust (92%).

The doc says error-only entries push the raw display path. The code does the opposite, and correctly so — :1454:

let file_key = mds::sanitize_control_chars_wire(&display_path).into_owned();

All four error-entry sites (:1468, :1479, :1515, :1591) push file_key, and the diff shows the change explicitly ("file": display_path"file": file_key). The companion comment at :1448-1453 describes the new behaviour correctly, so two comments in one file now disagree.

No runtime impact today. The risk is a fifth error-entry site added by a maintainer who trusts this doc and pushes display_path directly — reintroducing CWE-150 via a hostile filename into JSON files[].file. Doc drift on an invariant is how invariants die.

Fix: replace these three lines with

/// Error-only entries (`{"file": …, "error": …}`) bypass `to_canonical_json`, so callers
/// must apply `sanitize_control_chars_wire` themselves — see `file_key` in
/// `lint_one_file_accumulating`.

Regression also notes this is an undocumented wire delta: for a filename carrying a control/bidi character, the error-entry file value is now escaped where it previously was raw. Worth a one-line CHANGELOG note under ### Fixed.

Comment thread crates/mds-cli/src/lint.rs Outdated
Comment on lines 1534 to 1537
accumulate_result_json(&residual, json_files);
if let Err(e) = atomic_write_file(file, &new_source) {
eprintln!("error writing {}: {}", safe_path(file), safe_inline(&e));
return FileTally::Error;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A write failure under --fix --format json emits a clean JSON result and no error entry — complexity, 92%, reproduced.

accumulate_result_json pushes the post-fix residual before the write is attempted; on failure the function returns FileTally::Error having pushed no JSON error entry. The read-failure path in the same function does it correctly (:1513-1518):

json_files.push(serde_json::json!({ "file": file_key, "error": e.serialize() }));
return FileTally::Error;

Reproduced with a read-only directory so atomic_write_file's temp-file create fails:

$ chmod 555 ro/sub && mds lint ro/sub --fix --format json ; echo "exit=$?"
exit=2
--stdout--
{"files":[],"truncated":false,"version":1}      <- reads as "clean tree, nothing found"
--stderr--
error writing ro/sub/a.mds: cannot create temp file ... Permission denied (os error 13)

The file on disk still contains the duplicate export. A consumer parsing stdout sees a clean result; only the exit code and stderr disagree — the same divergence class as the --fix --check finding, where the JSON leg lost a convention its human twin never needed.

Fix: attempt the write first; on failure push a structured entry matching the read-failure shape three lines above, and only accumulate_result_json(&residual, json_files) after the write succeeds. Apply the same to the PartiallyFixed arm at :1558-1562.

(atomic_write_file returns miette::Report, not MdsError; if .serialize() is unavailable there, {"file": file_key, "error": {"code":"mds::io","message": safe_inline(&e)}} is an acceptable equivalent. The load-bearing part is that some error entry appears and the post-fix result does not.)

Comment thread packages/mds/src/types.ts
* `lintVirtual` resolves imports against the caller-supplied module map.
* Passing a non-null value throws `mds::invalid_options`.
*/
basePath?: never;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING — basePath?: never legalizes { basePath: undefined }, which lintFile/lintVirtual reject at runtime — typescript, 97%.

?: never means "absent, or present with value undefined". exactOptionalPropertyTypes is not enabled (tsconfig.base.json has strict and noUncheckedIndexedAccess only), so this type-checks under tsconfig.types.json:

const p3b: LintFileOptions = { basePath: undefined };   // NO ERROR

But the runtime rejects it. Probe against the built dist/:

OK    | compileFile {basePath: undefined}
OK    | checkFile   {basePath: undefined}
THROW | lintFile    {basePath: undefined} -> [mds::invalid_options] unknown option key "basePath"; recognised keys are: vars, rules
THROW | lintVirtual {basePath: undefined} -> [mds::invalid_options] unknown option key "basePath"; recognised keys are: vars, rules

This is a regression, not a pre-existing wart. On main, LintFileOptions omitted basePath entirely, so {basePath: undefined} was a TS2353 excess-property error — the type and the runtime agreed. Adding basePath?: never removed that agreement for the two lint file surfaces while compileFile/checkFile kept it.

The shipped README compounds it: packages/mds/README.md:149,158,182 give all three file-surface types the byte-identical annotation, and :127 states "{basePath: undefined} is treated as absent on both backends". A consumer reading the published tarball reasonably concludes uniform behaviour; two of the three surfaces disagree.

Failure scenario: a wrapper that spells out the full option shape — const opts: LintFileOptions = { vars, rules: {}, basePath: undefined } — compiles clean under strict and throws on every call, while the same shape passed to compileFile works fine.

The published .d.ts is the correct half of this pair; the fix belongs in util/options.ts (see the comment there). Blocking is still right: the change is ~3 lines with zero existing-test churn and this diff introduced it.

Comment on lines +162 to +165
const BASEPATH_REJECTORS: ReadonlyMap<MethodName, () => Error & { code: string }> = new Map([
['compileFile', makeFileBasePathError],
['checkFile', makeFileBasePathError],
] as const);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runtime half of the basePath?: never divergencetypescript (97%) and consistency C-5 (88%) independently.

lintFile/lintVirtual are absent from this map, so assertKnownKeys does not skip basePath for them (:220) and the key's mere presence trips the generic unknown-key path. That is why {basePath: undefined} throws on these two surfaces and passes on compileFile/checkFile — a type/runtime divergence this diff introduced (see types.ts:359).

Consistency adds the second, permanent cost: the same operation produces two different messages from two published packages. 🔒 Published error strings harden at the tag.

Call @mdscript/mds @mdscript/mds-napi
compileFile(f, {basePath}) option "basePath" is not valid for compileFile/checkFile; … byte-identical — locked by U-OV-27
checkFile(f, {basePath}) same byte-identical
lintFile(f, {basePath}) unknown option key "basePath"; recognised keys are: vars, rules option "basePath" is not valid for lintFile; the base directory is derived from the file path (crates/mds-napi/src/lib.rs:900-906)
lintVirtual(m, e, {basePath}) generic form purpose-built form (lib.rs:923+)

The three TS types treat basePath identically (?: never at types.ts:164, :183, :359) while the runtime treats two of four methods differently. The KNOWN RESIDUAL note at :152-161 documents the deferral honestly — but the deferral is what produces the divergence.

Fix: add both methods with the napi strings copied verbatim. assertKnownKeys then skips basePath automatically, so undefined passes and a non-null value still hard-errors:

const BASEPATH_REJECTORS = new Map([
  ['compileFile', makeFileBasePathError],
  ['checkFile',   makeFileBasePathError],
  ['lintFile',    makeLintFileBasePathError],
  ['lintVirtual', makeLintVirtualBasePathError],
] as const);

then mirror the existing compileFile guard in node.ts:324/:330 and browser.ts:135. Cost is low: grepping packages/mds/__test__/*.mjs for both "not valid for lintFile" and "recognised keys are: vars, rules" finds no existing test asserting either — no golden churn. Extend U-OV-27 to cover the two new messages.

Comment thread scripts/verify-no-control-bytes.mjs Outdated
// Use NUL as delimiter (matching getStagedFiles' -z output) so that git paths
// containing a literal LF do not split into two batch requests and
// desynchronize the response parser.
const stdin = Buffer.from(paths.map(p => `:${p}\0`).join(''), 'utf8');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING — --staged mode false-passes on any filename beginning with 0: — security, 98%, reproduced end-to-end (not inferred).

:<path> is git revision syntax, not a literal path. Git's revision parser checks whether the character after : is a stage digit followed by another colon — :0:foo means stage 0 of foo, not the file literally named 0:foo. A tracked file whose name starts with 0: is silently redirected to a different blob, and that different blob is what gets scanned.

$ printf 'CLEAN CONTENT\n'   > payload.mds
$ printf 'HOSTILE\033[31m\n' > '0:payload.mds'      # raw ESC 0x1B
$ git add -A

$ node scripts/verify-no-control-bytes.mjs --staged
✓ source-hygiene gate: Scanned 2 file(s), 28 byte(s)     EXIT=0     ← FALSE PASS

$ node scripts/verify-no-control-bytes.mjs            # full-tree, readFileSync
✖ 0:payload.mds: hazardous codepoint U+001B at byte offset 7
✖ source-hygiene gate FAILED — Scanned 2 file(s), 27 byte(s)   EXIT=1

The byte counts are the tell: staged mode reports 28 bytes for two files whose real combined size is 27 — it read the 14-byte payload.mds twice and never opened the hostile file at all.

Blast radius is bounded. The authoritative full-tree scan uses readFileSync with no revision parsing and is wired into both ci.yml:363 and release.yml:40, so both catch this. What fails is the local pre-commit layer — defense in depth, and the layer that determines whether contributors trust the gate.

This refutes a claim in the PR description: "Neither new gate script can produce a false pass — both were run with planted positive controls." The positive controls are real and good, but they exercise scanBuffer/isHazardous, not the index-read path's revision parsing. There is no case in scripts/__test__/verify-no-control-bytes.spec.mjs for an ambiguous path. This is precisely the PF-013 shape the file's own D-CB7 header warns about.

Fix (removes the revision parser from the loop entirely): git diff --cached --raw already carries the index blob SHA in field 3 of every header. Address blobs by SHA, never by path.

// getStagedFiles(): capture the new-sha alongside the mode
const newSha = fields[3];
files.push({ path, sha: newSha, mode: newMode, skip, staged: true });

// readAllIndexBlobs(): request by SHA — no `:` prefix, no revision syntax
const stdin = Buffer.from(entries.map(e => `${e.sha}\n`).join(''), 'utf8');
// ...and key the result Map by path while iterating `entries` in the same order.

Belt-and-braces alternative if the path form is kept: reject any staged path matching /^[0-3]:/ with exit 1. Either way, add the spec case that plants 0:payload.mds with a raw ESC and asserts exit 1 — the positive control this vector currently lacks.

Comment thread crates/mds-core/src/lint/config.rs Outdated
Comment on lines 296 to 302
since = "0.4.0",
note = "prefer `LintConfig::from_rules_checked`, which returns the unknowns report \
in one atomic call — all built-in callers have migrated"
)]
#[must_use]
pub fn from_rules(rules: HashMap<String, Severity>) -> Self {
LintConfig { rules }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LintConfig::from_rules is deprecated-since-birth and contradicts its own doc — architecture, 88%. Tag-admissible as a breaking change under ADR-010.

LintConfig is entirely new public API in v0.4.0git show v0.3.0:crates/mds-core/src/lint/config.rs → file does not exist; git grep "pub struct LintConfig\|from_rules" v0.3.0 -- crates/mds-core/src → nothing. The type has never shipped. Yet the constructor ships already-deprecated, while :273-276 blesses it as a supported escape hatch:

"from_rules remains available for external crates that have already performed the unknowns check separately; it will not be removed without a major-version bump."

An intentionally-supported path must not emit deprecation warnings. As written, every legitimate use of the documented escape hatch forces #[allow(deprecated)]and the repo already has to do this to its own type in two places, crates/mds-core/tests/api_surface.rs:1091 and :1150, plus twice more in the doctest 10 lines above. The API cannot be exercised as documented without suppressing its own warning.

The tag freezes the contradiction: removing it later needs a major bump; keeping it ships a constructor that is both blessed and warned-against for the life of the 0.x line. Right now it has zero external callers by construction.

Fix (preferred, one line net): delete from_rules. Pre-checked external callers use let (config, _) = LintConfig::from_rules_checked(..). Drop the #[allow(deprecated)]/#[expect] scaffolding at api_surface.rs:1091/:1150 and in the doctest.

Fix (alternative): keep from_rules but remove the #[deprecated] attribute, keeping only the "Prefer from_rules_checked" prose at :273.

Shipping both as-is is the only option that should not survive the tag. Note the footgun is not the checked/unchecked pair — from_rules_checked returning (Self, Option<UnknownRuleNames>) is good design and #[must_use]. The footgun is shipping the unchecked half with two mutually exclusive contracts attached.

Comment thread .github/workflows/ci.yml Outdated
python -m pip install --find-links dist --no-index mdscript
python -c "import mdscript; r = mdscript.compile('Hello {n}!', vars={'n': 'CI'}); print(r.kind, r.output)"
python -m pip install --find-links dist --no-index markdown-script
python -c "import markdown_script; r = markdown_script.compile('Hello {n}!', vars={'n': 'CI'}); print(r.kind, r.output)"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wheel-install smoke test cannot fail, and it advertises dead syntax — python, HIGH, 95%. Line modified by this wave's rename commit.

Two defects in one line:

  1. No assertion. The step passes as long as import + compile do not raise. print() is not a gate.
  2. {n} no longer interpolates. After the {{x}} migration (feat!: double-brace {{x}} interpolation with lint --fix migration (v0.4.0) #237) single-brace is legacy and is not substituted. Verified against the installed wheel:
    m.compile('Hello {n}!',  vars={'n':'CI'}).output  ->  'Hello {n}!'   # unchanged
    m.compile('Hello {{n}}!', vars={'n':'CI'}).output ->  'Hello CI!'    # correct
    

So CI prints markdown Hello {n}! and goes green. This is the only step that exercises the fully-built-and-installed wheel end to end, and it would stay green if variable interpolation were completely broken in the wheel. It also prints the deprecated syntax as if it were the canonical example.

Not a tag blocker — nothing Python ships in v0.4.0 (crates/mds-python/Cargo.toml:14 publish = false, no PyPI/maturin step in release.yml). Gate it on #292/#132, before the first publish.

Fix:

python -c "import markdown_script as m; r = m.compile('Hello {{n}}!', vars={'n': 'CI'}); assert r.kind == 'markdown', r.kind; assert r.output == 'Hello CI!', repr(r.output); print('smoke ok:', r.output)"

Comment thread CHANGELOG.md Outdated
Comment on lines +910 to +913
`check()` and `checkFile()` accept `{ vars?, basePath? }` — source-map options
(`sourceMap`, `sourcesContent`) are not valid for check calls and are rejected with
`mds::invalid_options`. `CompileOptions` retains `sourceMap`/`sourcesContent`.
TS interface implementers: `check`/`checkFile` signatures narrow to `CheckOptions`. (#196)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CHANGELOG states the opposite of the shipped checkFile contract — regression R-01, 97%. The single item worth gating the tag on from the regression lens.

Both sentences are now false:

  • packages/mds/src/node.ts:306export function checkFile(path: string, options?: CheckFileOptions)not CheckOptions
  • packages/mds/src/types.ts:183CheckFileOptions declares basePath?: never
  • packages/mds/src/node.ts:307-313 — passing basePath throws mds::invalid_options, synchronously

The correct entry does exist at CHANGELOG.md:580-595 — but 385 lines later. A consumer reading top-down hits this stale #196 text first and is told to do exactly the thing that now throws, in a way .catch() does not intercept. Because the tag is irreversible, the wrong text is permanent in the 0.4.0 release notes.

Fix — amend the stale #196 entry in place:

-  `check()` and `checkFile()` accept `{ vars?, basePath? }` — source-map options
+  `check()` accepts `{ vars?, basePath? }`; `checkFile()` accepts `{ vars? }` only
+  (see the `basePath` rejection entry below) — source-map options
...
-  TS interface implementers: `check`/`checkFile` signatures narrow to `CheckOptions`. (#196)
+  TS interface implementers: `check` narrows to `CheckOptions`, `checkFile` to
+  `CheckFileOptions`. (#196, amended by #180)

Comment thread CHANGELOG.md Outdated
- **`mds lint`** — 9-rule static analyzer for `.mds` templates (#61). Available
across all surfaces (CLI, Rust, napi, WASM, Python) with byte-identical canonical
JSON output.
- **`mds lint`** — 10-rule static analyzer for `.mds` templates (#61). Available

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"10-rule" heading over a 9-bullet cataloguedocumentation D1 (98%) and regression R-04 (94%) independently.

This wave changed the headline count but not the list beneath it:

-- **`mds lint`** — 9-rule static analyzer for `.mds` templates (#61). Available
+- **`mds lint`** — 10-rule static analyzer for `.mds` templates (#61). Available

The enumerated Rules catalogue at :123-131 still contains exactly nine bullets. main was self-consistent (9 bullets under "9-rule"); the branch is self-contradicting, and the inconsistency was introduced here.

The missing rule is legacy-interpolation — the tenth entry in crates/mds-core/src/lint/rules/mod.rs ALL_RULE_NAMES, dispatched at crates/mds-core/src/lint/mod.rs:134. It appears exactly once in the entire CHANGELOG (:676), as an instruction inside the {x}{{x}} migration — never as a catalogued rule. It is the rule driving the single largest migration in this release.

The catalogue is the documented configuration surface for mds.json lint.rules. A user auditing which rules to disable never learns it exists, and with #224's new warn-on-unknown-name behaviour cannot discover the spelling by guessing — a typo produces a warning naming the recognised rules, but only at runtime.

Fix — add the missing bullet after :131, matching the catalogue's format:

  - `legacy-interpolation` (warn): single-brace `{x}` interpolation, superseded by
    `{{x}}` (Tier A — auto-fixed by `mds lint --fix`)

Comment thread spec.md Outdated
| `span`, `fix_edits` | **Raw byte offsets** into the unmodified source — deliberately not sanitized. These are numeric position values and must reflect the original source exactly. |

This invariant applies across all surfaces that emit `"version": 1` JSON: CLI
(`mds lint --format json`), napi (`lintVirtual` / `lint` / `lintFile`), WASM
(`lintVirtual` / `lint`), and Python (`lint_virtual` / `lint` / `lint_file`).
All four surfaces emit byte-identical values.
All four surfaces emit byte-identical values on the fields they share, with two exceptions. First, `"lint_warnings"` is a binding-surface-only key: it is absent from the CLI's `--format json` output (the CLI writes unknown-rule warnings to stderr instead). Second, the `"file"` key differs when the source is piped via stdin: `mds lint -` (CLI) relabels the internal virtual-FS key `"input.mds"` to `"<stdin>"` at the output boundary; the binding surfaces (`lintVirtual` / `lint` on napi, WASM, and Python) retain `"input.mds"`. All other fields — `message`, `help`, `rule`, `severity`, `span`, `fix_edits` — are byte-identical across all four surfaces.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence is factually wrong about lintVirtual — and it is the only normative statement of the asymmetryconsistency C-1 (95%); documentation D2 (90%) flags a second defect in the same line.

C-1 — lintVirtual does not retain "input.mds". It emits the caller-supplied entry key. The repo's own golden proves it, crates/mds-napi/__test__/index.spec.mjs:1067-1075:

'…"span":{"length":10,"offset":4}}],"file":"main.mds"}],…';
const result = lintVirtual({ 'main.mds': '---\nunused_key: value\n---\nHello!\n' }, 'main.mds');

Only the string-source lint(source) entrypoint emits input.mds — via mds::lint_strlint_source(source, STRING_SOURCE_MAP_LABEL, …) (crates/mds-core/src/lib.rs:1216-1220, sourcemap.rs:79). lintFile/lint_file emit the real path.

Second defect in the same sentence: the parenthetical spells Python's methods in JS camelCase, while :1069-1070 two lines above correctly writes lint_virtual / lint / lint_file.

A surface-agnostic consumer keying diagnostics off result.files[].file gets four different values for "the source identity" of a non-file input, and the only place that says so is this paragraph — which is wrong about one of them.

D2 (avoids PF-015) — the closing "All other fields … are byte-identical across all four surfaces" is a universal quantifier in a normative document whose falsification is a review finding rather than a test failure. It is not mechanically enforced: the only live cross-surface byte comparison, crates/mds-python/tests/test_parity.py:254-278, deliberately uses a clean source (its own docstring says so), so with zero diagnostics message, help, rule, severity, span and fix_edits are never compared between the CLI and any binding at all.

Fix for C-1:

… the binding surfaces retain `"input.mds"` on their **string-source** entrypoint only
(`lint` on napi and WASM, `lint` on Python). `lintFile` / `lint_file` emit the supplied
path, and `lintVirtual` / `lint_virtual` emit the caller-supplied entry key.

Fix for D2: scope the claim to the enumerated set and name what pins it, noting the with-findings CLI-vs-binding comparison is not currently covered by a live differential test.

Comment on lines 113 to 116
))
{
return;
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diagnostic is emitted as a short-circuit side effect of &&, now inconsistent with its sibling arm — complexity A3, 90%.

if !is_used
    && !builder.push(make_diag(severity, filename, format!(), Some(), imp.offset, "@import".len()))
{
    return;
}

Nothing marks this line as the emit point. Insert one more predicate after !is_used — the single most likely future edit to this rule — and the diagnostic silently stops being emitted, with no compiler complaint.

This diff already converted the identical construct in the Selective arm to the readable nested form (:137-147), so one 113-line function now spells the same operation two ways.

Fix (4 lines, zero behaviour change):

if !is_used {
    if !builder.push(make_diag()) {
        return;
    }
}

For the record, the rest of this rule came back clean: the name_offsets.get(i) degradation is written as a total match with an explicit rationale for why it is neither an expect nor a debug_assert! (:119-129, cites PF-005/PF-012), and it degrades offset and length together — the correct choice.

Comment thread scripts/verify-no-control-bytes.mjs Outdated
Comment on lines +673 to +678
hazardHits.push({
path: entry.path,
codepoint: hit.codepoint,
byteOffset: hit.byteOffset,
hexCtx: hexContext(buf, hit.byteOffset),
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded hazard-hit accumulation — a denial of service against the gate itself — reliability, 100%, reproduced empirically.

scanBuffer returns one record per hazard occurrence and the caller retains every one, each carrying a freshly allocated ~54-char hexCtx. Nothing caps hits per file, hits per run, or lines printed — :637 (const hazardHits = []), this push, and the report loop at :742-746. The NUL short-circuit at :469-475 does not protect this path: a file can be hazard-dense without a single NUL byte (lone-CR legacy text, dense C1, a non-NUL binary blob).

Measured on a 2 MB file of 0x01 in a throwaway git repo:

exit=1
stderr lines:   4,194,323
stderr bytes:   275,713,558
peak RSS:       569,125,184  (569 MB)
wall clock:     14.54 s

~285x memory amplification over input size. Extrapolating: a ~20 MB file OOM-kills Node; a ~50 KB file already emits 100k stderr lines. In the pre-commit hook that stalls the developer's terminal and floods scrollback; in the source-hygiene CI job and in release.yml's version-gate it produces hundreds of MB of Actions log.

Fail direction is closed (Node OOM aborts non-zero), so this is not a fail-open — but it violates the project rule that every loop, retry and resource has an explicit bound. This repo already solved exactly this problem for lint: mds::MAX_DIAGNOSTICS caps at 1,000 and sets truncated.

Fix — mirror the lint cap, ~8 lines:

const MAX_HITS_PER_FILE = 20;
const MAX_HITS_TOTAL = 1000;
let suppressedHits = 0;
// …then report `${suppressedHits} further hazardous codepoint(s) suppressed` after the loop.

Truncating output cannot weaken the gate — one retained hit is already sufficient to exit 1.

/// nested path `sub\d.mds` using the native separator (0x5B < 0x5C), but AFTER
/// it with the emitted forward slash (0x5B > 0x2F), reversing the array order
/// relative to the emitted key order.
fn relative_display(path: &Path, root: &Path) -> String {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A shipping, documented wire break with zero test executions on the platform it describesrust (88%) and testing S1 (92%) independently.

The PR ships this as a breaking wire change: "On Windows, separators are now normalized to forward slashes — a path that appeared as sub\c.mds now appears as sub/c.mds. A consumer splitting on \ will silently stop matching."

Coverage, checked three ways:

  • .github/workflows/ci.yml:22-23Rust — fmt, clippy, test is ubuntu-latest only, and relative_display is reachable only from crates/mds-cli/tests/*.rs and this file's #[cfg(test)] module, so the entire mds-cli test surface is Linux-only.
  • The two unit tests are Unix-scoped by construction: relative_display_preserves_literal_backslash_on_unix (:1969) and its control-byte sibling (:2022). crates/mds-cli/tests/cli_lint.rs:2036 states outright that there is no #[cfg(windows)] sibling.
  • The Windows CI matrices (JS packages, Python — build & test) do build the CLI, but every Windows CLI invocation is single-file or stdin (test_parity.py:267,316,407,488; packages/mds/__test__/lint.spec.mjs:251,400). mds lint <dir> never runs on Windows anywhere, and directory mode is the only caller of relative_display.

The code is right; only the evidence is missing. collect_mds_files_inner (output.rs:432-433) builds every entry as dir.join(name), so strip_prefix(root) always succeeds and rel contains only Component::Normal — which on Windows can never contain \ or /. Using .replace('\\', "/") instead would have been a CWE-22 traversal vector on Unix, and the rustdoc names that vector. PF-003/#133 bit this repo on exactly this shape before.

Fix, cheapest first — neither is a code change:

(a) A platform-independent ordering unit test over the emitted strings, closing the rationale at :313-319 (bytes 0x30..=0x5B sort between / 0x2F and \ 0x5C, so "sub[abc.mds" must sort after "sub/d.mds"). Runs on the existing ubuntu job.

(b) One directory-mode case in packages/mds/__test__/lint.spec.mjs, which already spawns the CLI (findMdsCli()) and runs on windows-latest: assert no files[].file contains a backslash, and that files[] is in ascending byte order.

If neither fits the tag window, record the gap against the existing #147/#148 Windows-CI trackers so it is not rediscovered after the crates.io publish is irreversible.

Comment thread packages/mds/src/types.ts Outdated
* derived from the file path). The fields are declared directly so that adding
* a new string-surface option never implicitly appears on the file surface.
*/
export interface FileOptions {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FileOptions is missing its Compile prefix — this diff completes the family and makes the gap permanent — consistency C-2, 92%. 🔒 published TS type surface.

Operation String surface File surface
compile CompileOptions (:120) FileOptions (here)
check CheckOptions (:95) CheckFileOptions (:176, new)
lint LintOptions (:311) LintFileOptions (:342)

FileOptions predates this branch, but until now it was the only file-surface option type, so there was no family to be asymmetric with. CheckFileOptions creates the family and makes the gap load-bearing. It is now visible verbatim in three places added by this diff — util/options.ts:79-87 (OptionsFor, where it breaks the column), the three @ts-expect-error comments at __test__/types/consumer-node.ts:69-75, and mirrored into napi (FileOpts/parse_file_opts at crates/mds-napi/src/lib.rs:566 beside CheckFileOpts at :610 and LintFileOpts at :898).

FileOptions is already on npm from v0.1–v0.3 so it must survive as an alias regardless. v0.4.0 is the last release where introducing the symmetric name costs nothing; after the tag, CheckFileOptions/LintFileOptions are permanently paired with a sibling that does not match them.

Fix — non-breaking, additive, ~10 lines:

export interface CompileFileOptions { /* current FileOptions body */ }

/** @deprecated Renamed to {@link CompileFileOptions} in v0.4.0 for symmetry with
 *  CheckFileOptions / LintFileOptions. Retained as an alias; will be removed in v1.0.0. */
export type FileOptions = CompileFileOptions;

Export both from node.ts:348-370, update OptionsFor.compileFile, and rename the private Rust identifiers FileOptsCompileFileOpts, parse_file_optsparse_compile_file_opts (private — zero semver cost).

Comment thread crates/mds-cli/src/main.rs Outdated
Comment on lines +136 to +138
/// Under --quiet the summary is suppressed on a warn-only or clean run, but is
/// always printed when error- or resource-limited files are present so the non-zero
/// exit is never unexplained. --quiet also suppresses the `fix rejected:` and

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clap help asserts a --quiet guarantee that lint does not provide — consistency C-4, 92%.

The two clauses of this sentence contradict each other. A warn-only directory run exits 1FileTally::WarnOnly = 1 and exit_code(self) = self as i32 (lint.rs:1187-1198), reached via max_tally.exit_code() at lint.rs:1417-1419 — while the summary gate at lint.rs:1405 is:

if !quiet || error_file_count > 0 || limit_file_count > 0 {

So warn-only + --quiet = exit 1 with zero stderr. The non-zero exit is unexplained. The rustdoc 90 lines away states this correctly (lint.rs:1226-1227: "mds lint --quiet <dir> on a warn-only tree exits 1 with no output"), but long_about is the text users actually read.

Cross-subcommand — --quiet has two distinct meanings:

Command can --quiet yield a non-zero exit with zero stderr?
build <dir> (build.rs:1645) no
check <dir> (main.rs:371) no
fmt <dir> write (fmt.rs:354) no
fmt --check <dir> (fmt.rs:349) YES — exit 1 on would-reformat
lint <dir> warn-only (lint.rs:1405) YES — exit 1
lint --fix --check <dir> (lint.rs:1416) YES — exit 1

Fix:

/// Under --quiet the summary is suppressed on a clean or warn-only run and printed
/// when error- or resource-limited files are present. A warn-only run therefore exits
/// 1 with no stderr under --quiet — same as `mds fmt --check --quiet`. Drop --quiet if
/// a script needs the exit code explained.

Comment thread crates/mds-cli/src/lint.rs Outdated
Comment on lines +1401 to +1408
// {limit} resource-limited`. Fixed arity, comma-separated; matches the sibling
// shape used by `build`/`check`/`fmt` and keeps the line greppable across all
// summary-emitting subcommands.
if !quiet || error_file_count > 0 || limit_file_count > 0 {
eprintln!(
"{clean_count} clean, {warn_file_count} with warnings, \
{error_file_count} with errors, {limit_file_count} resource-limited"
);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new lint summary breaks the sibling shape this very comment claims to match — consistency C-3, 90%. 🔒 stderr contract, now normative at spec.md:958-959.

The comment claims the format "matches the sibling shape used by build/check/fmt and keeps the line greppable across all summary-emitting subcommands." The four emitters:

Subcommand Emitter Site
build <dir> {ok} built, {fail} failed build.rs:1646
check <dir> {ok} passed, {fail} failed main.rs:372
fmt <dir> {changed} formatted, {unchanged} unchanged, {fail} failed fmt.rs:355
fmt --check <dir> {changed} would reformat, {unchanged} unchanged, {fail} failed fmt.rs:350
lint <dir> {clean} clean, {warn} with warnings, {error} with errors, {limit} resource-limited here

Every sibling terminates in N failed. Lint contains no failed token, and is the only one using multi-word bucket labels where the others use single-word past participles. A CI script running grep -oE '[0-9]+ failed' across mds build/check/fmt output returns nothing for mds lint — silently, with exit 0 from grep's perspective in a pipeline.

Fix — pick one:

  1. Align the error bucket: {clean} clean, {warn} with warnings, {error} failed, {limit} resource-limited. This also matches the D3-a rationale at :1216-1218, which explicitly says the "with errors" bucket makes "the same conflation mds build's failed bucket makes".
  2. Or delete the inaccurate clause "and keeps the line greppable across all summary-emitting subcommands" from :1402-1403, and add a note to spec.md:958-959 that lint's summary vocabulary is deliberately distinct.

Option 1 is preferable while the string is still unpublished.

Comment on lines +731 to +735
#[deprecated(
since = "0.4.0",
note = "use `apply_fixes_incremental`; not a drop-in swap, the reverify closure must \
be `Fn`, not `FnOnce`. To be removed in v0.5.0; see the item docs."
)]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The #[deprecated(note)] carries the hazard that self-announces and omits the one with no compiler signal — documentation D4, 92%.

The note names hazard (a), the FnOnceFn bound tightening. That hazard already self-announces: it is a hard compile error the moment a caller swaps the function.

It omits hazard (b): apply_fixes_incremental can return FixOutcome::PartiallyFixed, which apply_fixes never did. A _ => {} arm compiles clean and drops partial fix results at runtime — no compiler signal at all. #[must_use = "a dropped FixOutcome silently discards the fix result"] at :736 does not close this: must_use fires on a dropped value, not on a _ => match arm.

The note string is what a downstream #![deny(warnings)] consumer sees in their build output, and it is the one place that mentions only the hazard they would have found anyway. The trailing "see the item docs." is the only pointer to migration delta #2. The item rustdoc at :683-688 documents (b) correctly and precisely — this is solely about the compiler-visible note.

Related, same root cause (D5, 90%): FixOutcome's enum doc at :263 tells external callers they must include a _ => {} wildcard arm — the exact pattern that triggers hazard (b) — and the two statements never cross-reference each other. Worth one sentence at :263 naming PartiallyFixed explicitly.

Fix:

note = "use `apply_fixes_incremental`; not a drop-in swap. (1) the reverify closure must be \
        `Fn`, not `FnOnce`; (2) it can return `FixOutcome::PartiallyFixed`, which a `_` match \
        arm silently discards. To be removed in v0.5.0; see the item docs."

Deferrable to 0.4.1: crates/mds-core/src/lint/fix.rs does not exist at tag v0.3.0, so no external caller can hit either hazard today.

Comment thread .github/workflows/ci.yml Outdated
# Scoped to this job only (not repo-wide rust-toolchain.toml) to limit blast radius.
# WHEN BUMPING: rebuild, re-measure with the new compiler, and update the
# budget-history ledger comment above with the new baseline before changing the pin.
- uses: dtolnay/rust-toolchain@1.96.0

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new pin resolves to a mutable branch, not a tag or a SHA — dependencies, 92%.

$ gh api repos/dtolnay/rust-toolchain/git/ref/tags/1.96.0
{"message":"Not Found","status":"404"}

$ gh api repos/dtolnay/rust-toolchain/branches/1.96.0
1.96.0 -> 01ba1edad32c6f80dbcce879d3e0fa5a00b2a84e

Branches are mutable, so the action code executing in this job — the only job that runs the 850,000-byte WASM size guard — can change at any time without a commit here. The stated purpose of the pin is that compiler drift must not "silently change the wasm-opt output size independently of any source change"; a mutable installer ref reintroduces exactly that class of uncommitted drift one layer up. The pin constrains the toolchain while leaving the installer floating.

The repo already demonstrates the right pattern — .github/actions/setup-wasm/action.yml:9,12 SHA-pins both third-party actions with a human-readable version comment.

Calibration: not a regression in kind. @stable, @1.88, Swatinem/rust-cache@v2 and mlugg/setup-zig@v2 are all already mutable refs on main. This does not block the tag.

Fix:

- uses: dtolnay/rust-toolchain@01ba1edad32c6f80dbcce879d3e0fa5a00b2a84e  # 1.96.0

Cooldown itself is compliant: 1.96.0 published 2026-05-28T17:50:42Z — 83 days of soak. Nitpick on :70: the comment says "released 2026-05-25", off by 3 days. The conclusion is unaffected, but that comment is the load-bearing written record of the cooldown decision.

The reason this line matters most: the pin was added to ci.yml and not to release.yml:262, which still uses @stable — and release.yml is the job that builds the WASM that ships. See the summary comment (R5).

Comment thread scripts/verify-pr-checks.mjs Outdated
* and the verifier would emit a PASS. EXPECTED_CONTEXTS closes this gap by
* asserting presence, mirroring Tier A semantics.
*/
export const EXPECTED_CONTEXTS = ['Source hygiene'];

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EXPECTED_CONTEXTS closes the absence gap for 1 of 4 non-required CI jobs — reliability, 85%; corroborated by testing S2 (88%).

The file's own docstring at :30-35 states the reason Tier A+ exists:

"Tier B alone cannot make it binding because Tier B only iterates runs that ALREADY EXIST in the check-run list; an absent job has nothing to iterate. Tier A+ fills this gap by asserting presence (applies ADR-009, avoids PF-013: absence is never evidence of success)."

That reasoning is correct and applies verbatim to three more jobs. Verified against the live branch-protection payload (exactly 6 required contexts) and ci.yml job names:

ci.yml job In branch protection? In EXPECTED_CONTEXTS?
Source hygiene no yes
Python — build & test no no
examples/ gitignore coverage no no
Python — wheel install smoke no no

Failure scenario: someone adds a paths: filter, renames, or deletes one of those three. It vanishes from the check-run list — Tier A never named it, Tier A+ never named it, Tier B has nothing to iterate — so evaluateChecks returns exitCode: 0 and prints the --admin --match-head-commit merge command. This branch is a live rehearsal: it renames the Python distribution mdscriptmarkdown-script and rewrites both Python CI jobs (ci.yml:330-331). The job names happened not to change; nothing in the gate would have caught it if they had.

Fix — one line:

export const EXPECTED_CONTEXTS = [
  'Source hygiene',
  'Python — build & test',
  'examples/ gitignore coverage',
  'Python — wheel install smoke',
];

The em-dash in Python — build & test is U+2014 — copy it from ci.yml, do not retype it. Better still: promote all four to required contexts in branch protection so Tier A covers them and this hardcoded list can shrink back to zero.

dean0x and others added 19 commits August 20, 2026 22:58
complexity-01 (F-01): single-file `--fix --check --format json` called
`std::process::exit(1)` inside the `WouldFix` arm without first calling
`emit_result`, making the downstream emit unreachable and producing zero
bytes on stdout.  Fix: call `emit_result` immediately before the exit,
mirroring directory mode (AC-F-14 / issue #36 twin).

complexity-02 (F-14): in `lint_one_file_accumulating`, both the `Fixed`
and `PartiallyFixed` arms called `accumulate_result_json` BEFORE
`atomic_write_file`.  On a write failure the clean post-fix result was
already in the envelope (consumer saw `{"files":[],...}` — reads as
clean) while exit was 2.  Fix: write first; on failure push a structured
`{"file":…,"error":…}` entry matching the read-failure shape.

complexity-09: all three `PartiallyFixed` arms (single-file,
dir-JSON, dir-human) printed "Partially fixed: …" BEFORE calling
`atomic_write_file`, so on a write failure the operator saw a success
label followed immediately by an error.  Fix: print after write, matching
the already-correct `Fixed:` arms.

Tests: adds `file_fix_check_json_emits_parseable_json_before_exit_1`
(single-file twin of the existing dir test) and
`file_fix_json_dir_write_failure_emits_structured_error_not_stale_result`
(unix-gated, read-only dir triggers the F-14 path).  All 114 cli_lint
tests and 11 print_discipline tests pass.

TASK_ID: resolve-b2a-lint-output

Co-Authored-By: Claude <noreply@anthropic.com>
…rrect soak date

python-01: Replace vacuous wheel smoke test (no assertion, legacy {n} syntax) with
an asserting command that uses double-brace {{n}} interpolation and verifies both
r.kind == 'markdown' and r.output == 'Hello CI!' so interpolation regressions are
actually caught.

dependencies-02: SHA-pin all dtolnay/rust-toolchain refs in ci.yml using SHAs
resolved via gh api repos/dtolnay/rust-toolchain/git/ref/heads/<version>:
  stable  → 4360b52568e2003a75bf9bc1d59f33a8e3fc893c
  1.88    → 2eae45db285e407f22119950686d47e1101e071b
  1.96.0  → 01ba1edad32c6f80dbcce879d3e0fa5a00b2a84e
Each pin carries a trailing # <version> comment matching the setup-wasm convention.
The js job's @stable ref is intentionally left unpinned (see performance-06).

dependencies-03: Correct the 1.96.0 release date from 2026-05-25 to 2026-05-28
in the load-bearing soak decision comment.

performance-06: Add a DELIBERATE comment to the js job explaining that @stable is
intentionally not pinned — it provides the sole CI coverage of the stable/released
compiler building the native addon on all three host OSes.

Co-Authored-By: Claude <noreply@anthropic.com>
…admes)

Fixes eight documentation issues classified FIX_NOW by the Triage agent:

consistency-01/documentation-02 (spec.md:1071): Correct wrong claim that
lintVirtual/lint_virtual retain "input.mds" — only the string-source lint()
entrypoint does; lintVirtual/lint_virtual emit the caller-supplied entry key.
Fix Python method casing to snake_case (lint_virtual). Scope the byte-identical
claim to what is mechanically enforced, noting the parity test uses a clean
source with no findings.

regression-06 (spec.md:1071): Add one sentence documenting the source-map
stdin-relabeling asymmetry — mds build - replaces "input.mds" with "<stdin>"
in sources[]; binding surfaces always carry "input.mds" or the caller-supplied
entry key.

consistency-14 (spec.md:898): Document mds check directory-mode summary
string ("N passed, N failed") and its --quiet suppression rule, which was
documented for build and lint but missing for check.

regression-08 (packages/mds/README.md): Add note that basePath: '' (empty
string) is rejected — not treated as absent; callers should omit the key.

documentation-11 (packages/mds/README.md): Hoist sync-throw caveat to cover
all option-validation errors (not just basePath for compileFile/checkFile);
name all three Promise-returning file-path methods (compileFile, checkFile,
lintFile) where .catch() does not capture synchronous validation errors.

consistency-06 (all four surface READMEs): Add one-line note in each lint
section documenting the files[].file values per surface and the CLI-vs-binding
asymmetry.

python-09 (crates/mds-python/README.md): Retarget issue link to reference
both #292 (rename + name registration) and #132 (wheel matrix + PyPI
publishing pipeline) with their correct scopes.

Co-Authored-By: Claude <noreply@anthropic.com>
regression-01 (F-10): Correct stale #196 entry — check() accepts
{ vars?, basePath? } while checkFile() accepts { vars? } only via
CheckFileOptions (basePath?: never). TS implementers must narrow
check to CheckOptions, checkFile to CheckFileOptions. Fold in
testing-04 residual: warn that diag.span !== undefined is no longer
a sufficient guard now that help/span are | null.

documentation-01 (F-12): Add missing legacy-interpolation bullet to
the 10-rule catalogue — the rule was enumerated in code but absent
from the CHANGELOG list, leaving a count of 9 that contradicted the
"10-rule" headline.

documentation-03: Add ### Removed section for packages/mds/src/index.ts
deletion. The file was not in the package exports map so no supported
import path is affected; notes the seven internal backend types that
were only reachable via that path.

regression-07: Replace vacuous "TypeScript interface implementers"
warning with a factual note that MdsBaseBackend/MdsNodeBackend are
internal types defined only in the deleted index.ts and never
reachable via @mdscript/mds published imports.

documentation-08: Move MdsError::source_name() and
MdsError::is_string_source() from the ### BREAKING interpolation
section to ### Added — both are purely additive public methods.

Co-Authored-By: Claude <noreply@anthropic.com>
… basePath; native.ts depth guard

Resolves typescript-01 (absorbs consistency-05 and consistency-19) and typescript-03.

typescript-01: lintFile and lintVirtual now emit purpose-built basePath errors
byte-identical to napi (parse_lint_file_opts / parse_lint_virtual_opts) instead
of falling through to the generic "unknown option key" message. Adds two new
error factories (makeLintFileBasePathError, makeLintVirtualBasePathError) and
registers them in BASEPATH_REJECTORS so assertKnownKeys skips basePath for these
methods (allowing {basePath: undefined}) while getBasePathError still hard-errors
on any non-undefined value. Deletes the superseded KNOWN RESIDUAL comment.

consistency-19: tightens getBasePathError guard from `!= null` to `!== undefined`
so explicit {basePath: null} is rejected for all four BASEPATH_REJECTORS methods,
achieving parity with napi's has_named_property gate which fires for any present
key regardless of value.

typescript-03: adds defense-in-depth basePath guards to createNativeBackend's
compileFile and checkFile methods (PF-004 pattern from wrapWithFileOps).

Tests: extends U-OV-27 to cover all four file-surface methods with per-method
wrapperFn/addonFn/wasmScript cases; adds U-OV-36 for {basePath: null}
wrapper-vs-napi byte parity across all four methods. 303 tests, 0 failures.

Co-Authored-By: Claude <noreply@anthropic.com>
…ntrol-bytes

security-01 (PF-024, MERGE-BLOCKING): readAllIndexBlobs built its git
cat-file --batch request as `:${path}`. A staged file named "0:payload.mds"
became ":0:payload.mds", which git parses as stage-0 revision syntax for
"payload.mds" and returns a DIFFERENT (clean) blob — a false pass. Fix:
address blobs by SHA captured from git diff --cached --raw field 3 (never
by :<path> revision syntax). Defense-in-depth: also reject paths matching
/^[0-3]:/ in getStagedFiles() with exit 1.

security-02: getTrackedFiles ran git ls-files -sz scoped to process.cwd()
with no root check, so running from a subdirectory silently scanned a
partial tree and printed an identical pass line. Fix: resolve repo root via
git rev-parse --show-toplevel, exit 1 if cwd differs, and include the
scanned root in the pass line so the output is self-describing.

complexity-07: out.slice(pos, pos + size) validated only NaN/negative;
it never checked pos + size <= out.length. Buffer.slice clamps silently,
so a truncated cat-file response would scan a short-but-valid blob and
pass (fail-open). Fix: assert the bounds; log path/declared-size/available
bytes and process.exit(2) if violated.

Three spec cases added, each with a before/after observation for PF-013
compliance. security-01 confirmed false-pass (exit 0) before the fix.

Co-Authored-By: Claude <noreply@anthropic.com>
reliability-02: EXPECTED_CONTEXTS expanded from 1 to 4 entries; adds
'Python — build & test', 'examples/ gitignore coverage', and
'Python — wheel install smoke'. Tier A+ updated to support matrix-style
prefix matching ('ctx (' prefix) so the single base job name covers all
6 Python matrix variants without listing each one. Tier B skip logic
updated to exclude matrix variants of Tier A+ contexts.

complexity-08: main() arg parsing rewritten — proper loop that excludes
--required-from's value from the PR number search; unknown flags are
rejected (not silently ignored). evaluateChecks() gains prNumber so the
emitted merge command is 'gh pr merge <N> --squash --admin
--match-head-commit <sha>', unambiguous regardless of current branch.

reliability-10: Tier B silent-drop of completed+null conclusion removed.
The null case now falls through to the whitelist check and fails closed
(absorbed by security-13 whitelist).

security-13: Tier B inverted from blacklist to whitelist — only
'success' passes; skipped, neutral, null, and any future GitHub
conclusion value now FAIL. Matches Tier A semantics. Updates two
existing tests that described the old advisory behaviour.

security-11: encodeURIComponent added at all three URL construction
sites (fetchCheckRuns headSha, fetchStatuses headSha,
fetchRequiredContexts branch). branch falls back to baseBranch from PR
API response (network-derived data).

14 new regression tests added; 2 existing tests updated to reflect the
whitelist change. All 139 gate tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
…s-05 cast, ts-07 prose

consistency-02: Add CompileFileOptions interface (symmetry with CheckFileOptions /
LintFileOptions). FileOptions kept as @deprecated type alias so existing consumers
keep compiling without changes. OptionsFor.compileFile, MdsNodeBackend.compileFile,
and all internal call-sites updated to the new name; CompileFileOptions added to
the node.ts re-export list. browser.ts is unchanged — file-surface types are
node-only. Rust FileOpts (private type alias in mds-napi/src/lib.rs) is out of
scope per the allowed-files constraint; noted in implementation report.

typescript-04: Delete the MdsBackend type alias. It was exported from neither
node.ts nor browser.ts, so consumers could not reach it. Its @deprecated note
pointed at MdsNodeBackend, also unexported. Leaving the end-state (project quality
rule: strip transition residue).

typescript-05 (wasm.ts:448): Drop `as _WasmCompileInput` assertion on
compileOpts(options). CompileOptions is structurally compatible with
_WasmCompileInput (all members optional; variable — not fresh literal — so no
excess-property check). The sibling check() call already passes without a cast.

typescript-07 (types.ts LintDiagnostic.span): Soften the doc prose from
"key always present; only value is null" to "may be absent or null", matching
the span?: declaration. Reviewers explicitly ruled against dropping ? before
the tag (testing-04 adjudication); prose now matches the type. README:212
carries the same contradiction — that file is owned by another agent (noted).

Co-Authored-By: Claude <noreply@anthropic.com>
performance-01 (F-04):
- Pin dtolnay/rust-toolchain in publish-npm to the same 1.96.0 SHA
  (01ba1edad32c) that ci.yml's wasm job uses, eliminating the compiler
  drift that let the published WASM binary grow unchecked.
- Add post-build WASM size check (850,000-byte threshold, mirroring
  ci.yml exactly) before the first npm publish step as defence-in-depth
  if the pins ever drift again (PF-021).

reliability-07:
- Add --max-time 20 and --connect-timeout 10 to the crates.io index
  poll curl invocation so each of the 20 attempts has an explicit
  wall-clock bound (project reliability rule: every loop and resource
  must have a fixed bound).

reliability-12:
- Add "Assert tagged SHA has green CI history" step to version-gate
  (tag pushes only). Reads required contexts from main branch
  protection (D-PR2/ADR-009 anti-vacuous-green), fetches check-runs
  for github.sha via bounded pagination (3 pages × 100), and fails
  closed on any context that is not completed+success (PF-017:
  CANCELLED/skipped/in_progress are never success). Adds
  checks: read permission to the version-gate job.

compliance-03:
- Add publish = false to crates/mds-napi/Cargo.toml and
  crates/mds-wasm/Cargo.toml to prevent an accidental
  `cargo publish --workspace` from pushing non-crates.io crates
  to the irreversible registry. Mirrors the pattern already in
  crates/mds-python/Cargo.toml. Does not touch mds-wasm's
  explicit license/repository fields (wasm-pack compatibility).

Co-Authored-By: Claude <noreply@anthropic.com>
…solve-b6b-config-rs)

architecture-01 (F-09, TAG-BLOCKING) — chose option (a): delete
LintConfig::from_rules entirely. The function has never shipped (absent
from v0.3.0) and has no external callers — mds-napi, mds-wasm,
mds-python, and mds-cli all call from_rules_checked. Deleting it
leaves the end-state, not the transition. The consistency-10 pairing
with fix.rs is resolved by deletion (no alignment needed).

Callers in api_surface.rs at :1089-1092 and :1148-1151 drop their
#[expect(deprecated)] scaffolding and call from_rules_checked. The
unfulfilled_lint_expectations count in the fix_api_apply_fixes_exists
doc is updated from 11 to 10 (api_surface no longer contributes one).

documentation-09 — soften the brittle published doctest in
find_unknown_rule_names from assert_eq!(KNOWN_LINT_RULES.len(), 10) to
assert!(!KNOWN_LINT_RULES.is_empty()). The doctest demonstrates
find_unknown_rule_names, not the rule count; a future rule #11 would
have broken cargo test --doc.

documentation-10 — merge the two consecutive duplicate paragraphs on
UnknownRuleNames (#[non_exhaustive] / no-struct-literal) into one,
keeping the ADR-010 citation and accessor guidance.

complexity-11 — add warning_plural_byte_identical_to_format_equivalent
test that pins the plural branch of format_unknown_rule_names_warning
to byte-equality with the canonical format!/join equivalent. The
singular branch was already pinned via assert_eq! in
warning_singular_names_rule_and_full_registry; this closes the gap.

complexity-03 — restructure the Alias arm in unused_import::check from
the `&&` short-circuit form to a nested-if with an intermediate `let
diag = make_diag(...)` binding. This matches the Selective arm's shape
(which has an intermediate let binding for name offsets) and makes it
explicit that the diagnostic push is unconditionally executed whenever
is_used is false — inserting a predicate between them would be
syntactically visible. Zero behaviour change; all existing tests pass.

Verification: cargo fmt --all --check (0), cargo clippy -p mds-core
--all-targets -- -D warnings (0), cargo nextest run -p mds-core
(1347/1347), cargo test --doc -p mds-core (all pass).
grep 'since = ' crates/**/*.rs: one hit — fix.rs:746 (0.4.0, valid).

Co-Authored-By: Claude <noreply@anthropic.com>
… consistency issues

Consistency-07/21: move the six BREAKING sections to lead the [Unreleased]
section (matching the [0.3.0] precedent), ordered by blast radius:
  1. lint JSON wire contract (#202, #203, #211)
  2. Error/lint messages \uXXXX literals (#176)
  3. Options validation, directory walker, source-map labels, check API (#196)
  4. File-method basePath rejection, TS option types, WASM basePath (#180, #213)
  5. Interpolation syntax {x} -> {{x}} (#236)
  6. Strict cross-type comparisons, @extends FM, interior-verbatim, filesystem (#146, #150, #151, #152, #154)

Consistency-07 secondary: add inline issue refs to the three BREAKING headings
that previously pushed refs only into #### sub-headings.

Consistency-08: fix "see the BREAKING subsection below" in ### Fixed —
the referent is now above; replace with anchor link.

Consistency-15: replace the sole ' -- ' (line 334 before reorder) with
an em dash, matching the 130 em dashes used elsewhere in the file.

Documentation-13: soften "The stdin source identity is **always** <stdin>"
to "(CLI only — see below)" to prevent skimmers from missing the binding-
surface qualification 14 lines later.

Regression-02: append "Fixed: <path> and Would fix: <path> now emitted in
JSON directory mode" to the lint JSON wire-change ledger — these eprintln!
calls in lint.rs:1555/1632 break zero-stderr CI assertions silently.

Regression-05: add a ### Fixed bullet noting that LintDiagnostic.to_dict()
now conditionally includes "line"/"column" in the span object, and
LintResult.files[] now parses these fields; no built-in rule sets them
so live-lint output is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
…, test coverage

compliance-06: minimize protection-main.json fixture to only the fields
fetchRequiredContexts actually reads (required_status_checks.contexts and
checks[].context). Removes enforce_admins, required_signatures, pr-review
settings, and app_id beyond the fixture — all were unreachable by any code
path, while the admin-policy fields were actively disclosing security posture.

compliance-07: declare .devflow/docs/design/ and .devflow/docs/reviews/ as
deliberately shared via explicit .gitignore negation patterns. Closes the gap
where four tracked files lived under a directory with no declared exception —
the policy was enforced by accident, not by the ignore file.

architecture-07: fix misdescribing comment in packages/mds/src/util/options.ts.
The comment claimed "updating the expected strings in the byte-comparison tests"
but no such strings exist; `:56` is a substring check. The guard that actually
enforces key order is the U-OV-14 / U-OV-31 wrapper-vs-napi strictEqual
message comparisons.

architecture-08: add direct unit tests for fetchCheckRuns exercising the
MAX_PAGES=20 bound (call count pinned to 20 — fails if cap raised or removed)
and the total_count guard, without going through main(). Adds fetchCheckRuns
to the spec imports.

Note: dependencies-01 + performance-04 (Cargo.toml duplicate comments and
provenance qualifiers) were already addressed in commit 9103536 — confirmed
no-op.

Co-Authored-By: Claude <noreply@anthropic.com>
reliability-01: add MAX_HITS_PER_FILE (20) and MAX_HITS_TOTAL (1000) caps
to the hazardHits accumulation loop in main(). A 2 MB file of 0x01 bytes
empirically produced 569 MB peak RSS and 275 MB of stderr at uncapped
output (285x amplification). One retained hit is already sufficient to
exit 1, so the cap cannot weaken the gate. Truncation is made visible via
a suppressed-count notice in the failure report.

security-03: restructure scanBuffer() to treat 0x00 as an allowlisted
codepoint for BINARY_ALLOWLIST entries rather than early-returning from the
whole file. The previous early return silently disabled all 21 hazard
classes (bidi, C1, BOM, etc.) for any binary-allowlisted file — semantics
the D-CB6 header never documented, arming silently on the first legitimate
entry added.

security-12: add gitVersion() / MIN_GIT_MAJOR / MIN_GIT_MINOR constants
(mirrors the verify-pr-checks.mjs MIN_GH_MAJOR pattern) and check the
installed git version before entering --staged mode. git cat-file
--batch -z requires git >= 2.42; an older git exits 129 with no useful
message. Fail-closed exit 2 (indeterminate) is preserved; this is a
diagnosability fix only.

Tests: 3 new spec cases (146 total, 0 failures).
  - reliability-01: hazard-dense fixture, cap exercised, suppressed notice visible
  - security-03: NUL-bearing allowlisted file + bidi override exits 1
    (before fix: exits 0; after fix: exits 1 — ADR-009 observed)
  - security-12: fake old git exits 2 with version diagnostic

Co-Authored-By: Claude <noreply@anthropic.com>
security-04/regression-03/rust-02: Correct relative_display rustdoc —
the three contradicting lines claimed error-only entries push the raw
display path, but all six push sites use the pre-sanitized file_key.
Rewritten to state that both entry types carry identically-sanitized
file values (ADR-008).

rust-03/reliability-09/complexity-10: Rewrite self-contradicting
debug_assert comment — "in production code" and "compiles away in
release mode" cannot both be true. New comment honestly names this as a
dev-only tripwire (PF-005 in intent, not substance) and states the
release consequence: miscounted status line, never data corruption.

rust-04: Harden relative_display fallback — filter to Component::Normal
so the unwrap_or(path) fallback on an absolute path joins only named
segments instead of producing malformed output (//foo/bar.mds or
C:/\/foo/bar.mds). UNREACHABLE today per both call sites; comment
explains. One added filter() call.

consistency-03 (option b): Delete the false "matches the sibling
shape"/"greppable" claim from the AD-216-4 comment. Lint uses
deliberately distinct vocabulary from build/check/fmt ("N failed"):
the four-bucket partition expresses distinct failure modes not captured
by a single failed count. Choosing (b) because tests, spec §7.5, and
CHANGELOG.md all pin the current "with errors" wording, and spec.md/
CHANGELOG.md are being concurrently edited by other agents.

Co-Authored-By: Claude <noreply@anthropic.com>
python-02: Add two regression tests that cover the line/column parsing
path (lib.rs:789-798) and the conditional emission in as_json()
(lib.rs:600-621) via LintResult(canonical). ADR-009 verified: both tests
fail with AssertionError when the parsing is stubbed to hardcoded None.

python-10: Replace false comment claiming serde_json::Map preserves
insertion order (requires the preserve_order feature, which is not
enabled). State the accurate fact: Map is a BTreeMap and keys sort
alphabetically regardless of insertion order.

python-03: Replace write_text with write_bytes in test_sm_py9_live_cli_
source_map_parity (last remaining write_text fixture site). Adds PF-020
rationale comment matching the four converted sites in conftest and
test_parity.py.

python-08: Add diagnostic count guard (>= 2) to test_crlf_input_parity
alongside the existing files-truthiness check, matching siblings
test_ac_p1_24 and test_par5b.

python-07: Annotate all three normalizer helpers (drop_file_key x2,
strip_file_key) with proper dict[str, Any] -> list[dict[str, Any]]
signatures. Adds from typing import Any import. Removes dead
# type: ignore[type-arg] comments that implied mypy checked test modules
when it does not.

Test count: 242 before → 244 after (2 new tests from python-02).
All 244 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
The previous fix for compliance-07 introduced !.devflow/docs/reviews/ and
!.devflow/docs/design/ as broad negation patterns, exposing 66 previously-
ignored paths as untracked — the entire .devflow/docs/reviews/ tree plus 16
untracked design docs, all of which may contain local file paths and personal
email addresses.

Replace with a ladder that scopes the exception to exactly the three already-
tracked design-plan files. .devflow/docs/reviews/ is restored to fully ignored
with no negation. The fourth tracked file (under reviews/) remains tracked via
the git index, which overrides gitignore regardless of whether a pattern matches.

Verified: 66 untracked → 0 untracked; git ls-files unchanged (same 4 files).

Co-Authored-By: Claude <noreply@anthropic.com>
…inferred-obj, regression-10 stale comment

typescript-02: copy the compileFile synchronous-throw contract paragraph onto
lintFile's JSDoc, adding the missing `.catch()` warning — callers relying on
.catch() to handle option-validation errors would silently miss them.

testing-03: add LintDiagnostic literals with help: null / span: null to both
consumer-node.ts and consumer-browser.ts. ADR-009 verification confirmed:
reverting `| null` on either field causes TS2322 on these assignments, making
the breaking type widening detectable at compile time.

testing-05: add inferred-object case to consumer-node.ts. Empirical check
(removing @ts-expect-error) confirmed: TS2322 fires — Type 'string' is not
assignable to type 'undefined' — so the PR description's claim that this shape
is accepted was wrong. Fixture encodes the ACTUAL compiler behaviour.

regression-10: rewrite the "per-surface builders" comment in U-OV-29 to
describe the current forwardOpts / METHOD_KEYS mechanism (the per-surface
builders were deleted in this wave).

Co-Authored-By: Claude <noreply@anthropic.com>
…iet help (resolve-b2c)

testing-01 / F-16 (a): Add platform-independent Rust unit test
`directory_sort_ascending_over_normalized_display_strings` (no #[cfg] guard)
that constructs paths via PathBuf::join and asserts byte-wise ascending order
over the normalised forward-slash display strings. The [/[ ordering
('/' 0x2F < '[' 0x5B) pins that relative_display uses '/' not the native
backslash — ADR-009 verified: changing join("/") to join("\\") causes the
assertion on display_sub_a to fail immediately with "sub\\a.mds" ≠ "sub/a.mds".

Also adds an optional #[cfg(windows)] test documenting the C:\proj\sub\c.mds
→ sub/c.mds normalisation for when the Rust matrix gains windows-latest.

testing-01 / F-16 (b): Add U-L13 to packages/mds/__test__/lint.spec.mjs
(the suite that already runs on windows-latest in CI). Spawns the CLI on the
new lint-dir-order fixture directory (3 files: b.mds, sub/a.mds, sub[after.mds)
and asserts (1) no backslash in any files[].file key and (2) ascending byte-wise
order: "b.mds" < "sub/a.mds" < "sub[after.mds". Follows the CI-hard-fail /
local-warn convention of U-L11 and U-L12.

consistency-04: Fix --quiet long_about in main.rs. The previous text claimed
"the non-zero exit is never unexplained" — false for warn-only directory runs
which exit 1 with zero stderr under --quiet. Replaces the false claim with an
enumerated two-exception model: (a) --fix --check pending-fix case, (b) directory
warn-only run — mirroring the already-correct rustdoc comment ~90 lines away.

Co-Authored-By: Claude <noreply@anthropic.com>
…pers

Three independent agents added basePath depth guards for compileFile and
checkFile (in both wrapWithFileOps and createNativeBackend) but did not
apply the same treatment to lintFile and lintVirtual, which have the same
silent-drop exposure via forwardOpts. Add the missing guards for parity.

Four agents independently defined captureMsg, VIRTUAL_MODS, and
VIRTUAL_ENTRY inside separate test closures in options-validation.spec.mjs.
All four captureMsg implementations are semantically identical; extract
the three helpers to describe scope to eliminate the duplication.

No behavior changes — guards fire in the same order (public wrapper first,
depth guard second), and the extracted helpers are functionally equivalent.
All 2091 Rust tests, 304 JS tests, and 146 gate tests pass.
dean0x and others added 3 commits August 21, 2026 00:17
…ted value

- parser_tests.rs: remove `use super::helpers::*` — all pub(super) items from
  parser_helpers.rs are already available via `use super::*` (parser.rs does
  `use helpers::*` which pulls them into parser's namespace). Fixes clippy
  error `-D unused-imports` introduced when `LintConfig::from_rules` was
  removed in d28bbcc.

- ci.yml: correct Python smoke-test expected value from 'Hello CI!' to
  'Hello CI!\n'. The compiler appends a trailing newline on markdown output;
  the assertion was wrong, not the implementation. Verified locally:
  repr(r.output) == "'Hello CI!\\n'" against the installed wheel.

Co-Authored-By: Claude <noreply@anthropic.com>
Folds in PR #308 (22-commit review-resolution pass): emit-before-exit
ordering rule (PF-004, 7 realized defects, ResultSink deferred to #309),
ADR-008 open decision on fix_edits[].new_text sanitization, error-only
push-site sanitization, LintConfig::from_rules deletion, PartiallyFixed
wildcard arm must_use trap, clippy stale cache gotcha, Windows ordering
coverage, Python to_dict conditional span fields, and write_bytes fixture
requirement (PF-020).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment