Skip to content

feat(lsp,wasm): surface MIR-stage lints as warnings - #2873

Merged
slepp merged 2 commits into
hew-lang:mainfrom
gertybotbot:lsp-wasm-mir-lints
Aug 11, 2026
Merged

feat(lsp,wasm): surface MIR-stage lints as warnings#2873
slepp merged 2 commits into
hew-lang:mainfrom
gertybotbot:lsp-wasm-mir-lints

Conversation

@gertybotbot

Copy link
Copy Markdown
Contributor

Closes #2176.

What changed

Both hew-lsp/src/server/analysis.rs and hew-wasm/src/lib.rs stopped at HIR, so MIR-stage lints reached only the CLI. Both now lower to MIR and run run_mir_lints on the module they already lowered for HIR diagnostics — no second lowering — and apply the CLI's suppression policy (// hew:allow(...), LintLevel::Allow, and skipping out-of-range spans that belong to imported modules).

Findings render as warnings on a dedicated path. The existing HIR conversions map every diagnostic to LSP ERROR / wasm "error" unconditionally; reusing them would make a style finding fail an editor buffer the native compiler accepts.

Lowering failures degrade silently. IrPipeline::diagnostics (the hard E_MIR_* move/init errors) are dropped rather than promoted into editor errors, and the already-computed HIR diagnostics are never lost. MIR lints run only when HIR lowering is clean.

Scope correction

The issue names dead_store and clean_counter. clean_counter does not exist at d4793f894run_mir_lints calls detect_dead_stores and nothing else. This PR therefore surfaces dead_store, and the plumbing is keyed off IrPipeline::lint_warnings rather than any specific lint, so clean_counter and any future MIR lint surface in both places with no further work.

Verification

Exit codes captured bare, not through a pipe.

command rc
cargo test -p hew-lsp -p hew-wasm 0 (351 passed, 0 failed)
cargo clippy --all-features --all-targets 0
cargo build -p hew-wasm --release --target wasm32-unknown-unknown 0

Six new tests, each proving discrimination rather than presence — three in the LSP (one at the unit seam, three via protocol_smoke) and three in wasm:

  • the dead store produces the finding, and its severity really is WARNING / "warning", not error;
  • a near-identical for i in 0..n accumulator, where every store is read, stays silent;
  • // hew:allow(dead_store) suppresses it on both surfaces.

Ground truth confirmed at the CLI before any code was written: the positive fixture warns, the accumulator control is silent.

Bundle cost — please read before merging

The issue asks for this to be measured, and it is not free:

bytes
main @ d4793f894 4,771,978 4.55 MiB
this branch 6,482,389 6.18 MiB
delta +1,710,411 +1.63 MiB (+35.8%)

Measured on raw cargo build --release --target wasm32-unknown-unknown from a clean worktree of main in a separate target dir. This is pre-wasm-opt; the shipped playground artifact goes through wasm-pack with -O1, which I could not run locally, so the delivered delta will be smaller than this — but the direction and rough magnitude stand.

The runtime cost is already gated (MIR lowering runs only on buffers that parse, type-check and lower cleanly, so it stays off mid-edit buffers). The code-size cost is from linking hew-mir into the bundle and no gating removes it — that would need a split artifact or lazy second module, which is a bigger design decision than this issue.

If +1.6 MiB pre-opt is unacceptable for the playground, the LSP half stands alone and I'm happy to split the wasm half out into its own PR behind whatever loading strategy you prefer.

Closes hew-lang#2176.

Checker-stage lints already reach the CLI, LSP and playground through the
type checker's `warnings` vector. MIR-stage lints need backward-liveness
dataflow that only exists after MIR lowering, and both the LSP's
`analyze_document` and the wasm `parse_and_type_check` stopped at HIR, so
`dead_store` appeared only via the CLI.

Both surfaces now lower to MIR and run `run_mir_lints` on the module they
already lowered for HIR diagnostics, reusing the CLI's suppression policy
(`hew:allow` directives, `LintLevel::Allow`, out-of-range spans from
imported modules).

Rendered as warnings on a dedicated path. The existing HIR conversions map
every diagnostic to LSP ERROR / wasm "error" unconditionally; reusing them
would have made a style finding fail an editor buffer the compiler accepts.

Lowering failures degrade silently: `IrPipeline::diagnostics` (the hard
`E_MIR_*` errors) are dropped rather than promoted into editor errors, and
the already-computed HIR diagnostics are never lost. MIR lints run only
when HIR lowering is clean, keeping the cost off buffers that are mid-edit
and already erroring.

Scope: `dead_store` is the only MIR lint at this commit -- `run_mir_lints`
calls `detect_dead_stores` and nothing else, so `clean_counter` (named in
the issue) does not exist here yet. The plumbing is keyed off
`IrPipeline::lint_warnings`, so any future MIR lint surfaces in both
places with no further work.
Formatting-only reflow; no logic change.  cargo fmt --all -- --check
now exits 0.
@gertybotbot

Copy link
Copy Markdown
Contributor Author

CI hit two failures at 25fd34c29; pushed 1d935b9d8 for the first. The second is not from this PR and is worth flagging on its own.

1. Clippy & format — mine, fixed. Formatting only. Local cargo clippy --all-targets was clean, but clippy does not cover rustfmt, so four wrapped call sites in hew-lsp/src/server/analysis.rs, hew-lsp/tests/protocol_smoke.rs and hew-wasm/src/lib.rs slipped through. 1d935b9d8 is a pure reflow (+21/−11, no logic change); cargo fmt --all -- --check now exits 0.

2. Build & test (macOS arm64) — pre-existing flake, untouched by this branch.

FAIL hew-cli::supervisor_init_args_e2e supervisor_child_i32_fixed_sleep_oracle_misses_delayed_turn
panicked at hew-cli/tests/supervisor_init_args_e2e.rs:217:5:
the former fixed sleeps must miss a delayed report; got stdout: a=7 b=99

This branch touches only hew-lsp, hew-wasm and hew-mir; it changes nothing in hew-cli and nothing that test exercises (git diff --name-only main...HEAD has no supervisor path). main is green on the same lane at d4793f894.

The test is a negative timing counterfactual: the actor sleeps 100ms inside report(), main waits 50ms after calling it, and the assertion is that stdout comes back empty. That is a ~20ms wall-clock margin held only by the child staying slower than the parent's fixed sleep — under runner contention the delayed report lands and the "must miss" assertion fires. Failure took 0.59s, so it raced rather than hung.

Worth noting this is the one assertion #2855 (test(cli): synchronize supervisor init args oracle) deliberately left on fixed sleeps — it converted the positive oracles to bounded await ask/reply while preserving this counterfactual. It is the last fixed-sleep timing assertion in the file, and it is the one that flaked.

I have not touched it: it is a deliberate control and rewriting it to be schedule-independent (assert on ordering rather than on elapsed wall-clock) is a decision for whoever owns that oracle, not a drive-by in an unrelated PR. Happy to take it as a separate change if wanted. Re-running the lane should pass.

@gertybotbot

Copy link
Copy Markdown
Contributor Author

Reviewed against post-rc1 main (4ce27c7). Cherry-picks cleanly; the no-double-lowering claim holds — collect_hir_diagnostics runs once and the retained HirModule is what goes to MIR, and MIR findings render through their own warning path rather than the unconditional-ERROR HIR mapping. Both are the right calls.

The cost is on the editor hot path, and it isn't measured. did_change calls reanalyze on every buffer change (hew-lsp/src/server/handlers/text_sync.rs:110-114), which after debounce reaches refresh_document_and_dependentsanalyze_document. With this change, every settled parse-clean/type-clean/HIR-clean edit now runs a full HIR→MIR lowering plus the MIR lint pass, and so do all affected open importers.

To be precise about what this is and isn't: it is not redundant HIR lowering, and debounce means it's not literally per-keystroke. It is a new lowering stage added to the interactive path. The PR measured WASM bundle size but supplied no LSP latency or allocation numbers, and bundle size doesn't speak to this. A before/after analyze_document timing on a representative module — ideally one with a few importers open — would settle whether this is free or whether the MIR pass wants to move off the debounced path onto an idle/deferred one.

Stale comment to fix here. hew-mir/src/lower/mod.rs:3797-3800 reads "CLI front end only — the LSP / wasm front ends stop at HIR and never reach MIR (issue #2176)." This PR is what makes that false, so it should update it as part of closing #2176.

Merge before #2878. The two PRs were written against each other's absence and the interaction is invisible to git. Landing this one first keeps its time-qualified scope correction (clean_counter does not exist; run_mir_lints calls detect_dead_stores and nothing else) true as written. #2878 then updates the remaining CLI-only wording. Noted on that PR as well; it separately needs a rebase to compile against rc1.

@slepp
slepp enabled auto-merge (squash) August 11, 2026 16:46
@slepp
slepp merged commit 3ff40cc into hew-lang:main Aug 11, 2026
9 checks passed
@slepp

slepp commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Merged. Keeping the LSP/wasm severity mapping on its own path instead of reusing the HIR-to-error conversion is the right call, and the controls make the tests prove something — silent on the accumulator loop where every store is read, suppressed by the allow directive.

On the bundle size you flagged: accepted, parity with the CLI's diagnostics is worth it. Thanks for surfacing it explicitly rather than burying it.

gertybotbot added a commit to gertybotbot/hew that referenced this pull request Aug 11, 2026
…ture

v0.6.0-rc1 changed `instr_reads_writes` to return a 3-tuple
`(reads, writes, interior_writes)`. `faint.rs` destructured it as a pair
at both call sites, so this branch did not compile against main:

    error[E0308]: mismatched types
      --> hew-mir/src/faint.rs:190:17
      expected a tuple with 3 elements, found one with 2 elements

GitHub reported the branch MERGEABLE/CLEAN and CI was green because the
branch and rc1 never touch the same lines; the break only appears once
the two are combined.

Handle the third element rather than discarding it. An interior write
mutates through a place whose MIR slot bytes do not change (BytesAppend
rewriting its receiver buffer, Drop on a variant place) and never appears
in `writes`, which is exactly how a counter can be observed without the
analysis seeing it:

  - seed interior targets as observable in collect_seeds_and_edges
  - refuse to classify an accumulate step whose counter or temp is
    interior-written

Both are redundant today, since every interior-writing instruction is
impure and its reads are already seeded through the pure_single_dest ==
None path. They are kept explicit because that is a property of the
current `is_pure_value_instr` allowlist rather than an invariant of the
IR: adding an interior-writing instruction to that allowlist would
otherwise let the lint call a counter dead while it is still mutated
through an alias. A lint that tells users to delete code must fail toward
silence.

Also notes hew-lang#2176/hew-lang#2873 as the pending editor-surfacing work in the
run_mir_lints doc comment instead of asserting CLI-only surfacing.

cargo test -p hew-mir --test diagnostics faint: 14 passed
cargo test -p hew-cli --test lint_pass_e2e: 40 passed
cargo clippy -p hew-mir --all-targets: clean
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.

Surface MIR-stage lints (dead_store / clean_counter) in the LSP and wasm/playground

2 participants