Wado

WEP: Unused Diagnostics

Context

The Wado compiler today has no equivalent of rustc's unused_imports, unused_variables, or dead_code lints. Users get no signal when imports, locals, parameters, or private functions are written but never consumed. The infrastructure for warnings exists (Severity::Warning, Logger, CompilerHost::emit_diagnostic), and the elaborator re-architecture (see elaborator rearchitecture) provides the analysis substrate this WEP consumes: per-module bindings (use→def edges, local-binding registry), per-module structural indices (AstIndex), and the new liveness pass that runs between annotate_bodies and reify. The elaborate-time liveness pass is established by this WEP — the rearchitecture WEP commits only to "there is a pass here, its output lives on Semantics"; the policy that determines what is live, what is suppressed, and what is reported is owned here.

The reachability roots for "unused" in Wado are not what rustc uses:

Without explicit lints, three problems persist: silent dead code accumulates across refactors, unused imports pollute namespace resolution and slow type checking, and the LSP cannot offer the "greyed-out unused" hint that editor users expect.

Decision

Add an unused-diagnostics subsystem driven entirely by elaborate-time analysis, producing Severity::Warning diagnostics through the existing CompilerHost. Two passes contribute, both consuming &Semantics and running before reify:

  1. Reference pass — walks ModuleSemantics.bindings (use→def edges) for every user-authored module. Emits UnusedImport, UnusedVariable, UnusedParameter. A binding is reported when no edge has it as the def-side.
  2. Liveness pass — the elaborate-time DCE pass introduced by elaborator rearchitecture. Computes source-level reachability from world-export roots over the cross-module call graph encoded in bindings and the dispatch facts. Emits DeadFunction and DeadGlobal for the source items the computation does not reach (its Liveness::dead_items).

Both passes are guarded by CompilerOptions::unused_diagnostics (on by default). No package-kind toggle is needed: pub and export already name the complete set of package-external roots for every entry-point kind (command, service, lib).

The optimize-time DCE in optimize/dce.rs stays in place as a silent post-monomorphization cleanup — it removes monomorphic instances and inlined-away code that became unreachable through specialisation, none of which are user-source-level dead. It no longer participates in diagnostic emission. The two roles separate cleanly: source-level "you wrote this and nothing uses it" is elaborate-time; "this monomorph fell out after inlining" is optimize-time and silent.

What is in scope (MVP)

Lint Pass Code
UnusedImport Reference UnusedImport
UnusedVariable Reference UnusedVariable
UnusedParameter Reference UnusedParameter
DeadFunction Liveness DeadFunction
DeadGlobal Liveness DeadGlobal
TestOnlyFunction Liveness TestOnlyFunction
TestOnlyGlobal Liveness TestOnlyGlobal

What is out of scope (deferred to follow-up WEPs / PRs)

Reachability roots (package-external boundary)

The pass runs two independent reachability closures over the same call graph:

Production root (seeds E) Source
pub / export items Visibility::Public on a function or global (export ⟹ pub); the package-external boundary
Items satisfying world-export contracts Functions whose name and signature satisfy a world export (command / service / lib); identified during annotate
#[export]-attributed items Raw Wasm exports
Items in wasm_module_sources re-exports Bridged Wasm module exports
impl / trait methods Seeded live as call-graph intermediaries (method-level dead detection deferred)

Each user-authored free function / global is classified by membership:

test blocks are world-independent roots of T, never E: a function reached only from a test is therefore reported, not silently kept alive — the behaviour Rust's test-build masks. Reify still gates emission on live_items = E ∪ T, so test-reachable code compiles (in non-test worlds the optimize-time DCE drops it).

The #[export] / world-export part of the production root set matches the existing optimize-time DCE root set in optimize/dce.rs (compute_reachable_from_entries); the liveness pass restates them at the source level so reachability can be computed before TIR is emitted. Synthesised items — CM binding wrappers, effect-dispatch helpers, monomorphisation clones, auto-derived impls — are not in Semantics at all (they are born during synthesis / monomorphize) and therefore cannot appear in either the live set or the unused set. The optimize-time DCE continues to remove them silently.

pub (Visibility::Public) is a root, exactly like export. The one divergence from optimize/dce.rs: a Wado-to-Wado dependency is today always consumed from source (see Package Manifest §"Wado-to-Wado Optimization"), so compute_reachable_from_entries does not root pub yet — the consumer's own build supplies real reachability from its own roots. Preserving pub for a standalone published .wasm (provider metadata) is deferred.

internal (Visibility::Internal) is never a root: it is package-internal visibility only, so an unreferenced internal fn is dead code regardless of entry-point kind.

Stdlib exclusion

Functions, imports, and locals whose ModuleSource is Core, Wasi, Builtin, or Wasm are excluded from both passes when the entry module is user-authored. Stdlib never emits unused diagnostics into the user's build output.

Generated-module exclusion

A module carrying the #![generated] inner attribute (Gale parser output, wado-from-idl bindings, and any other machine-emitted source) is excluded from the lint entirely: it is never hand-edited, so flagging its unused items is pure noise. The module still seeds the liveness closure, so items it calls stay live. This is the principled alternative to embedding #[allow(dead_code)] in inlined runtime support — generated parsers carry the marker already, so no attribute is baked into every emitted file.

Suppression

Implementation

Source files

File Description
wado-compiler/src/elaborator/liveness.rs Owned by the elaborator rearchitecture. Computes source-level reachability and returns a Liveness value (live SymbolKey set, plus per-module unused-item / unused-binding lists).
wado-compiler/src/elaborator/liveness/unused.rs This WEP's contribution: walks the Liveness output plus ModuleSemantics.bindings and emits the five diagnostics. Stdlib exclusion, underscore suppression, and root policy live here.
wado-compiler/src/compiler_host.rs Adds Code::UnusedImport, UnusedVariable, UnusedParameter, DeadFunction, DeadGlobal and their Display mappings.
wado-compiler/src/logger.rs Adds Logger::warn_at(code, message, span, file) for span-bearing warnings.
wado-compiler/src/lib.rs Adds CompilerOptions::unused_diagnostics. Invokes the diagnostic emitter once the elaborator has produced Semantics and its Liveness.
wado-compiler/src/ast_index.rs Adds is_param(id) predicate (1-bit table) so the reference pass can distinguish UnusedVariable from UnusedParameter.
wado-cli Adds --no-unused flag.
wado-lsp Invokes the diagnostic emitter from Engine::diagnostics. Renders UnusedImport / UnusedVariable / UnusedParameter as Hint-severity unused-token decorations as well.

Algorithms

Unused imports

Walk every UseDecl in user-authored modules. For each UseItem:

If every item in a single UseDecl is unused, emit one diagnostic at UseDecl.span; otherwise emit per-item diagnostics at AstIndex::name_span_of(item.id).

Prerequisite check: confirm elaborator records UseItem::Simple.id as a use-site in references during use-decl resolution. If not, add the single record_reference_to_decl call there.

Unused variables and parameters

Build a single inverted index from Semantics::iter_references():

use_count: IndexMap<SymbolKey, usize>
for (_use_key, def_key) in annotated.iter_references():
    *use_count.entry(def_key).or_insert(0) += 1

Walk Semantics::locals. For each (key, sym) whose kind is Variable:

Dead functions and globals

The elaborate-time liveness pass computes the closure of reachable source items from the root set (see Reachability roots) over the source-level call graph. The graph mechanism — node set, the per-module enclosing-item index, the edge sources (ModuleBindings.references plus the dispatch facts in TypeAnnotations), precise FunctionRef resolution, and the fail-loud treatment of generic trait dispatch — is owned by the elaborator rearchitecture (see its DCE / Liveness section). The closure is a single BFS over a static graph, so there is no fixed-point loop and no per-iteration dedup.

The emitter walks Liveness::dead_items and reports DeadFunction or DeadGlobal for each user-authored entry. Stdlib modules and synthesised items (which are not in Semantics at all) never enter dead_items.

Span: Semantics::name_span_of(key), with fallback to the item's span for declarations without a narrow name span.

Globals dropped during optimize-time DCE (e.g., constants inlined into all callers) do not surface as DeadGlobal because they were live at the source level — that is the intended separation.

Pipeline integration

parse → bind → load → analyze
   ↓
annotate_decls
   ↓
annotate_bodies   (per module, populates ModuleSemantics)
   ↓
liveness          (source-level reachability → Liveness on Semantics)
   ↓
emit unused diagnostics
   ↓                                    ↘
reify                                    LSP terminates here
   ↓
monomorphize → lower → optimize         (optimize-time DCE runs silently)
   ↓
codegen

Both the reference pass and the diagnostic emission against Liveness sit immediately after liveness. The LSP path consumes the same emitter (it reads diagnostics from Engine::diagnostics), so users get the warnings in the editor before reify runs. Batch compilation continues into reify, which skips items absent from Liveness::live_items.

compile_with_options gates the emitter on CompilerOptions::unused_diagnostics. Engine::diagnostics does the same.

Migration plan

The reference-pass diagnostics (UnusedImport, UnusedVariable, UnusedParameter) depend only on ModuleSemantics.bindings, which the existing elaborator already populates; they can land before the elaborator rearchitecture completes. The liveness-pass diagnostics (DeadFunction, DeadGlobal) depend on the new liveness pass and land with stage 6 of the rearchitecture (see elaborator rearchitecture).

Phase 1 — diagnostic plumbing

Phase 2 — reference pass

Phase 3 — liveness pass and DeadFunction / DeadGlobal

Phase 3b — reify gating (Design B)

Reify gating is the input-shrinking win (monomorphize / lower / optimize see only the reachable closure). The first attempt gated inside reify and broke two contracts, both now understood:

  1. Diagnostics in dead code. Wado reports errors in unreachable code (effect / stores / purity / world-conformance). Those checks run on the emitted TIR after reify, so dropping a dead function suppressed its error. The fix is structural — produce those diagnostics from Semantics (AST + recorded facts) so they see the whole program regardless of what reify emits. This also lets the LSP surface them (it builds no TIR). See the rearchitecture WEP's DCE / Liveness note.
  2. A cross-module reachability gap — a dropped-live function the source-level graph missed (cross_module_type_identity ICE'd at WIR build). The graph must be a sound over-approximation of reachable.

Gating is therefore disabled until both are addressed:

Batch switch — landed (Semantics effect check), 3 fixtures still red:

compile_with_options now runs check_effects_semantic on the whole program and the TIR check_effects is removed. The full E2E suite is at 2836 / 2842 (6 failures = 3 fixtures × 2 opt levels). Resolved on the way (each was also an LSP false positive / under-report):

Async effect checking — resolved with no new rule:

Investigation (temporarily restoring the old TIR check) established that the async failures were not a pre/post-synthesis artefact: the new check runs pre-synthesis and applies the same resource-effect rule the old check did. WaitableSet / ErrorContext are simply real capabilities the failing handlers used (WaitableSet::new(), ErrorContext::new()) without holding — true positives. They are not reachable in the propagation closure from anything an HTTP handler holds (WaitableSet appears in an operation signature only in Subtask::join(set: &WaitableSet)), so the new check correctly flagged them. The old check passed them only through a resource-specific leniency.

The accurate failure count with async checked is 3 fixtures (cm_waitable_set_new, cm_waitable_set_poll, cm_error_context). The ~75 other task return handlers pass: they only touch Future / Response / Stream, held via signature propagation. So no task return rule is needed — the fix is just to declare the capability the three handlers actually use.

Phase 1b design — effect check on Semantics

effect_check.rs today walks the emitted TIR. The port reimplements the same logic over the parsed AST plus the facts annotate already records, so it runs after annotate_bodies and sees every function — dead or live — for both batch and LSP. To keep every commit green, land it incrementally:

Data mapping (TIR read → Semantics source):

TIR read (today) Semantics source
func.effects TypeAnnotations.function_effects[fn_key]
func.benign_effects #[benign(E)] on the AST Function.attrs
func.is_ambient #[ambient] on the AST Function.attrs
func.task_return_type function_task_returns[fn_key]
param.type_id fn_param_types[fn_key]
func.return_type fn_return_types[fn_key]
is_cm_binding / is_dispatch_wrapper n/a — synthesised, not in Semantics; skip rule disappears
struct field / variant payload TypeIds struct_field_types[struct_key], variant decls
resource decls AST Item::Resource per module
call-site FunctionRef the dispatch facts (below)
ResolvedType lookups the TypeTable snapshot on Semantics

Call-site callee resolution (the body walk visits the AST; each call kind maps to the fact that names its callee):

AST call site Fact → callee
free call f() references[callee_ident_id] → fn SymbolKey
recv.m() method_dispatch[call_id].function_ref
T::m() / Self::m() static_method_dispatch[call_id].function_ref
operator / index operator_dispatch / index_assign_dispatch
? / From from_call_facts[expr_id]
for x of e for_of_iterator[for_id] (into_iter / next)
indirect call (closure value) ResolvedType::Function.effects of the callee type

Algorithm (unchanged from the TIR version, restated on facts):

  1. Build the effect index keyed by (module, mangled_name)mangled_name from method_names[method_key].mangled for methods, the source name for free functions — carrying effects, is_ambient, benign, is_method, and param TypeIds.
  2. Build resource_names, struct_fields, variant_payloads, and the propagation closure (build_propagation_closure) from the same decl and type data, now read off Semantics rather than TirModule.
  3. For each user function: current_effects = expand(declared effects + signature_resources(param/return/task-return types) + benign).
  4. Walk the AST body; at each call site resolve the callee via the table above, compute get_function_effects (callee effects + resource effect for direct resource methods − benign, with effect-param resolution), and report EffectError for any effect not in current_effects.

Stores (1c) and purity (1d) follow the same data mapping; stores reuses the ref-parameter detection (ResolvedType::Ref / MutRef over fn_param_types).

Phase 4 — CLI wiring and reference pass

Test plan

E2E fixtures (under tests/fixtures/):

Each carries the appropriate stderr_contains entries in its __DATA__ block. wado-lsp integration tests cover the Semantics-layer lints through the diagnostics path.

Consequences

Benefits

Costs

Risks and mitigations