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:
pubandexportboth cross the package boundary.exportadditionally crosses the CM boundary (export ⟹ pub); apub-only item reaches other Wado packages natively, with no CM representation (see Visibility). Both apply uniformly acrosscommand,service, andlibentry points: alibpackage exposes itspub/exportitems as its public API (see Package Manifest).internalis package-internal visibility. Aninternal fnthat no caller invokes inside the package is dead code, even in alibpackage — it is not part of the package's public API surface.- The standard library is a separate package whose
pub/exportitems are visible from user code under special rules. Stdlib must never receive user-facing unused warnings. - Synthesised functions (CM bindings, effect-dispatch wrappers, monomorphisation clones, auto-derived impls) are not source-authored and must not be reported regardless of reachability.
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:
- Reference pass — walks
ModuleSemantics.bindings(use→def edges) for every user-authored module. EmitsUnusedImport,UnusedVariable,UnusedParameter. A binding is reported when no edge has it as the def-side. - 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
bindingsand the dispatch facts. EmitsDeadFunctionandDeadGlobalfor the source items the computation does not reach (itsLiveness::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)
unused_mut,unused_type_param,unused_assignmentdead_type,dead_trait_impl,unreachable_pattern,dead_closure_functor- The
unusedlint group and#[deny(...)](only#[allow(dead_code)], item- and module-level, is implemented — see Suppression) - Per-
UseItem::InterfaceFunctionsgranularity (function-level inside an interface import) - Workspace-aware multi-package root computation
- Preserving
pub-only items inoptimize/dce.rsfor the not-yet-implemented provider-metadata standalone-.wasmpublish path (see Reachability roots)
Reachability roots (package-external boundary)
The pass runs two independent reachability closures over the same call graph:
E— the production closure, seeded by the roots below.T— the test closure, seeded bytestblocks only.
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:
∈ E→ live (no diagnostic).∈ T \ E→ test-only: used by tests but not production →TestOnlyFunction/TestOnlyGlobal. Reported in non-test builds only (duringwado testthe reachingtestblocks are the point, so it would be noise).∉ E ∧ ∉ T→ dead →DeadFunction/DeadGlobal.
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
- Variables and parameters whose source name begins with
_are silent. This is the same convention rustc uses and is whatwado formatis expected to produce when auto-fixing. Wildcardimports (use _ from "...") are silent — they exist for side effects.#[allow(dead_code)]on a function or global waives itsDeadFunction/DeadGlobal/TestOnlyFunction/TestOnlyGloballint — for unavoidable unused items (e.g. a library module being built up ahead of the export that will reach it). The attribute name matches rustc'sdead_codelint.#![allow(dead_code)]as a module inner attribute waives the lint for every item in the file — the idiom for test-helper files whose functions exist only to backtestblocks. The liveness pass still records the item's call-graph edges; only its candidacy for the lint is dropped. The broaderunusedlint group (and#[deny(...)]) is deferred to the follow-up that lands the reference pass.
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:
Simple { id, .. }andNamespace { name, .. }: reportUnusedImportwhen no entry inSemantics::referenceshas the item's use-siteSymbolKeyas itsuse_keyand no entry resolves to the item's importeddef_key.Wildcard: skipped (side-effect imports).InterfaceFunctions: reported only at the whole-UseDecllevel for MVP; per-function granularity is deferred.
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:
- Skip if
sym.namestarts with_. - Skip if
use_count.get(&key) > 0. - Determine
is_paramviaAstIndex::is_param(key.ast_id)on the module's index. EmitUnusedParameterorUnusedVariableatsym.span(which is thename_spanrecorded byrecord_local_symbol).
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
- [x] Add
Code::DeadFunction,DeadGlobaland theirDisplaystrings. (UnusedImport/UnusedVariable/UnusedParameterland with the reference pass.) - [x] Add
Logger::warn_at(code, message, span). - [x] Add
CompilerOptions::unused_diagnostics(defaulttrue). - [ ] Add
AstIndex::is_param(id)and tests (reference pass).
Phase 2 — reference pass
- [ ] Confirm the elaborator records
UseItem::Simple.idas a use-site; patch if missing. - [ ] Implement the reference-pass emitter (imports, locals, params) against
Semantics. - [ ] Wire into
compile_with_optionsandEngine::diagnostics. - [ ] Add fixtures under
tests/fixtures/unused_*.wado; touchtests/e2e.rs.
Phase 3 — liveness pass and DeadFunction / DeadGlobal
- [x] Land
elaborator/liveness.rs: the enclosing-item index (an AST id-collector), edge collection overreferences, the BFS, and theLivenessfield onSemantics. Free functions and globals are precise; every method is seeded live as an intermediary (so the free-function reachability stays sound without the operator /?/ for-of dispatch edges). - [x] Implement the emitter (
DeadFunction,DeadGlobal) consumingLiveness::dead_items; tests intests/unused_diagnostics.rs. - [x] Two-closure 3-way classification (
live/test-only/dead): test blocks seedTonly, neverE;live_items = E ∪ T(reify gating unchanged). AddsTestOnlyFunction/TestOnlyGlobal, emitted in non-test builds. Unit tests intests/unused_diagnostics.rs. - [x] E2E warning assertions:
warnings_contains/warnings_not_containsfixture-spec fields (compile_capturing_warningsin the harness);dead_fn_*/dead_global_*fixtures cover dead and test-only. - [ ] Package-wide analysis so
Tis populated outside the entry module (load the whole package viawado testdiscovery;wado checkpackage mode). Until thentest-onlyonly surfaces fortestblocks in the compiled entry's own module graph.
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:
- 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. - A cross-module reachability gap — a dropped-live function the
source-level graph missed (
cross_module_type_identityICE'd at WIR build). The graph must be a sound over-approximation of reachable.
Gating is therefore disabled until both are addressed:
- [x] 1b.
check_effects_semantic(&Semantics)(AST + facts) landed and wired into the LSP (Engine::diagnostics). Covers free / method / static / indirect calls, declared effects, signature resources (incl. struct-nested), resource injection, the propagation closure, effect-parameter resolution, and#[benign]. Batch still runs the TIRcheck_effects(see "Batch switch — blocked" below). - [ ] 1c. Port
check_storeslikewise. - [ ] 1d. Port
check_default_puritylikewise. - [ ] 1a. Move the world-export conformance check (
export-required, param / return mismatch) off the gated TIR — read the entry module's AST /Semanticsincompile_with_options(where the target world is known). Record the world-export root set onSemanticsfor the liveness roots. - [ ] Close the liveness graph's cross-module gaps (foreign-keyed
referencesfromwith_module_perspective, inlined-foreign-AST, namespace imports, test-world roots).
- [ ] Close the liveness graph's cross-module gaps (foreign-keyed
- [ ] Re-enable reify gating on
Liveness::live_itemsand validate the full E2E suite green (fail-loud: a dropped-live item ICEs).
- [ ] Re-enable reify gating on
- [ ] The optimize-time DCE never carried a user-facing diagnostic role, so there is nothing to retire — it stays as silent cleanup as designed.
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):
- [x] Stdlib per-module facts (
effect_ops,function_effects,fn_param_types, …) were not seeded from the snapshot, so the propagation closure missed stdlib effect→resource propagation (Stdout → Stream → StreamWritable). The snapshot now clones each stdlib module'stypes. - [x] Bundled stdlib
.wadofiles load asModuleSource::Localwith awasi:/core:scheme path;is_user_authorednow excludes them. - [x] Effect identity is non-canonical:
EffectRef::Concrete.module_sourcereflects the recording module's import perspective (userwith Stdout→ entry module; stdlib →wasi:cli). Effects are now canonicalised by name through the declaration index before comparison / closure lookup. - [x] Effect-handler scopes:
with H => … do { … }grantsHto the do-block body (pushed / popped around the body walk). - [x] Indirect calls through a function-typed parameter resolve the callee via the enclosing function's parameter types.
- [x] Variant-payload-nested resources: a
(module, variant) → payload typesmap feeds the closure andsignature_resources. - [x] Default-expression purity was a side effect of the non-canonical
bug, fixed by canonicalisation (
check_default_puritystays on TIR and fires again).
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.
- [x] Remove the interim
async-skip;asyncbodies are effect-checked. - [x] Declare the used capability on the three handlers (
with WaitableSet/with ErrorContext); HTTP handlers accept awithclause. Full E2E green.
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:
- First wire the new checker into the LSP path (
Engine::diagnostics) only; the batch path keeps the TIR-basedcheck_effects. No double emission (LSP builds no TIR), existing fixtures unchanged, and the LSP gains effect diagnostics immediately. - Once it reaches diagnostic parity (validated against the
effect_check_*fixtures), switch the batch path over and delete the TIR-based checker. Reify gating (Phase 3b.3) can then turn on.
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):
- Build the effect index keyed by
(module, mangled_name)—mangled_namefrommethod_names[method_key].mangledfor methods, the source name for free functions — carrying effects,is_ambient,benign,is_method, and paramTypeIds. - Build
resource_names,struct_fields,variant_payloads, and the propagation closure (build_propagation_closure) from the same decl and type data, now read offSemanticsrather thanTirModule. - For each user function:
current_effects = expand(declared effects + signature_resources(param/return/task-return types) + benign). - 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 reportEffectErrorfor any effect not incurrent_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
- [ ]
wado-cli: add--no-unusedflag forcompile/run/serve/dump. - [ ] Reference pass:
UnusedImport/UnusedVariable/UnusedParameter. - [ ] Method-level dead detection (the dispatch edges; stop seeding every method live).
Test plan
E2E fixtures (under tests/fixtures/):
unused_import_basic.wadounused_import_partial.wadounused_import_wildcard_silent.wadounused_var_basic.wadounused_var_underscore_silent.wadounused_param_basic.wadounused_param_underscore_silent.wadodead_fn_private.wadodead_fn_export_root.wadodead_fn_generic.wadodead_fn_test_world.wadodead_fn_pub_is_root.wado(apub fnwith no in-package caller is not dead)dead_fn_internal_no_caller.wado(aninternal fnwith no in-package caller is dead)dead_fn_cascade_via_global.wado(function reachable until a dead global is removed)dead_global_basic.wadodead_global_used_by_dead_fn.wado(cascade across iterations)unused_stdlib_no_report.wado
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
- Users get immediate, location-precise feedback on dead imports, locals, parameters, and private functions, matching the experience every rustc user expects.
- LSP "greyed-out unused" works without extra effort because the
diagnostic emitter consumes the same
Semantics+Livenessthat batch compilation produces. - The optimize-time DCE keeps doing its silent removal job. Source- level dead code is reported once, at the right phase, with the right span; post-optimization specialisation noise stays internal.
reifyconsumesLiveness::live_itemsto skip dead items, reducing the input size ofmonomorphize/lower/optimize.
Costs
- New warnings will fire on every existing Wado source file the first
time CI runs the new compiler. The MVP defaults
unused_diagnosticstotrue; a one-time cleanup pass on the in-tree examples and fixtures is part of Phase 2 / Phase 3 landing. - The reference-pass emitter and the liveness-pass emitter are two
small additional walks over
Semanticsper compilation. Both are linear in the size of the source-level call graph and cheap relative to the elaborator itself.
Risks and mitigations
- Risk: a generic function with all monomorphisations unreachable is
reported once at the source level, but rare edge cases
(specialisation through effect dispatch) could leave one
monomorph reachable from a path that the source-level call graph
in
bindingsdoes not see. Mitigation: such cases would also break the source-levelbindingsedge model used by goto-def and find-references — fix them there. The liveness pass does not invent its own graph. - Risk: the source-level call graph misses a runtime-reachable item
(e.g. dispatch through a trait object built from a string at
runtime — not yet a Wado feature, but a future hazard). Mitigation:
livenessonly consults edges recorded duringannotate; adding new edge kinds is part of the language feature that introduces them.
