Skip to content

Commit 045c070

Browse files
feat(verify): human-readable error messages + grouped tw-verify output (refs #126 — source-line resolution defers to #129) (#159)
## Summary Closes the prose-readability slice of #126: reword all `#[error(...)]` templates and replace `tw-verify`'s `{:?}` debug-dump with a grouped, location-anchored bulleted list. ## Changes **lib.rs — error message rewording (Display-only; no API breaks)** Pattern: `L<n> (<aspect>): <subject is X but should be Y>`. | Variant | Old (excerpt) | New (excerpt) | |---|---|---| | `LinearUsedMultiple` | "Level 10 violation: function 0, param 0 — Linear (own) param loaded 5 times on some path" | "L10 (linearity): function #0 parameter #0 is a Linear (own) resource but is used 5 times on some control-flow path; Linear resources must be consumed exactly once (possible duplication)" | | `ExclBorrowAliased` | "Level 7 violation: function 0, param 0 — ExclBorrow (mut) param aliased (2 simultaneous references)" | "L7 (aliasing): function #0 parameter #0 is an ExclBorrow (&mut) reference but 2 simultaneous borrows occur on some control-flow path; at most one is permitted" | | `ModuleNotIsolated` | "Level 13 violation: {reason}" | "L13 (module isolation): {reason}" | | `LinearImportCalledMultiple` | "Level 10 boundary violation: caller fn 7 calls import 'consume' 2 time(s)…" | "L10 (linearity, cross-module): caller function #7 calls Linear import 'consume' 2 times on some control-flow path; Linear imports must be called at most once on every path" | | `MissingDependentRegions` | "Level 2 violation: typedwasm.access-sites section emitted without companion typedwasm.regions section (MissingDependentCarrier)" | "L2 (region binding): typedwasm.access-sites section is present but the companion typedwasm.regions section is missing — access-site (region, field) keys have nothing to resolve against" | Same treatment for `LinearNotUsed`, `LinearDroppedOnSomePath`, `LinearImportDroppedOnSomePath`, `CapabilitiesError::*`, `AccessSiteError::*`. **VerifyError Vec-wrappers**: replaced raw `{:?}` debug with a `display_first_then_ellipsis` helper that renders as `"3 L7/L10/L13 ownership violation(s) — <first error's full Display>; … and 2 more"`. **bin/tw-verify.rs — grouped output** Replaces the previous flat `{:?}` / `{e}` printouts with grouping: - `OwnershipError`s are bucketed by `func_idx` (with `(module-scope)` bucket for L13). Per bucket, errors are sorted by `param_idx` and printed as ` - <message>`. - `CrossError`s are bucketed by `caller_func_idx`. - L2 / L15 errors get an explicit `FAIL <pass> ({n} violation(s))` header + bulleted list. Sample output (before / after) — see commit message for details. ## Out of scope Source-line resolution ("at `.twasm` line N") **defers to #129** (source maps). The verifier still has only wasm-level indices today; the `#0` parameter / `#N` function notation makes that boundary explicit in the prose. ## Test plan - [x] `cargo test -p typed-wasm-verify` — 65 lib tests pass (62 baseline + 3 new Display tests) - [x] `cargo test -p typed-wasm-verify --all-features` — 80 lib + 10 cross-compat + 5 cross-compat-real all green - [x] `cargo clippy -p typed-wasm-verify --all-features --tests` — no new warnings (the pre-existing `collapsible-match` at `verify.rs:323` remains; not mine) - [x] No test compared raw error strings (all use `matches!` on variants), so rewording is backwards-compatible for the test suite Refs #126. Source-line resolution defers to #129. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b45e01e commit 045c070

2 files changed

Lines changed: 191 additions & 25 deletions

File tree

crates/typed-wasm-verify/src/bin/tw-verify.rs

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
23
//
34
// `tw-verify` — command-line front-end for the typed-wasm verifier.
45
//
@@ -10,16 +11,14 @@
1011
//
1112
// Exit codes: 0 = verified, 1 = a check failed, 2 = usage / I/O error.
1213
//
13-
// This is the executable that makes "verifiable end-to-end by
14-
// `typed-wasm-verify`" a single command rather than a library call.
15-
// The in-tree producer `crates/typed-wasm-codegen` (`tw build`) already
16-
// self-verifies via `verify_from_module`; `tw-verify` is the standalone
17-
// front-end for checking a `.wasm` from any producer (incl. external /
18-
// third-party modules).
14+
// Diagnostic format (#126): violations are printed as a level-prefixed
15+
// bulleted list grouped by function-index, so a regression points at a
16+
// named function rather than a `[…]` debug-dump. Source-line resolution
17+
// ("at .twasm line N") defers to #129 (source maps).
1918

2019
use std::process::ExitCode;
2120

22-
use typed_wasm_verify::verify_from_module;
21+
use typed_wasm_verify::{verify_from_module, OwnershipError, VerifyError};
2322

2423
fn main() -> ExitCode {
2524
let args: Vec<String> = std::env::args().collect();
@@ -54,8 +53,16 @@ fn main() -> ExitCode {
5453
// 2. L7 (aliasing) + L10 (linearity) + L13 (module isolation).
5554
match verify_from_module(&bytes) {
5655
Ok(()) => println!("ok L7/L10/L13 ownership verification"),
56+
Err(VerifyError::Ownership(errs)) => {
57+
print_ownership_violations(&errs);
58+
return ExitCode::FAILURE;
59+
}
60+
Err(VerifyError::Cross(errs)) => {
61+
print_cross_violations(&errs);
62+
return ExitCode::FAILURE;
63+
}
5764
Err(e) => {
58-
eprintln!("tw-verify: ownership verification FAILED: {e}");
65+
eprintln!("tw-verify: verification error: {e}");
5966
return ExitCode::FAILURE;
6067
}
6168
}
@@ -65,7 +72,13 @@ fn main() -> ExitCode {
6572
match typed_wasm_verify::verify_access_sites_from_module(&bytes) {
6673
Ok(errs) if errs.is_empty() => println!("ok L2 access-site verification"),
6774
Ok(errs) => {
68-
eprintln!("tw-verify: access-site violations: {errs:?}");
75+
eprintln!(
76+
"FAIL L2 access-site verification ({} violation(s)):",
77+
errs.len()
78+
);
79+
for e in &errs {
80+
eprintln!(" - {e}");
81+
}
6982
return ExitCode::FAILURE;
7083
}
7184
Err(e) => {
@@ -79,7 +92,13 @@ fn main() -> ExitCode {
7992
match typed_wasm_verify::verify_capabilities_from_module(&bytes) {
8093
Ok(errs) if errs.is_empty() => println!("ok L15 capability verification"),
8194
Ok(errs) => {
82-
eprintln!("tw-verify: capability violations: {errs:?}");
95+
eprintln!(
96+
"FAIL L15 capability verification ({} violation(s)):",
97+
errs.len()
98+
);
99+
for e in &errs {
100+
eprintln!(" - {e}");
101+
}
83102
return ExitCode::FAILURE;
84103
}
85104
Err(e) => {
@@ -91,3 +110,74 @@ fn main() -> ExitCode {
91110
println!("VERIFIED {path}");
92111
ExitCode::SUCCESS
93112
}
113+
114+
/// Group OwnershipErrors by their func_idx (or by "(module-scope)" for
115+
/// the L13 `ModuleNotIsolated` variant which has no func_idx), and print
116+
/// a bulleted list under each header. Within a function, errors are
117+
/// sorted by param_idx then by Display.
118+
fn print_ownership_violations(errs: &[OwnershipError]) {
119+
use std::collections::BTreeMap;
120+
121+
let mut by_func: BTreeMap<String, Vec<&OwnershipError>> = BTreeMap::new();
122+
for e in errs {
123+
let key = match e {
124+
OwnershipError::LinearNotUsed { func_idx, .. }
125+
| OwnershipError::LinearDroppedOnSomePath { func_idx, .. }
126+
| OwnershipError::LinearUsedMultiple { func_idx, .. }
127+
| OwnershipError::ExclBorrowAliased { func_idx, .. } => format!("function #{func_idx}"),
128+
OwnershipError::ModuleNotIsolated { .. } => "(module-scope)".to_string(),
129+
};
130+
by_func.entry(key).or_default().push(e);
131+
}
132+
133+
eprintln!(
134+
"FAIL L7/L10/L13 ownership verification ({} violation(s) in {} location(s)):",
135+
errs.len(),
136+
by_func.len()
137+
);
138+
for (loc, group) in &by_func {
139+
eprintln!(" in {loc}:");
140+
let mut sorted: Vec<&&OwnershipError> = group.iter().collect();
141+
sorted.sort_by_key(|e| match e {
142+
OwnershipError::LinearNotUsed { param_idx, .. }
143+
| OwnershipError::LinearDroppedOnSomePath { param_idx, .. }
144+
| OwnershipError::LinearUsedMultiple { param_idx, .. }
145+
| OwnershipError::ExclBorrowAliased { param_idx, .. } => *param_idx,
146+
OwnershipError::ModuleNotIsolated { .. } => 0,
147+
});
148+
for e in sorted {
149+
eprintln!(" - {e}");
150+
}
151+
}
152+
}
153+
154+
/// Group CrossErrors by caller_func_idx. Print bulleted list per caller.
155+
fn print_cross_violations(errs: &[typed_wasm_verify::CrossError]) {
156+
use std::collections::BTreeMap;
157+
use typed_wasm_verify::CrossError;
158+
159+
let mut by_caller: BTreeMap<u32, Vec<&CrossError>> = BTreeMap::new();
160+
for e in errs {
161+
let caller = match e {
162+
CrossError::LinearImportCalledMultiple {
163+
caller_func_idx, ..
164+
}
165+
| CrossError::LinearImportDroppedOnSomePath {
166+
caller_func_idx, ..
167+
} => *caller_func_idx,
168+
};
169+
by_caller.entry(caller).or_default().push(e);
170+
}
171+
172+
eprintln!(
173+
"FAIL L10 cross-module boundary verification ({} violation(s) in {} caller(s)):",
174+
errs.len(),
175+
by_caller.len()
176+
);
177+
for (caller, group) in &by_caller {
178+
eprintln!(" in caller function #{caller}:");
179+
for e in group {
180+
eprintln!(" - {e}");
181+
}
182+
}
183+
}

crates/typed-wasm-verify/src/lib.rs

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,20 +71,20 @@ impl OwnershipKind {
7171
/// Mirrors OCaml `Tw_verify.ownership_error`.
7272
#[derive(Debug, Clone, PartialEq, Eq, Error)]
7373
pub enum OwnershipError {
74-
#[error("Level 10 violation: function {func_idx}, param {param_idx} Linear (own) param dropped on all paths (must be consumed exactly once)")]
74+
#[error("L10 (linearity): function #{func_idx} parameter #{param_idx} is a Linear (own) resource but is never used; Linear resources must be consumed exactly once on every path")]
7575
LinearNotUsed { func_idx: u32, param_idx: u32 },
7676

77-
#[error("Level 10 violation: function {func_idx}, param {param_idx} Linear (own) param dropped on some paths (per-path min uses = 0; must be consumed on every path)")]
77+
#[error("L10 (linearity): function #{func_idx} parameter #{param_idx} is a Linear (own) resource that is consumed on some control-flow paths but dropped on others; Linear resources must be consumed exactly once on every path")]
7878
LinearDroppedOnSomePath { func_idx: u32, param_idx: u32 },
7979

80-
#[error("Level 10 violation: function {func_idx}, param {param_idx} Linear (own) param loaded {count} times on some path (exactly 1 required; possible duplication)")]
80+
#[error("L10 (linearity): function #{func_idx} parameter #{param_idx} is a Linear (own) resource but is used {count} times on some control-flow path; Linear resources must be consumed exactly once (possible duplication)")]
8181
LinearUsedMultiple {
8282
func_idx: u32,
8383
param_idx: u32,
8484
count: u32,
8585
},
8686

87-
#[error("Level 7 violation: function {func_idx}, param {param_idx} ExclBorrow (mut) param aliased ({count} simultaneous references; at most 1 permitted)")]
87+
#[error("L7 (aliasing): function #{func_idx} parameter #{param_idx} is an ExclBorrow (&mut) reference but {count} simultaneous borrows occur on some control-flow path; at most one is permitted")]
8888
ExclBorrowAliased {
8989
func_idx: u32,
9090
param_idx: u32,
@@ -97,23 +97,23 @@ pub enum OwnershipError {
9797
/// or table — a cross-module shared-state channel outside the
9898
/// declared function-import boundary. Carrier-free (standard
9999
/// import/memory sections only; no ownership-section ABI change).
100-
#[error("Level 13 violation: {reason}")]
100+
#[error("L13 (module isolation): {reason}")]
101101
ModuleNotIsolated { reason: String },
102102
}
103103

104104
/// A cross-module ownership violation found in a caller's function body.
105105
/// Mirrors OCaml `Tw_interface.cross_error`.
106106
#[derive(Debug, Clone, PartialEq, Eq, Error)]
107107
pub enum CrossError {
108-
#[error("Level 10 boundary violation: caller fn {caller_func_idx} calls import '{import_name}' {count} time(s) on some path (Linear param; must be called at most once)")]
108+
#[error("L10 (linearity, cross-module): caller function #{caller_func_idx} calls Linear import '{import_name}' {count} times on some control-flow path; Linear imports must be called at most once on every path")]
109109
LinearImportCalledMultiple {
110110
caller_func_idx: u32,
111111
import_func_idx: u32,
112112
import_name: String,
113113
count: u32,
114114
},
115115

116-
#[error("Level 10 boundary violation: caller fn {caller_func_idx} calls import '{import_name}' on some paths but not others (Linear param dropped on zero-call path)")]
116+
#[error("L10 (linearity, cross-module): caller function #{caller_func_idx} calls Linear import '{import_name}' on some control-flow paths but not on others; calls must be balanced across all paths")]
117117
LinearImportDroppedOnSomePath {
118118
caller_func_idx: u32,
119119
import_func_idx: u32,
@@ -122,18 +122,35 @@ pub enum CrossError {
122122
}
123123

124124
/// Top-level verification failures (parse + verify).
125+
///
126+
/// The `Ownership` and `Cross` variants carry vectors of inner errors
127+
/// whose Display impls each emit a full natural-language explanation; the
128+
/// vector wrappers below render as "N L7/L10 violation(s): <first>; …"
129+
/// so a single-line log line is still informative and the full per-error
130+
/// detail is one `Vec::iter()` away for richer surfaces like `tw-verify`.
125131
#[derive(Debug, Error)]
126132
pub enum VerifyError {
127133
#[error("wasm parse error: {0}")]
128134
Parse(#[from] wasmparser::BinaryReaderError),
129135

130-
#[error("ownership violations: {0:?}")]
136+
#[error("{} L7/L10/L13 ownership violation(s) — {}", .0.len(), display_first_then_ellipsis(.0))]
131137
Ownership(Vec<OwnershipError>),
132138

133-
#[error("cross-module boundary violations: {0:?}")]
139+
#[error("{} L10 cross-module boundary violation(s) — {}", .0.len(), display_first_then_ellipsis(.0))]
134140
Cross(Vec<CrossError>),
135141
}
136142

143+
/// Helper for the vector-variant Display impls: format the first inner
144+
/// error fully, then append "… and N more" if there are more, otherwise
145+
/// just the first. Empty vectors render as "(empty)".
146+
fn display_first_then_ellipsis<E: std::fmt::Display>(errs: &[E]) -> String {
147+
match errs.split_first() {
148+
None => "(empty)".to_string(),
149+
Some((first, [])) => first.to_string(),
150+
Some((first, rest)) => format!("{first}; … and {} more", rest.len()),
151+
}
152+
}
153+
137154
/// Custom-section name carrying ownership annotations. Producer-neutral as
138155
/// of the 2026-05-26 rename; both AffineScript (`Codegen.build_ownership_section`)
139156
/// and Ephapax (`ephapax-wasm`) emit and read this name.
@@ -167,14 +184,14 @@ pub const REGION_IMPORTS_SECTION_NAME: &str = "typedwasm.region-imports";
167184
#[cfg(feature = "unstable-l15")]
168185
#[derive(Debug, Clone, PartialEq, Eq, Error)]
169186
pub enum CapabilitiesError {
170-
#[error("Level 15 violation: function index {func_idx} (entry {entry_idx}) is out of bounds for wasm function section (function_count = {function_count})")]
187+
#[error("L15 (capabilities): typedwasm.capabilities entry #{entry_idx} declares function #{func_idx} but the module only has {function_count} function(s)")]
171188
FuncIdxOutOfRange {
172189
entry_idx: u32,
173190
func_idx: u32,
174191
function_count: u32,
175192
},
176193

177-
#[error("Level 15 violation: capability index {cap_idx} in function entry {entry_idx} (func_idx = {func_idx}) is out of bounds for capability table (capability_count = {capability_count})")]
194+
#[error("L15 (capabilities): typedwasm.capabilities entry #{entry_idx} (for function #{func_idx}) requires capability #{cap_idx} but the capability table only has {capability_count} entries")]
178195
CapabilityIdxOutOfRange {
179196
entry_idx: u32,
180197
func_idx: u32,
@@ -192,24 +209,24 @@ pub enum AccessSiteError {
192209
/// `typedwasm.regions` section — the access-site entries reference
193210
/// `region_id` + `field_id` keys with nothing to resolve them
194211
/// against otherwise.
195-
#[error("Level 2 violation: typedwasm.access-sites section emitted without companion typedwasm.regions section (MissingDependentCarrier)")]
212+
#[error("L2 (region binding): typedwasm.access-sites section is present but the companion typedwasm.regions section is missing — access-site (region, field) keys have nothing to resolve against")]
196213
MissingDependentRegions,
197214

198-
#[error("Level 2 violation: access-site entry {entry_idx}: func_idx {func_idx} is out of bounds for wasm function section (function_count = {function_count})")]
215+
#[error("L2 (region binding): typedwasm.access-sites entry #{entry_idx} declares function #{func_idx} but the module only has {function_count} function(s)")]
199216
FuncIdxOutOfRange {
200217
entry_idx: u32,
201218
func_idx: u32,
202219
function_count: u32,
203220
},
204221

205-
#[error("Level 2 violation: access-site entry {entry_idx}: region_id {region_id} is out of bounds for typedwasm.regions table (region_count = {region_count})")]
222+
#[error("L2 (region binding): typedwasm.access-sites entry #{entry_idx} references region #{region_id} but typedwasm.regions only declares {region_count} region(s)")]
206223
RegionIdOutOfRange {
207224
entry_idx: u32,
208225
region_id: u32,
209226
region_count: u32,
210227
},
211228

212-
#[error("Level 2 violation: access-site entry {entry_idx}: field_id {field_id} is out of bounds for region {region_id}'s field table (field_count = {field_count})")]
229+
#[error("L2 (region binding): typedwasm.access-sites entry #{entry_idx} references field #{field_id} of region #{region_id}, but that region only has {field_count} field(s)")]
213230
FieldIdOutOfRange {
214231
entry_idx: u32,
215232
region_id: u32,
@@ -387,4 +404,63 @@ mod tests {
387404
}
388405
assert_eq!(OwnershipKind::from_byte(99), OwnershipKind::Unrestricted);
389406
}
407+
408+
#[test]
409+
fn ownership_error_display_is_natural_language() {
410+
let e = OwnershipError::LinearUsedMultiple {
411+
func_idx: 3,
412+
param_idx: 1,
413+
count: 5,
414+
};
415+
let s = e.to_string();
416+
assert!(s.starts_with("L10 (linearity):"), "got: {s}");
417+
assert!(s.contains("function #3 parameter #1"), "got: {s}");
418+
assert!(s.contains("used 5 times"), "got: {s}");
419+
assert!(s.contains("exactly once"), "got: {s}");
420+
}
421+
422+
#[test]
423+
fn verify_error_ownership_summary_renders_count_and_first() {
424+
let e = VerifyError::Ownership(vec![
425+
OwnershipError::LinearNotUsed {
426+
func_idx: 0,
427+
param_idx: 0,
428+
},
429+
OwnershipError::ExclBorrowAliased {
430+
func_idx: 1,
431+
param_idx: 0,
432+
count: 2,
433+
},
434+
OwnershipError::ModuleNotIsolated {
435+
reason: "module owns linear memory yet imports memory 'Host.memory'".to_string(),
436+
},
437+
]);
438+
let s = e.to_string();
439+
// Header: total count + level mix
440+
assert!(
441+
s.starts_with("3 L7/L10/L13 ownership violation(s)"),
442+
"got: {s}"
443+
);
444+
// First inner error's full Display is included
445+
assert!(
446+
s.contains("L10 (linearity): function #0 parameter #0"),
447+
"got: {s}"
448+
);
449+
// Tail summarises remainder
450+
assert!(s.contains("… and 2 more"), "got: {s}");
451+
}
452+
453+
#[test]
454+
fn cross_error_display_includes_import_name() {
455+
let e = CrossError::LinearImportCalledMultiple {
456+
caller_func_idx: 7,
457+
import_func_idx: 0,
458+
import_name: "consume".to_string(),
459+
count: 2,
460+
};
461+
let s = e.to_string();
462+
assert!(s.starts_with("L10 (linearity, cross-module):"), "got: {s}");
463+
assert!(s.contains("caller function #7"), "got: {s}");
464+
assert!(s.contains("'consume'"), "got: {s}");
465+
}
390466
}

0 commit comments

Comments
 (0)