diff --git a/crates/synth-cli/src/main.rs b/crates/synth-cli/src/main.rs index f6aae872..12c11551 100644 --- a/crates/synth-cli/src/main.rs +++ b/crates/synth-cli/src/main.rs @@ -543,6 +543,23 @@ enum Commands { /// grows down from the top of SRAM. #[arg(long, value_name = "BYTES")] stack_size: Option, + + /// #952: on the `--all-exports` path, declining a function the module + /// EXPORTS now exits non-zero by default — a build that gates on `$?` + /// (every build) must not ship an object silently missing a public + /// entry point. Declining a non-exported internal helper (pulled in + /// only for reachability, #235) never fails the build either way; + /// that skip is routine and stays a warning. Pass this flag to + /// restore the pre-#952 exit-0 behavior for callers who genuinely + /// want the partial object — e.g. an `--all-exports` corpus sweep + /// over many modules, where per-function declines are expected and + /// counted downstream rather than treated as a build failure. + /// Inert on the single-function `--func-index`/`--func-name` path, + /// which already hard-errors `?`-style on any decline of the one + /// requested function — there is nothing for this flag to loosen + /// there. + #[arg(long)] + allow_skipped_exports: bool, }, /// Disassemble an ARM ELF file (e.g., synth disasm output.elf) @@ -669,6 +686,7 @@ fn main() -> Result<()> { stack_layout, stack_size, proven_safe, + allow_skipped_exports, } => { // #882: track whether `-b/--backend` was given explicitly (the // mismatch diagnostics differ: an explicit backend lists the @@ -756,6 +774,7 @@ fn main() -> Result<()> { volatile_segments, stack_layout, proven_safe, + allow_skipped_exports, )?; // If --link requested, invoke the cross-linker @@ -1611,6 +1630,9 @@ fn compile_command( // VCR-MEM-004 (#901): path to scry's safe-accesses.json. Consumed only on // the --all-exports module path; `None` (the default) is byte-identical. proven_safe: Option, + // #952: opt-in to keep exiting 0 when a REQUESTED export is declined on + // the --all-exports path (default is now a hard, non-zero-exit refusal). + allow_skipped_exports: bool, ) -> Result<()> { // Validate backend exists let registry = build_backend_registry(); @@ -1683,6 +1705,7 @@ fn compile_command( volatile_segments, stack_layout, proven_safe, + allow_skipped_exports, ); } @@ -2801,6 +2824,13 @@ fn compile_all_exports( stack_layout: StackLayout, // VCR-MEM-004 (#901): scry's safe-accesses.json, or None (byte-identical). proven_safe: Option, + // #952: `--allow-skipped-exports` — keep exiting 0 when a REQUESTED + // export is declined (the pre-#952 behavior). Default false: a declined + // export is now a hard refusal, so a build gating on `$?` cannot ship an + // object silently missing a public entry point. Never affects a declined + // non-exported internal helper (#235 reachability) — that stays a + // warning-only skip either way. + allow_skipped_exports: bool, ) -> Result<()> { let path = input.context("--all-exports requires an input file")?; @@ -3535,7 +3565,13 @@ fn compile_all_exports( // Compile each function via the selected backend let mut compiled_funcs = Vec::new(); - let mut skipped_funcs: Vec<(String, String)> = Vec::new(); + // #952: the third field marks whether the skipped function is a REQUESTED + // export (`func.export_name.is_some()`) rather than an internal helper + // pulled in only for reachability (#235). That distinction is what the + // exit-code gate below keys on — a build gating on `$?` must fail when a + // named public entry point silently vanished, but not when an unexported + // implementation detail did. + let mut skipped_funcs: Vec<(String, String, bool)> = Vec::new(); // #778 phase 3: collect per-function WCET intermediates (own-body cycles + // direct call sites, or a decline) and a `func_` → position map, so the // module-level composer can resolve direct calls across the call graph AFTER @@ -3636,7 +3672,11 @@ fn compile_all_exports( code for it rather than a silent miscompile (GI-FPU-001, #369)", backend.name() ); - skipped_funcs.push((name.clone(), format!("unsupported operator: {reason}"))); + skipped_funcs.push(( + name.clone(), + format!("unsupported operator: {reason}"), + func.export_name.is_some(), + )); continue; } // VCR-PERF-002 Phase 2 (#494): fact-spec — behind SYNTH_FACT_SPEC and @@ -3745,7 +3785,7 @@ fn compile_all_exports( backend.name(), e ); - skipped_funcs.push((name.clone(), e.to_string())); + skipped_funcs.push((name.clone(), e.to_string(), func.export_name.is_some())); continue; } }; @@ -3889,7 +3929,7 @@ fn compile_all_exports( all_exports.len(), skipped_funcs .iter() - .map(|(n, _)| n.as_str()) + .map(|(n, _, _)| n.as_str()) .collect::>() .join(", ") ); @@ -3901,6 +3941,43 @@ fn compile_all_exports( ); } + // #952: a declined function the module EXPORTS — as opposed to an + // internal helper pulled in only for #235 reachability — is not a + // routine skip. Before this gate, the compile above still exited 0: a + // build gating on `$?` (every build) accepted the object and shipped it + // with a public entry point silently missing. Refuse loudly instead, + // unless the caller opted in with `--allow-skipped-exports` (the + // `--all-exports` corpus-sweep shape, where per-function declines are + // expected and counted downstream rather than treated as a build + // failure). Placed AFTER the `compiled_funcs.is_empty()` bail above so a + // module whose ONLY export was skipped keeps that existing message + // ("nothing to emit") rather than being relabeled here. + if !allow_skipped_exports { + let skipped_exports: Vec<&str> = skipped_funcs + .iter() + .filter(|(_, _, is_export)| *is_export) + .map(|(n, _, _)| n.as_str()) + .collect(); + if !skipped_exports.is_empty() { + let total_exports = all_exports + .iter() + .filter(|f| f.export_name.is_some()) + .count(); + anyhow::bail!( + "#952: {} of {} requested export(s) were skipped (not in the \ + output object): {}. Exiting non-zero rather than shipping an \ + object that is silently missing a public entry point — a build \ + gating on `$?` would otherwise accept it. Pass \ + --allow-skipped-exports if the partial object is what you \ + want (e.g. an --all-exports sweep over a corpus, where \ + per-function declines are expected and counted downstream).", + skipped_exports.len(), + total_exports, + skipped_exports.join(", ") + ); + } + } + // Check if any function has relocations (import calls) let has_relocations = compiled_funcs.iter().any(|f| !f.relocations.is_empty()); diff --git a/crates/synth-cli/tests/call_indirect_275_selfcontained.rs b/crates/synth-cli/tests/call_indirect_275_selfcontained.rs index 91ef1f98..4efe0b03 100644 --- a/crates/synth-cli/tests/call_indirect_275_selfcontained.rs +++ b/crates/synth-cli/tests/call_indirect_275_selfcontained.rs @@ -100,6 +100,13 @@ fn test_275_selfcontained_a32_still_declines_loudly() { path.to_str().unwrap(), "--target", "cortex-r5", + // #952: `entry` is this fixture's SOLE export, and it is the + // function this test expects to be loud-skipped (the residual + // A32 #275 decline). Since v0.57 a declined REQUESTED export + // exits non-zero by default; this test wants the partial object + // (to inspect its symtab below), which is exactly the opt-in + // `--allow-skipped-exports` case, not a regression. + "--allow-skipped-exports", "-o", elf, ]) diff --git a/crates/synth-cli/tests/i64_call_arg_decline_929.rs b/crates/synth-cli/tests/i64_call_arg_decline_929.rs index 67750a80..d22e591c 100644 --- a/crates/synth-cli/tests/i64_call_arg_decline_929.rs +++ b/crates/synth-cli/tests/i64_call_arg_decline_929.rs @@ -61,6 +61,13 @@ fn compile(tag: &str, wat: &str) -> (bool, String, String) { "cortex-m3", "--all-exports", "--relocatable", + // #952: `f` is the sole export in the two decline fixtures below, + // and it is exactly the function #929 declines — since v0.57 that + // exits non-zero by default. These tests want the decline (and + // its diagnostic) with the compile still reporting success, which + // is the opt-in `--allow-skipped-exports` case. A no-op on + // I32_ONLY, where nothing is skipped. + "--allow-skipped-exports", "-o", obj.to_str().unwrap(), ]) diff --git a/crates/synth-cli/tests/rv32_local_promo_flip_472.rs b/crates/synth-cli/tests/rv32_local_promo_flip_472.rs index dc6e40f4..a476938e 100644 --- a/crates/synth-cli/tests/rv32_local_promo_flip_472.rs +++ b/crates/synth-cli/tests/rv32_local_promo_flip_472.rs @@ -77,6 +77,14 @@ fn compile(rel: &str, out: &str, promo_on: bool) -> Vec { "rv32imac", "--all-exports", "--relocatable", + // #952: `gust_kernel.wasm`'s `gust_poll` export already declines + // on RV32 (unrelated pre-existing gap: `GlobalGet` unsupported in + // the RV32 skeleton) in BOTH arms of this corpus sweep. Since + // v0.57 that exits non-zero by default; this gate compares + // per-function BYTE SIZES across the arms and is unaffected by a + // function absent from both, so the partial object is exactly + // what it wants. + "--allow-skipped-exports", ]) .output() .expect("run synth compile"); diff --git a/crates/synth-cli/tests/skipped_export_exit_952.rs b/crates/synth-cli/tests/skipped_export_exit_952.rs new file mode 100644 index 00000000..b5cf1a99 --- /dev/null +++ b/crates/synth-cli/tests/skipped_export_exit_952.rs @@ -0,0 +1,196 @@ +//! #952 — a declined REQUESTED export must exit non-zero, not 0. +//! +//! Measured on v0.56.0: +//! +//! ```wat +//! (module +//! (func $g (param i32 i64) (result i32) (local.get 0)) +//! (func (export "f") (result i32) (call $g (i32.const 7) (i64.const 9)))) +//! ``` +//! +//! ```text +//! $ synth compile ctrl.wat -b arm -t cortex-m3 --all-exports --relocatable -o ctrl.o +//! warning: skipping function 'f': ... #929: call arg 1 is 64-bit ... Declining +//! rather than emitting a silent miscompile +//! warning: 1 of 2 functions were skipped (not in output): f +//! $ echo $? +//! 0 +//! $ llvm-objdump -t ctrl.o | grep 'F .text' +//! 00000000 l F .text 00000004 func_0 # only the callee — 'f' is gone +//! ``` +//! +//! `f` is the module's sole export. The compile that declined it exited 0, so +//! any build gating on `$?` — which is every build — ships the object missing +//! its one public entry point. +//! +//! # Why this test reads synth's own stdout/stderr, not `synth disasm` +//! +//! `proven_safe_imported_memory_932.rs` already hit this: the first version of +//! that test counted mnemonics in `synth disasm` output and passed on macOS +//! while returning ZERO on the ubuntu runner, because disassembly TEXT is +//! host-dependent (register-name spelling, mnemonic width suffixes, and so +//! on). This test reads the exit code (`ExitStatus`, not text) and synth's own +//! diagnostic text on stderr — both identical on every host — never +//! disassembly. +//! +//! # The asymmetry this test protects +//! +//! Declining a non-exported internal helper (pulled in only for #235 +//! reachability) is routine and must keep exiting 0 — that is the +//! NEGATIVE CONTROL below. Only a decline of a function the module actually +//! `(export ...)`s must flip the exit code. Getting this backwards (failing +//! the build on every skip) would break the `--all-exports` corpus-sweep +//! callers this repo already has (`wast_conformance_928_differential.py` and +//! others) that intentionally compile many modules expecting some functions +//! to decline — hence `--allow-skipped-exports`, tested last. + +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn synth() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_synth")) +} + +fn workdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("synth-952-{tag}")); + std::fs::create_dir_all(&d).expect("temp dir"); + d +} + +fn compile(dir: &std::path::Path, wat: &str, out_name: &str, extra: &[&str]) -> Output { + let src = dir.join("m.wat"); + std::fs::write(&src, wat).expect("write wat"); + let obj = dir.join(out_name); + let mut c = Command::new(synth()); + c.args([ + "compile", + src.to_str().unwrap(), + "-b", + "arm", + "-t", + "cortex-m3", + "--all-exports", + "--relocatable", + "-o", + obj.to_str().unwrap(), + ]); + c.args(extra); + c.output().expect("run synth compile") +} + +fn stderr(o: &Output) -> String { + String::from_utf8_lossy(&o.stderr).into_owned() +} + +/// gale's exact repro (via #929): `f` is the module's SOLE export, and it is +/// the function that gets declined (the callee's i64 param forces the i64 +/// register-pair marshalling #929 refuses on the CALLER, `f`). +const REQUESTED_EXPORT_DECLINED: &str = r#"(module + (func $g (param i32 i64) (result i32) (local.get 0)) + (func (export "f") (result i32) (call $g (i32.const 7) (i64.const 9)))) +"#; + +/// `f` is exported and compiles fine; `$hard` is an internal, NON-exported +/// helper pulled in only because `f` calls it (#235 reachability). `$hard`'s +/// f64 result makes IT decline on a soft-float target — but nothing asked for +/// `$hard` by name, so its absence is routine, not a build failure. +/// +/// Verified empirically before writing this test (against the unmodified +/// v0.56.1 binary) that this fixture actually PRODUCES a skip of a +/// non-exported function: `$hard` unreachable-but-uncalled produces no skip +/// at all (it is simply never compiled), so the helper MUST be called from +/// the export for `reachable_from_exports` to pull it in and then decline it. +const ONLY_HELPER_DECLINED: &str = r#"(module + (func $hard (result f64) (f64.sqrt (f64.const 2.0))) + (func $helper (result i32) (call $hard) (drop) (i32.const 1)) + (func (export "f") (result i32) (call $helper))) +"#; + +/// RED (must pass only after the fix): a compile that declines a REQUESTED +/// export exits non-zero. Before the #952 fix this test fails — the process +/// exits 0 with the export silently absent from the object. +#[test] +fn declined_requested_export_exits_nonzero() { + let dir = workdir("red"); + let out = compile(&dir, REQUESTED_EXPORT_DECLINED, "ctrl.o", &[]); + + // Anchor: the skip must actually have happened, not just an unrelated + // failure. If this assertion stops matching (e.g. #929's message text + // changes upstream), the test below is meaningless and must be revisited + // rather than silently passing on some other error. + let err = stderr(&out); + assert!( + err.contains("skipping function 'f'"), + "fixture must decline 'f' specifically (the anchor this test relies \ + on) — got:\n{err}" + ); + + assert!( + !out.status.success(), + "#952: a compile that declines a REQUESTED export ('f', the module's \ + sole export) must exit non-zero — a build gating on `$?` must not \ + accept an object silently missing its public entry point. \ + stderr:\n{err}" + ); +} + +/// NEGATIVE CONTROL, both before and after the fix: skipping only a +/// non-exported internal helper must still exit 0. This is the asymmetry +/// #952 explicitly preserves — routine helper skips are not build failures. +/// If this test ever starts failing, the fix over-broadened the gate to fail +/// the build on ANY skip, not just a skipped export. +#[test] +fn skipped_nonexported_helper_still_exits_zero() { + let dir = workdir("negctrl"); + let out = compile(&dir, ONLY_HELPER_DECLINED, "ctrl.o", &[]); + let err = stderr(&out); + + // Non-vacuity: a control that never exercises a skip proves nothing (the + // #275/A32 lesson — see call_indirect_275_selfcontained.rs). Anchor on + // BOTH the per-function warning naming the skipped helper AND the + // aggregate count, so a future refactor that stops skipping `$hard` + // (e.g. broadens f64 support) fails this test loudly rather than leaving + // it passing for the wrong reason. + assert!( + err.contains("skipping function") && err.contains("were skipped"), + "fixture must actually skip the non-exported helper for this control \ + to mean anything — got:\n{err}" + ); + assert!( + !err.contains("skipping function 'f'"), + "the EXPORT 'f' must not be the one skipped — this fixture is meant \ + to isolate a helper-only skip. stderr:\n{err}" + ); + + assert!( + out.status.success(), + "#952 negative control: skipping only a non-exported internal helper \ + (never asked for by name) must still exit 0 — only a declined \ + REQUESTED export may fail the build. stderr:\n{err}" + ); +} + +/// `--allow-skipped-exports` restores the pre-#952 exit-0 behavior for +/// callers who genuinely want the partial object (the `--all-exports` +/// corpus-sweep shape). +#[test] +fn allow_skipped_exports_restores_exit_zero() { + let dir = workdir("allow"); + let out = compile( + &dir, + REQUESTED_EXPORT_DECLINED, + "ctrl.o", + &["--allow-skipped-exports"], + ); + let err = stderr(&out); + assert!( + err.contains("skipping function 'f'"), + "the decline must still happen (and still warn) under the opt-out — \ + only the EXIT CODE changes. stderr:\n{err}" + ); + assert!( + out.status.success(), + "--allow-skipped-exports must restore exit 0 on a declined requested \ + export. stderr:\n{err}" + ); +} diff --git a/scripts/repro/call_indirect_275_selfcontained_differential.py b/scripts/repro/call_indirect_275_selfcontained_differential.py index c72e4c2c..4f836614 100755 --- a/scripts/repro/call_indirect_275_selfcontained_differential.py +++ b/scripts/repro/call_indirect_275_selfcontained_differential.py @@ -85,7 +85,11 @@ def check_selfcontained_a32_residual() -> int: its builder emits no funcref table, so a dispatch would be the #717 R11 collision (a silent miscompile).""" out = "/tmp/ci275_selfcontained_r5.elf" - r = run(["--target", "cortex-r5", "-o", out]) + # #952: `entry` is this fixture's sole export and the function expected + # to be loud-skipped here (the residual A32 decline this test verifies). + # Since v0.57 that exits non-zero by default; opt in to the partial + # object so the symtab/stderr checks below can still run. + r = run(["--target", "cortex-r5", "--allow-skipped-exports", "-o", out]) fails = 0 if r.returncode != 0: print(f"self-contained cortex-r5: compile hard-failed (expected skip-and-continue):\n{r.stderr}") diff --git a/scripts/repro/float_select_return_782_differential.py b/scripts/repro/float_select_return_782_differential.py index 4b7cecc9..8d04f142 100644 --- a/scripts/repro/float_select_return_782_differential.py +++ b/scripts/repro/float_select_return_782_differential.py @@ -74,7 +74,7 @@ def compile_elf(out, target): r = subprocess.run( [SYNTH, "compile", str(WAT), "-o", out, "-b", "arm", - "--target", target, "--all-exports"], + "--target", target, "--all-exports", "--allow-skipped-exports"], capture_output=True, text=True, env={"PATH": "/usr/bin:/bin"}, ) return r.returncode == 0, (r.stderr + r.stdout) diff --git a/scripts/repro/i64_float_conv_869_differential.py b/scripts/repro/i64_float_conv_869_differential.py index 83eb620d..f9aa5311 100644 --- a/scripts/repro/i64_float_conv_869_differential.py +++ b/scripts/repro/i64_float_conv_869_differential.py @@ -384,7 +384,7 @@ def main(): # two-word build) — undefined encodings on FPv4-SP. Absent symbol = the # honest decline; present = the capability gate regressed. compile_or_die("/tmp/i64_float_conv_869_m4f.o", - ["-b", "arm", "--target", "cortex-m4f", "--all-exports"], + ["-b", "arm", "--target", "cortex-m4f", "--all-exports", "--allow-skipped-exports"], "ARM32 cortex-m4f") _, _, m4f_syms = load("/tmp/i64_float_conv_869_m4f.o") for fn in FAMILY: @@ -396,7 +396,7 @@ def main(): # ==== execute the boundary tables (cortex-m7dp self-contained) ========= compile_or_die("/tmp/i64_float_conv_869_arm.elf", - ["-b", "arm", "--target", "cortex-m7dp", "--all-exports"], + ["-b", "arm", "--target", "cortex-m7dp", "--all-exports", "--allow-skipped-exports"], "ARM32 cortex-m7dp") text, base, syms = load("/tmp/i64_float_conv_869_arm.elf") for fn in FAMILY: diff --git a/scripts/repro/i64_globals_643_differential.py b/scripts/repro/i64_globals_643_differential.py index 9f29a2ba..0562741c 100644 --- a/scripts/repro/i64_globals_643_differential.py +++ b/scripts/repro/i64_globals_643_differential.py @@ -64,7 +64,7 @@ def compile_synth(out, backend_args): env = {"PATH": "/usr/bin:/bin"} - cmd = [SYNTH, "compile", str(WAT), "-o", out, "--all-exports"] + backend_args + cmd = [SYNTH, "compile", str(WAT), "-o", out, "--all-exports", "--allow-skipped-exports"] + backend_args r = subprocess.run(cmd, capture_output=True, text=True, env=env) if r.returncode != 0: sys.exit(f"compile failed ({backend_args}): {r.stderr}") diff --git a/scripts/repro/i64_param_518_riscv_loudskip.py b/scripts/repro/i64_param_518_riscv_loudskip.py index f1824d20..7a158303 100644 --- a/scripts/repro/i64_param_518_riscv_loudskip.py +++ b/scripts/repro/i64_param_518_riscv_loudskip.py @@ -51,8 +51,14 @@ def compile_riscv(): + # #952: every I64_PARAM_FNS entry below is a NAMED EXPORT this fixture + # expects to be loud-skipped — since v0.57 a declined requested export + # exits non-zero by default, so this analysis-only oracle (which wants + # the partial object to inspect stderr + the symtab, not a hard failure) + # needs the opt-in. cmd = [SYNTH, "compile", str(WAT), "-o", OUT, "-b", "riscv", - "--target", "riscv32imac", "--all-exports", "--relocatable"] + "--target", "riscv32imac", "--all-exports", "--relocatable", + "--allow-skipped-exports"] r = subprocess.run(cmd, capture_output=True, text=True, env={"PATH": "/usr/bin:/bin"}) if r.returncode != 0: diff --git a/scripts/repro/trunc_sat_782_differential.py b/scripts/repro/trunc_sat_782_differential.py index 62411570..71ffb19f 100644 --- a/scripts/repro/trunc_sat_782_differential.py +++ b/scripts/repro/trunc_sat_782_differential.py @@ -432,7 +432,7 @@ def main(): # ==== ARM32 self-contained (cortex-m7dp): execute ALL EIGHT forms ====== compile_or_die("/tmp/trunc_sat_782_arm.elf", - ["-b", "arm", "--target", "cortex-m7dp", "--all-exports"], + ["-b", "arm", "--target", "cortex-m7dp", "--all-exports", "--allow-skipped-exports"], "ARM32 cortex-m7dp") text, base, syms = load("/tmp/trunc_sat_782_arm.elf") for fn, (aty, rty) in SIGS.items(): @@ -456,7 +456,7 @@ def main(): # trunc_sat_f32_* needs only single-precision VFP; the f64-source forms # ride the existing double-FPU honest-reject. compile_or_die("/tmp/trunc_sat_782_m4f.elf", - ["-b", "arm", "--target", "cortex-m4f", "--all-exports"], + ["-b", "arm", "--target", "cortex-m4f", "--all-exports", "--allow-skipped-exports"], "ARM32 cortex-m4f") m4f_text, m4f_base, m4f_syms = load("/tmp/trunc_sat_782_m4f.elf") for fn, (aty, rty) in I32_FNS.items(): @@ -497,7 +497,7 @@ def main(): # ==== aarch64: all eight, unicorn + native ============================== compile_or_die("/tmp/trunc_sat_782_a64.o", - ["-b", "aarch64", "--all-exports"], "aarch64") + ["-b", "aarch64", "--all-exports", "--allow-skipped-exports"], "aarch64") a64_code, a64_base, a64_syms = load("/tmp/trunc_sat_782_a64.o") host_native = platform.machine() in ("arm64", "aarch64") checked_native = 0 diff --git a/scripts/repro/unreachable_665_differential.py b/scripts/repro/unreachable_665_differential.py index fba9fa3d..d20f3a98 100644 --- a/scripts/repro/unreachable_665_differential.py +++ b/scripts/repro/unreachable_665_differential.py @@ -74,7 +74,7 @@ def compile_elf(out, backend_args): r = subprocess.run( - [SYNTH, "compile", str(WAT), "-o", out, *backend_args, "--all-exports"], + [SYNTH, "compile", str(WAT), "-o", out, *backend_args, "--all-exports", "--allow-skipped-exports"], capture_output=True, text=True, env={"PATH": "/usr/bin:/bin"}, ) if r.returncode != 0: diff --git a/scripts/repro/wast_conformance_928_differential.py b/scripts/repro/wast_conformance_928_differential.py index 578bead3..d4361fa3 100644 --- a/scripts/repro/wast_conformance_928_differential.py +++ b/scripts/repro/wast_conformance_928_differential.py @@ -128,7 +128,17 @@ def compile_module(wat_text: str, tmp: Path) -> tuple[Path, bytes]: obj = tmp / "m.o" r = subprocess.run( [SYNTH, "compile", str(src), "-o", str(obj), "--target", "cortex-m4", - "--all-exports", "--relocatable"], + "--all-exports", "--relocatable", + # #952: this IS the corpus sweep that issue names as the intended + # `--allow-skipped-exports` caller — individual per-function + # declines are expected and already counted below by reason + # (per-assertion, via `symbol-missing`), not a compile failure for + # the whole file. Without this flag, since v0.57 a module where ANY + # exported function declines (not just ALL of them) now exits + # non-zero, which would collapse partial-decline files into + # `compile-declined` and undercount CHECKED assertions this oracle's + # `emulations >= 263` floor depends on. + "--allow-skipped-exports"], capture_output=True, text=True, ) if r.returncode != 0 or not obj.exists():