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 |
Items in wasm_module_sources re-exports |
Bridged Wasm module exports |
| impl methods | Seeded live as call-graph intermediaries (method-level dead detection deferred) |
| Declared methods no dispatch fact names | An interface / resource operation, or a method carrying a parameter default. See below |
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 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.
A declared method carrying a default body is an edge rather than a root, so a
free function that only an unreached default body calls is dead.
Elaborator Architecture
§Liveness says which dispatch fact it rides, why the two kinds above have none,
and what the rule leaves open for a library's pub trait.
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/integration/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/integration/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. - [x] LSP wiring: the pure
lint_diagnostics(&Semantics, unused, is_test_world)builder is shared by the batch path andEngine::diagnostics, which applies it at query time (toggle stays live viaEngine::set_unused_diagnostics) and keeps only entry-document items. The LSP runs the command world, so test-only items are reported. Diagnostics carry the LSPUnnecessarytag. Tests inwado-lsp/tests/diagnostics.rs.
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). Each effect is now resolved to its declaration where it is written. - [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.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.
Known gaps
- The
dead_codelint sees only thetestblocks of the modules the compilation loads. A function whose only user is a test in a file the compilation does not load, such as a test file that imports it, is reported "never used" rather than "only used by tests".
