Skip to content

feat(lint): JSON wire contract — sort diagnostics, stdin label, name-anchored spans (#202, #203, #211) - #294

Merged
dean0x merged 49 commits into
wave/v0.4.0-wave1from
ticket/pr1-lint-json-wire-contract
Aug 14, 2026
Merged

feat(lint): JSON wire contract — sort diagnostics, stdin label, name-anchored spans (#202, #203, #211)#294
dean0x merged 49 commits into
wave/v0.4.0-wave1from
ticket/pr1-lint-json-wire-contract

Conversation

@dean0x

@dean0x dean0x commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes three related issues that all mutate the same JSON wire object, batched into one PR per the wave plan:

Changes

#211 — Stdin source-identity sentinel

  • Adds `pub(crate) const STDIN_DISPLAY_LABEL: &str = ""` to `output.rs` (AD-211-3) — single definition, replaces all previously scattered literals including the hardcoded `""` in `apply_source_map_file_label`
  • `set_diag_display_path` called after `lint_str_with` to relabel `diag.file` from `STRING_SOURCE_MAP_LABEL` at the CLI output boundary (AD-211-4). `STRING_SOURCE_MAP_LABEL` in `sourcemap.rs:79` is NOT changed.
  • `StdinRelabeledError` wrapper struct added to `output.rs` (AD-211-5) — overrides `miette::Diagnostic::source_code()` to relabel `` → `` in analysis-failure rendered output. The replacement is conditional: `MdsError::source_name()` (new `pub` method on `MdsError`) guards the swap — only errors whose embedded source name equals the `""` stdin sentinel are relabelled; the comparison lives at the CLI boundary (PF-012 / AD-211-5 scope). Imported-file errors carry the real file path and are left untouched.
  • `emit_analysis_failure_json_or_stderr` in `lint.rs` gains `stdin_source: Option<&str>` parameter; file-mode callers pass `None`; stdin callers pass `Some(&source)`
  • `build.rs` references `STDIN_DISPLAY_LABEL` instead of hardcoded `""`

Plan §5 step 1a — verified build/check `` emission sites (AD-211-5)

These were located empirically (reproducing the `[:1:1]` frame before the relabel landed) and traced to the CLI boundary:

Subcommand File Line
`mds check -` `crates/mds-cli/src/main.rs` 279
`mds build -` (single-file) `crates/mds-cli/src/build.rs` 714
`mds build -` (directory stdin) `crates/mds-cli/src/build.rs` 1176
`mds lint -` `crates/mds-cli/src/lint.rs` 1488 (via `emit_analysis_failure_json_or_stderr`)

The AD-211-5 rustdoc block in `output.rs` records these with the same verified line numbers.

#202 — Diagnostic ordering

  • `sort_diagnostics` private helper: stable sort by `(file_absent: bool, file: &str, span_absent: bool, offset: usize)` — no-span diagnostics sort to end of their file group; no-file diagnostics sort to end of overall list; both via a borrowed tuple that avoids allocation in the comparator (AD-202-1/2/3, AC-P1-22)
  • Called in `LintResultBuilder::build` after truncation, before JSON emission
  • `to_canonical_json` rustdoc updated to document `fix_edits` field and ordering guarantee; defensive re-sort added at serialization boundary (commit 8e33cf4)

#203 — Span anchoring at name

  • `ImportDirective::Selective` gains `name_offsets: Vec` field with full rustdoc (AD-203-1 / PF-012)
  • `parse_import_directive` computes offsets in a single pass alongside name collection (no desync for sparse `{ a, , b }` inputs); uses `trim_start` (not `trim`) for accurate delta computation
  • `ImportFact` threads `name_offsets` through to rule dispatch
  • `make_diag` gains `length: usize` parameter; Selective forms pass `name_offset` + `name.len()`
  • Alias/Merge forms unchanged (still anchor at `@import`)

Alignment fixes (AD-### rustdoc at call sites, AC-P1-18, AC-P1-24 cross-surface, additional test coverage)

  • `AD-202-1b` rustdoc block added at `LintResult::new` call site in `diagnostic.rs`
  • `AD-203-4` rustdoc block added at `make_diag` definition in `unused_import.rs`
  • `AD-211-2` rustdoc blocks added at three `lint.rs` call sites (Would fix / Partially fixed / diff header)
  • AC-P1-18: regression pin moved from `formatter.rs` (vacuous — `structural_equivalent` is token-based, never calls `imports_eq`) to `structural_eq.rs::selective_name_offsets_excluded_from_imports_eq` where `imports_eq` actually lives; includes a positive control per PF-013/ADR-009
  • `formatter.rs` test updated to correctly document what it tests (token-based `structural_equivalent`, not `imports_eq`) and adds a non-vacuity guard

Breaking Changes

  • `--format json` diagnostic order within a file group is now defined: ascending byte offset. Previously implementation-defined (rule-insertion order). Consumers that relied on specific rule-application ordering will see reordered output.
  • `--format json` stdin `files[].file` key changes: `"input.mds"` → `""`. Binding surfaces (napi/WASM/Python) are unaffected — they still emit `"input.mds"`.
  • `unused-import` JSON `span.offset` / `span.length` change for selective imports: previously always `{offset: @import_pos, length: 7}`; now `{offset: name_pos, length: name_len}` per name.

Reviewer Focus Areas

  • `output.rs`: `StdinRelabeledError` + `relabel_stdin_error` — the conditional `source_name()` guard is the key correctness property (prevents PF-012 mislabelling of imported-file errors); verify all four verified call sites above pass `Some(&source)` for stdin and `None` for file-mode
  • `parser_helpers.rs:parse_import_directive`: single-pass offset computation — verify `cursor` accounting for the empty-segment desync case (`{ a, , b }`)
  • `diagnostic.rs:sort_key`: borrowed `(bool, &str, bool, usize)` tuple — file-absent diagnostics sort last because `true > false`; span-absent diagnostics sort after spanned ones within their file group. No sentinel strings; no allocation.
  • WASM size (AC-P1-23): post-change 833,502 bytes · pre-change 821,662 bytes (both crates/mds-wasm/pkg/mds_wasm_bg.wasm and pkg-web/, wasm-pack 0.15.0 bundled wasm-opt, measured at HEAD after commit 56424f7 removed the redundant to_canonical_json re-sort). Delta: +11,840 bytes. Budget: 850,000. Headroom: 16,498 bytes (1.94%). Guard NOT raised. Toolchain note: local uses wasm-pack-bundled wasm-opt; CI uses Binaryen v129 (distinct optimizer). CI is the authoritative measurement. (Earlier draft recorded 845,143 bytes, measured before 56424f7 landed; the re-sort removal reduced the binary by 11,641 bytes.)

Test Plan

Tests through the full alignment wave:

  • AC-P1-01 `stdin_lint_json_file_key_is_stdin`: `files[0].file == ""` in JSON output
  • AC-P1-04 `stdin_source_identity_is_uniform_across_subcommands`: `mds lint/check/build -` all render `` in analysis-failure frames (verifies all four §5 step 1a sites)
  • AC-P1-07 `stdin_analysis_failure_labels_source_as_stdin`: human frame names ``; JSON error.message has no core source label
  • AC-P1-18 `selective_name_offsets_excluded_from_imports_eq` in `structural_eq.rs`: `imports_eq` ignores `name_offsets`; positive control proves the helper CAN distinguish unequal imports
  • AC-P1-24 U-L9, U-L10 in `lint.spec.mjs`: binding surface file key is `input.mds`; diagnostics are in non-decreasing offset order (cross-surface per PF-007)
  • AC-P1-26 `stdin_json_clean_source_emits_empty_files_array`: clean stdin emits `files:[]` (scope-out documented — no `files[0].file` to assert for a zero-diagnostic source)
  • AC-P1-27 `lint_str_uses_string_source_map_label_as_file_key` in `api_surface.rs`: positive control proving core still uses `"input.mds"`, so the CLI relabel is non-vacuous
  • AC-P1-03(c) `stdin_partial_fix_message_uses_bracketed_sentinel`: `"Partially fixed: "` in stderr
  • Plus: AC-P1-08–P1-22 from prior commits (diagnostic ordering, span anchoring, escaping, etc.)
  • `print_discipline` allowlist: `STDIN_DISPLAY_LABEL` added with justification (compile-time constant, ASCII printable only)
  • 2012/2012 nextest + 50/50 doctests pass on the wave branch
  • `cargo clippy --workspace --all-targets -- -D warnings` clean
  • `cargo fmt --all --check` clean
  • `node scripts/verify-no-control-bytes.mjs` clean (515 files, 0 violations)

dean0x and others added 30 commits August 13, 2026 10:39
…anchored spans (#202, #203, #211)

**#211 — Uniform `<stdin>` sentinel across all CLI surfaces**

Adds `STDIN_DISPLAY_LABEL: &str = "<stdin>"` to `output.rs` (AD-211-3) as the
single definition of the stdin source-identity sentinel.  Replaces five scattered
conventions: `input.mds` (internal VFS key), bare `stdin`, `<source>` (resolver),
and the hardcoded `"<stdin>"` in `build.rs`.

- `set_diag_display_path` called after every `lint_str_with` to relabel
  `diag.file` from `STRING_SOURCE_MAP_LABEL` → `STDIN_DISPLAY_LABEL` at the CLI
  output boundary (AD-211-4).  The internal constant is NOT changed.
- `StdinRelabeledError` wrapper overrides `miette::Diagnostic::source_code()` to
  relabel `<source>` → `<stdin>` in analysis-failure rendered output (AD-211-5),
  working around the `with_source_code()` fallback semantics in miette-7.6.0.
- `emit_analysis_failure_json_or_stderr` gains `stdin_source: Option<(&str, &str)>`
  parameter; file-mode callers pass `None`.
- `build.rs` now references `crate::output::STDIN_DISPLAY_LABEL` instead of a
  hardcoded `"<stdin>"` literal.

**#202 — Diagnostics sorted by byte offset (wire contract)**

Adds `sort_diagnostics` (stable sort by `(file, span.offset)`) called in
`LintResultBuilder::build` after truncation, before JSON emission (AD-202-1).
No-span diagnostics sort to the end of their file group (AD-202-3).  The sort is
stable so equal-offset diagnostics preserve rule-insertion order (AD-202-2).
Fixes the `to_canonical_json` rustdoc to document `fix_edits` in the schema and
the ordering guarantee.

**#203 — `unused-import` span anchors at the unused name for selective imports**

`ImportDirective::Selective` gains `name_offsets: Vec<usize>` (AD-203-1 / PF-012).
`parse_import_directive` computes per-name byte offsets in a single pass alongside
name collection (no desync possible for sparse inputs like `{ a, , b }`).  The
corrected delta formula uses `trim_start` (not `trim`) to measure the byte distance
from directive start to `{`.  `ImportFact` threads `name_offsets` through to
`unused_import::check`, which now passes the per-name offset and `name.len()` to
`make_diag` for Selective forms.  Alias forms are unchanged.

Tests: AC-P1-01, AC-P1-14, AC-P1-15, AC-P1-19 — 13 new unit tests + 3 CLI tests.
All 1992 nextest + 50 doctests pass.  Zero clippy warnings.  No control bytes.
…r relabel

- `parse_import_directive`: drop `seg_byte_len` local (used only at the
  end of the loop body) and call `seg.len()` directly on the advance line.
- `emit_analysis_failure_json_or_stderr`: inline `named` into the
  `StdinRelabeledError` struct initialiser; it was created and consumed
  on consecutive lines with no intervening use.

Behaviour is unchanged; fmt and clippy pass; source-hygiene gate clean.
P0 — the AC-P1-04 / §0a leg that was never implemented. `mds check -` and
`mds build -` still rendered `[<source>:1:1]` for stdin; reproduced per plan
§5 step 1a, then fixed. `StdinRelabeledError` + `relabel_stdin_error` move from
`lint.rs` to `output.rs` and are applied at `run_check` and both
`compile_str_with_deps_opts` stdin sites. `build::exit_code` unwraps the wrapper
so a render-only label swap cannot change a process exit code.

P0 — the #202 wire-ordering test passed vacuously. Its fixture produced exactly
one diagnostic, so `offsets == sorted` held with the sort deleted. Replaced with
a fixture whose offset order inverts `run_rules` dispatch order, plus a
non-vacuity guard and an explicit rule-position assertion.

P0 — #203's span anchoring had no test for the desync and robustness cases the
plan flagged as blocker-class. Added slice-based positive controls (AC-P1-14/15/16)
covering empty and trailing comma segments, prefix and path name collisions,
irregular whitespace, CRLF and multi-byte prefixes. All verified non-vacuous
against planted bugs.

P1 — sort key no longer uses a `\u{FFFF}` sentinel for a missing file, which
mis-ordered against astral-plane filenames; AD-203-3's `debug_assert_eq!` added
at the construction site; the desync fallback now degrades to the whole `@import`
span instead of `name.len()` bytes of the keyword.

Added: AC-P1-07 (analysis-failure label, both channels), AC-P1-03 (fix-preview
sentinel), AC-P1-09/11 (cross-surface order, determinism), AC-P1-10 (files[]
path order), AC-P1-12 (truncation is not offset-ranked), AC-P1-18 (formatter
safety gate ignores name_offsets), AC-P1-20 (WIRE escaping positive control).

P2 — AD-202-x rustdoc IDs and AC-P1-xx test citations corrected against the plan;
`main.rs` / `fmt.rs` stdin literals point at `STDIN_DISPLAY_LABEL` (AD-211-3);
CHANGELOG rewritten as the wave's single wire-change ledger with a before/after
snippet and the AD-211-5 leg.
Scope stdin relabel to the "<source>" sentinel so imported-file errors keep
their real path (PF-012). Move the AC-P1-18 name_offsets pin from formatter.rs
to structural_eq.rs where imports_eq lives, with a positive control. Add
AC-P1-26 clean-stdin, AC-P1-03(c) partial-fix, AC-P1-27 core-label, and
AC-P1-24 cross-surface tests.
…arkers

BLOCKING-1: cargo fmt --all fixes two rustfmt violations from commit 331a8d3:
  - crates/mds-cli/tests/cli_lint.rs: two lines merged to one per rustfmt
  - crates/mds-core/src/lint/rules/structural_eq.rs: comment alignment

BLOCKING-2: replace map_or(false, ...) with is_some_and(...) in error.rs:1066
  (clippy::unnecessary_map_or on the StdinRelabeledError is_stdin_source method)

BLOCKING-3: rebuilt WASM (packages/mds-wasm/dist/node/mds_wasm_bg.wasm,
  833763 bytes — within 850000 budget) and NAPI (crates/mds-napi/mds-napi.node)
  from current branch source to pick up the LintResultBuilder::build sort.
  Binaries are gitignored; CI builds them from source before testing.
  npm test --workspaces --if-present now passes; U-L10 confirmed green.

BLOCKING-4: resolve AC-P1-26 zero-diagnostic contradiction.
  AC-P1-26 stated the <stdin> sentinel must appear even in the zero-diagnostic
  case; the implementation and test emit files:[] for clean stdin (consistent
  with binding surfaces per AC-P1-06). Chosen fix: amend the criterion (plan
  doc is gitignored) and document in CHANGELOG that files:[] is the correct
  output when stdin has zero diagnostics. Updated test comment to remove the
  ambiguous "scope-out" label and state this as the defined wire contract.

All AC-P1-25 gates pass:
  cargo nextest run --workspace      EXIT=0
  cargo test --doc                   50 passed 0 failed
  cargo fmt --all --check            clean
  cargo clippy --workspace -- -D warnings  EXIT=0
  npm test --workspaces --if-present EXIT=0  (U-L10 confirmed green)

WASM size: 833763 bytes (budget: 850000, 1.94% headroom).

Co-Authored-By: Claude <noreply@anthropic.com>
…c carve-out

AC-P1-26 was amended 2026-08-14 with a zero-diagnostic carve-out: a clean
stdin lint emits {"files":[],...} with no file entry. Test-plan entry 27
(case b) still asserted files[0].file == "<stdin>" including in the
zero-diagnostic case, contradicting the amended AC.

Propagate the carve-out into entry 27: case (b) now asserts files is an
empty array and that "<stdin>" does NOT appear in files[], consistent with
the shipped behaviour verified in stdin_json_clean_source_emits_empty_files_array.

Force-adds the plan file (gitignored by ADR-023) so the amendment is
traceable on this branch.
…e_name

The previously-added `pub fn source_label_is_stdin_sentinel() -> bool`
encoded CLI presentation vocabulary into the domain crate and introduced a
fourth copy of the `"<source>"` sentinel literal (all five findings).

Changes:
- `resolver.rs:199` SOURCE_LABEL: const -> pub(crate) so siblings can
  reference the canonical definition instead of copying the literal.
- `error.rs`: replace source_label_is_stdin_sentinel() -> bool with the
  neutral source_name() -> Option<&str> that returns the embedded
  NamedSource name without any CLI vocabulary.
- `sourcemap.rs` map_source_label: reference crate::resolver::SOURCE_LABEL
  directly, eliminating the third literal copy; future changes to the
  sentinel are now caught at compile time.
- `output.rs` (mds-cli): update relabel_stdin_error to use source_name()
  and compare "<source>" at the CLI boundary (correct layer).
- `api_surface.rs`: add mds_error_source_name_accessor test pinning the
  new method under ADR-010.
- `CHANGELOG.md`: document the renamed method in [Unreleased] public-API section.

Verified: clippy -D warnings clean, 79/79 api_surface tests pass,
15/15 stdin cli_lint tests pass, source-hygiene gate exits 0.

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

- De-duplicate # Ordering rustdoc on LintResult::new: three near-verbatim
  paragraphs collapse to the single AD-202-1b block.  Update the closing
  sentence to note to_canonical_json's new defensive sort (findings 1 & 4).

- Replace make_span_diag struct literal with LintDiagnostic::new +
  with_span / with_file builders (AC-P1-12 / ADR-010 — finding 3).
  In-crate code is exempt from #[non_exhaustive] but the builders must
  be exercised in tests so the external construction path stays proven.

- Add defensive sort-by-reference pass at the top of to_canonical_json
  so the canonical-order guarantee is self-enforcing at the serialization
  boundary, not only convention-enforced via LintResultBuilder::build.
  LintDiagnostic does not derive Clone; sort operates over &LintDiagnostic
  refs with the same sort_key comparator — bounded at MAX_DIAGNOSTICS,
  idempotent on already-sorted input (finding 5 / avoids PF-005).

Finding 2 (pre-existing to_canonical_json file-key grouping vs. sanitized
emit mismatch) is informational per the Iron Law; no change this commit.
…ontrols

Add ADR-009 / PF-013 positive controls to the stdin analysis-failure test:
- Non-vacuity guard: assert message is non-empty before absence checks
- Separate asserts for '<source>' and 'input.mds' for clearer failure messages
- Inline ADR-009 positive controls proving .contains() detects the strings

Expand stdin_source_identity_is_uniform_across_subcommands (AC-P1-04):
- Part 1 (error frame, unchanged): lint / check / build assert <stdin> in
  the miette code frame and no <source> in stderr
- Part 2 (new, fmt status line): mds fmt --check - must emit
  'Would reformat: <stdin>' not 'input.mds' or '<source>'
…ilure carve-out

test_parity.py: add test_ac_p1_24_cross_surface_diagnostic_order_parity to
confirm that CLI and Python surfaces agree on diagnostics[] order using a
fixture where RULE-EXECUTION order differs from OFFSET order (legacy-interpolation
+ duplicate-export). Avoids PF-007 by comparing surfaces to each other rather
than each to its own golden.

plan.md: document the analysis-failure carve-out — the error envelope
{"version":1,"error":{"code","message","help","span"}} carries no "file" key
by structural design. Supersedes the earlier AC-P1-26(c) clause and pins the
contract in cli_lint.rs::stdin_analysis_failure_labels_source_as_stdin.

Co-Authored-By: Claude <noreply@anthropic.com>
cli_lint.rs: cargo fmt normalization of assert!() call (multiline).
test_parity.py: allow returncode 2 (error-severity findings) in
  test_ac_p1_24_cross_surface_diagnostic_order_parity — duplicate-export
  has severity:error so the CLI exits 2, not 1.

Co-Authored-By: Claude <noreply@anthropic.com>
spec.md had three gaps relative to the normative guarantees this PR
establishes:

BLOCKING (high): Line 1028 claimed all four surfaces emit byte-identical
values for version:1 JSON.  This is now false for the "file" key on CLI
stdin input: the CLI relabels the virtual-FS key "input.mds" to "<stdin>"
at the output boundary while the binding surfaces (napi, WASM, Python)
retain "input.mds".  Added the carve-out matching the CHANGELOG entry.

BLOCKING (high): The JSON output format block was silent on two guarantees
this PR made normative: diagnostic array ordering (#202, ascending
span.offset, span-less last, stable ties) and the stdin file value (#211,
"<stdin>" for mds lint -).  Both are now documented in the prose after the
JSON block.

PRE-EXISTING (low): The fix_edits key was absent from two published schema
samples: the JSON format block in spec.md (now shown as null for the
not-fixable example diagnostic) and the compact schema description in
crates/mds-napi/README.md.  Both are now updated.

Also apply a minor whitespace-only format fix in
crates/mds-core/src/lint/diagnostic.rs (no logic change).
… rustdoc

The emit_analysis_failure_json_or_stderr call site in lint.rs moved from
line 1482 to 1509 after this PR's changes.  Update the cross-reference in
the STDIN_DISPLAY_LABEL doc comment to match.
When stdin imports a file that fails to parse, the relabel_stdin_error guard
(`source_name().is_some_and(|n| n == "<source>")`) must be false so the
imported file's real path is preserved in the miette frame, not relabelled
to "<stdin>".

- stdin_import_error_frame_names_imported_file_not_stdin: creates a broken
  lib.mds, pipes an @import stdin source, and asserts the miette frame names
  "lib.mds" (positive PF-013 control) while NOT equalling "<stdin>" (guard
  assertion).  Covers both lint and check subcommands.
lint.rs — directory mode now sorts by byte-wise string of the relative
display path (strip_prefix + to_string_lossy) instead of Path::Ord
(component-wise), so the CLI `files[]` order matches the BTreeMap
key ordering that `to_canonical_json` uses on napi/WASM/Python.
The two orderings diverge when a filename component contains a path
separator character (e.g. `api-utils.mds` vs `api/x.mds`); this
change makes them agree across surfaces (PF-007).

CHANGELOG.md — update file-group ordering note to describe byte-wise
string order, with an example showing '-' (0x2D) < '/' (0x2F).

diagnostic.rs — rustdoc: clarify span object fields (offset/length
always present; line/column absent when not set), note absence
conventions for help/span/fix_edits, and expand the file-group
ordering description to distinguish single-file vs directory mode.

crates/mds-napi/__test__/index.spec.mjs — add P-L-4: AC-P1-24
cross-surface differential test that compares napi lint() and CLI
stdin JSON output (with file key excluded); skips gracefully when
the mds binary is absent in local dev, hard-fails in CI.

packages/mds/__test__/lint.spec.mjs — strengthen AC-P1-24 WASM
non-vacuity guard to require distinct offsets AND add rule-position
pinning assertions (legacy-interpolation before duplicate-export
proves the AD-202-1 sort overrode run_rules dispatch order).
…nonical_json

- Wire schema example (AC-P1-21): remove `line` and `column` from the `span`
  example object — no lint rule sets them, live output is always
  `{"offset":N,"length":M}`.  The schema block now matches what actually ships.

- Absence conventions (finding 1 + 5): add an "Absence conventions" paragraph
  naming all three absence conventions in one place: `"help"`, `"span"`, and
  `"fix_edits"` serialize as JSON `null` when `None`; within a present `"span"`
  object, `"line"` and `"column"` are omitted entirely (key absent, not `null`)
  when not set.  Notes that `mds-python` unconditionally emits only
  `"offset"` and `"length"` in the span object.

- `<unknown>` ordering note (finding 3): add a clarifying sentence after the
  `"<unknown>"` fallback key description noting that BTreeMap lexicographic
  order places `"<unknown>"` before alphabetic paths in the JSON `files` array,
  which disagrees with `LintResult.diagnostics` where file-less entries sort
  last — academic because no lint rule emits `file: None`.

Findings 2 (triplicated # Ordering) and 4 (struct literals in make_span_diag)
were fixed in the preceding commit (8e33cf4).

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

Finding 5 (low): the CHANGELOG stated the offset-ordering guarantee
unconditionally; add the qualifying clause that the guarantee applies
to results produced by the lint engine, while LintResult::new emits
in the caller-supplied order — matching the qualifier already present
in to_canonical_json's rustdoc (AD-202-1b).

Finding 3 (low): no recorded evidence that AC-P1-22's wall-clock
criterion is met. Static analysis confirms the sort key is a fully
borrowed tuple (bool, &str, bool, usize) — zero per-comparison
allocations — and sort_diagnostics is called at most once per
LintResultBuilder::build over n <= MAX_DIAGNOSTICS (1,000) items.
Document this evidence in the changelog so the recorded-evidence gap
is closed without requiring a live timing measurement.

Findings 1, 2, and 4 were already resolved by prior commits on this
branch (8e33cf4, f19a609, e1fdadf) and required no further changes.
spec.md:996 incorrectly stated '"span" is absent for diagnostics that
lack a source location'. The code (diagnostic.rs:814) emits the key
with a null value via serde, matching the rustdoc added in the same
delta (diagnostic.rs:729-730) and the Python parity test assertion
at test_parity.py:249 ('span must be None (not absent) when not set').

Change the sentence to state that "span" is JSON null, aligning the
spec with the implementation and removing the contradiction between
two normative documents.

Co-Authored-By: Claude <noreply@anthropic.com>
Three review findings on error.rs, all stemming from the same root cause:
the `source_name()` rustdoc pointed callers at `resolver::SOURCE_LABEL`
which is `pub(crate)` and unreachable from downstream crates, causing
mds-cli to hardcode `"<source>"` with no compile-time link to the definition.

Changes:
- Add `MdsError::is_string_source() -> bool` that performs the sentinel
  comparison inside mds-core using `crate::resolver::SOURCE_LABEL`, giving
  the caller a compile-time-coupled predicate instead of a magic literal.
- Fix `source_name()` rustdoc: remove the broken `resolver::SOURCE_LABEL`
  reference; redirect to `is_string_source()`; correct the sanitization
  guidance (display_sanitized() sanitizes the Display message, not the
  source name; callers should use sanitize_control_chars /
  sanitize_control_chars_wire).
- Update output.rs::relabel_stdin_error to use `e.is_string_source()`
  instead of `e.source_name().is_some_and(|n| n == "<source>")`.
- Add api_surface test `mds_error_is_string_source` with positive control
  (sentinel value returns true) and two negative controls (real path and
  source-less error return false) per PF-013.

Co-Authored-By: Claude <noreply@anthropic.com>
…-P1-28(c)

U-L11 was titled "napi/WASM ... whichever init() selected" but init() always
chooses native where the napi addon resolves, leaving the WASM branch dead on
every CI host (PF-007 silent-backend-switch pitfall). AC-P1-24 requires a
four-surface differential (napi, WASM, Python, CLI) and AC-P1-28(c) requires
WASM string-source lint to emit files[].file == 'input.mds'.

Add U-L12 which:
  (a) loads the WASM backend directly via initWasmNode() + createWasmBackend(),
      bypassing init()'s native preference; asserts getBackend() === 'wasm'
      so a silent backend switch is detected immediately (avoids PF-007)
  (b) asserts files[].file === 'input.mds' for WASM string-source lint,
      guarding against the wrong-lever regression where STRING_SOURCE_MAP_LABEL
      is changed instead of extending the CLI output-boundary relabel (AC-P1-28(c))
  (c) compares WASM diagnostics[] against CLI diagnostics[] with the file key
      stripped -- the actual cross-surface parity differential (AC-P1-24, avoids
      PF-007 which states per-surface goldens cannot prove cross-surface parity)

In CI the WASM module is built before npm test (js job: wasm-pack build step);
the test is a hard failure there. Locally without a built WASM it warns and
skips. The CLI differential leg additionally skips if no CLI binary is found.

Applies PF-007, avoids PF-013.

Co-Authored-By: Claude <noreply@anthropic.com>
…json re-sort

ci.yml: change vacuous-pass to hard fail when a WASM artifact is missing
(finding 2b).  Previously the guard `continue`d with a `::warning::` on
a missing file, so the 850,000-byte budget check passed silently when the
build did not produce the artifact.  Now emits `::error::` and `exit 1`,
matching the guard's intent as a hard enforcement step.

diagnostic.rs: remove the defensive re-sort added in 8e33cf4 from
`to_canonical_json` (medium finding).  `LintResultBuilder::build` already
sorts every engine-produced `LintResult` via the single choke point
`sort_diagnostics`; the per-call re-sort inside `to_canonical_json` was
redundant for those results and added unnecessary WASM code size.  For
`LintResult::new` callers the docstring already documented that the caller
supplies the order; the re-sort contradicted that contract.  Remove it and
update the docstring accordingly.  The AD-202-1 comment at the call site
now states the single-choke-point guarantee explicitly.

Co-Authored-By: Claude <noreply@anthropic.com>
…tion-path controls

The two positive controls in `stdin_analysis_failure_labels_source_as_stdin`
(lines 1618-1625 before this commit) were tautologies over string literals:
  `"<source>:1:1".contains("<source>")` — proves only `str::contains` exists
  `"failed to read input.mds".contains("input.mds")` — same

A tautology never exercises the actual extraction under test
(`serde_json::from_str` → `v["error"]["message"].as_str()`), so the controls
would still pass if the serializer moved the value to a different key and the
absence assertions below became vacuous.

Fix (ADR-009 / PF-013): apply the SAME extraction pipeline to a SYNTHETIC JSON
document embedding each forbidden value.  The serde parse + key traversal must
succeed AND return a string that contains the forbidden value — if either fails,
the following absence assertion on real CLI output would be meaningless.

Also add explicit `eprintln!` skip notice to
`directory_json_file_key_escapes_control_bytes_in_paths` before the bare
`return` when `fs::write` fails on control-byte filenames.  The bare return
was silent in test output; the notice makes skipping visible.  A comment
records the Windows coverage gap alongside #147/#148 (PF-013 / ADR-009).
…repo root

__dirname is crates/mds-napi/__test__, so two levels up lands in crates/,
not the repository root.  The CLI binary lookup
  path.join(REPO_ROOT, 'target', profile, 'mds')
therefore produced <repo>/crates/target/debug/mds, which never exists,
making the AC-P1-24 cross-surface differential permanently skip in CI
(with CI=true it threw; without it it warned and skipped).

Fix: change '../..' → '../../..' to match the sibling helper at
packages/mds/__test__/lint.spec.mjs:27.

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

The resolver.rs:200-201 rustdoc claimed pub(crate) "so sibling modules
(error, sourcemap) can reference the single definition" without explaining
HOW each module references it — the claim was accurate after commit 933c913
added is_string_source(), but left the design implicit.

Two confirmed review findings:
1. (BLOCKING) The original rustdoc was written when error.rs had NO code
   reference to SOURCE_LABEL, making the claim factually wrong. 933c913 fixed
   the code (adding is_string_source() and updating output.rs), but the rustdoc
   text still said only "sibling modules can reference it" without naming the
   mechanism.
2. (medium) Same root cause — rustdoc's claim about error was factually wrong
   at review time and "should not be left as guidance."

Fix: rewrite the pub(crate) paragraph to name both consumers explicitly and
explain the design:
- sourcemap::map_source_label compares against this constant directly.
- error::MdsError::is_string_source wraps the comparison in a public predicate;
  CLI callers use is_string_source(), not this constant, so the coupling is
  compile-time even across the crate boundary.

This makes the rustdoc unambiguously correct for the current code and serves
as architectural guidance for future callers.

Co-Authored-By: Claude <noreply@anthropic.com>
Three sibling docs still listed LintDiagnostic without the fix_edits key
despite the type declarations (packages/mds/src/types.ts and
crates/mds-python/python/mdscript/_mdscript.pyi) already carrying it.
A consumer reading these docs to enumerate diagnostic fields would
silently drop fix_edits data the API actually returns.

Updated:
- packages/mds/README.md — universal @mdscript/mds npm comment
- crates/mds-python/README.md — Python LintDiagnostic field list
- examples/python/README.md — Python code-snippet field list

Wording matches crates/mds-napi/README.md, spec.md, and
examples/linting/README.md (already fixed in the prior batch).

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

The mds_cli fixture was calling pytest.skip() unconditionally when the
CLI binary was not found, including in CI. This meant the three Python
cross-surface parity tests (test_par5_live_cli_lint_json_parity,
test_ac_p1_24_cross_surface_diagnostic_order_parity,
test_par5b_live_cli_lint_differential_with_findings) could silently not
run in CI while the equivalent JS tests (P-L-4 / U-L11 / U-L12) would
throw a hard error.

Now the fixture calls pytest.fail() when CI env var is set and no
binary is found, matching the JS tests' policy. Outside CI it still
calls pytest.skip() so local development without a built CLI is not
disrupted.

Co-Authored-By: Claude <noreply@anthropic.com>
Add `lint_result_new_preserves_caller_order_through_to_canonical_json`
to the `diagnostic.rs` test suite.  The test builds a `LintResult` via
`LintResult::new` with diagnostics in deliberate reverse-offset order
(50 before 10) and asserts that `to_canonical_json` emits them in the
same caller-supplied order — not re-sorted.

This pins the contract documented in:
  * `LintResult::new` # Ordering rustdoc (AD-202-1b)
  * `to_canonical_json` rustdoc (caller order preserved for ::new callers)
  * CHANGELOG.md §1 ("a LintResult assembled via LintResult::new is
    emitted in the order the caller supplied")

A future "defensive re-sort" inside `to_canonical_json` would flip the
assertion to offset 10 first, making the regression immediately visible
rather than silently violating the published wire contract.

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

- output.rs:157 corrected from main.rs:279 to main.rs:280 (verified with awk)
- output.rs:160 corrected from lint.rs:1509 ("version": 1, inside JSON envelope)
  to lint.rs:1520 (the actual relabel_stdin_error call site, per awk verification)
- output.rs:166-171 two consecutive comment blocks merged into one to eliminate
  the dual-block divergence risk flagged in Finding 4; is_string_source() detail
  is preserved inline in the merged block
- lint.rs:748 stale numeric cross-reference lint.rs:1176/1197/1340/1364 replaced
  with function names lint_one_file_accumulating/lint_one_file_human (which do
  not drift as lines shift)
- lint.rs:849 stale numeric cross-reference lint.rs:1176/1340 replaced with the
  same function names

Fixes Finding 1, Finding 3, Finding 4 (BLOCKING × 3) from review batch.
Finding 2 (LOW): is_string_source() usage established by 933c913 already satisfies
the comment-accuracy nit; no further change needed.

Co-Authored-By: Claude <noreply@anthropic.com>
…1b no-sort

Finding 1(b): as_json was missing conditional line/column keys in the span
object.  to_canonical_json uses absence convention (key absent, not null) when
line/column are not set; as_json now matches.

Supporting fix: the `files` getter was hardcoding `line: None, column: None`
when reconstructing Span from the JSON backing store; now reads from JSON.

Finding 2 (sanitize_lint_value doc): adds AD-202-1b explanatory comment —
to_canonical_json preserves caller-supplied order for LintResult::new callers;
sort_diagnostics is the single ordering choke point; this function intentionally
does not re-sort.

Also in diagnostic.rs: fix stale comment ("unconditionally emits only offset
and length" → "also conditionally emits line and column when set"); extend
build_no_span_sorts_to_end to assert span:null on the canonical-JSON wire path
(AC-P1-08).
Finding 3 (MEDIUM): add `display_label: &str` parameter to
`plan_and_apply_fixes`; call `set_diag_display_path` on the Fixed and
PartiallyFixed residuals inside the function.  The four call sites now
pass the label directly (STDIN_DISPLAY_LABEL, filename, &display_path)
and the seven downstream `set_diag_display_path` call sites are deleted.
A future caller cannot omit the relabel by mistake.

Findings 1 & 2 (LOW): change `files.sort_by_key` to
`sort_by_cached_key` with a `(String, OsString)` key so the closure is
evaluated once per element instead of once per comparison (AC-P1-22),
and non-UTF-8 filenames that produce identical lossy strings are broken
by a deterministic raw byte tiebreak (Finding 2).

Finding 4 (LOW): add the `mds lint <dir>` file-group ordering change to
the "A consumer breaks if it" enumeration in CHANGELOG.md so the
wire-contract break is visible at a glance alongside the other four.

Findings 1 & 5 are the same: sort_by_key heap-allocation is fixed once
by the sort_by_cached_key change above.
dean0x and others added 19 commits August 14, 2026 02:58
…ng 4)

The wire-contract ledger JSON snippet at the top of the BREAKING block shows
only `rule` and `span` — a reader might mistake these for the complete object
shape.  Add `// abbreviated — see spec.md for the full schema` as the first
comment inside the jsonc fence so the intent is clear.

Closes review finding [low] F4 (PR1 batch).
…ile key

Extract `relative_display(path, root) -> String` that strips the root
prefix and normalises to forward-slash separators.  Use it for both the
directory-mode sort key (run_lint_directory) and the emitted JSON
`files[].file` value (lint_one_file_accumulating, lint_one_file_human),
eliminating the two-spelling divergence (finding 2: sort used
to_string_lossy().into_owned() while the JSON key used
.display().to_string()) and making byte-wise sort order platform-
independent (finding 4: native separator was 0x5C on Windows,
reordering relative to sibling names vs the 0x2F documented in
CHANGELOG:131-132).

On Unix, replace('\\', "/") is a no-op (paths never contain '\'); on
Windows it normalises the native separator so the documented example
(api-utils.mds before api/x.mds because '-' 0x2D < '/' 0x2F) holds
regardless of host.

Also simplifies the sort_by_cached_key closure from a (String, OsString)
tuple key to a single String, matching the single-String return of the
helper and making the sort's intent explicit.  The sort remains
sort_by_cached_key — one allocation per element, O(n) total (AC-P1-22).

Findings 1 and 3 (sort_by_key heap allocation on every comparison) were
already resolved in the prior commit; this commit makes the sort key
and the emitted value byte-identical by construction so that the two
descriptions cannot silently drift again.

Co-Authored-By: Claude <noreply@anthropic.com>
…, document Windows file key change

Three documentation correctness fixes in the [Unreleased] breaking-changes block:

1. `MdsError::source_name()` bullet rewrote to remove the contradictory advice
   ("compare the returned name against `\"<source>\"` themselves") that is now
   explicitly forbidden by the method's own rustdoc.  The sentinel is pub(crate)
   and unreachable from downstream crates, so the old guidance would produce
   uncompilable code.

2. Add missing `MdsError::is_string_source() -> bool` bullet to the public-API
   list.  The method was added in 933c913 and pinned in api_surface.rs, but was
   absent from the CHANGELOG, inconsistent with the wave's declared single ledger
   (AC-P1-21).

3. The wire-contract "A consumer breaks if it" block now documents the Windows
   `files[].file` value change: `relative_display` normalises `\` → `/`, so
   nested paths change from `sub\c.mds` to `sub/c.mds` on Windows.  Only the
   ordering change (Path::Ord → byte-wise string) was previously documented; the
   key-value change was absent.

Co-Authored-By: Claude <noreply@anthropic.com>
… ordering pin, deepEqual)

Fix three confirmed findings in the U-L12 WASM cross-surface test (lint.spec.mjs):

1. Assertion (a) vacuous comment: createWasmBackend() hardcodes getBackend: () => 'wasm',
   so the test constructs the backend directly and cannot detect a silent backend switch.
   Downgrade the block comment and inline comment from a PF-007 guard claim to an honest
   API shape check (consistent with the same-shape-vacuity analysis in b2e73d4).

2. Missing rule-position ordering assertion: U-L12 lacked the legacyIdx < dupIdx pin
   that its napi sibling P-L-4 already has. The fixture's factually-wrong comment
   ("diverge from the CLI" on delete) is corrected -- sort_diagnostics lives in
   LintResultBuilder::build and both surfaces go through it. Add the three-assertion
   ordering pin (rules present + legacy before dup) so deleting the sort in
   LintResultBuilder::build makes this test fail (genuine regression detector).

3. Wire-contract comparison precision: deepEqual -> deepStrictEqual for the WASM-vs-CLI
   parity assertion, so a type drift such as offset:"59" vs offset:59 is caught.

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

output.rs:160 cited lint.rs:1520 as the `mds lint -` call site of
relabel_stdin_error; that line is the serde_json::json! envelope inside
emit_analysis_failure_json_or_stderr, not the relabelling call (which is
at lint.rs:1532).  Commit 447d94c moved the citation from :1509 → :1520
as part of a "fix stale citations" pass but left it wrong; commit 81746d0
then inserted 16 lines earlier in lint.rs and widened the gap.

Because this indirect call site lies inside emit_analysis_failure_json_or_stderr
(whose entry point also moves when lines are inserted), cite the stable
function symbol instead of any specific line number, per the recommendation
in the review finding.

Separately, lib.rs:601 cited diagnostic.rs:780-785 for the conditional
line/column span keys; the correct range is 776-781 (lines 776-778 guard
`s.line`, lines 779-781 guard `s.column`; lines 782-785 are closing braces
and the start of fix_edits_json).

Both fixes are comment-only; no runtime behaviour changes.

Co-Authored-By: Claude <noreply@anthropic.com>
The WASM size comment (AC-P1-23) was recorded before commit 56424f7
removed the redundant to_canonical_json re-sort. That removal reduced
the binary by 11,641 bytes, leaving the ledger 11,641 bytes over the
actual build artefact (845,143 claimed vs 833,502 measured).

Correct all three derived figures:
  - delta:      +23,481  -> +11,840 bytes
  - post-change: 845,143 -> 833,502 bytes (wasm-pack 0.15.0 bundled wasm-opt)
  - headroom:   4,857 / 0.57% -> 16,498 / 1.94% against the 850,000 budget

Also update the PR #294 body to match (finding-3 explicit requirement).
Guard is satisfied; budget is NOT exceeded. (AC-P1-23)
…strings

Three binding-surface docstrings still documented the wire-format
diagnostic object as `{..., span?}`, omitting `fix_edits` and using
optional-key notation that contradicts the spec (span is JSON null,
not an absent key).

- crates/mds-napi/src/lib.rs:lint() -- was `{rule, severity, message,
  help, fixable, span?}`, now `{..., span, fix_edits}` with a null-
  convention note
- crates/mds-wasm/src/lib.rs:lint() -- identical fix
- crates/mds-python/src/lib.rs:LintDiagnostic -- was
  `{..., help?, fixable, span?}`, now `{..., help, fixable, span,
  fix_edits}` with prose updated to cover all three always-present
  None-able attributes

The napi surface flows into the shipped index.d.ts that npm consumers
read; the Python one documents the pyclass that now exposes a
fix_edits getter.  Matches the canonical schema already in
diagnostic.rs::to_canonical_json() rustdoc (lines 695-758).

AC-P1-21 (wire schema documented on all binding surfaces).

Co-Authored-By: Claude <noreply@anthropic.com>
Four confirmed review findings, all in spec.md:

- (medium) files[] array ordering guarantee was absent from the normative
  schema doc. Added: directory mode sorts by byte-wise string comparison
  of the forward-slash-separated relative display path (e.g. api-utils.mds
  before api/x.mds because '-' (0x2D) < '/' (0x2F)).

- (low) Diagnostic ordering guarantee stated unconditionally; CHANGELOG
  section 1 qualifies it with a construction-path note. Added a one-clause
  parenthetical: CLI and binding surfaces always use LintResultBuilder, so
  the ordering is always in effect on those surfaces; LintResult::new
  preserves caller-supplied order. Aligns spec.md with CHANGELOG and the
  LintResult::new rustdoc without changing the substance of the guarantee.

- (low) spec.md documented only the success envelope. Added:
  - In directory mode, files[].file is the forward-slash-separated
    relative path (e.g. src/template.mds).
  - Per-file error entry shape: {"file":"…","error":{…}} without a
    "diagnostics" key, emitted when a file produces a config/IO failure.
  - Analysis-failure envelope shape: {"version":1,"error":{…}} with no
    "files", "truncated", or "file" key.
  - Consumer MUST-handle note for both envelope shapes.

  The CHANGELOG snippet at line 97 defers to spec.md via "abbreviated —
  see spec.md for the full schema"; these additions close that gap.

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

packages/mds/README.md line 156 marked fix_edits as optional (fix_edits?)
but to_canonical_json emits it unconditionally via serde_json::json!, which
serializes Option::None as JSON null — not absent. The key is always present.

The sibling doc crates/mds-napi/README.md:66 already wrote fix_edits without
the optional marker; the two docs now agree.

Same defect class as commit 064dea2 ('fix span absence convention - null not
absent on wire').

Co-Authored-By: Claude <noreply@anthropic.com>
The WASM size record added by ec692fc corrected the byte counts but left
the branch name as `feat/lint-json-wire-contract`.  The actual branch is
`ticket/pr1-lint-json-wire-contract`.  Fix the reference so the permanent
engineering record is accurate.

Numbers confirmed: 833,502 bytes on disk, 16,498 bytes (1.94%) headroom.
…rustfmt

Four confirmed review findings fixed in lint.rs:

1. BLOCKING (CWE-22/CWE-41): relative_display used .replace('\\', "/")
   unconditionally, turning a literal backslash filename byte on Unix into a
   path separator. A file named "sub/../../etc/evil.mds" emitted as
   "sub/../../../etc/evil.mds" on the wire — an arbitrary-path traversal
   vector. Two files "a/b.mds" and "a\b.mds" silently collided. Fixed by
   using Path::components().join("/") which is platform-aware: backslash is
   a separator only on Windows, a plain filename byte on Unix. Adds
   #[cfg(unix)] regression test asserting the collision cannot reoccur.

2. BLOCKING (fmt): FixFileOutcome::Fixed { new_source, residual } was a
   single-line struct literal; rustfmt requires multi-line. Fixed by running
   cargo fmt --all.

3. SHOULD-FIX (stale comment, lines ~687-689): Sentence claiming "the write
   path's residual is relabeled separately after the fix_outcome match below"
   was left over from before plan_and_apply_fixes absorbed that relabel.
   Removed the stale sentence; updated comment to reflect current state.

4. SHOULD-FIX (inaccurate comment, lines ~1058-1062): Comment claimed sort
   key and JSON files[].file emitted key are "byte-identical by construction"
   — false for control-byte paths where to_canonical_json applies
   sanitize_control_chars_wire after sorting. Softened to note the exception
   for that narrow class of filenames (AC-P1-20).

Applies ADR-009 (security completeness requires a positive control — the
regression test asserts the specific collision the old code produced).
Applies ADR-010 (non_exhaustive public API — test constructs paths via
string literals, not struct literals).
…AD-211-5)

MdsError Display templates do not interpolate ctx.file_str, so the JSON
error.message field cannot carry <source> — only rendered diagnostic frames
(stderr) do. Tighten the breaking-change sentence accordingly.
…n test comments

Addresses three confirmed review findings and one source hygiene violation:

Finding 5 (BLOCKING): sort key diverged from emitted key for control-byte
filenames. files.sort_by_cached_key previously sorted on the raw
relative_display output, but to_canonical_json emits sanitize_control_chars_wire
of that value. A file named with byte 0x01 sorts at position 0x01 in raw order
but its emitted key starts with '\' (0x5C), so the array order and the emitted
key order were inconsistent — violating AC-P1-10. Fixed by wrapping the sort key
in sanitize_control_chars_wire(..).into_owned(). Adds regression test
sort_key_sanitizes_control_byte_filenames to assert the raw/sanitized ordering
mismatch is detected and the sanitized sort key produces the correct order.
Control byte is constructed at runtime (char::from(1u8)) per PF-018.

Finding 3 (SHOULD-FIX): run_lint_file had no explicit set_diag_display_path
call. The Rejected and NothingToFix arms were relabeled correctly only because
mds::lint independently derives the same basename; the silent coupling would
break if the two derivations ever diverged. Added explicit relabel matching the
pattern in lint_one_file_accumulating and lint_one_file_human.

Finding 2 (stale rustdoc): relative_display rustdoc updated to document the real
motivation for forward slashes — byte-range ordering (0x30-0x5B bytes between /
and \) — rather than the old "documented example" framing. Matching comments in
run_lint_directory and lint_one_file_human updated for accuracy.

PF-018 (source hygiene): the test docstring had three literal U+0001 bytes
embedded in comments (injected by a prior Write pass). Source hygiene gate
reported them as hazardous codepoints. Replaced with text descriptions ("byte
0x01", "JSON-escape prefix", "JSON-emitted key starts with `\`").

Applies ADR-009 (regression test with positive control: asserts the OLD sort
order is provably wrong before asserting the new order is correct).
Co-Authored-By: Claude <noreply@anthropic.com>
…ings

Three review findings from the batch review of ticket/pr1-lint-json-wire-contract:

Finding 3 (high, BLOCKING): `cargo fmt --all --check` was failing at
lint.rs:1752–1753 — commit b5a93df introduced two two-line variable
declarations that rustfmt collapses to single lines. Fixed.

Finding 4(c) (high, BLOCKING): The `(String, OsString)` deterministic
tiebreak that commit 88e5ca4 had added was silently dropped by 81746d0
and not restored by subsequent commits. Without it, two non-UTF-8
filenames whose `to_string_lossy` representations collide fall back to
readdir enumeration order, which is non-deterministic. Re-add
`p.as_os_str().to_os_string()` as a secondary sort key. Still O(n)
allocations via `sort_by_cached_key` (AC-P1-22).

Finding 5 (medium): The `relative_display` rustdoc and sort comment
claimed sort position and `files[].file` are "byte-identical for every
input". After b5a93df the claim holds for diagnostic entries (both sort
and `to_canonical_json` apply `sanitize_control_chars_wire`), but error-
only entries push the raw display path — a pre-existing asymmetry.
Update the docstring and sort comment to say "consistent with the
sanitized key for diagnostic entries" and note the error-entry asymmetry
explicitly. Repeat fix in the test doc-comment at the same wording.

Findings 1 and 4(a)/(b) were already addressed by bd7fbcf and b5a93df.
…; remove ephemeral finding IDs

Three fixes to crates/mds-cli/src/lint.rs:

1. Sanitize `file` key in error-only JSON entries (medium).
   Error-only entries (`{"file":…,"error":…}`) in directory mode bypassed
   `to_canonical_json`'s `sanitize_control_chars_wire` pass, emitting raw
   control bytes in `files[].file` while diagnostic entries were sanitized.
   Compute `file_key = sanitize_control_chars_wire(&display_path)` once per
   file and use it in all four error-entry pushes, aligning error entries with
   the spec.md normative sanitization contract (ADR-008).

2. Document `display_label` parameter in `plan_and_apply_fixes` (low).
   The function's rustdoc listed `base_dir` but not the new `display_label`
   parameter added by the refactor, leaving the published wire-value carrier
   undocumented against the function's own convention.  Added doc paragraph
   describing what the parameter carries and its relationship to the wire key.

3. Replace ephemeral `finding N` identifiers with durable references (medium).
   Comments at four sites referenced "finding 2", "finding 3", "finding 4"
   from an ephemeral review artifact with no durable referent.  Replaced with
   AC-P1-10 where that acceptance criterion governs, and with inline invariant
   statements elsewhere (leaving the end-state, not the transition).

The `relative_display` backslash fix (two high-severity findings) was already
committed in bd7fbcf (components()-based join) with a regression test in the
same commit; this commit addresses only the three remaining findings.
Three inline comments in run_lint_stdin carried transition notes of the
form "(was bare 'stdin' before this PR)" alongside the AD-211-2 citation.
Those notes encode the old state, not the end-state — a tombstone pattern
the project rules explicitly forbid (project CLAUDE.md: "Leave the
end-state, not the transition").  The code is self-documenting via
STDIN_DISPLAY_LABEL, whose own rustdoc block covers AD-211-1/2/3.

Also adds a missing blank `///` separator in `relative_display`'s doc
comment: the "Error-only entries" paragraph and the "Forward slashes"
paragraph were running together as one block despite covering distinct
topics (caller asymmetry vs. byte-order rationale for forward slashes).
- Strip transition residue for source_label_is_stdin_sentinel: the method was
  introduced and replaced entirely within this branch (331a8d3 -> b81fe07) and
  never existed on main or wave/v0.4.0-wave1, so the CHANGELOG entry pointed
  consumers at a symbol no released version ever carried, and the api_surface
  rustdoc carried a tombstone naming a dead symbol (project rule: leave the
  end-state, not the transition).

- Correct the to_canonical_json ordering rustdoc: file groups are keyed on the
  RAW diag.file while the emitted "file" string is the WIRE-sanitized form, so
  the previous claim that this ordering matches the CLI directory sort (which
  sorts on the SANITIZED relative display path) was imprecise. Record why the
  divergence is unreachable from the engine: every entry point passes exactly
  one filename, so an engine-produced LintResult always has one file group.

- Narrow the CHANGELOG file-group-ordering bullet to the CLI directory-mode
  contract it actually describes, for the same reason.
Finding 4 (PF-013): stdin_lint_diagnostic_includes_code_frame asserted
"<stdin>" present in human output but never asserted "input.mds" absent.
Its JSON sibling asserts both. Add the missing negative assertion so the
pair is symmetric (presence-plus-absence is a stronger guarantee than
presence-only).

Finding 5 (ledger): pr1-lint-json-plan.md recorded the wave-1 WASM
baseline as 820,305 (locally measured at main). The CI-measured
wave/v0.4.0-wave1 baseline is 821,662 bytes — consistent with ci.yml
which records 821,662 as the baseline (821,662 + 11,840 = 833,502;
headroom 16,498 / 1.94% against the 850,000 guard). Update the plan doc
to record both figures with the toolchain context explaining the
1,357-byte difference (local Binaryen vs CI Binaryen v129).

Findings 1, 2, 3: ALREADY-RESOLVED by commit 56424f7 which removed the
redundant to_canonical_json re-sort. sort_diagnostics is now the single
ordering choke point; both rustdoc blocks agree; CHANGELOG is accurate.

Co-Authored-By: Claude <noreply@anthropic.com>
write_text(newline=None) translates \n to os.linesep (\r\n) on Windows,
so the CLI was linting CRLF bytes while the Python surface processed the
in-memory LF string — causing byte-offset divergence in the AC-P1-24
cross-surface parity tests (offsets 29→30 and 57→62 on Windows).

The defect is in the test harness only; both surfaces were correct for
the bytes they actually received.

Changes:
- test_parity.py L262, L305: write_text → write_bytes (file fixtures)
- test_parity.py L408: input=src → input=src.encode("utf-8"), drop
  text=True, decode stdout/stderr explicitly (.decode("utf-8"))
- conftest.py L88: write_text → write_bytes in cli_build helper (latent
  CRLF risk in test_par2_* tests whose fixtures carry no offsets)
- Add test_crlf_input_parity: deliberate CRLF contract test — feeds
  genuine CRLF bytes to both surfaces and asserts they AGREE, converting
  an accidental platform gap into a tested regression contract.

Co-Authored-By: Claude <noreply@anthropic.com>
@dean0x
dean0x merged commit cbb11d4 into wave/v0.4.0-wave1 Aug 14, 2026
16 checks passed
@dean0x
dean0x deleted the ticket/pr1-lint-json-wire-contract branch August 14, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant