Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
c847198
ci: check the IR is internally consistent, not just well-shaped
jlucaso1 Jul 28, 2026
2180744
enums: resolve the ones WA composes instead of declares
jlucaso1 Jul 28, 2026
2e92004
enums: use ? for the unbound-operand path clippy flagged
jlucaso1 Jul 28, 2026
df8f19f
notif: read the three action shapes that were deferred, and state the…
jlucaso1 Jul 28, 2026
861094c
fetch: pin what the bootloader yielded, not just what it produced
jlucaso1 Jul 28, 2026
d8eee2d
review: report unknown field types, keep the pins on an empty run, fo…
jlucaso1 Jul 28, 2026
0ce3a14
review: ratchet the baseline, widen the linter's reach, and fix three…
jlucaso1 Jul 28, 2026
bb3de9f
fix: gate the bootloader lock update with the feature its function li…
jlucaso1 Jul 28, 2026
04fbd20
review: pin only wasm handles, accumulate them, and refuse a rebound …
jlucaso1 Jul 28, 2026
b199dd2
review: keep the bootloader map within one release, and check WAM's e…
jlucaso1 Jul 28, 2026
d1fb4c8
review: accumulate the handle map on the productive path too
jlucaso1 Jul 28, 2026
031a442
review: close the guard, scope and catalog gaps the last round left open
jlucaso1 Jul 28, 2026
57a9693
review: bound the callback recursion, scope parameters, and check wha…
jlucaso1 Jul 28, 2026
7fd3716
review: version-scope the cache handles, close two shadow holes, tigh…
jlucaso1 Jul 28, 2026
7cfe9e5
review: keep spans with the buffer they index, and bind every paramet…
jlucaso1 Jul 28, 2026
273032a
review: pin every aliasing handle, refuse a shadowed callee, require …
jlucaso1 Jul 28, 2026
59d05d6
review: stop the linter crashing on the malformed IR it exists to catch
jlucaso1 Jul 28, 2026
d74959e
review: keep the lockfile inside the CDN allowlist, and stop refusing…
jlucaso1 Jul 28, 2026
2712fd5
review: baseline unresolved enums by identity, not by total
jlucaso1 Jul 28, 2026
065daa9
review: prefer the callee the caller bound, and check three more IR s…
jlucaso1 Jul 28, 2026
4f5a70d
review: pre-register hoisted bindings, and check five more IR shapes
jlucaso1 Jul 28, 2026
1e75bd3
review: rebind the context on the other expansion path, and fix the p…
jlucaso1 Jul 28, 2026
dd0e6a8
review: key unresolved enums by semantic path, and hoist var bindings…
jlucaso1 Jul 28, 2026
ab0f020
review: keep parameter-local writes local, and let scoping give the l…
jlucaso1 Jul 28, 2026
9afdbf7
review: cross-reference appstate collections, and close two gaps in t…
jlucaso1 Jul 28, 2026
9f456e6
review: keep a destructured shadow lexical, and drop a rebound handle…
jlucaso1 Jul 28, 2026
37f53f9
review: model the mutation extends performs, and tombstone rejected h…
jlucaso1 Jul 28, 2026
62af5ac
test: cover all three loop-header forms, not just for-of
jlucaso1 Jul 28, 2026
b5dd0c2
review: build the tombstone from pre-filter ids, and hoist only what …
jlucaso1 Jul 28, 2026
41c9d58
review: restrict exports to the module object, and reconcile aliases …
jlucaso1 Jul 28, 2026
d3c92e0
review: give every lexical construct a scope, and stop the linter cra…
jlucaso1 Jul 28, 2026
33ac176
review: pin the proto path the IR actually writes, and count unresolv…
jlucaso1 Jul 28, 2026
4a30b73
review: one lexical collector for all four scopes, and scalar fallbac…
jlucaso1 Jul 28, 2026
c9e1f2b
review: resolve the value payload against the proto, and tombstone th…
jlucaso1 Jul 28, 2026
c09ba9e
review: stop rejecting a legal enum alias, and check the payload fiel…
jlucaso1 Jul 29, 2026
ee948bc
review: read loop and switch headers in the enclosing scope
jlucaso1 Jul 29, 2026
1987c5a
review: read a mapped collection from either ternary branch
jlucaso1 Jul 29, 2026
ab74276
review: require the export receiver to be unshadowed at the assignment
jlucaso1 Jul 29, 2026
7c1d92c
review: unwrap the paren, widen the receiver shadow, watch every exte…
jlucaso1 Jul 29, 2026
ef41dfd
review: mirror the receiver filter into arrows, and refuse a contradi…
jlucaso1 Jul 29, 2026
322fd44
review: filter each mapped-child candidate before falling back
jlucaso1 Jul 29, 2026
4e7d5c5
review: search nested branches for the mapped call, and pin accessor …
jlucaso1 Jul 29, 2026
fc2f43e
review: catch a mutating extends anywhere, honour the TDZ, reconcile …
jlucaso1 Jul 29, 2026
f80ea56
lint: record the flattened discriminated shapes instead of certifying…
jlucaso1 Jul 29, 2026
7c837fa
lint: count the flattening per extra entry, at its measured value
jlucaso1 Jul 29, 2026
e332de1
review: pin the flattening by identity, and refuse two more ambiguous…
jlucaso1 Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ jobs:
pip install --quiet jsonschema
python scripts/validate-schemas.py generated

# The schemas check SHAPE; this checks that the document is usable. A
# consumer generating code from the IR — in any language — has to trust
# that an enum names its values, a byte pin matches its length, a union
# has alternatives. Those are invariants JSON Schema cannot express, and
# the counted states are held to a baseline so a constraint that starts
# slipping through shows up as a rise rather than as silence.
- name: Check IR internal consistency
run: python scripts/lint-ir.py generated

# Reproducibility gate: restore the *exact* bundle set generated/ was built from
# (from the durable release store, verified against the committed lockfile) and
# regenerate in --check mode. Proves every committed generated/ is byte-for-byte
Expand Down
7 changes: 5 additions & 2 deletions crates/wa-codegen/src/notif_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,8 @@ fn emit_action_tables(notifications: &[NotificationDef]) -> String {
l.push_str(" pub name: &'static str,\n");
l.push_str(" pub wire_tag: &'static str,\n");
l.push_str(" pub fields: &'static [NotifActionField],\n");
l.push_str(" /// Whether every branch producing this action carries the list.\n");
l.push_str(" pub required: bool,\n");
l.push_str("}\n\n");
l.push_str("/// One arm of a notification's payload action union.\n");
l.push_str("#[derive(Debug, Clone, Copy)]\n");
Expand Down Expand Up @@ -450,10 +452,11 @@ fn emit_action_tables(notifications: &[NotificationDef]) -> String {
.iter()
.map(|c| {
format!(
"NotifActionChild {{ name: {}, wire_tag: {}, fields: {} }}",
"NotifActionChild {{ name: {}, wire_tag: {}, fields: {}, required: {} }}",
rust_lit(&c.name),
rust_lit(&c.wire_tag),
field_list(&c.fields)
field_list(&c.fields),
c.required
)
})
.collect();
Expand Down
246 changes: 230 additions & 16 deletions crates/wa-enums/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//! and keep only enums whose values are all-integer or all-string literals.
#![cfg(not(target_arch = "wasm32"))]

use std::collections::HashMap;
use std::collections::{HashMap, HashSet};

use oxc_allocator::Allocator;
use oxc_ast::ast::{
Expand Down Expand Up @@ -105,6 +105,12 @@ fn extract_from_module(slice: &str, module: &str) -> Vec<InternalEnumDef> {
/// catalog pass so the two resolution sites can't drift.
fn resolve_pending(r: &mut NamedResolver) {
for (export, local) in &r.pending {
// Same refusal as `NamedResolver::local`, spelled out because the method borrows
// all of `r` while `exports` below needs a disjoint mutable borrow. An export
// bound to a name the module rebinds is exactly as unsafe to publish here.
if r.shadowed.contains(local) {
continue;
}
if let Some(data) = r.locals.get(local) {
Comment thread
jlucaso1 marked this conversation as resolved.
r.exports
.entry(export.clone())
Expand All @@ -127,11 +133,7 @@ pub fn resolve_named_enum(module_slice: &str, module: &str, name: &str) -> Optio
if ret.panicked {
return None;
}
let mut r = NamedResolver {
locals: HashMap::new(),
exports: HashMap::new(),
pending: Vec::new(),
};
let mut r = NamedResolver::new();
r.visit_program(&ret.program);
resolve_pending(&mut r);
let (value_kind, variants) = r.exports.get(name)?.clone();
Expand Down Expand Up @@ -179,11 +181,7 @@ fn extract_plain_object_enums(slice: &str, module: &str) -> Vec<InternalEnumDef>
if ret.panicked {
return Vec::new();
}
let mut r = NamedResolver {
locals: HashMap::new(),
exports: HashMap::new(),
pending: Vec::new(),
};
let mut r = NamedResolver::new();
r.visit_program(&ret.program);
resolve_pending(&mut r);
let mut out: Vec<InternalEnumDef> = Vec::new();
Expand All @@ -203,17 +201,138 @@ fn extract_plain_object_enums(slice: &str, module: &str) -> Vec<InternalEnumDef>

struct NamedResolver {
locals: HashMap<String, EnumData>,
/// Names bound to two DIFFERENT enum bodies somewhere in the module.
///
/// `locals` is flat while JavaScript is lexically scoped, so a minified module that
/// reuses a short name inside a nested function — `var e={A:"a"}` at the top, then
/// `function f(){var e={X:"x"}}` — overwrites the outer binding that a later
/// `extends({}, e, …)` still refers to at runtime. Publishing `X` there would be a
/// closed value set naming members the runtime never accepts, which is the one thing
/// this crate must not do; both readers of `locals` refuse such a name instead.
///
/// Tracking real scopes would be the complete answer. Refusal is the cheap one that
/// cannot be wrong, and no bundle in the pinned set has a single collision — so the
/// choice costs nothing today and stays correct if that changes.
shadowed: HashSet<String>,
exports: HashMap<String, EnumData>,
pending: Vec<(String, String)>,
}

impl NamedResolver {
fn new() -> Self {
Self {
locals: HashMap::new(),
shadowed: HashSet::new(),
exports: HashMap::new(),
pending: Vec::new(),
}
}

/// The body bound to `name`, unless the module rebinds it to a different one.
fn local(&self, name: &str) -> Option<&EnumData> {
if self.shadowed.contains(name) {
return None;
}
self.locals.get(name)
}

fn bind_local(&mut self, name: &str, data: EnumData) {
match self.locals.get(name) {
Some(prev) if *prev != data => {
self.shadowed.insert(name.to_string());
}
_ => {}
}
self.locals.insert(name.to_string(), data);
}
}

impl NamedResolver {
/// Evaluate `babelHelpers.extends(a, b, …)` into one enum, left to right.
///
/// Each operand must be something already understood — an object literal, an
/// `$InternalEnum(…)`, or a local this pass has already bound. Anything else refuses
/// the whole thing rather than publishing the operands it could read: a partial
/// merge is a closed value set that omits members the runtime accepts, which is
/// worse than recording the loss.
///
/// Later operands override earlier keys, as the runtime does. A single value kind is
/// required, so a string enum merged with an int one resolves to nothing.
fn merge_extends(&self, e: &Expression) -> Option<EnumData> {
let call = as_call(e)?;
let callee = wa_oxc::as_member(&call.callee)?;
if callee.1 != "extends" || as_identifier(callee.0) != Some("babelHelpers") {
return None;
}
// The first operand is the TARGET, and `extends` mutates it. WA's convention is
// `extends({}, …)`, which mutates a throwaway; `extends(base, {B:"b"})` would also
// add `B` to `base` itself, so recovering only the result would publish a `BASE`
// export that is missing a member the runtime has. Recovering the composition is
// not worth modelling the mutation, so a non-empty target refuses the whole thing.
match call
.arguments
.first()
.and_then(arg_expr)
.and_then(as_object)
{
Some(o) if o.properties.is_empty() => {}
_ => return None,
Comment thread
jlucaso1 marked this conversation as resolved.
}
let mut kind: Option<EnumValueKind> = None;
let mut merged: Vec<EnumVariant> = Vec::new();
for arg in &call.arguments {
let operand = arg_expr(arg)?;
Comment thread
jlucaso1 marked this conversation as resolved.
// An empty `{}` is the conventional first operand and contributes nothing —
// and `parse_enum` rejects it (no variants), so it has to be recognised
// before the refusal path or it kills every merge.
if let Some(o) = as_object(operand)
&& o.properties.is_empty()
{
continue;
}
let (k, variants) = match enum_object(operand).and_then(parse_enum) {
Some(data) => data,
// A bare identifier must already be bound by this pass.
None => self.local(as_identifier(operand)?)?.clone(),
Comment thread
jlucaso1 marked this conversation as resolved.
};
if !variants.is_empty() {
match kind {
Some(prev) if prev != k => return None,
_ => kind = Some(k),
}
}
for v in variants {
match merged.iter_mut().find(|x| x.name == v.name) {
Some(existing) => *existing = v,
None => merged.push(v),
}
}
}
if merged.is_empty() {
return None;
}
Some((kind?, merged))
}
}

impl<'a> Visit<'a> for NamedResolver {
fn visit_variable_declarator(&mut self, d: &VariableDeclarator<'a>) {
if let Some(local) = d.id.get_identifier_name()
&& let Some(obj) = d.init.as_ref().and_then(enum_object)
&& let Some(data) = parse_enum(obj)
{
self.locals.insert(local.to_string(), data);
if let Some(local) = d.id.get_identifier_name() {
Comment thread
jlucaso1 marked this conversation as resolved.
let data = d
.init
.as_ref()
.and_then(enum_object)
.and_then(parse_enum)
// WA composes enums as well as declaring them:
// `VISIBILITY_WITH_ERROR = extends({}, VISIBILITY, {error: "error"})`.
// Recognising only literals left every composed one unresolvable, and the
// fields validating against them shipped as `"type": "enum"` with no
// values — 41 lost constraints, the largest single entry in
// `dropsByReason`.
.or_else(|| d.init.as_ref().and_then(|e| self.merge_extends(e)));
Comment thread
jlucaso1 marked this conversation as resolved.
if let Some(data) = data {
self.bind_local(local.as_str(), data);
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
}
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
}
walk::walk_variable_declarator(self, d);
}
Expand Down Expand Up @@ -408,6 +527,101 @@ fn parse_enum(obj: &ObjectExpression) -> Option<EnumData> {
mod tests {
use super::*;

#[test]
fn a_composed_enum_merges_its_operands() {
// WA composes enums as well as declaring them, and the composed ones are the
// interesting ones: `VISIBILITY_WITH_ERROR` is `VISIBILITY` plus the `error`
// sentinel the parser checks for. Recognising only literals left 41 constraints
// unresolvable — the largest single entry in `dropsByReason`.
let module = r#"__d("WAWebPrivacySettings",[],(function(t,n,r,o,a,i){
var e={all:"all",contacts:"contacts",none:"none"},
l=babelHelpers.extends({},e,{error:"error"});
i.VISIBILITY=e,i.VISIBILITY_WITH_ERROR=l
}),66);"#;
let def = resolve_named_enum(module, "WAWebPrivacySettings", "VISIBILITY_WITH_ERROR")
.expect("the composed export resolves");
let values: Vec<&str> = def
.variants
.iter()
.map(|v| match &v.value {
Scalar::Str(s) => s.as_str(),
_ => "<non-string>",
})
.collect();
assert_eq!(
values,
["all", "contacts", "none", "error"],
"in merge order"
);
// The base is still resolvable on its own, and does NOT gain the sentinel.
let base = resolve_named_enum(module, "WAWebPrivacySettings", "VISIBILITY").unwrap();
assert_eq!(base.variants.len(), 3);
}

#[test]
fn a_composition_that_mutates_its_target_is_refused() {
// `extends(base, {B:"b"})` adds `B` to `base` ITSELF at runtime. Recovering only
// the result would publish a `BASE` export missing a member the runtime has —
// an enum that claims to reject a value it accepts. WA's convention is an empty
// target, so requiring one costs nothing and rules the mutation out.
let module = r#"__d("M",[],(function(t,n,r,o,a,i){
var e={A:"a"},l=babelHelpers.extends(e,{B:"b"});
i.BASE=e,i.WITH=l
}),1);"#;
assert!(resolve_named_enum(module, "M", "WITH").is_none());
// And the target keeps exactly what it was written with.
let base =
resolve_named_enum(module, "M", "BASE").expect("the plain export still resolves");
assert_eq!(base.variants.len(), 1);
}

#[test]
fn a_name_the_module_rebinds_is_refused_everywhere_it_is_read() {
// `locals` is flat; JavaScript is not. A minifier that reuses `e` inside a nested
// function overwrites the outer binding that the composition below still refers
// to at runtime, so a flat map would publish `X,B` — a closed set naming a member
// the runtime never accepts, and omitting one it does. Both readers refuse.
let module = r#"__d("M",[],(function(t,n,r,o,a,i){
var e={A:"a"};
function f(){var e={X:"x"};return e}
var l=babelHelpers.extends({},e,{B:"b"});
i.WITH=l,i.ALIAS=e
}),1);"#;
assert!(
resolve_named_enum(module, "M", "WITH").is_none(),
"the composition reads a rebound operand"
);
assert!(
resolve_named_enum(module, "M", "ALIAS").is_none(),
"`resolve_pending` reads the same rebound name"
);
// A name rebound to the SAME body is not ambiguous — refusing it would throw away
// a resolvable enum for nothing.
let same = r#"__d("M",[],(function(t,n,r,o,a,i){
var e={A:"a"};
function f(){var e={A:"a"};return e}
i.ALIAS=e
}),1);"#;
assert_eq!(
resolve_named_enum(same, "M", "ALIAS")
.expect("an identical rebinding is still resolvable")
.variants
.len(),
1
);
}

#[test]
fn a_composition_over_something_unreadable_is_refused() {
// A partial merge is a CLOSED value set missing members the runtime accepts —
// worse than recording the loss, so an unresolvable operand refuses the whole.
let module = r#"__d("M",[],(function(t,n,r,o,a,i){
var e={all:"all"},l=babelHelpers.extends({},e,computed());
i.WITH=l
}),1);"#;
assert!(resolve_named_enum(module, "M", "WITH").is_none());
}

fn run(src: &str) -> Vec<InternalEnumDef> {
let defs = wa_transform::extract_module_definitions(src);
extract_enums_from_modules(src, &defs, "1.0").enums
Expand Down
15 changes: 12 additions & 3 deletions crates/wa-fetch/src/bootloader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,15 @@ pub struct WasmResolution {
pub rounds: usize,
/// Requests actually sent.
pub requests: usize,
/// Requests that failed or returned a non-2xx / unparseable body, as diagnostics.
/// Non-fatal: resolution degrades to whatever the other requests returned.
/// Everything that degraded resolution, as diagnostics — a failed request, but also
/// a page that shipped no endpoint parameters or deferred no components. Non-fatal:
/// resolution degrades to whatever the other requests returned.
pub failures: Vec<String>,
/// Of those, the ones that were an actual request failing. Counted separately because
/// "requests that failed" and "reasons resolution was degraded" are different
/// questions, and a pinned request-health number that includes a pre-request
/// diagnostic contradicts its own name.
pub failed_requests: usize,
}

impl WasmResolution {
Expand Down Expand Up @@ -154,7 +160,10 @@ pub fn resolve_wasm_with(
out.requests += 1;
match fetch_payload(client, &url) {
Ok(payload) => collect_bx_data(&payload, &mut bx),
Err(why) => out.failures.push(format!("{param} round {round}: {why}")),
Err(why) => {
out.failures.push(format!("{param} round {round}: {why}"));
out.failed_requests += 1;
}
}
}
out.rounds += 1;
Expand Down
2 changes: 1 addition & 1 deletion crates/wa-fetch/src/discover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ pub fn discover_from_html(status: u16, html: &str, base_url: &str) -> Result<Dis

/// Is this URL a WebAssembly payload? Query/fragment is ignored so a cache-busted URL
/// still counts.
pub(crate) fn is_wasm_url(url: &str) -> bool {
pub fn is_wasm_url(url: &str) -> bool {
url.split(['?', '#'])
.next()
.unwrap_or(url)
Expand Down
2 changes: 1 addition & 1 deletion crates/wa-fetch/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ mod testutil;
pub use bootloader::{WasmResolution, WasmResolveOptions, resolve_wasm_with};
pub use discover::{
BootloaderParams, Discovered, Sources, WA_WEB_URL, build_wa_version, discover_bundle_urls_with,
discover_from_html,
discover_from_html, is_wasm_url,
};
pub use download::{Bundle, DownloadFailure, DownloadOptions, DownloadOutcome, bundle_file_name};
pub use http::{FetchError, HttpClient, HttpResponse};
Expand Down
Loading
Loading