Skip to content

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

Open
dean0x wants to merge 7 commits into
wave/v0.4.0-wave1from
ticket/pr1-lint-json-wire-contract
Open

feat(lint): JSON wire contract — sort diagnostics, stdin label, name-anchored spans (#202, #203, #211)#294
dean0x wants to merge 7 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 845,143 bytes · pre-change 821,662 bytes (both `packages/mds-wasm/dist/node/mds_wasm_bg.wasm` and `dist/web/`, same toolchain: wasm-pack bundled wasm-opt, system Binaryen absent; verified with clean builds on each tree). Delta: +23,481 bytes. Budget: 850,000. Headroom: 4,857 bytes (0.57%). Guard NOT raised. Toolchain note: local uses wasm-pack-bundled wasm-opt; CI uses Binaryen v129 (distinct optimizer). CI is the authoritative measurement. The delta vs the initial 833,763 measurement reflects two additional commits to WASM-reachable code landed after the initial build: `b81fe07` (replace `source_label_is_stdin_sentinel` with neutral `source_name` in `error.rs`/`resolver.rs`) and `8e33cf4` (add defensive re-sort in `to_canonical_json` — WASM-reachable, so it contributes code size even if the sort is idempotent on already-sorted input).

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 7 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>
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