Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
16339e1
feat(lint): JSON wire contract — sort diagnostics, stdin label, name-…
dean0x Aug 13, 2026
d150f65
refactor(lint): remove one-shot intermediates in offset loop and erro…
dean0x Aug 13, 2026
25bed9f
fix: address self-review issues (#202, #203, #211)
dean0x Aug 13, 2026
331a8d3
fix(lint): address Evaluator alignment findings (#202, #203, #211)
dean0x Aug 13, 2026
1678323
fix(lint): address QA blockers — fmt/clippy/AC-P1-26/binary rebuild m…
dean0x Aug 13, 2026
a60591d
docs(plan): reconcile test-plan entry 27 with AC-P1-26 zero-diagnosti…
dean0x Aug 13, 2026
b81fe07
refactor(mds-core): replace source_label_is_stdin_sentinel with sourc…
dean0x Aug 13, 2026
8e33cf4
fix(lint): address review findings — docs, ADR-010 constructor, wire …
dean0x Aug 13, 2026
c5ac468
test(lint): strengthen AC-P1-04/07/27 with non-vacuity and positive c…
dean0x Aug 13, 2026
add3ce6
test(lint): add AC-P1-24 cross-surface parity test + plan analysis-fa…
dean0x Aug 13, 2026
fed72e3
test(lint): format, returncode guard, and Part 3 for AC-P1-04
dean0x Aug 13, 2026
0761617
docs(spec): fix three review findings on JSON wire contract section
dean0x Aug 13, 2026
0362d45
docs(output): update stale lint.rs line-number reference in output.rs…
dean0x Aug 13, 2026
9b5077d
test(lint): add PF-012 guard test for stdin import-error source identity
dean0x Aug 13, 2026
f19a609
fix(lint): align dir sort to BTreeMap byte-order + doc/test improvements
dean0x Aug 13, 2026
e1fdadf
docs(lint): fix span schema and document absence conventions in to_ca…
dean0x Aug 13, 2026
7ba6458
docs(changelog): add LintResult::new qualifier and AC-P1-22 performan…
dean0x Aug 13, 2026
064dea2
docs(spec): fix span absence convention — null not absent on wire
dean0x Aug 14, 2026
933c913
fix(error): add is_string_source() predicate; fix source_name() rustdoc
dean0x Aug 14, 2026
32a076c
test(lint): add U-L12 WASM cross-surface differential for AC-P1-24/AC…
dean0x Aug 14, 2026
56424f7
fix(ci,lint): harden WASM size guard + remove redundant to_canonical_…
dean0x Aug 14, 2026
b2e73d4
test(lint): replace tautological ADR-009 controls with genuine extrac…
dean0x Aug 14, 2026
ac5a356
fix(napi): correct REPO_ROOT path — '../..' resolves to crates/, not …
dean0x Aug 14, 2026
a0288ee
docs(resolver): clarify SOURCE_LABEL pub(crate) consumers and CLI acc…
dean0x Aug 14, 2026
4255df6
docs(lint): add fix_edits to remaining three consumer docs
dean0x Aug 14, 2026
5d52cbc
fix(python): fail in CI when mds CLI binary is missing for cross-surf…
dean0x Aug 14, 2026
f190e84
test(lint): pin AD-202-1b caller-order contract via regression test
dean0x Aug 14, 2026
447d94c
docs(output,lint): fix stale line-number citations and merge duplicat…
dean0x Aug 14, 2026
aaf0b4c
fix(mds-python): mirror line/column in as_json span; document AD-202-…
dean0x Aug 14, 2026
88e5ca4
refactor(lint): centralize display-label relabel and harden dir sort
dean0x Aug 14, 2026
eb25051
docs(changelog): mark before/after JSON snippet as abbreviated (Findi…
dean0x Aug 14, 2026
81746d0
fix(lint): extract relative_display helper for unified sort key and f…
dean0x Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
580 changes: 580 additions & 0 deletions .devflow/docs/design/v040-wave1/20260812_0046/pr1-lint-json-plan.md

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ jobs:
run: |
for f in crates/mds-wasm/pkg/mds_wasm_bg.wasm crates/mds-wasm/pkg-web/mds_wasm_bg.wasm; do
if [ ! -f "$f" ]; then
echo "::warning::WASM file missing: $f"
continue
echo "::error::WASM file missing: $f"
exit 1
fi
raw=$(wc -c < "$f" | tr -d ' ')
gz=$(gzip -c "$f" | wc -c | tr -d ' ')
Expand All @@ -109,6 +109,11 @@ jobs:
# signature help pushed optimized binary to ~808K locally (wasm-opt v117
# bundled + release profile); +headroom for CI toolchain variance (CI uses
# Binaryen v129 which may differ).
# PR #294 (2026-08-14, feat/lint-json-wire-contract): sort + stdin-label +
# name-span-anchor added +23,481 bytes; wave/v0.4.0-wave1 baseline 821,662,
# post-change 845,143 (local wasm-pack bundled wasm-opt, independently
# verified). Guard NOT raised: 4,857 bytes (0.57%) headroom. CI uses
# Binaryen v129 (distinct toolchain from local). (AC-P1-23)
# Follow-up: pin the wasm build toolchain to make the size deterministic
# and re-tighten this guard.
if [ "$raw" -gt 850000 ]; then
Expand Down
104 changes: 104 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,99 @@ via struct literals. Use the named constructor or builder listed for each:
suitable for miette render boundaries. `mds-cli`'s diagnostic render path now delegates to
this method instead of assembling sanitized copies itself, keeping the escape logic co-located
with the struct definition (PF-014).
- **`MdsError::source_name() -> Option<&str>`** — a new method that returns the name embedded
in the error's `NamedSource`, or `None` for errors without a source (e.g. `MdsError::Io`).
This replaces the previously-unreleased `source_label_is_stdin_sentinel()` method, which
encoded CLI presentation vocabulary into the domain crate. `source_name()` is domain-neutral:
callers that need to detect the string-source analysis path compare the returned name against
the sentinel value `"<source>"` themselves.

#### Lint JSON wire contract (#202, #203, #211)

> This block is the **single wire-change ledger** for the lint JSON envelope.
> Later changes to `mds lint --format json` append here rather than opening a
> parallel section, so a consumer has one place to read.

**Before / after**, for `mds lint - --format json` on a source with one unused
selective import:

```jsonc
// abbreviated — see spec.md for the full schema
// before
{ "files": [ { "diagnostics": [
{ "rule": "duplicate-export", "span": { "length": 7, "offset": 59 } },
{ "rule": "unused-import", "span": { "length": 7, "offset": 0 } }
], "file": "input.mds" } ], "truncated": false, "version": 1 }

// after
{ "files": [ { "diagnostics": [
{ "rule": "unused-import", "span": { "length": 5, "offset": 10 } },
{ "rule": "duplicate-export", "span": { "length": 7, "offset": 59 } }
], "file": "<stdin>" } ], "truncated": false, "version": 1 }
```

**A consumer breaks if it** keys off `files[].file == "input.mds"` for CLI stdin
output, matches `<source>` in an `error.message` or rendered frame, relies on
`diagnostics[]` arriving in rule-execution order, assumes `unused-import`
spans have length 7, or relies on the `mds lint <dir>` file-group order being
component-wise (`Path::Ord`). File groups are now ordered by the byte-wise string
of the relative display path (e.g. `api-utils.mds` sorts before `api/x.mds`
because `'-'` (0x2D) < `'/'` (0x2F)).

**1. Diagnostics are sorted by byte offset (#202).** Within each
`files[].diagnostics` array, diagnostics are ordered by ascending `span.offset`
for results produced by the lint engine; a `LintResult` assembled directly via
`LintResult::new` is emitted in the order the caller supplied.
Previously the order was rule-execution order (implementation-defined).

- Diagnostics without a span sort to the end of their file group.
- Equal-offset diagnostics preserve rule-execution order (stable sort).
- File groups have a defined order: `mds lint <dir>` sorts by the byte-wise
(lexicographic) string representation of the relative display path, matching
the `BTreeMap` key ordering that `to_canonical_json` uses on the binding
surfaces (napi / WASM / Python). Both order by byte-wise string on the file
key — e.g. `api-utils.mds` sorts before `api/x.mds` because `'-'` (0x2D) <
`'/'` (0x2F).
- Ordering is established on `LintResult.diagnostics` itself, so the CLI human
path and the napi / WASM / Python surfaces observe the same order.
- **Truncation is unchanged and is NOT offset-ranked.** When `truncated` is
`true`, the retained diagnostics are still the first `MAX_DIAGNOSTICS` (1,000)
in rule-execution order, re-sorted afterwards — not the 1,000 smallest offsets.
- **Sort cost (AC-P1-22):** The sort key is a borrowed tuple `(bool, &str, bool,
usize)` — zero per-comparison heap allocations. The sort runs at most once per
`LintResultBuilder::build` call over n <= `MAX_DIAGNOSTICS` (1,000) items.

**2. The stdin source identity is always `<stdin>` (#211).** Every CLI context
that names a stdin source now uses the single sentinel `<stdin>`:

- the JSON `files[].file` key (previously `"input.mds"`, the internal VFS key);
- human diagnostic frames for `mds lint -` (previously `input.mds`);
- fix-preview status lines and diff headers (previously bare `stdin`);
- the **analysis-failure envelope** — a stdin source that fails the check gate
used to render `<source>:L:C`, the resolver's internal label. `mds check -` and
`mds build -` rendered `<source>` on the same path and now render `<stdin>`
too, so all four subcommands agree. Note: the analysis-failure JSON envelope
shape is `{"version":1,"error":{"code","message","help","span"}}` — it carries
**no `file` key** (unlike the success envelope which has `files[].file`). A
JSON consumer reading `error` results MUST NOT look for a `file` key there.

`mds::STRING_SOURCE_MAP_LABEL` is **unchanged** and remains `"input.mds"`: it is a
virtual-FS entry key, not a display label. The napi, WASM and Python lint APIs
continue to report `"input.mds"` for string-source input. The relabel is applied
only at the CLI output boundary.

**Zero-diagnostic behaviour:** when stdin lint completes with no findings, the
JSON is `{"files":[],"truncated":false,"version":1}` — no file entry. The
`<stdin>` sentinel appears in `files[0].file` only when at least one diagnostic is
emitted. This matches non-stdin zero-diagnostic behaviour and keeps the JSON
identical across the CLI and binding surfaces (napi, WASM, Python) for the clean
case.

**3. `unused-import` spans anchor at the unused name (#203).** For selective
imports (`@import { name1, name2 } from "path"`), the span now covers the unused
name rather than the `@import` keyword, and `span.length` is the name's length
instead of a constant 7. Alias imports (`@import "path" as alias`) are unchanged —
their span still covers the `@import` keyword.

#### New `fix_edits` field on `LintDiagnostic`

Expand Down Expand Up @@ -834,6 +927,17 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead.
`source_map=true` (from config), two overlapping errors could fire. The
messages-mode stdout path now emits exactly one warning.

- **`mds lint --fix --format json <file>` no longer emits `"file": "input.mds"`
for residual diagnostics.** In single-file mode with both `--fix` and
`--format json`, the `files[].file` key in the JSON output for residual
(post-fix) diagnostics was the internal VFS label `"input.mds"` instead of the
real file basename. The reverify closure inside `plan_and_apply_fixes` calls
`lint_str_with`, which sets `diag.file` to `STRING_SOURCE_MAP_LABEL`; the
resulting residual was not relabeled before `emit_result`. Fixed by calling
`set_diag_display_path(&mut residual, filename)` in the `Fixed` and
`PartiallyFixed` match arms of `run_lint_file`, mirroring the existing relabel
in directory mode (which was already correct).

## [0.3.0] — 2026-06-28

### **BREAKING** — Intrinsic output format (removes `--format` flag and `compileMessages` API)
Expand Down
21 changes: 17 additions & 4 deletions crates/mds-cli/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,15 @@ pub(crate) fn parse_cli_value(val: String) -> mds::Value {
/// and correctly fall through to exit code 1. Only `MdsError` values converted via
/// `.map_err(miette::Error::from)` are categorized.
pub(crate) fn exit_code(err: &miette::Error) -> i32 {
if let Some(mds_err) = err.downcast_ref::<MdsError>() {
// AD-211-5: `StdinRelabeledError` is a render-only wrapper around an `MdsError`.
// It must be unwrapped here or wrapping an error to fix its DISPLAY label would
// silently change its EXIT CODE (a wrapped FileNotFound would fall through to 1
// instead of 2). The label swap is not allowed to have behavioural side effects.
let mds_err = err.downcast_ref::<MdsError>().or_else(|| {
err.downcast_ref::<crate::output::StdinRelabeledError>()
.map(crate::output::StdinRelabeledError::inner)
});
if let Some(mds_err) = mds_err {
match mds_err {
MdsError::Io { .. } | MdsError::FileNotFound { .. } | MdsError::NotMdsFile { .. } => 2,
MdsError::ResourceLimit { .. } => 3,
Expand Down Expand Up @@ -699,8 +707,11 @@ pub(crate) fn compile_to_content(
// Stdin: compile from source string using cwd as base_dir.
// read_stdin enforces MAX_FILE_SIZE (PF-004).
let (source, cwd) = read_stdin()?;
// AD-211-1 / AD-211-5: a string-source compile labels its errors `<source>`
// (resolver's SOURCE_LABEL). Relabel to the uniform CLI sentinel here, at the
// boundary that knows the input was stdin.
mds::compile_str_with_deps_opts(&source, Some(&cwd), runtime_vars, opts)
.map_err(miette::Error::from)?
.map_err(|e| crate::output::relabel_stdin_error(&e, &source))?
} else {
// File path: compile_with_deps_opts routes through the resolver which enforces
// MAX_FILE_SIZE and check_symlink (PF-004 compliance).
Expand Down Expand Up @@ -990,7 +1001,8 @@ pub(crate) fn apply_source_map_file_label(
if stdin_label {
for src in &mut sm.sources {
if src == STRING_SOURCE_MAP_LABEL {
*src = "<stdin>".to_string();
// AD-211-3: use the centralised sentinel from output.rs.
*src = crate::output::STDIN_DISPLAY_LABEL.to_string();
}
}
}
Expand Down Expand Up @@ -1159,8 +1171,9 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> {
.with_source_map_base(source_map_base);

let (source, cwd) = read_stdin()?;
// AD-211-1 / AD-211-5: same stdin relabel as `compile_to_content`.
let result = mds::compile_str_with_deps_opts(&source, Some(&cwd), runtime_vars, opts)
.map_err(miette::Error::from)?;
.map_err(|e| crate::output::relabel_stdin_error(&e, &source))?;
if !quiet {
for w in &result.warnings {
crate::output::eprint_warning(w);
Expand Down
13 changes: 10 additions & 3 deletions crates/mds-cli/src/fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,20 +128,27 @@ fn format_source_named(
// ── stdin mode ───────────────────────────────────────────────────────────────

fn run_fmt_stdin(flags: FmtFlags) -> Result<()> {
use crate::output::STDIN_DISPLAY_LABEL;

let FmtFlags { check, diff, quiet } = flags;
let (source, cwd) = read_stdin()?;
let result = format_source_named(&source, Some(&cwd), "<stdin>")?;
// AD-211-3: one definition of the stdin sentinel, shared with lint/check/build.
let result = format_source_named(&source, Some(&cwd), STDIN_DISPLAY_LABEL)?;

if diff {
print_diff(&render_unified_diff(&source, &result.formatted, "<stdin>"))?;
print_diff(&render_unified_diff(
&source,
&result.formatted,
STDIN_DISPLAY_LABEL,
))?;
} else if !check {
// Plain filter mode: formatted content is the output.
write_stdout(&result.formatted)?;
}

if check && result.changed {
if !quiet {
eprintln!("Would reformat: <stdin>");
eprintln!("Would reformat: {STDIN_DISPLAY_LABEL}");
}
std::process::exit(1);
}
Expand Down
Loading
Loading