From c847198a0b3c975784c511ec8c07179338576505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 12:22:33 -0300 Subject: [PATCH 01/46] ci: check the IR is internally consistent, not just well-shaped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JSON Schemas validate shape: that a field has the keys it should, of the right types. They cannot say whether the document is USABLE — whether a field the IR calls an enum names its legal values, whether a byte pin is the length it claims, whether a union carries the alternatives that make it one. Those are the questions a consumer writing a generator has to answer, and nothing checked them. This replaces the "compile the generated Rust in CI" follow-up. That guard was aimed at an artifact that is on its way out — the committed contract is the IR and its schemas, and a consuming library implements the generator in its own language. A guard on the Rust output would have been work spent on the part that is being removed; this one is language-neutral and sits on what survives. Two kinds of finding. An internal contradiction (a `literalValue` on an integer field that is not an integer, a `byteMin` above its `byteMax`, an `enumRef` with no variants) always fails: the document is saying two things at once. A counted state (an enum WA composes at runtime and the extractor cannot name) is held to a baseline, so it may shrink but a rise fails — that is a constraint starting to slip through unnoticed. Verified by sabotage rather than by reading: injecting one of each contradiction into a copy of the artifact produces five errors plus one baseline regression and exit 1, and the real artifact exits 0. One thing the writing of it taught: keying "is this a field?" on `method`/ `wireName` looked right and silently skipped appstate, which carries neither — the linter reported a clean count while missing a domain. It keys on the type vocabulary now. --- .github/workflows/ci.yml | 9 +++ scripts/lint-ir.py | 168 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100755 scripts/lint-ir.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37ccdfe..7a8262a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py new file mode 100755 index 0000000..41f6323 --- /dev/null +++ b/scripts/lint-ir.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Check invariants of the generated IR that the JSON Schemas cannot express. + +The schemas validate SHAPE: that a field has the keys it should, of the right +types. They cannot say whether the document is *usable* — whether a field the IR +calls an enum actually names its legal values, whether a byte pin is the length +it claims, whether a union carries the variants that make it a union. Those are +the questions a consumer writing a generator in any language has to answer, and +they are what this checks. + +Two kinds of finding: + +* **errors** — an internal contradiction. A `literalValue` on an integer field + that is not an integer is not a gap in extraction; it is a document that says + two things at once. These always fail. +* **counted** — a known, legitimately non-empty state, held to a BASELINE. An + enum WA composes at runtime cannot be resolved, and the IR records that under + `dropsByReason` rather than pretending; the count may shrink (extraction + improved) but a rise means a new construct started slipping through unnoticed. + +Network-free and deterministic, so it runs in CI beside the schema validation. + +Usage: scripts/lint-ir.py [generated-dir] (default: ./generated) +""" +import json +import sys +from pathlib import Path + +# Counted states, with the value observed when this guard was introduced. Raising +# one of these is a deliberate act: it means a constraint the extractor used to +# recover is now being lost, and the number has to be updated with a reason. +BASELINE = { + "enum field with no values": 173, + "content integer with no byte width": 0, +} + +# `contentInt` reads DECIMAL TEXT, so it has no byte width and must not be asked +# for one; `contentUint(N)` reads N big-endian bytes and must always carry it. +WIDTH_BEARING = {"contentUint"} + +# The `ParsedFieldType` vocabulary, mirroring `wa_ir::ParsedFieldType`. A value +# outside it in a `type` position is itself worth reporting. +FIELD_TYPES = { + "string", "integer", "timestamp", "timestamp_millis", "enum", "bytes", + "jid", "user_jid", "lid_user_jid", "device_jid", "lid_device_jid", + "group_jid", "newsletter_jid", "call_jid", "broadcast_jid", "status_jid", + "jid_typed", "bool", "union", +} + + +def walk(node, visit, path=""): + """Depth-first over every dict in the document, with a readable path.""" + if isinstance(node, dict): + visit(node, path) + for k, v in node.items(): + walk(v, visit, f"{path}/{k}") + elif isinstance(node, list): + for i, v in enumerate(node): + walk(v, visit, f"{path}/{i}") + + +def check_field(f, path, errors, counts): + """Invariants of one `ParsedField`-shaped object.""" + t = f.get("type") + method = f.get("method", "") + + # An enum that names no legal values is the "is this unconstrained, or did we + # lose the constraint?" ambiguity the IR exists to remove. + if t == "enum" and not f.get("enumRef") and not f.get("enumKeys"): + counts["enum field with no values"] += 1 + + # The pending marker must never reach the artifact: an empty variant list + # tells a consumer the field admits NO value. + ref = f.get("enumRef") + if isinstance(ref, dict) and not ref.get("variants"): + errors.append(f"{path}: enumRef {ref.get('name')!r} has no variants") + + if method in WIDTH_BEARING and "byteLength" not in f: + counts["content integer with no byte width"] += 1 + + lv = f.get("literalValue") + if lv is not None: + if t == "integer": + try: + int(lv) + except (TypeError, ValueError): + errors.append(f"{path}: literalValue {lv!r} is not an integer") + if t == "bytes": + if any(c not in "0123456789abcdef" for c in lv) or len(lv) % 2: + errors.append(f"{path}: literalValue {lv!r} is not lowercase hex") + elif "byteLength" in f and len(lv) != f["byteLength"] * 2: + errors.append( + f"{path}: literalValue is {len(lv) // 2} bytes, " + f"byteLength says {f['byteLength']}" + ) + + # A union whose alternatives are absent is not a union — a consumer has + # nothing to switch on. + if t == "union" and len(f.get("unionVariants") or []) < 2: + errors.append(f"{path}: union carries fewer than two variants") + + for lo, hi in (("byteMin", "byteMax"), ("intMin", "intMax")): + if lo in f and hi in f and f[lo] > f[hi]: + errors.append(f"{path}: {lo} {f[lo]} exceeds {hi} {f[hi]}") + + # An echo rule with no path says "this equals something in the request" and + # then does not say what. + if "referencePath" in f and not f["referencePath"]: + errors.append(f"{path}: referencePath is empty") + + +def check_assertion(a, path, errors): + if a.get("kind") == "reference" and not a.get("referencePath"): + errors.append(f"{path}: reference assertion with no referencePath") + if a.get("kind") == "attr" and not a.get("name"): + errors.append(f"{path}: attr assertion with no attribute name") + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else "generated") + errors: list[str] = [] + counts = dict.fromkeys(BASELINE, 0) + + docs = sorted(root.glob("*/index.json")) + if not docs: + sys.exit(f"no domain documents under {root}") + + for doc in docs: + data = json.loads(doc.read_text()) + domain = doc.parent.name + + def visit(node, path, domain=domain): + # Recognised by the TYPE VOCABULARY, not by auxiliary keys. Keying on + # `method`/`wireName` looked right and silently skipped the appstate + # fields, which carry neither — the linter would have reported a clean + # count while missing a whole domain. + if node.get("type") in FIELD_TYPES: + check_field(node, f"{domain}{path}", errors, counts) + if "kind" in node and "reference_path" not in node: + check_assertion(node, f"{domain}{path}", errors) + + walk(data, visit) + + ok = True + for name, observed in sorted(counts.items()): + allowed = BASELINE[name] + if observed > allowed: + print(f"REGRESSION {name}: {observed} (baseline {allowed})") + ok = False + else: + mark = "ok " if observed == allowed else "ok ↓" + print(f"{mark} {name}: {observed} (baseline {allowed})") + + for e in errors: + print(f"ERROR {e}") + + if errors: + print(f"\n{len(errors)} internal contradiction(s) in the IR") + return 1 + if not ok: + print("\na counted state rose above its baseline — a constraint is being lost") + return 1 + print(f"\n{len(docs)} document(s) internally consistent") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 21807448d02e74d03c57f1fccac6d43e088874fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 12:39:50 -0300 Subject: [PATCH 02/46] enums: resolve the ones WA composes instead of declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `babelHelpers.extends({}, VISIBILITY, {error: "error"})` is how WA builds an enum out of another one — every privacy setting has a `…_WITH_ERROR` twin carrying the sentinel the parser actually checks for. The resolver recognised object literals and `$InternalEnum` calls and nothing else, so those exports resolved to nothing and every field validating against one shipped as `"type": "enum"` with no values. It was the largest single entry in `dropsByReason`. The merge is left-to-right with later keys overriding, as the runtime does, and each operand must be something already understood: a literal, an `$InternalEnum`, or a local this pass has already bound. Anything else refuses the WHOLE composition — a partial merge is a closed value set missing members the runtime accepts, which is worse than recording the loss. iq dropsByReason 41 -> 33 fieldEnumRefs 151 -> 167 enum fields with no values (lint) 173 -> 157 VISIBILITY_WITH_ERROR now reads `all, contacts, contact_blacklist, none, error`, and the three other privacy compositions resolve alongside it. The empty `{}` first operand needed its own case: `parse_enum` rejects it for having no variants, so it fell through to the refusal path and killed every merge before it started. The first version of this shipped with that bug and recovered nothing — the counters said so, which is why the linter's counted states are worth having. --- crates/wa-enums/src/lib.rs | 120 ++++++++++- generated/iq/index.json | 408 +++++++++++++++++++++++++++++++++++-- generated/manifest.json | 6 +- scripts/lint-ir.py | 2 +- 4 files changed, 511 insertions(+), 25 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index d39aec6..5778137 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -207,13 +207,81 @@ struct NamedResolver { pending: Vec<(String, String)>, } +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 { + 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; + } + let mut kind: Option = None; + let mut merged: Vec = Vec::new(); + for arg in &call.arguments { + let operand = arg_expr(arg)?; + // 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 => match as_identifier(operand) { + Some(name) => self.locals.get(name)?.clone(), + None => return None, + }, + }; + 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() { + 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))); + if let Some(data) = data { + self.locals.insert(local.to_string(), data); + } } walk::walk_variable_declarator(self, d); } @@ -408,6 +476,48 @@ fn parse_enum(obj: &ObjectExpression) -> Option { 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(), + _ => "", + }) + .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_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 { let defs = wa_transform::extract_module_definitions(src); extract_enums_from_modules(src, &defs, "1.0").enums diff --git a/generated/iq/index.json b/generated/iq/index.json index 92e3e9f..b4c8f4d 100644 --- a/generated/iq/index.json +++ b/generated/iq/index.json @@ -34905,43 +34905,205 @@ "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "ALL_NONE_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "ONLINE_VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "match_last_seen", + "value": "match_last_seen" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "CALL_ADD_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "known", + "value": "known" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", @@ -34987,7 +35149,33 @@ "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } } ], "repeats": true @@ -35004,43 +35192,205 @@ "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "ALL_NONE_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "ONLINE_VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "match_last_seen", + "value": "match_last_seen" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "CALL_ADD_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "known", + "value": "known" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", @@ -35086,7 +35436,33 @@ "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } } ] } diff --git a/generated/manifest.json b/generated/manifest.json index 80ea840..1f7aa39 100644 --- a/generated/manifest.json +++ b/generated/manifest.json @@ -12,7 +12,7 @@ "constraints": { "errorArms": 645, "errorTexts": 577, - "fieldEnumRefs": 151, + "fieldEnumRefs": 167, "fieldLiterals": 1784, "referenceConstraints": 584, "typedErrorVariants": 104 @@ -26,7 +26,7 @@ "dropsByReason": { "iq builder substring present but no AST iq call": 5, "mixin fragment (folded into requests, not a standalone stanza)": 66, - "response enum argument not structurally resolvable": 41 + "response enum argument not structurally resolvable": 33 }, "excludedFragments": 66, "stanzas": 143, @@ -69,7 +69,7 @@ "iq": { "file": "iq/index.json", "schema": "schema/iq.schema.json", - "sha256": "455d2a98f9e9ead8311bd8b5fc2cbadfabf6247ce699b5fa5d36f3bb796b3a28" + "sha256": "f733ed4de82a381f600f38570e94b3b682d06f898dc60329b71aeb257c7f0671" }, "mex": { "file": "mex/index.json", diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 41f6323..9f11e7d 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -30,7 +30,7 @@ # one of these is a deliberate act: it means a constraint the extractor used to # recover is now being lost, and the number has to be updated with a reason. BASELINE = { - "enum field with no values": 173, + "enum field with no values": 157, "content integer with no byte width": 0, } From 2e9200422805cfd6a6a11f52810e90ca57ccc4fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 12:40:45 -0300 Subject: [PATCH 03/46] enums: use ? for the unbound-operand path clippy flagged --- crates/wa-enums/src/lib.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 5778137..de7cda9 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -239,10 +239,7 @@ impl NamedResolver { 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 => match as_identifier(operand) { - Some(name) => self.locals.get(name)?.clone(), - None => return None, - }, + None => self.locals.get(as_identifier(operand)?)?.clone(), }; if !variants.is_empty() { match kind { From df8f19f7523dba463e80b8e284a0d348d032142c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 12:46:54 -0300 Subject: [PATCH 04/46] notif: read the three action shapes that were deferred, and state the boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three findings left open at the end of the review, plus the thing that made them worth leaving open: nothing said what the action reader models. * An accessor passed through an ALIAS. The minifier hoists nearly every read into a local, so `var x = attrString("jid"); h(x)` handed the helper the text `x` — no wire read in it, and meaningless inside the helper's own parse. Arguments are resolved through the caller's scope before the binding decides, so the alias form works. * A mapped-child callback passed BY NAME. `mapChildrenWithTag("participant", parseP)` is a function too; rejecting the identifier before looking at its body emitted the child with an empty field list, which tells a consumer the element carries nothing. * A collection only one branch carries. `NotifActionChild` gains `required`, weakened when a branch producing the same action lacks it — the same all-branches accounting `required` already does for a scalar, one level up. Serialized only when false, so the common case adds nothing to the document. And a `# What this models, and what it does not` section on the module. Reading these arms means interpreting minified JavaScript, and this file has grown a small scope-and-control-flow analysis to do it; six review rounds were spent discovering its edges one construct at a time. Writing the boundary down is what turns the next such finding from a surprise into a decision — and it records the invariant that matters if this grows: a reader that guesses is worse than one that reports a gap. 576 tests; each of the three reverted alone to watch its test fail. The named-callback path shipped broken on the first attempt: the `(…)` that makes a declaration parse as an expression also hides it behind a `ParenthesizedExpression`, so the function gate rejected it and the child came back empty. Second time that wrapper has cost a round. --- crates/wa-codegen/src/notif_export.rs | 7 +- crates/wa-ir/src/lib.rs | 14 ++++ crates/wa-ir/src/notif.rs | 11 +++ crates/wa-notif/src/actions.rs | 114 +++++++++++++++++++++++--- crates/wa-notif/src/tests.rs | 62 +++++++++++++- generated/schema/notif.schema.json | 4 + 6 files changed, 199 insertions(+), 13 deletions(-) diff --git a/crates/wa-codegen/src/notif_export.rs b/crates/wa-codegen/src/notif_export.rs index 862454f..89d7b48 100644 --- a/crates/wa-codegen/src/notif_export.rs +++ b/crates/wa-codegen/src/notif_export.rs @@ -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"); @@ -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(); diff --git a/crates/wa-ir/src/lib.rs b/crates/wa-ir/src/lib.rs index 59042e8..df938f1 100644 --- a/crates/wa-ir/src/lib.rs +++ b/crates/wa-ir/src/lib.rs @@ -31,6 +31,20 @@ pub use abprops::*; pub use appstate::*; pub use enums::*; pub use incoming::*; +/// `true` — the serde default for a flag whose absence means "yes". +/// +/// Paired with [`is_true`] so the common case stays out of the document: a child that +/// every branch carries serializes no `required` key at all, and only the exception is +/// written down. +pub fn default_true() -> bool { + true +} + +/// Whether a flag is at its default, for `skip_serializing_if`. +pub fn is_true(b: &bool) -> bool { + *b +} + pub use iq::*; pub use mex::*; pub use notif::*; diff --git a/crates/wa-ir/src/notif.rs b/crates/wa-ir/src/notif.rs index 299bb02..d1a6c45 100644 --- a/crates/wa-ir/src/notif.rs +++ b/crates/wa-ir/src/notif.rs @@ -250,6 +250,17 @@ pub struct NotifActionChild { /// The fields read off each element. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub fields: Vec, + /// Whether EVERY branch producing this action carries the collection. + /// + /// `if (c) return {actionType: A, participants: …}; return {actionType: A}` yields two + /// legal shapes for one action, and only one of them has `participants`. Without this + /// the metadata claimed the list is always present — the same over-assertion that + /// `required` on a scalar field exists to prevent, one level up. + #[serde( + default = "crate::default_true", + skip_serializing_if = "crate::is_true" + )] + pub required: bool, } /// The incoming-dispatch IR document: version stamp + the dispatcher module name + diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index b656886..e1d87d5 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -26,6 +26,38 @@ //! //! Both the case labels and the `actionType` values are `Module.CONST.MEMBER` //! references, so they are resolved against the defining module rather than guessed. +//! +//! # What this models, and what it does not +//! +//! Reading those arms means interpreting minified JavaScript, and this file has grown a +//! small scope-and-control-flow analysis to do it. It is not a general one, and the +//! boundary is worth stating rather than rediscovering: a construct outside it does not +//! produce a *wrong* action so much as a thin one, and knowing which is which is the +//! difference between trusting the output and auditing it. +//! +//! **Modelled.** Bindings in statement order (`var`, plain and comma-sequence +//! assignment, `let`/`const` confined to their block); branch scopes that clone and +//! rejoin, with a name any branch rewrote tombstoned rather than guessed; `if`/`else`, +//! bare blocks, `switch` with fall-through suffixes, `try`/`catch`/`finally` (the +//! finalizer's writes are definite, the body's are not), every loop form (`do…while` +//! runs once, so its writes are definite unless a path breaks out), and reachability — +//! nothing after a statement that exits on every path is collected. Module-local +//! helpers are inlined, bounded, including one applied to a wire read passed by value or +//! through an alias. Ambiguity is refused: two branches binding one output key to +//! different wire reads drop the key, and a table or object that cannot be read whole +//! (a spread, a computed member) is refused entirely rather than half-reported. +//! +//! **Not modelled.** Values that flow through anything other than a local binding or a +//! helper parameter — a property of an object, an array element, a closure over an outer +//! function's variable. Loop iteration: a body is analysed once, so a field whose source +//! depends on which pass wrote it is not resolved. Any arithmetic or string building on +//! a wire value. `label:`/`continue label`. Nested functions other than the helpers +//! reached explicitly, which is deliberate — descending into them is what would drag the +//! top-level child-tag dispatch into an arm's returns. +//! +//! When a construct falls outside, the arm loses the field rather than inventing one, +//! and `dropsByReason` counts what was seen and not recovered. That is the invariant to +//! preserve if this grows: a reader that guesses is worse than one that reports a gap. use std::collections::HashMap; @@ -751,6 +783,13 @@ impl BranchFold { self.def.action_type = from.action_type; } merge_fields(&mut self.def.fields, from.fields, false, &mut self.dead); + // A collection this branch does NOT carry cannot be claimed as always present — + // the same all-branches accounting `merge_fields` does for scalars. + for existing in self.def.children.iter_mut() { + if !from.children.iter().any(|c| c.name == existing.name) { + existing.required = false; + } + } for c in from.children { match self.def.children.iter_mut().find(|x| x.name == c.name) { // Same output name and the same element: their field sets are @@ -764,7 +803,11 @@ impl BranchFold { Some(existing) => { self.dead_children.insert(existing.name.clone()); } - None => self.def.children.push(c), + // First seen in a LATER branch, so an earlier one lacked it. + None => self.def.children.push(NotifActionChild { + required: false, + ..c + }), } } // A constant only some branches stamp is not a property of the action. @@ -1361,7 +1404,7 @@ fn expand_shape( depth: u8, ) -> Vec { if depth <= MAX_INLINE_DEPTH - && let Some(src) = local_call_source(deref_ident(result, scope), ctx) + && let Some(src) = local_call_source(deref_ident(result, scope), ctx, scope) { let expanded = expand_helper(wire_tag, &src.0, &src.1, ctx, depth + 1); if !expanded.is_empty() { @@ -1519,9 +1562,9 @@ fn fold_shape<'b, 'a>( // A helper whose whole result is a repeated element (`return // child.mapChildrenWithTag("participant", …)` — where every participant // list lives). The caller names it after the key it was bound to. - if let Some(child) = mapped_child("", e, scope, ctx.consts) { + if let Some(child) = mapped_child("", e, scope, ctx) { def.children.push(child); - } else if let Some(src) = local_call_source(e, ctx) { + } else if let Some(src) = local_call_source(e, ctx, scope) { inline_local(&src.0, &src.1, def, ctx, depth); } } @@ -1533,18 +1576,28 @@ fn fold_shape<'b, 'a>( const MAX_INLINE_DEPTH: u8 = 3; /// The source of the module-local helper `e` calls, if it is one. -fn local_call_source(e: &Expression, ctx: &ArmCtx) -> Option<(String, Vec)> { +fn local_call_source<'b, 'a>( + e: &'b Expression<'a>, + ctx: &ArmCtx, + scope: &Scope<'b, 'a>, +) -> Option<(String, Vec)> { let call = as_call(e)?; let name = as_identifier(&call.callee)?; let src = ctx.locals.get(name).cloned()?; // The argument TEXT, so `inline_local` can bind the helper's formals. A span outside // the module source (a synthesized node) yields nothing for that position rather than // a wrong slice. + // + // Resolved through the caller's scope FIRST. The minifier hoists nearly every read + // into a local, so `var x = child.attrString("jid"); h(x)` passes the text `x` — which + // carries no wire read, means nothing inside the helper's own parse, and made the + // binding decline. Splicing what `x` is BOUND to is what makes the alias form work. let args = call .arguments .iter() .map(|a| { arg_expr(a) + .map(|e| deref_ident(e, scope)) .map(oxc_span::GetSpan::span) .and_then(|sp| ctx.source.get(sp.start as usize..sp.end as usize)) .unwrap_or("") @@ -1720,13 +1773,13 @@ fn fold_object<'b, 'a>( write_key(def, key, KeyValue::Const(c)); continue; } - if let Some(child) = mapped_child(key, value, scope, ctx.consts) { + if let Some(child) = mapped_child(key, value, scope, ctx) { write_key(def, key, KeyValue::Child(child)); continue; } // A helper call in value position (`participants: y(chat, child, tag)`): inline // it under this key — that is where every participant list actually lives. - if let Some(src) = local_call_source(strip_guard(value), ctx) + if let Some(src) = local_call_source(strip_guard(value), ctx, scope) && depth < MAX_INLINE_DEPTH { let mut nested = empty_action(String::new()); @@ -1789,7 +1842,7 @@ fn mapped_child<'b, 'a>( key: &str, e: &'b Expression<'a>, scope: &Scope<'b, 'a>, - consts: &ConstResolver, + ctx: &ArmCtx, ) -> Option { let call = as_call(strip_guard(e))?; if callee_method(call)? != wap::MAP_CHILDREN_WITH_TAG { @@ -1799,10 +1852,11 @@ fn mapped_child<'b, 'a>( let mut fields = Vec::new(); for arg in &call.arguments { if let Some(e) = arg_expr(arg) { - collect_accessor_fields(e, scope, consts, &mut fields); + collect_accessor_fields(e, scope, ctx, &mut fields); } } Some(NotifActionChild { + required: true, name: key.to_string(), wire_tag: wire_tag.to_string(), fields, @@ -2128,9 +2182,10 @@ fn is_wire_accessor(method: &str) -> bool { fn collect_accessor_fields<'b, 'a>( e: &'b Expression<'a>, outer: &Scope<'b, 'a>, - consts: &ConstResolver, + ctx: &ArmCtx, out: &mut Vec, ) { + let consts = ctx.consts; // No whole-body pre-pass. `collect_returns` installs the callback's own `var` // bindings in STATEMENT ORDER, which is the same rule it already documents for the // top level: hoisting moves the declaration, not the assignment, so snapshotting the @@ -2144,6 +2199,13 @@ fn collect_accessor_fields<'b, 'a>( e, Expression::FunctionExpression(_) | Expression::ArrowFunctionExpression(_) ) { + // A module-local callback passed BY NAME — `mapChildrenWithTag("participant", + // parseParticipant)` — is a function too. Rejecting the identifier before looking + // at its body emitted the child with an empty field list, which tells a consumer + // the element carries nothing. + if let Some(src) = as_identifier(e).and_then(|n| ctx.locals.get(n)) { + collect_named_callback_fields(src, ctx, out); + } return; } // Per RETURN SHAPE, then merged — the same rule the action arms and the value-position @@ -2197,6 +2259,38 @@ fn collect_accessor_fields<'b, 'a>( } } +/// Read a NAMED mapped-child callback's per-element fields, re-parsing it in its own +/// allocator the way the value-position helpers are inlined. +/// +/// The returned fields are owned, so nothing from that parse escapes it. +fn collect_named_callback_fields(fn_src: &str, ctx: &ArmCtx, out: &mut Vec) { + let alloc = Allocator::default(); + let wrapped = format!("({fn_src})"); + let ret = wa_oxc::parse_cjs(&alloc, &wrapped); + if ret.panicked { + return; + } + let Some(func) = ret.program.body.iter().find_map(|s| match s { + Statement::ExpressionStatement(es) => Some(&es.expression), + _ => None, + }) else { + return; + }; + // The `(…)` wrapper that makes a declaration parse as an expression also hides it + // behind a `ParenthesizedExpression`, which fails the function gate downstream. + let func = match func { + Expression::ParenthesizedExpression(p) => &p.expression, + other => other, + }; + let mut fields = Vec::new(); + collect_accessor_fields(func, &Scope::new(), ctx, &mut fields); + for f in fields { + if !out.iter().any(|x| x.name == f.name) { + out.push(f); + } + } +} + /// Every `{ key: }` property reachable in one result shape. fn collect_shape_fields<'b, 'a>( shape: &'b Expression<'a>, diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 4db5bd6..a340637 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -1229,7 +1229,7 @@ __d("WAWebCommsHandleLoggedInStanza",["WAWebHandleGroupNotification"],function(g }); }; }, 1); __d("WAWebHandleGroupNotificationConst",[],(function(t,n,r,o,a,i,l){ - l.GROUP_NOTIFICATION_TAG=Object.freeze({EVICT:"evict",COND:"cond",REBIND:"rebind",PICK:"pick",BARE:"bare",HELPER:"helper",ESCAPE:"escape",TAIL:"tail",JOIN:"join",SEQ:"seq",SUFFIX:"suffix",AFTER:"after",TRY:"try_tag",FIN:"fin",FINCOND:"fincond",FINTHROW:"finthrow",MULTI:"multi",MULTI3:"multi3",DEAD:"dead",LIT:"lit",LOOP:"loop_tag",FINTRY:"fintry",PARAM:"param",BLK:"blk",EXH:"exh",NFT:"nft",LATE:"late",DOW:"dow",SHADOW:"shadow",CATCHP:"catchp",DOWX:"dowx",SAME:"same",NEST:"nest",DOWB:"dowb",PVAL:"pval",SPREAD:"spread",DOWBRK:"dowbrk",FINW:"finw",FORI:"fori",CONDW:"condw",SPRD:"sprd"}); + l.GROUP_NOTIFICATION_TAG=Object.freeze({EVICT:"evict",COND:"cond",REBIND:"rebind",PICK:"pick",BARE:"bare",HELPER:"helper",ESCAPE:"escape",TAIL:"tail",JOIN:"join",SEQ:"seq",SUFFIX:"suffix",AFTER:"after",TRY:"try_tag",FIN:"fin",FINCOND:"fincond",FINTHROW:"finthrow",MULTI:"multi",MULTI3:"multi3",DEAD:"dead",LIT:"lit",LOOP:"loop_tag",FINTRY:"fintry",PARAM:"param",BLK:"blk",EXH:"exh",NFT:"nft",LATE:"late",DOW:"dow",SHADOW:"shadow",CATCHP:"catchp",DOWX:"dowx",SAME:"same",NEST:"nest",DOWB:"dowb",PVAL:"pval",SPREAD:"spread",DOWBRK:"dowbrk",FINW:"finw",FORI:"fori",CONDW:"condw",SPRD:"sprd",ALIAS:"alias",NAMEDCB:"namedcb",OPTCH:"optch"}); }), 1); __d("WAWebGroupType",[],(function(t,n,r,o,a,i,l){ l.GROUP_ACTIONS=Object.freeze({FIRST:"first",SECOND:"second",THIRD:"third"}); @@ -1238,6 +1238,7 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr function hlp(e){ return {actionType:o("WAWebGroupType").GROUP_ACTIONS.SECOND, extra:e.attrString("extra")}; } function norm(v){ return v == null ? null : v; } function mk(v){ return {actionType:o("WAWebGroupType").GROUP_ACTIONS.THIRD, id:v}; } + function parseP(p){ return {id:p.attrString("jid"), nick:p.maybeAttrString("nick")}; } function h(e){ var x=e.mapChildrenWithTag("child", function(t){ switch (t.tag) { @@ -1368,6 +1369,15 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.SPRD: return babelHelpers.extends({actionType:o("WAWebGroupType").GROUP_ACTIONS.FIRST}, {id:t.attrString("jid"), ...base}); + case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.ALIAS: { + var av = t.attrString("jid"); + return mk(av); + } + case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.NAMEDCB: + return {actionType:o("WAWebGroupType").GROUP_ACTIONS.FIRST, who:t.mapChildrenWithTag("participant", parseP)}; + case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.OPTCH: + if (t.hasChild("full")) return {actionType:o("WAWebGroupType").GROUP_ACTIONS.SECOND, who:t.mapChildrenWithTag("p", function(p){ return {id:p.attrString("jid")}; })}; + return {actionType:o("WAWebGroupType").GROUP_ACTIONS.SECOND, other:t.attrString("o")}; case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.SUFFIX: switch (t.attrString("k")) { case "a": t.attrString("setup"); @@ -2079,3 +2089,53 @@ fn an_action_object_with_a_spread_is_refused() { a.fields ); } + +#[test] +fn a_helper_given_an_aliased_read_binds_its_parameter() { + // The minifier hoists nearly every read into a local, so `var av = attrString("jid"); + // mk(av)` passes the text `av` — no wire read in it, and meaningless inside the + // helper's own parse. Resolving the argument through the caller's scope first is what + // makes the alias form work; deciding on the raw text declined it. + let ir = extract_notif(GROUP_ACTIONS_EDGE_BUNDLE, "2.3000.test"); + let a = edge_action(&ir, "alias"); + assert_eq!(a.action_type.as_deref(), Some("third")); + let f = a + .fields + .iter() + .find(|f| f.name == "id") + .expect("the id field"); + assert_eq!(f.wire_name, "jid"); +} + +#[test] +fn a_named_mapped_child_callback_is_read() { + // `mapChildrenWithTag("participant", parseP)` — a module-local callback by NAME is a + // function too. Rejecting the identifier before looking at its body emitted the child + // with no fields, telling a consumer the element carries nothing. + let ir = extract_notif(GROUP_ACTIONS_EDGE_BUNDLE, "2.3000.test"); + let a = edge_action(&ir, "namedcb"); + let child = a + .children + .iter() + .find(|c| c.name == "who") + .expect("the child"); + let names: Vec<&str> = child.fields.iter().map(|f| f.name.as_str()).collect(); + assert!( + names.contains(&"id") && names.contains(&"nick"), + "fields: {names:?}" + ); +} + +#[test] +fn a_child_only_one_branch_carries_is_optional() { + // Two branches, one action, and only one carries the collection. Claiming it is always + // present is the same over-assertion `required` prevents for a scalar, one level up. + let ir = extract_notif(GROUP_ACTIONS_EDGE_BUNDLE, "2.3000.test"); + let a = edge_action(&ir, "optch"); + let child = a + .children + .iter() + .find(|c| c.name == "who") + .expect("the child survives"); + assert!(!child.required, "a branch without it makes it optional"); +} diff --git a/generated/schema/notif.schema.json b/generated/schema/notif.schema.json index e13a2d5..237060c 100644 --- a/generated/schema/notif.schema.json +++ b/generated/schema/notif.schema.json @@ -183,6 +183,10 @@ "description": "The output field the mapped list lands in (`\"participants\"`).", "type": "string" }, + "required": { + "description": "Whether EVERY branch producing this action carries the collection.\n\n`if (c) return {actionType: A, participants: …}; return {actionType: A}` yields two\nlegal shapes for one action, and only one of them has `participants`. Without this\nthe metadata claimed the list is always present — the same over-assertion that\n`required` on a scalar field exists to prevent, one level up.", + "type": "boolean" + }, "wireTag": { "description": "The repeated element's wire tag (`\"participant\"`).", "type": "string" From 861094c61a415a76503442ae5de9391b89b6828a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 12:53:07 -0300 Subject: [PATCH 05/46] fetch: pin what the bootloader yielded, not just what it produced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JS never carries a wasm URL — it asks the bootloader for a numeric `bx` id, and only the page's resource map turns that id into something fetchable. Nothing recorded that map, so a change in the bootloader's shape surfaced only as a wasm count that quietly stopped growing. `wasmResources` cannot cover it either: it counts every handle, most of which address theme images that come and go with UI work, so it is deliberately unguarded. `wasm.lock.json` gains the extraction: the resolved id→URI map, how many handles the page inlined before any request, and how many endpoint requests were sent and failed. A run that suddenly needs more rounds, or starts failing, is the early signal that the endpoint contract moved — and the map itself can be diffed across releases. What is pinned is the EXTRACTION, not the page. The HTML carries nonces and timestamps and is byte-unstable by construction; hashing it would fail the determinism gate for reasons that have nothing to do with the protocol. The key is absent, not empty, when a run never asked the bootloader anything — a local `--bundles` regen must not be readable as "the map is empty now", and the lock is written only by the authoritative fetch path in the first place. 576 tests, determinism check clean, IR consistent. --- crates/whatspec/src/lock.rs | 79 +++++++++++++++++++++++++++++++--- crates/whatspec/src/main.rs | 31 ++++++++++--- crates/whatspec/src/restore.rs | 3 +- 3 files changed, 100 insertions(+), 13 deletions(-) diff --git a/crates/whatspec/src/lock.rs b/crates/whatspec/src/lock.rs index 140afa8..0ab1bea 100644 --- a/crates/whatspec/src/lock.rs +++ b/crates/whatspec/src/lock.rs @@ -12,6 +12,8 @@ //! of bundle contents, not their filenames or order. [`set_hash`] fingerprints that //! multiset in one line; [`BundleLock`] carries the full per-bundle detail. +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; /// One bundle's identity, as collected during a generation run. The `url` is present @@ -144,6 +146,32 @@ pub struct WasmLockEntry { pub size: u64, } +/// The bootloader extraction, pinned. +/// +/// The JS never carries a wasm URL: it asks the bootloader for a numeric `bx` id, and only +/// the page's resource map turns that id into something fetchable. Nothing recorded that +/// map, so a change in the bootloader's shape showed up only as a wasm count that quietly +/// stopped growing — and `wasmResources` is deliberately unguarded, because most handles +/// address theme images that come and go. +/// +/// What is pinned is the EXTRACTION, not the page. The HTML carries nonces and timestamps +/// and is byte-unstable by construction; hashing it would fail the determinism gate for +/// reasons that have nothing to do with the protocol. The resolved id→URI map is stable +/// for a given release, so it can be diffed across releases and read on its own. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootloaderPins { + /// Handles the page inlined, before any request to the bootloader endpoint. + pub handles_from_page: usize, + /// Every `bx` id that resolved to a wasm URL, page and endpoint together. + pub wasm_handles: BTreeMap, + /// Endpoint requests sent, and how many came back unusable. A run that suddenly + /// needs more rounds, or starts failing, is the early signal that the endpoint + /// contract moved. + pub requests: usize, + pub failed_requests: usize, +} + /// The wasm lockfile (`generated/wasm.lock.json`) — what a fetch run resolved and stored. /// /// Deliberately **separate** from [`BundleLock`]: @@ -165,18 +193,30 @@ pub struct WasmLock { pub wasm_set_hash: String, pub wasm_count: usize, /// Every payload, sorted by `(sha256, url)` for a deterministic, diffable file. + /// What the page's bootloader actually yielded this run — see [`BootloaderPins`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bootloader: Option, pub wasm: Vec, } impl WasmLock { - /// Build a lock from a fetch run's resolved payloads. - pub fn new(wa_version: &str, mut wasm: Vec) -> Self { + /// Build a lock from a fetch run's resolved payloads and what the bootloader yielded. + /// + /// `bootloader` is `None` only for a path that never asked it anything; a local + /// `--bundles` regen does not reach here at all, so a recorded map is never + /// overwritten with nothing. + pub fn with_bootloader( + wa_version: &str, + mut wasm: Vec, + bootloader: Option, + ) -> Self { wasm.sort_by(|a, b| a.sha256.cmp(&b.sha256).then_with(|| a.url.cmp(&b.url))); let ids: Vec = wasm.iter().map(WasmLockEntry::as_bundle_id).collect(); Self { wa_version: wa_version.to_string(), wasm_set_hash: set_hash(&ids), wasm_count: wasm.len(), + bootloader, wasm, } } @@ -333,12 +373,13 @@ mod tests { #[test] fn wasm_lock_sorts_fingerprints_and_round_trips() { - let lock = WasmLock::new( + let lock = WasmLock::with_bootloader( "2.3000.1", vec![ wasm_entry("ff", 9, "https://s/y/voip.wasm", Some("32180")), wasm_entry("aa", 1, "https://s/x/liboqs.wasm", None), ], + None, ); assert_eq!(lock.wasm[0].sha256, "aa", "sorted by content hash"); assert_eq!(lock.wasm[0].file_name, "liboqs.wasm"); @@ -364,19 +405,24 @@ mod tests { // (the published JS archive name and the reproducibility gate depend on it). let bundles = vec![id("aa", 1, Some("https://s/a.js"))]; let js = BundleLock::new("v", bundles.clone()); - let wasm = WasmLock::new("v", vec![wasm_entry("bb", 2, "https://s/w.wasm", None)]); + let wasm = WasmLock::with_bootloader( + "v", + vec![wasm_entry("bb", 2, "https://s/w.wasm", None)], + None, + ); assert_ne!(js.set_hash, wasm.wasm_set_hash); assert_eq!(js.set_hash, BundleLock::new("v", bundles).set_hash); } #[test] fn wasm_self_consistency_catches_tampering() { - let lock = WasmLock::new( + let lock = WasmLock::with_bootloader( "v", vec![ wasm_entry("aa", 1, "https://s/a.wasm", None), wasm_entry("bb", 2, "https://s/b.wasm", None), ], + None, ); let mut tampered = lock.clone(); tampered.wasm[0].sha256 = "cc".to_string(); @@ -395,4 +441,27 @@ mod tests { .contains("wasmCount") ); } + #[test] + fn the_bootloader_map_survives_a_lock_round_trip() { + // The id→URI map is the only thing that turns the numeric handle the JS asks for + // into something fetchable, and nothing recorded it — a change in the bootloader + // showed up only as a wasm count that quietly stopped growing. + let pins = BootloaderPins { + handles_from_page: 12, + wasm_handles: BTreeMap::from([("30933".into(), "https://s/a.wasm".into())]), + requests: 4, + failed_requests: 1, + }; + let lock = WasmLock::with_bootloader( + "2.3000.TEST", + vec![wasm_entry("aa", 1, "https://s/a.wasm", Some("30933"))], + Some(pins.clone()), + ); + let back: WasmLock = serde_json::from_str(&lock.to_pretty_json()).expect("round trip"); + assert_eq!(back.bootloader.as_ref(), Some(&pins)); + // And a run that never asked the bootloader writes no key at all, so an offline + // regen cannot be mistaken for "the map is empty now". + let bare = WasmLock::with_bootloader("2.3000.TEST", Vec::new(), None); + assert!(!bare.to_pretty_json().contains("bootloader")); + } } diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index 73cccc6..9fb63ab 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -277,6 +277,7 @@ fn update(args: &[String]) -> Result<()> { source, bundles, wasm, + boot_pins, authoritative, } = load_source(&opts)?; eprintln!("WhatsApp version: {wa_version}"); @@ -354,7 +355,7 @@ fn update(args: &[String]) -> Result<()> { if authoritative && !wasm.is_empty() { let lock_path = opts.out.join(WASM_LOCK_NAME); warn_if_wasm_shrank(&lock_path, &wasm); - let lock = WasmLock::new(&wa_version, wasm); + let lock = WasmLock::with_bootloader(&wa_version, wasm, boot_pins); fs::write(&lock_path, lock.to_pretty_json()) .with_context(|| format!("write {}", lock_path.display()))?; eprintln!( @@ -934,6 +935,10 @@ struct Loaded { /// for on the fetch path. Never part of `source`: these are binaries, and the /// extractors parse `source` as JavaScript. wasm: Vec, + /// What the bootloader yielded this run, when the fetch path ran — see + /// [`lock::BootloaderPins`]. `None` for a local `--bundles` regen, which never + /// asks the bootloader anything and must not clobber a recorded map with nothing. + boot_pins: Option, /// `true` only for the fetch path, which knows every bundle's origin URL and is /// therefore the *authoritative* source of a full lockfile. The offline `--bundles` /// path fingerprints the same inputs (for `--check`) but never rewrites the lock, @@ -959,6 +964,7 @@ fn load_source(opts: &Options) -> Result { // directory of `.js` files, so it stays empty (and the committed wasm // lock is left untouched — see `authoritative`). wasm: Vec::new(), + boot_pins: None, authoritative: false, }) } @@ -1072,12 +1078,14 @@ fn fetch_source(opts: &Options) -> Result { bundles.len() ); maybe_save_bundles(opts, &bundles)?; - let wasm = load_wasm(opts, &discovered, Some(remote_version.as_str()))?; + let (wasm, boot_pins) = + load_wasm(opts, &discovered, Some(remote_version.as_str()))?; return Ok(Loaded { wa_version: version, source: concat_bundles(&bundles), bundles: bundle_ids(&bundles), wasm, + boot_pins, authoritative: true, }); } @@ -1122,12 +1130,13 @@ fn fetch_source(opts: &Options) -> Result { // must never be able to cost the (expensive) JS download a retry. // The discovered version keys the cache; it does not gate the fetch. A run stamped // with `--wa-version` over an unreadable page still collects its payloads. - let wasm = load_wasm(opts, &discovered, discovered.wa_version.as_deref())?; + let (wasm, boot_pins) = load_wasm(opts, &discovered, discovered.wa_version.as_deref())?; Ok(Loaded { wa_version: version, source: concat_bundles(&outcome.bundles), bundles: bundle_ids(&outcome.bundles), wasm, + boot_pins, authoritative: true, }) } @@ -1179,9 +1188,9 @@ fn load_wasm( opts: &Options, discovered: &wa_fetch::Discovered, remote_version: Option<&str>, -) -> Result> { +) -> Result<(Vec, Option)> { if !opts.wasm { - return Ok(Vec::new()); + return Ok((Vec::new(), None)); } // The cache is keyed by the *discovered* version. Without one it can't be used at all @@ -1197,6 +1206,14 @@ fn load_wasm( } let resolution = wa_fetch::resolve_wasm(discovered, &wa_fetch::WasmResolveOptions::default()); + // Pinned alongside the payloads: the id→URI map is the ONLY thing that turns the + // numeric handle the JS asks for into something fetchable, and nothing recorded it. + let pins = lock::BootloaderPins { + handles_from_page: resolution.from_page, + wasm_handles: resolution.by_id.clone(), + requests: resolution.requests, + failed_requests: resolution.failures.len(), + }; eprintln!( "resolved {} wasm payload(s): {} from the page, {} handle(s) after {} bootloader \ round(s) ({} request(s))", @@ -1290,7 +1307,7 @@ fn load_wasm( // previous run's payloads behind, or a runner would consume a set no lock // describes. Not an error — a blocked resolution must not fail a spec update. maybe_save_wasm(opts, &payloads)?; - return Ok(Vec::new()); + return Ok((Vec::new(), Some(pins))); } // Handles: what this run resolved, plus what the cache recorded for payloads it is @@ -1312,7 +1329,7 @@ fn load_wasm( let entries = wasm_entries(&payloads, &handles); maybe_save_wasm(opts, &payloads)?; - Ok(entries) + Ok((entries, Some(pins))) } /// Give every payload a file name that is unique across the set and safe on disk, deriving diff --git a/crates/whatspec/src/restore.rs b/crates/whatspec/src/restore.rs index 50569c7..9a1788a 100644 --- a/crates/whatspec/src/restore.rs +++ b/crates/whatspec/src/restore.rs @@ -1109,7 +1109,7 @@ mod tests { // ── wasm restore ──────────────────────────────────────────────────────────────── fn wasm_lock_for(entries: &[(&str, &str, &[u8])]) -> crate::lock::WasmLock { - crate::lock::WasmLock::new( + crate::lock::WasmLock::with_bootloader( "2.3000.TEST", entries .iter() @@ -1121,6 +1121,7 @@ mod tests { size: bytes.len() as u64, }) .collect(), + None, ) } From d8eee2d6a89d1043689d4e93f95f3bd7053b7816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 13:01:40 -0300 Subject: [PATCH 06/46] review: report unknown field types, keep the pins on an empty run, fold child optionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings on the follow-ups themselves. The linter's docstring said an unrecognized `type` was worth reporting and the code used the vocabulary as its DETECTOR, so a typo'd or newly-added type silently excused the field from every other check — the doc promising what the code did not do, which is the class this whole guard exists to catch. A field is now anything with a known type OR anything carrying a key only a response field has, and the second clause reports the type. The marker only counts when the object has a `type` at all: assertions carry `referencePath` too, and the first version of this flagged four of them as untyped fields. `load_wasm` returns pins even when it resolved no payloads — a blocked or changed bootloader is exactly the state the pins were added to record — but the lock write is guarded on a non-empty payload set, so that state was the one case where they were dropped. An empty run now rewrites only the bootloader section, leaving the recorded payload set alone, which is what the emptiness guard is actually protecting. And merging a same-tag child ignored the incoming `required`. `from` may itself be a fold of several branches that already knows the child is absent from one, so keeping `existing.required` re-asserted a presence the incoming side had disproved. 577 tests, determinism clean, IR consistent. The unknown-type check verified by renaming one `type` in a copy of the artifact: one error, exit 1. --- crates/wa-notif/src/actions.rs | 5 +++++ crates/whatspec/src/main.rs | 41 +++++++++++++++++++++++++++++++++- scripts/lint-ir.py | 35 +++++++++++++++++++++++------ 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index e1d87d5..f9aa468 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -795,6 +795,11 @@ impl BranchFold { // Same output name and the same element: their field sets are // alternatives and merge under the rule above. Some(existing) if existing.wire_tag == c.wire_tag => { + // `from` may itself be a FOLD of several branches, and already know + // the child is absent from one of them. Merging only the fields kept + // `existing.required` true and re-asserted a presence the incoming + // side had already disproved. + existing.required &= c.required; let dead = self.dead_child_fields.entry(c.name.clone()).or_default(); merge_fields(&mut existing.fields, c.fields, false, dead); } diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index 9fb63ab..fc4afa3 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -350,7 +350,18 @@ fn update(args: &[String]) -> Result<()> { // `update` (no `--wasm`) must leave a previously committed wasm lock alone rather // than truncate it to an empty set it never looked for. if authoritative && opts.wasm && wasm.is_empty() { - warn_if_wasm_lock_is_now_stale(&opts.out.join(WASM_LOCK_NAME), &wa_version); + let lock_path = opts.out.join(WASM_LOCK_NAME); + warn_if_wasm_lock_is_now_stale(&lock_path, &wa_version); + // A blocked or changed bootloader is EXACTLY the state the pins were added to + // record, and it is also the state that yields no payloads — so dropping them + // here would lose the diagnostic precisely when it matters. The recorded payload + // set is preserved (that is what the emptiness guard protects); only the + // bootloader section is rewritten. + if let Some(pins) = &boot_pins + && let Err(e) = update_lock_bootloader(&lock_path, pins) + { + eprintln!(" wasm lock not updated with the bootloader map: {e:#}"); + } } if authoritative && !wasm.is_empty() { let lock_path = opts.out.join(WASM_LOCK_NAME); @@ -2062,6 +2073,34 @@ struct NotifCounts { drops: std::collections::BTreeMap, } +/// Rewrite only the bootloader section of an existing wasm lock, leaving the payload set +/// it records untouched. +/// +/// Used when a run resolved no payloads: the emptiness guard exists so an unproductive +/// run cannot truncate the committed set, but the bootloader outcome from that same run +/// is the diagnostic worth keeping. A missing or unreadable lock is left alone — there is +/// no payload set to preserve, and inventing one from an empty run is what the guard +/// forbids. +#[cfg(feature = "fetch")] +fn update_lock_bootloader(lock_path: &Path, pins: &lock::BootloaderPins) -> Result<()> { + let raw = fs::read_to_string(lock_path)?; + let mut lock: WasmLock = serde_json::from_str(&raw)?; + if lock.bootloader.as_ref() == Some(pins) { + return Ok(()); + } + lock.bootloader = Some(pins.clone()); + fs::write(lock_path, lock.to_pretty_json()) + .with_context(|| format!("write {}", lock_path.display()))?; + eprintln!( + "updated {} bootloader map ({} wasm handle(s), {} request(s), {} failed)", + lock_path.display(), + pins.wasm_handles.len(), + pins.requests, + pins.failed_requests + ); + Ok(()) +} + /// Emit the incoming content-stanza read-shape catalog (`incoming/index.json`) — the /// field trees WA Web parses out of received message/receipt/call/ack stanzas. Neutral /// IR only; the codegen is a later phase. diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 9f11e7d..8c2d5ec 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -38,8 +38,18 @@ # for one; `contentUint(N)` reads N big-endian bytes and must always carry it. WIDTH_BEARING = {"contentUint"} -# The `ParsedFieldType` vocabulary, mirroring `wa_ir::ParsedFieldType`. A value -# outside it in a `type` position is itself worth reporting. +# Keys only a response field carries. They identify a `ParsedField` independently of +# whether its `type` is one we know — which is what lets an unrecognized type be +# REPORTED instead of skipped. Deliberately excludes `name`/`type` alone: appstate has +# its own vocabulary (`literal`, `boolString`, `jidOrZero`, proto type names) and the +# notification envelope carries `type` as the notification kind, so keying on those +# would flag correct documents. +FIELD_MARKERS = { + "method", "wireName", "enumRef", "enumKeys", "literalValue", "byteLength", + "byteMin", "byteMax", "intMin", "intMax", "unionVariants", "referencePath", +} + +# The `ParsedFieldType` vocabulary, mirroring `wa_ir::ParsedFieldType`. FIELD_TYPES = { "string", "integer", "timestamp", "timestamp_millis", "enum", "bytes", "jid", "user_jid", "lid_user_jid", "device_jid", "lid_device_jid", @@ -130,11 +140,22 @@ def main() -> int: domain = doc.parent.name def visit(node, path, domain=domain): - # Recognised by the TYPE VOCABULARY, not by auxiliary keys. Keying on - # `method`/`wireName` looked right and silently skipped the appstate - # fields, which carry neither — the linter would have reported a clean - # count while missing a whole domain. - if node.get("type") in FIELD_TYPES: + # A field is anything with a KNOWN type, or anything carrying a key only a + # response field has. The first clause reaches the appstate fields, which + # carry no `method`/`wireName`; the second is what makes an unrecognized type + # reportable rather than invisible — keying on the vocabulary alone meant a + # typo in `type` silently excused the field from every other check. + # A marker only counts when the object HAS a type: an assertion carries + # `referencePath` too, and flagging those as untyped fields was the first + # version of this check reporting four contradictions that were not. + known = node.get("type") in FIELD_TYPES + marked = "type" in node and any(k in node for k in FIELD_MARKERS) + if known or marked: + if not known: + errors.append( + f"{domain}{path}: field type {node.get('type')!r} is not in the " + f"ParsedFieldType vocabulary" + ) check_field(node, f"{domain}{path}", errors, counts) if "kind" in node and "reference_path" not in node: check_assertion(node, f"{domain}{path}", errors) From 0ce3a14510428553f60bc599dfeff258c98c6b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 13:10:33 -0300 Subject: [PATCH 07/46] review: ratchet the baseline, widen the linter's reach, and fix three pin defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven findings on the follow-ups. Two others in the same review were already fixed in d8eee2d, which the review predates. The linter accepted a DECREASE silently, which banks the difference as slack: 157 → 150 followed by seven newly unresolved enums is back at 157 and passes — precisely the drift the counted states exist to catch. It is a ratchet now; an improvement is real work and updating the number with it costs one line. Two invariants the linter claimed and did not check, both because they were gated on the `ParsedFieldType` vocabulary. Request-side and stanza attributes carry `enumRef` keyed by `kind`, so the pending marker — an empty `variants`, which tells a consumer the attribute admits NO value — could ship on those surfaces; and request content states the same byte pin as `constBytes` that a response states as `literalValue`, so neither malformed hex nor a length mismatch was checked there. Both are checked on every dictionary now. `babelHelpers.extends(base, {B: "b"})` MUTATES `base`. Recovering only the result would publish a `BASE` export missing a member the runtime has — an enum claiming to reject a value it accepts, which is the worst thing this can produce. WA's convention is an empty target, so requiring one costs nothing and rules the mutation out. Three on the bootloader pins: * they were built from this run's sample while the payload set accumulates from the cache, so a later successful run could DELETE ids an earlier one resolved while their payloads stayed — the supposedly diffable map differing between two runs of one release. Built from the accumulated handle map now. * `failures` also carries pre-request diagnostics (no endpoint parameters, no deferred components), so a `failedRequests` counted from its length contradicted its own name. `WasmResolution` counts request failures separately. * `NotifActionChild::required` skipped its `true` case, leaving a non-Rust consumer — who the IR is for — unable to tell "required by default" from "unspecified". Always serialized, and the schema now carries `default: true`. 577 tests, 12/12 schemas, determinism clean, IR consistent. --- crates/wa-enums/src/lib.rs | 31 +++++++++++++++++ crates/wa-fetch/src/bootloader.rs | 15 +++++++-- crates/wa-ir/src/notif.rs | 9 ++--- crates/whatspec/src/main.rs | 34 ++++++++++++------- generated/manifest.json | 2 +- generated/notif/index.json | 51 ++++++++++++++++++---------- generated/schema/notif.schema.json | 5 +-- scripts/lint-ir.py | 53 +++++++++++++++++++++++++----- 8 files changed, 153 insertions(+), 47 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index de7cda9..6db3ca5 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -224,6 +224,20 @@ impl NamedResolver { 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, + } let mut kind: Option = None; let mut merged: Vec = Vec::new(); for arg in &call.arguments { @@ -504,6 +518,23 @@ mod tests { 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_composition_over_something_unreadable_is_refused() { // A partial merge is a CLOSED value set missing members the runtime accepts — diff --git a/crates/wa-fetch/src/bootloader.rs b/crates/wa-fetch/src/bootloader.rs index fe3fa56..0798fe2 100644 --- a/crates/wa-fetch/src/bootloader.rs +++ b/crates/wa-fetch/src/bootloader.rs @@ -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, + /// 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 { @@ -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; diff --git a/crates/wa-ir/src/notif.rs b/crates/wa-ir/src/notif.rs index d1a6c45..81d8572 100644 --- a/crates/wa-ir/src/notif.rs +++ b/crates/wa-ir/src/notif.rs @@ -256,10 +256,11 @@ pub struct NotifActionChild { /// legal shapes for one action, and only one of them has `participants`. Without this /// the metadata claimed the list is always present — the same over-assertion that /// `required` on a scalar field exists to prevent, one level up. - #[serde( - default = "crate::default_true", - skip_serializing_if = "crate::is_true" - )] + /// + /// Always serialized. Skipping the `true` case would leave a non-Rust consumer — + /// which is who the IR is for — unable to tell "required by default" from + /// "unspecified", and the JSON Schema cannot carry the default either. + #[serde(default = "crate::default_true")] pub required: bool, } diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index fc4afa3..09a8842 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -1219,11 +1219,20 @@ fn load_wasm( let resolution = wa_fetch::resolve_wasm(discovered, &wa_fetch::WasmResolveOptions::default()); // Pinned alongside the payloads: the id→URI map is the ONLY thing that turns the // numeric handle the JS asks for into something fetchable, and nothing recorded it. - let pins = lock::BootloaderPins { + // + // Built from the map ACCUMULATED for this version, not from this run's sample. The + // endpoint answers the same request with different subsets, so pinning the latest + // sample would let a later successful run DELETE ids a previous one resolved — while + // their payloads stay in the set — and the supposedly diffable map would differ + // between two runs of an unchanged release. + let pin_from = |handles: &BTreeMap| lock::BootloaderPins { handles_from_page: resolution.from_page, - wasm_handles: resolution.by_id.clone(), + wasm_handles: handles + .iter() + .map(|(url, id)| (id.clone(), url.clone())) + .collect(), requests: resolution.requests, - failed_requests: resolution.failures.len(), + failed_requests: resolution.failed_requests, }; eprintln!( "resolved {} wasm payload(s): {} from the page, {} handle(s) after {} bootloader \ @@ -1313,21 +1322,24 @@ fn load_wasm( String::new() } ); - if payloads.is_empty() { - // Still sweep: an explicit `--wasm-out` that produced nothing must not leave the - // previous run's payloads behind, or a runner would consume a set no lock - // describes. Not an error — a blocked resolution must not fail a spec update. - maybe_save_wasm(opts, &payloads)?; - return Ok((Vec::new(), Some(pins))); - } - // Handles: what this run resolved, plus what the cache recorded for payloads it is // carrying over (their `bx` ids were learned in the run that downloaded them). + // Built before the empty-payload exit so the pins describe the accumulated map even + // when this particular run resolved nothing. let mut handles = invert(&discovered.bx_data); if let Some(cache) = &cache { handles.extend(cache.wasm_handles()); } handles.extend(resolution.handle_by_url()); + let pins = pin_from(&handles); + + if payloads.is_empty() { + // Still sweep: an explicit `--wasm-out` that produced nothing must not leave the + // previous run's payloads behind, or a runner would consume a set no lock + // describes. Not an error — a blocked resolution must not fail a spec update. + maybe_save_wasm(opts, &payloads)?; + return Ok((Vec::new(), Some(pins))); + } // The wasm cache attaches to the JS cache for the same version; if that isn't there // (e.g. the JS came straight from a download with no `--cache`), skip caching rather diff --git a/generated/manifest.json b/generated/manifest.json index 1f7aa39..9c98e53 100644 --- a/generated/manifest.json +++ b/generated/manifest.json @@ -79,7 +79,7 @@ "notif": { "file": "notif/index.json", "schema": "schema/notif.schema.json", - "sha256": "473f4d20fefe3136b264f3398811fb9af85dc5fca59022b727ca16f83420cd33" + "sha256": "c140136bbad10bf1412de40aa6408792530e27ae25b7606a400bed10c05aaf6e" }, "proto": { "file": "proto/WAProto.proto", diff --git a/generated/notif/index.json b/generated/notif/index.json index 2616b04..6b6257f 100644 --- a/generated/notif/index.json +++ b/generated/notif/index.json @@ -1834,7 +1834,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -1908,7 +1909,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -1962,7 +1964,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -2016,7 +2019,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -2078,7 +2082,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -2140,7 +2145,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -2194,7 +2200,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -2413,7 +2420,8 @@ "type": "integer", "required": true } - ] + ], + "required": true } ] }, @@ -2464,7 +2472,8 @@ "type": "integer", "required": true } - ] + ], + "required": true } ] }, @@ -2494,7 +2503,8 @@ "type": "integer", "required": true } - ] + ], + "required": true } ] }, @@ -2524,7 +2534,8 @@ "type": "integer", "required": true } - ] + ], + "required": true } ] }, @@ -2554,7 +2565,8 @@ "type": "integer", "required": true } - ] + ], + "required": true } ] }, @@ -2583,7 +2595,8 @@ "type": "integer", "required": true } - ] + ], + "required": true } ] }, @@ -2699,7 +2712,8 @@ "type": "user_jid", "required": false } - ] + ], + "required": true } ] }, @@ -2717,7 +2731,8 @@ "type": "user_jid", "required": true } - ] + ], + "required": true } ] }, @@ -2792,7 +2807,8 @@ "type": "user_jid", "required": true } - ] + ], + "required": true } ] }, @@ -2824,7 +2840,8 @@ "type": "group_jid", "required": true } - ] + ], + "required": true } ] }, diff --git a/generated/schema/notif.schema.json b/generated/schema/notif.schema.json index 237060c..7a1156f 100644 --- a/generated/schema/notif.schema.json +++ b/generated/schema/notif.schema.json @@ -184,8 +184,9 @@ "type": "string" }, "required": { - "description": "Whether EVERY branch producing this action carries the collection.\n\n`if (c) return {actionType: A, participants: …}; return {actionType: A}` yields two\nlegal shapes for one action, and only one of them has `participants`. Without this\nthe metadata claimed the list is always present — the same over-assertion that\n`required` on a scalar field exists to prevent, one level up.", - "type": "boolean" + "description": "Whether EVERY branch producing this action carries the collection.\n\n`if (c) return {actionType: A, participants: …}; return {actionType: A}` yields two\nlegal shapes for one action, and only one of them has `participants`. Without this\nthe metadata claimed the list is always present — the same over-assertion that\n`required` on a scalar field exists to prevent, one level up.\n\nAlways serialized. Skipping the `true` case would leave a non-Rust consumer —\nwhich is who the IR is for — unable to tell \"required by default\" from\n\"unspecified\", and the JSON Schema cannot carry the default either.", + "type": "boolean", + "default": true }, "wireTag": { "description": "The repeated element's wire tag (`\"participant\"`).", diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 8c2d5ec..675357e 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -79,12 +79,6 @@ def check_field(f, path, errors, counts): if t == "enum" and not f.get("enumRef") and not f.get("enumKeys"): counts["enum field with no values"] += 1 - # The pending marker must never reach the artifact: an empty variant list - # tells a consumer the field admits NO value. - ref = f.get("enumRef") - if isinstance(ref, dict) and not ref.get("variants"): - errors.append(f"{path}: enumRef {ref.get('name')!r} has no variants") - if method in WIDTH_BEARING and "byteLength" not in f: counts["content integer with no byte width"] += 1 @@ -119,6 +113,35 @@ def check_field(f, path, errors, counts): errors.append(f"{path}: referencePath is empty") +def check_enum_ref(node, path, errors): + """An `enumRef` anywhere, not only on a `ParsedField`. + + Request-side and stanza attributes carry one too, keyed by `kind` rather than `type`, + so gating this on the field-type vocabulary let the pending marker — an empty + `variants`, which tells a consumer the attribute admits NO value — ship on those + surfaces unchecked. + """ + ref = node.get("enumRef") + if isinstance(ref, dict) and not ref.get("variants"): + errors.append(f"{path}: enumRef {ref.get('name')!r} has no variants") + + +def check_const_bytes(node, path, errors): + """A request-side `constBytes` pin: the same invariant as a response `literalValue` + on a bytes field, written differently. Both say "these exact bytes"; only one was + checked.""" + cb = node.get("constBytes") + if not isinstance(cb, str): + return + if any(c not in "0123456789abcdef" for c in cb) or len(cb) % 2: + errors.append(f"{path}: constBytes {cb!r} is not lowercase hex") + elif "byteLength" in node and len(cb) != node["byteLength"] * 2: + errors.append( + f"{path}: constBytes is {len(cb) // 2} bytes, " + f"byteLength says {node['byteLength']}" + ) + + def check_assertion(a, path, errors): if a.get("kind") == "reference" and not a.get("referencePath"): errors.append(f"{path}: reference assertion with no referencePath") @@ -159,6 +182,9 @@ def visit(node, path, domain=domain): check_field(node, f"{domain}{path}", errors, counts) if "kind" in node and "reference_path" not in node: check_assertion(node, f"{domain}{path}", errors) + # Independent of the field gate — see each function's note. + check_enum_ref(node, f"{domain}{path}", errors) + check_const_bytes(node, f"{domain}{path}", errors) walk(data, visit) @@ -168,9 +194,15 @@ def visit(node, path, domain=domain): if observed > allowed: print(f"REGRESSION {name}: {observed} (baseline {allowed})") ok = False + elif observed < allowed: + # A RATCHET, not an upper bound. Accepting a decrease silently banks the + # difference as slack: 157 -> 150 followed by seven newly unresolved enums is + # back at 157 and passes, which is exactly the drift this is supposed to catch. + # An improvement is real work and updating the number with it costs one line. + print(f"IMPROVED {name}: {observed} (baseline {allowed}) — lower the baseline") + ok = False else: - mark = "ok " if observed == allowed else "ok ↓" - print(f"{mark} {name}: {observed} (baseline {allowed})") + print(f"ok {name}: {observed} (baseline {allowed})") for e in errors: print(f"ERROR {e}") @@ -179,7 +211,10 @@ def visit(node, path, domain=domain): print(f"\n{len(errors)} internal contradiction(s) in the IR") return 1 if not ok: - print("\na counted state rose above its baseline — a constraint is being lost") + print( + "\na counted state left its baseline — raise means a constraint is being lost, " + "fall means the baseline owes an update" + ) return 1 print(f"\n{len(docs)} document(s) internally consistent") return 0 From bb3de9f35b94042a4e75a0775a9aca0b16d3f018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 13:13:06 -0300 Subject: [PATCH 08/46] fix: gate the bootloader lock update with the feature its function lives behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `update_lock_bootloader` is `#[cfg(feature = "fetch")]` and its call site was not, so `cargo build -p whatspec --no-default-features` — a job this repo runs precisely because the tool must build without the network stack — stopped compiling. `cargo test --workspace` never exercises that configuration, and I never ran it. The `BTreeMap` import moves behind the same gate for the same reason: only the fetch path builds the pin map from it. Checked against the whole CI matrix this time rather than the subset I had been running: fmt, clippy (default and `--all-features`), the workspace tests, the no-default-features build, `wa-fetch` and `wa-ir` on wasm32, `wa-ir --features schema`, both artifact scripts, and the determinism gate. The two remaining no-fetch warnings are pre-existing on main, which now emits three of them. --- crates/whatspec/src/main.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index 09a8842..c4faa78 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -2,7 +2,10 @@ //! versioned artifacts (IQ specs, WAProto.proto, mex operations, appstate //! schemas) to disk, ready to be committed — locally or from CI. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; +// Only the fetch path builds the bootloader pin map from it. +#[cfg(feature = "fetch")] +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; @@ -357,6 +360,11 @@ fn update(args: &[String]) -> Result<()> { // here would lose the diagnostic precisely when it matters. The recorded payload // set is preserved (that is what the emptiness guard protects); only the // bootloader section is rewritten. + // Gated with the function it calls: without `fetch` there is no bootloader to + // have asked, and `boot_pins` is always `None`. The call site was NOT gated when + // the function was, so the no-default-features build stopped compiling — a + // configuration `cargo test --workspace` never exercises. + #[cfg(feature = "fetch")] if let Some(pins) = &boot_pins && let Err(e) = update_lock_bootloader(&lock_path, pins) { From 04fbd20314d7b5250224a90d38c28a20660290ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 13:39:43 -0300 Subject: [PATCH 09/46] review: pin only wasm handles, accumulate them, and refuse a rebound enum name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings from the Codex round on bb3de9f. Five are latent — they change no committed artifact today — and one changes the IR linter's count. - `BootloaderPins::wasm_handles` promised wasm and delivered the page's whole `bxData`: images and every other resource, which churn far more often than the wasm set and so swamped the diff signal the field exists for. Filtered with the resolver's own `is_wasm_url` rather than a second copy of the predicate. - An empty `--wasm` run replaced the bootloader section wholesale, deleting ids earlier runs had resolved from the endpoint while keeping the payloads they resolved. The update workflow passes no `--cache`, so the lock is the only record on that path. The map now accumulates; the diagnostics still describe the current run. - A mapped child behind a guard was always `required: true`, though `strip_guard` reaches through the guard to find it. Deriving that from `is_guarded` alone would be wrong in the other direction — the one live case, `t.hasChildren() ? t.mapChildrenWithTag(…) : [{wid: …}]`, always yields a collection — so only a literal absence on the far side makes it optional. - `NamedResolver::locals` is flat while JavaScript is lexically scoped, so a name rebound inside a nested function could feed a composition the wrong body and publish a closed set naming members the runtime never accepts. No bundle in the pinned set has a single collision; both readers now refuse the name rather than guess. - The linter counted appstate's two `protoEnum` fields as enums with no values. They name their values, just by reference — and with the baseline a ratchet, a new proto-backed enum could have cancelled out a real regression. Baseline 157 -> 155. - `literalValue` and `referencePath` are documented as mutually exclusive but were only ever checked apart. No live case, which is the point of adding it now. Every fix has a test that fails without it, and the two that could plausibly have been over-corrected are pinned from both sides. --- crates/wa-enums/src/lib.rs | 102 +++++++++++++++++++--- crates/wa-fetch/src/discover.rs | 2 +- crates/wa-fetch/src/lib.rs | 2 +- crates/wa-notif/src/actions.rs | 33 +++++++- crates/wa-notif/src/tests.rs | 43 +++++++++- crates/whatspec/src/main.rs | 144 ++++++++++++++++++++++++++++---- scripts/lint-ir.py | 28 ++++++- 7 files changed, 316 insertions(+), 38 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 6db3ca5..5ae0ccc 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -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::{ @@ -105,6 +105,12 @@ fn extract_from_module(slice: &str, module: &str) -> Vec { /// 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) { r.exports .entry(export.clone()) @@ -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(); @@ -179,11 +181,7 @@ fn extract_plain_object_enums(slice: &str, module: &str) -> Vec 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 = Vec::new(); @@ -203,10 +201,52 @@ fn extract_plain_object_enums(slice: &str, module: &str) -> Vec struct NamedResolver { locals: HashMap, + /// 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, exports: HashMap, 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. /// @@ -253,7 +293,7 @@ impl NamedResolver { 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.locals.get(as_identifier(operand)?)?.clone(), + None => self.local(as_identifier(operand)?)?.clone(), }; if !variants.is_empty() { match kind { @@ -291,7 +331,7 @@ impl<'a> Visit<'a> for NamedResolver { // `dropsByReason`. .or_else(|| d.init.as_ref().and_then(|e| self.merge_extends(e))); if let Some(data) = data { - self.locals.insert(local.to_string(), data); + self.bind_local(local.as_str(), data); } } walk::walk_variable_declarator(self, d); @@ -535,6 +575,42 @@ mod tests { 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 — diff --git a/crates/wa-fetch/src/discover.rs b/crates/wa-fetch/src/discover.rs index 02f0693..6e9c49f 100644 --- a/crates/wa-fetch/src/discover.rs +++ b/crates/wa-fetch/src/discover.rs @@ -199,7 +199,7 @@ pub fn discover_from_html(status: u16, html: &str, base_url: &str) -> Result bool { +pub fn is_wasm_url(url: &str) -> bool { url.split(['?', '#']) .next() .unwrap_or(url) diff --git a/crates/wa-fetch/src/lib.rs b/crates/wa-fetch/src/lib.rs index 9ebc318..3bf4b38 100644 --- a/crates/wa-fetch/src/lib.rs +++ b/crates/wa-fetch/src/lib.rs @@ -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}; diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index f9aa468..ffc0324 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -1861,7 +1861,19 @@ fn mapped_child<'b, 'a>( } } Some(NotifActionChild { - required: true, + // `strip_guard` above deliberately reaches THROUGH a guard to find the map call, + // so `participants: enabled && node.mapChildrenWithTag(…)` is recovered — and on + // that path there is a legal execution with no collection at all. Reporting it + // required regardless promised a consumer a collection that may never arrive, + // and `BranchFold` cannot correct it: the guard sits inside one object shape + // rather than splitting the arm into branches it could merge. + // + // What it is NOT is `!is_guarded(e)`. `read_field` runs `value_selection` before + // consulting the guard precisely because a ternary between two REAL values is a + // choice of source, not an absence — and the one live case here is exactly that: + // `requests: t.hasChildren() ? t.mapChildrenWithTag(…) : [{wid: …}]` always + // yields a collection. Only a literal absence on the far side makes it optional. + required: !guard_admits_absence(e), name: key.to_string(), wire_tag: wire_tag.to_string(), fields, @@ -1962,6 +1974,25 @@ fn value_selection<'b, 'a>( } } +/// Whether a guard around a value has an execution path that produces NO value. +/// +/// Stricter than [`is_guarded`], which asks only whether a guard is present. A ternary +/// selecting between two real values (`cond ? mapChildren(…) : [fallback]`) is guarded +/// yet always yields something, so treating every guard as optionality would publish +/// `required: false` for a collection that is in fact always there. `a && value` is the +/// opposite: the falsy path yields no value at all. +fn guard_admits_absence(e: &Expression) -> bool { + match e { + Expression::ConditionalExpression(c) => { + is_nullish(strip_guard(&c.consequent)) || is_nullish(strip_guard(&c.alternate)) + } + Expression::LogicalExpression(l) => { + l.operator == oxc_syntax::operator::LogicalOperator::And + } + _ => false, + } +} + /// Whether the expression is a literal absence — the far side of a presence guard. fn is_nullish(e: &Expression) -> bool { match e { diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index a340637..a2874e9 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -598,7 +598,7 @@ __d("WAWebHandleDeviceNotification",["WADeprecatedWapParser"],(function(t,n,r,o, __d("WAWebHandleGroupNotificationConst",[],(function(t,n,r,o,a,i,l){ var cache={}; cache.GROUP_NOTIFICATION_TAG={ADD:"WRONG_add",SUBJECT:"WRONG_subject"}; - var e=Object.freeze({ADD:"add",SUBJECT:"subject",EPHEMERAL:"ephemeral",NOT_EPHEMERAL:"not_ephemeral",MODIFY:"modify",GROWTH_LOCKED:"growth_locked",INVITE:"invite",LINK:"link",ANNOUNCE:"announcement",LOCKED:"locked",REVOKE_INVITE:"revoke",DESC:"description",UNLINK:"unlink"}); + var e=Object.freeze({ADD:"add",SUBJECT:"subject",EPHEMERAL:"ephemeral",NOT_EPHEMERAL:"not_ephemeral",MODIFY:"modify",GROWTH_LOCKED:"growth_locked",INVITE:"invite",LINK:"link",ANNOUNCE:"announcement",LOCKED:"locked",REVOKE_INVITE:"revoke",DESC:"description",UNLINK:"unlink",MEMBERSHIP:"membership"}); l.GROUP_NOTIFICATION_TAG=e; }), 1); __d("WAWebGroupApiConst",[],(function(t,n,r,o,a,i,l){ @@ -606,7 +606,7 @@ __d("WAWebGroupApiConst",[],(function(t,n,r,o,a,i,l){ l.GROUP_PARTICIPANT_TYPES=g; }), 1); __d("WAWebGroupType",[],(function(t,n,r,o,a,i,l){ - var d=Object.freeze({ADD:"add",SUBJECT:"subject",EPHEMERAL:"ephemeral",MODIFY:"modify",ANNOUNCE:"announce",RESTRICT:"restrict",REVOKE_INVITE:"revoke_invite",DESC_ADD:"desc_add",DESC_REMOVE:"desc_remove"}); + var d=Object.freeze({ADD:"add",SUBJECT:"subject",EPHEMERAL:"ephemeral",MODIFY:"modify",ANNOUNCE:"announce",RESTRICT:"restrict",REVOKE_INVITE:"revoke_invite",DESC_ADD:"desc_add",DESC_REMOVE:"desc_remove",MEMBERSHIP:"membership"}); l.GROUP_ACTIONS=d; }), 1); __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGroupType"],(function(t,n,r,o,a,i,l){ @@ -669,6 +669,11 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.UNLINK: return I(t); + case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.MEMBERSHIP: + return {actionType:o("WAWebGroupType").GROUP_ACTIONS.MEMBERSHIP, + picked: t.hasChildren() ? t.mapChildrenWithTag("picked_user", function(x){ return {id:x.attrUserJid("jid")}; }) : [{id:"fallback"}], + nulled: t.hasChild("opt") ? t.mapChildrenWithTag("nulled_user", function(x){ return {id:x.attrUserJid("jid")}; }) : null, + anded: t.hasChild("flag") && t.mapChildrenWithTag("anded_user", function(x){ return {id:x.attrUserJid("jid")}; })}; case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.DESC: @@ -791,6 +796,40 @@ fn group_action_arms_recover_fields_children_and_branches() { assert_eq!(body.wire_name, "body"); } +#[test] +fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { + // `strip_guard` reaches through a guard to find the map call, so all three arrive. + // Whether the collection can be MISSING is a different question from whether a guard + // is present, and answering it with `is_guarded` alone gets the first case wrong: + // WA's live `created_membership_requests` reads + // `t.hasChildren() ? t.mapChildrenWithTag(…) : [{wid: …}]`, which always yields a + // collection. Only a literal absence on the far side — or `&&`, whose falsy path + // yields no value at all — makes it optional. + let ir = extract_notif(GROUP_ACTIONS_BUNDLE, "2.3000.test"); + let arm = group_actions(&ir) + .into_iter() + .find(|a| a.wire_tag == "membership") + .expect("membership arm"); + let child = |name: &str| { + arm.children + .iter() + .find(|c| c.name == name) + .unwrap_or_else(|| panic!("no child {name}")) + }; + assert!( + child("picked").required, + "a ternary between two real values always yields a collection" + ); + assert!( + !child("nulled").required, + "`cond ? map(…) : null` may be absent" + ); + assert!(!child("anded").required, "`cond && map(…)` may be absent"); + // The guard must not cost the child's contents. + assert_eq!(child("anded").wire_tag, "anded_user"); + assert_eq!(child("anded").fields.len(), 1); +} + #[test] fn handler_without_a_const_keyed_switch_has_no_actions() { // Most handlers forward straight to a `WADeprecatedWapParser` and carry no action diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index c4faa78..cee2b82 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -1225,23 +1225,6 @@ fn load_wasm( } let resolution = wa_fetch::resolve_wasm(discovered, &wa_fetch::WasmResolveOptions::default()); - // Pinned alongside the payloads: the id→URI map is the ONLY thing that turns the - // numeric handle the JS asks for into something fetchable, and nothing recorded it. - // - // Built from the map ACCUMULATED for this version, not from this run's sample. The - // endpoint answers the same request with different subsets, so pinning the latest - // sample would let a later successful run DELETE ids a previous one resolved — while - // their payloads stay in the set — and the supposedly diffable map would differ - // between two runs of an unchanged release. - let pin_from = |handles: &BTreeMap| lock::BootloaderPins { - handles_from_page: resolution.from_page, - wasm_handles: handles - .iter() - .map(|(url, id)| (id.clone(), url.clone())) - .collect(), - requests: resolution.requests, - failed_requests: resolution.failed_requests, - }; eprintln!( "resolved {} wasm payload(s): {} from the page, {} handle(s) after {} bootloader \ round(s) ({} request(s))", @@ -1339,7 +1322,7 @@ fn load_wasm( handles.extend(cache.wasm_handles()); } handles.extend(resolution.handle_by_url()); - let pins = pin_from(&handles); + let pins = bootloader_pins(&resolution, &handles); if payloads.is_empty() { // Still sweep: an explicit `--wasm-out` that produced nothing must not leave the @@ -1445,6 +1428,39 @@ fn truncate_on_char_boundary(s: &str, max_bytes: usize) -> &str { &s[..end] } +/// The bootloader section of the wasm lock: what this run learned about turning a numeric +/// `bx` handle into something fetchable, which nothing else records. +/// +/// `handles` is the map ACCUMULATED for this version (page + cache + this run), not this +/// run's sample. The endpoint answers the same request with different subsets, so pinning +/// the latest sample would let a later successful run DELETE ids a previous one resolved — +/// while their payloads stay in the set — and the supposedly diffable map would differ +/// between two runs of an unchanged release. +/// +/// Only the wasm entries are pinned, as the field name promises. `handles` starts from the +/// page's `bxData`, the bootloader's map of EVERY resource — images and the rest — while +/// the other two contributors are already wasm-filtered (`by_id` keeps only wasm URLs, and +/// the cache filters against stored wasm payloads). Pinning the lot broke the field's +/// contract and swamped the diff signal it exists for, since unrelated resources churn far +/// more often than the wasm set does. `handles` itself is left whole: `store_wasm` and +/// `wasm_entries` look it up by a payload's own URL, where a non-wasm entry never matches. +#[cfg(feature = "fetch")] +fn bootloader_pins( + resolution: &wa_fetch::WasmResolution, + handles: &BTreeMap, +) -> lock::BootloaderPins { + lock::BootloaderPins { + handles_from_page: resolution.from_page, + wasm_handles: handles + .iter() + .filter(|(url, _)| wa_fetch::is_wasm_url(url)) + .map(|(url, id)| (id.clone(), url.clone())) + .collect(), + requests: resolution.requests, + failed_requests: resolution.failed_requests, + } +} + /// `bx` id → URI inverted into URI → `bx` id, lowest id winning on a shared URI (the /// input is sorted, so the choice is deterministic). #[cfg(feature = "fetch")] @@ -2101,10 +2117,25 @@ struct NotifCounts { /// is the diagnostic worth keeping. A missing or unreadable lock is left alone — there is /// no payload set to preserve, and inventing one from an empty run is what the guard /// forbids. +/// +/// The handle map ACCUMULATES over the lock's own previous value; only the request +/// diagnostics are replaced. Everywhere else the accumulation comes from the cache, but +/// the update workflow runs `--wasm-out` with no `--cache`, so on that path the lock is +/// the only surviving record of what earlier runs resolved from the endpoint — and +/// replacing the section wholesale would delete those ids while deliberately keeping the +/// payloads they resolved. That is precisely the "a later run must not shrink the map" +/// rule the pins were introduced to honour. #[cfg(feature = "fetch")] fn update_lock_bootloader(lock_path: &Path, pins: &lock::BootloaderPins) -> Result<()> { let raw = fs::read_to_string(lock_path)?; let mut lock: WasmLock = serde_json::from_str(&raw)?; + let mut pins = pins.clone(); + if let Some(prev) = &lock.bootloader { + for (id, url) in &prev.wasm_handles { + pins.wasm_handles.entry(id.clone()).or_insert(url.clone()); + } + } + let pins = &pins; if lock.bootloader.as_ref() == Some(pins) { return Ok(()); } @@ -3000,6 +3031,83 @@ mod tests { assert!(names.iter().all(|n| n.ends_with(".wasm") && n.len() <= 128)); } + #[cfg(feature = "fetch")] + #[test] + fn an_empty_run_accumulates_the_bootloader_map_instead_of_replacing_it() { + // The update workflow runs `--wasm-out` with NO `--cache`, so on that path the + // lock is the only record of what earlier runs resolved from the endpoint. A + // wholesale replace would drop those ids while keeping the payloads they + // resolved — the exact shrink the pins exist to prevent. Diagnostics are the + // opposite: they describe THIS run, so they must not accumulate. + let dir = std::env::temp_dir().join(format!("ws-boot-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir"); + let path = dir.join("wasm.lock.json"); + let pins = |handles: &[(&str, &str)], requests, failed| lock::BootloaderPins { + handles_from_page: 1, + wasm_handles: handles + .iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect(), + requests, + failed_requests: failed, + }; + + let before = lock::WasmLock::with_bootloader( + "2.3000.test", + vec![], + Some(pins(&[("11", "https://x/a.wasm")], 4, 0)), + ); + fs::write(&path, before.to_pretty_json()).expect("seed the lock"); + + // An unproductive run that saw only the page's own handle. + update_lock_bootloader(&path, &pins(&[("22", "https://x/b.wasm")], 1, 1)) + .expect("rewrite the section"); + + let after: lock::WasmLock = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + let boot = after.bootloader.expect("bootloader section"); + assert_eq!( + boot.wasm_handles.keys().collect::>(), + ["11", "22"], + "the earlier endpoint-resolved id survives" + ); + assert_eq!(boot.requests, 1, "diagnostics describe THIS run"); + assert_eq!(boot.failed_requests, 1); + fs::remove_dir_all(&dir).unwrap(); + } + + #[cfg(feature = "fetch")] + #[test] + fn only_wasm_handles_are_pinned() { + // The page's `bxData` maps EVERY resource, not just wasm, and it is one of the + // three sources folded into `handles`. Pinning the lot broke the field's own + // contract and swamped its diff signal with images that change far more often + // than the wasm set does. + let handles: BTreeMap = [ + ("https://x/a.wasm", "11"), + ("https://x/logo.png", "22"), + ("https://x/b.wasm?v=3", "33"), + ] + .iter() + .map(|(u, i)| (u.to_string(), i.to_string())) + .collect(); + let resolution = wa_fetch::WasmResolution { + from_page: 1, + requests: 2, + failed_requests: 0, + ..Default::default() + }; + let pins = bootloader_pins(&resolution, &handles); + assert_eq!( + pins.wasm_handles.keys().collect::>(), + ["11", "33"], + "the image is dropped and a query string does not hide a .wasm" + ); + // The diagnostics still come from the resolution, untouched by the filter. + assert_eq!((pins.handles_from_page, pins.requests), (1, 2)); + } + #[test] fn truncation_never_splits_a_character() { // A non-ASCII segment must not panic the run. diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 675357e..ea71e12 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -30,7 +30,9 @@ # one of these is a deliberate act: it means a constraint the extractor used to # recover is now being lost, and the number has to be updated with a reason. BASELINE = { - "enum field with no values": 157, + # 157 -> 155: appstate's two `protoEnum` fields were never unresolved, only + # unrecognised by the check below. + "enum field with no values": 155, "content integer with no byte width": 0, } @@ -76,7 +78,18 @@ def check_field(f, path, errors, counts): # An enum that names no legal values is the "is this unconstrained, or did we # lose the constraint?" ambiguity the IR exists to remove. - if t == "enum" and not f.get("enumRef") and not f.get("enumKeys"): + # + # `protoEnum` names the values too, just by pointing at a protobuf enum instead of + # inlining them (appstate's `SettingsSync.settingPlatform`). Omitting it counted two + # fully-resolved constraints as lost — and since the baseline is a ratchet, a new + # proto-backed enum could then silently cancel out a genuine unresolved-enum fix + # while the total held still. + if ( + t == "enum" + and not f.get("enumRef") + and not f.get("enumKeys") + and not f.get("protoEnum") + ): counts["enum field with no values"] += 1 if method in WIDTH_BEARING and "byteLength" not in f: @@ -112,6 +125,17 @@ def check_field(f, path, errors, counts): if "referencePath" in f and not f["referencePath"]: errors.append(f"{path}: referencePath is empty") + # `ParsedField::reference_path` is documented as mutually exclusive with + # `literal_value`: one pins a constant, the other pins a value copied from the + # request. Carried together they are two different answers to "what is this + # field's value", and a consumer cannot honour both. Checking each key on its own + # let the pair through — no live case today, which is the point of adding it now. + if lv is not None and f.get("referencePath"): + errors.append( + f"{path}: carries both literalValue and referencePath, " + f"which pin the value to different things" + ) + def check_enum_ref(node, path, errors): """An `enumRef` anywhere, not only on a `ParsedField`. From b199dd2407eb9e4a1d94616bdd1968c9540d8abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 13:53:31 -0300 Subject: [PATCH 10/46] review: keep the bootloader map within one release, and check WAM's enum links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the Codex round on 04fbd20. - On a version bump that resolved no payloads, the previous commit wrote this run's handles into a lock still stamped for the older release — and since that map now accumulates, it interleaved two releases' ids in a record whose whole purpose is being diffable. `warn_if_wasm_lock_is_now_stale` already detected the mismatch and only warned. The updater now takes the version and leaves a lock belonging to another one alone, saying so. - WAM's 1431 enum-typed fields name a module and expect the consumer to find it in the document's own `enums` catalog, but the linter only ever inspected inline `enumRef` objects. A dangling module — or a definition with no variants, which the schema permits — read as internally consistent while a consumer could not encode the field. All 1431 resolve today; the check is keyed on the document having a catalog rather than on the name "wam", so a second domain adopting the shape is covered without anyone remembering. Both have a test that fails without the fix; the linter's three failure modes (dangling, empty, ambiguous) are each verified against a mutated document. --- crates/whatspec/src/main.rs | 48 +++++++++++++++++++++++++++++++++---- scripts/lint-ir.py | 41 +++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index cee2b82..6066288 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -366,7 +366,7 @@ fn update(args: &[String]) -> Result<()> { // configuration `cargo test --workspace` never exercises. #[cfg(feature = "fetch")] if let Some(pins) = &boot_pins - && let Err(e) = update_lock_bootloader(&lock_path, pins) + && let Err(e) = update_lock_bootloader(&lock_path, pins, &wa_version) { eprintln!(" wasm lock not updated with the bootloader map: {e:#}"); } @@ -2125,10 +2125,29 @@ struct NotifCounts { /// replacing the section wholesale would delete those ids while deliberately keeping the /// payloads they resolved. That is precisely the "a later run must not shrink the map" /// rule the pins were introduced to honour. +/// +/// Accumulating is only sound WITHIN one release, so a lock stamped for a different +/// `wa_version` is left untouched. On a version bump that resolves nothing, the lock still +/// pins the previous release's payloads (`warn_if_wasm_lock_is_now_stale` says so); writing +/// this run's handles into it would attribute the new release's ids to the old one, and — +/// because the map accumulates — interleave two releases' ids into a provenance record +/// whose whole purpose is being diffable. #[cfg(feature = "fetch")] -fn update_lock_bootloader(lock_path: &Path, pins: &lock::BootloaderPins) -> Result<()> { +fn update_lock_bootloader( + lock_path: &Path, + pins: &lock::BootloaderPins, + wa_version: &str, +) -> Result<()> { let raw = fs::read_to_string(lock_path)?; let mut lock: WasmLock = serde_json::from_str(&raw)?; + if lock.wa_version != wa_version { + eprintln!( + " bootloader map not written: {} is stamped for {}, not {wa_version}", + lock_path.display(), + lock.wa_version + ); + return Ok(()); + } let mut pins = pins.clone(); if let Some(prev) = &lock.bootloader { for (id, url) in &prev.wasm_handles { @@ -3061,8 +3080,12 @@ mod tests { fs::write(&path, before.to_pretty_json()).expect("seed the lock"); // An unproductive run that saw only the page's own handle. - update_lock_bootloader(&path, &pins(&[("22", "https://x/b.wasm")], 1, 1)) - .expect("rewrite the section"); + update_lock_bootloader( + &path, + &pins(&[("22", "https://x/b.wasm")], 1, 1), + "2.3000.test", + ) + .expect("rewrite the section"); let after: lock::WasmLock = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); @@ -3074,6 +3097,23 @@ mod tests { ); assert_eq!(boot.requests, 1, "diagnostics describe THIS run"); assert_eq!(boot.failed_requests, 1); + + // ...but only within one release. On a version bump that resolved nothing, the + // lock still describes the PREVIOUS release, so accumulating this run's ids into + // it would interleave two releases in a record whose point is being diffable. + update_lock_bootloader( + &path, + &pins(&[("33", "https://x/c.wasm")], 9, 9), + "2.3000.next", + ) + .expect("a stale lock is not an error"); + let untouched: lock::WasmLock = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + untouched.bootloader.expect("still there"), + boot, + "a lock stamped for another version is left exactly as it was" + ); fs::remove_dir_all(&dir).unwrap(); } diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index ea71e12..85b450e 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -166,6 +166,44 @@ def check_const_bytes(node, path, errors): ) +def check_enum_catalog_refs(data, domain, errors): + """Enum references that point INTO the document's own top-level `enums` catalog. + + Every other enum in the IR inlines its values, so `check_enum_ref` — which looks at + an `enumRef` object in place — is enough. WAM instead names a module and expects the + consumer to find it in `enums`, and nothing verified the link resolved: a dangling + name, or a definition left with no variants (which the schema permits), still read as + "internally consistent" while a consumer could not encode the field at all. + + Keyed on the document HAVING a catalog rather than on the domain name, so a second + domain adopting the same shape is covered without anyone remembering to add it here. + """ + catalog = data.get("enums") + if not isinstance(catalog, list) or not catalog: + return + by_module = {} + for e in catalog: + if isinstance(e, dict) and "module" in e: + by_module.setdefault(e["module"], []).append(e) + + def visit(node, path): + if node.get("kind") != "enum" or "module" not in node: + return + module = node["module"] + found = by_module.get(module, []) + if not found: + errors.append(f"{domain}{path}: enum reference {module!r} is in no definition") + elif len(found) > 1: + errors.append( + f"{domain}{path}: enum reference {module!r} matches " + f"{len(found)} definitions, so it does not identify one" + ) + elif not found[0].get("variants"): + errors.append(f"{domain}{path}: enum reference {module!r} resolves to no values") + + walk(data, visit) + + def check_assertion(a, path, errors): if a.get("kind") == "reference" and not a.get("referencePath"): errors.append(f"{path}: reference assertion with no referencePath") @@ -211,6 +249,9 @@ def visit(node, path, domain=domain): check_const_bytes(node, f"{domain}{path}", errors) walk(data, visit) + # Needs the whole document, not one node: the reference and its definition sit in + # different subtrees. + check_enum_catalog_refs(data, domain, errors) ok = True for name, observed in sorted(counts.items()): From d1fb4c87922cdd8aad0846a996b3437a109d10d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 14:07:17 -0300 Subject: [PATCH 11/46] review: accumulate the handle map on the productive path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit fixed the shrink only where a run resolved nothing. A run that resolves a smaller endpoint subset also succeeds, and rebuilding the lock from its own sample deletes ids an earlier run resolved — the same defect, reached by a productive run rather than a blocked one. Without `--cache`, which is how the update workflow runs, the lock is the only record, so nothing else restores them. Both paths now share one rule, version-scoped as before: `merge_prior_handles` for the in-place edit, `pins_with_prior` for the paths that build a whole new lock. The ids are provenance and never enter `wasmSetHash`, so keeping one whose payload this run did not re-resolve costs nothing, while dropping it makes an unchanged release diff against itself. `pins_with_prior` is deliberately not feature-gated: its caller is on the ordinary write path and compiles in every configuration. --- crates/whatspec/src/main.rs | 108 ++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 5 deletions(-) diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index 6066288..e1f63ee 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -374,6 +374,11 @@ fn update(args: &[String]) -> Result<()> { if authoritative && !wasm.is_empty() { let lock_path = opts.out.join(WASM_LOCK_NAME); warn_if_wasm_shrank(&lock_path, &wasm); + // Accumulated here too, not only on the empty path. A run that resolves a SMALLER + // endpoint subset still succeeds, and replacing the section with its own sample + // would delete ids an earlier run resolved — the same shrink, just reached by a + // productive run instead of a blocked one. + let boot_pins = pins_with_prior(&lock_path, boot_pins, &wa_version); let lock = WasmLock::with_bootloader(&wa_version, wasm, boot_pins); fs::write(&lock_path, lock.to_pretty_json()) .with_context(|| format!("write {}", lock_path.display()))?; @@ -2149,11 +2154,7 @@ fn update_lock_bootloader( return Ok(()); } let mut pins = pins.clone(); - if let Some(prev) = &lock.bootloader { - for (id, url) in &prev.wasm_handles { - pins.wasm_handles.entry(id.clone()).or_insert(url.clone()); - } - } + merge_prior_handles(&mut pins, lock.bootloader.as_ref()); let pins = &pins; if lock.bootloader.as_ref() == Some(pins) { return Ok(()); @@ -2171,6 +2172,45 @@ fn update_lock_bootloader( Ok(()) } +/// Fold the handles a previous run recorded into `pins`, current run winning a collision. +/// +/// The map is a best-effort SUPERSET for one release: the bootloader endpoint answers the +/// same request with different subsets, so whichever run happens to be last must not get +/// to decide the whole map. Its ids are provenance, not identity — they do not enter +/// `wasmSetHash` — so keeping one whose payload this run did not re-resolve costs nothing, +/// while dropping it makes an unchanged release diff against itself. +fn merge_prior_handles(pins: &mut lock::BootloaderPins, prior: Option<&lock::BootloaderPins>) { + let Some(prior) = prior else { return }; + for (id, url) in &prior.wasm_handles { + pins.wasm_handles.entry(id.clone()).or_insert(url.clone()); + } +} + +/// [`merge_prior_handles`] against whatever the lock on disk already holds, for the write +/// paths that build a whole new [`WasmLock`] rather than editing the existing one. +/// +/// Only within the same release — a lock stamped for another `wa_version` describes a +/// different set of ids, and merging across the two would produce a map belonging to +/// neither. An unreadable or absent lock simply contributes nothing. +/// +/// Deliberately NOT `#[cfg(feature = "fetch")]`: its caller is on the ordinary write path +/// and compiles in every configuration. Gating the callee and not the caller is exactly +/// what broke the no-default-features build twice in this branch. +fn pins_with_prior( + lock_path: &Path, + pins: Option, + wa_version: &str, +) -> Option { + let mut pins = pins?; + let prior = fs::read_to_string(lock_path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .filter(|l| l.wa_version == wa_version) + .and_then(|l| l.bootloader); + merge_prior_handles(&mut pins, prior.as_ref()); + Some(pins) +} + /// Emit the incoming content-stanza read-shape catalog (`incoming/index.json`) — the /// field trees WA Web parses out of received message/receipt/call/ack stanzas. Neutral /// IR only; the codegen is a later phase. @@ -3117,6 +3157,64 @@ mod tests { fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn a_productive_run_accumulates_the_handle_map_too() { + // The empty-run path was fixed first, but a run that resolves a SMALLER endpoint + // subset also succeeds — and rebuilding the lock from its own sample deletes ids + // an earlier run resolved. Same shrink, reached by a productive run. Without + // `--cache` (which is how the update workflow runs) the lock is the only record, + // so nothing else would have restored them. + let dir = std::env::temp_dir().join(format!("ws-boot-nonempty-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir"); + let path = dir.join("wasm.lock.json"); + let pins = |handles: &[(&str, &str)]| lock::BootloaderPins { + handles_from_page: 1, + wasm_handles: handles + .iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect(), + requests: 4, + failed_requests: 0, + }; + let seed = lock::WasmLock::with_bootloader( + "2.3000.test", + vec![], + Some(pins(&[ + ("11", "https://x/a.wasm"), + ("22", "https://x/b.wasm"), + ])), + ); + fs::write(&path, seed.to_pretty_json()).expect("seed the lock"); + + // This run saw only one of the two, and one the earlier run had not. + let merged = pins_with_prior( + &path, + Some(pins(&[ + ("22", "https://x/b.wasm"), + ("33", "https://x/c.wasm"), + ])), + "2.3000.test", + ) + .expect("pins were supplied"); + assert_eq!( + merged.wasm_handles.keys().collect::>(), + ["11", "22", "33"], + "the id this run did not re-resolve survives" + ); + + // A lock for another release contributes nothing — merging across two would + // produce a map belonging to neither. + let isolated = pins_with_prior( + &path, + Some(pins(&[("33", "https://x/c.wasm")])), + "2.3000.next", + ) + .expect("pins were supplied"); + assert_eq!(isolated.wasm_handles.keys().collect::>(), ["33"]); + fs::remove_dir_all(&dir).unwrap(); + } + #[cfg(feature = "fetch")] #[test] fn only_wasm_handles_are_pinned() { From 031a442a67a35711f6308a3b6dfcd1f8ed11dab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 14:27:14 -0300 Subject: [PATCH 12/46] review: close the guard, scope and catalog gaps the last round left open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six from Codex on d1fb4c8 plus two from CodeRabbit. Every one is latent — no committed artifact changes. Guards, continuing the mapped-child fix: - `guard_admits_absence` matched on the expression directly, so a parenthesized guard answered "no guard here". `strip_guard` unwraps the same wrapper, and the two have to agree on what they are looking at; that wrapper has cost this file a field twice before. - A guard around a value-position HELPER was lost entirely: the inliner is handed `strip_guard(value)`, so the map inside looks unguarded. The guard belongs to the key, not the helper, and is now applied to what the helper yields. Scope: - Only `var`-form rebindings reached `shadowed`; a bare `e = {…}` never did, so a module reassigning an operand before composing it still published the stale body — the same wrong closed set through the one write form the guard did not watch. Rebound to something unreadable refuses the name too. - A named mapped-callback was resolved straight from the module's function table, ignoring the caller's bindings. A parameter of the same name shadows it at runtime, so an unrelated function's fields could be published as the element's. Linter: - Half a numeric range (`intMin` with no `intMax`) passed, because the check required both keys before looking at either. - `generated/enums/` is a catalog nothing references, so validating references alone left all 326 definitions unexamined. - `kind: "const"` with no `value` tells an emitter the value is fixed and then declines to say what it is. Only this direction: `kind` is shared with the assertion vocabulary, where `attr`/`content` carry a `value` legitimately, so the converse check would have flagged 1781 correct nodes. - `pins_with_prior` treated an unparseable lock as an absent one, silently dropping the handles it was accumulating to preserve. --- crates/wa-enums/src/lib.rs | 36 ++++++++++++++++++++++++++++++++++ crates/wa-notif/src/actions.rs | 30 +++++++++++++++++++++++++++- crates/wa-notif/src/tests.rs | 32 +++++++++++++++++++++++++++++- crates/whatspec/src/main.rs | 22 ++++++++++++++++----- scripts/lint-ir.py | 32 +++++++++++++++++++++++++++--- 5 files changed, 142 insertions(+), 10 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 5ae0ccc..8af7140 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -358,6 +358,21 @@ impl<'a> Visit<'a> for NamedResolver { } else if let Some(id) = as_identifier(&a.right) { self.pending.push((prop.to_string(), id.to_string())); } + } else if let Some(name) = a.left.get_identifier_name() { + // A bare `e = …` rebinding, which `visit_variable_declarator` never sees. + // Without this, only `var`-form rebindings reached `shadowed`, so a module + // that reassigns an operand before composing it still published the stale + // body — the same wrong closed set the shadow guard was added to prevent, + // reached through the one form it did not watch. + match enum_object(&a.right).and_then(parse_enum) { + Some(data) => self.bind_local(name, data), + // Rebound to something this pass cannot read: whatever `locals` still + // holds for the name is no longer what the runtime has, so the name is + // no more usable than an ambiguous one. + None => { + self.shadowed.insert(name.to_string()); + } + } } walk::walk_assignment_expression(self, a); } @@ -595,6 +610,27 @@ mod tests { resolve_named_enum(module, "M", "ALIAS").is_none(), "`resolve_pending` reads the same rebound name" ); + // A bare `e = …` rebinding is invisible to `visit_variable_declarator`, so only + // the `var` form reached `shadowed` at first — the same stale-body bug through + // the one write form the guard did not watch. + let assigned = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){e={X:"x"}} + var l=babelHelpers.extends({},e,{B:"b"}); + i.WITH=l,i.ALIAS=e + }),1);"#; + assert!(resolve_named_enum(assigned, "M", "WITH").is_none()); + assert!(resolve_named_enum(assigned, "M", "ALIAS").is_none()); + + // Rebound to something unreadable is no better: whatever `locals` still holds is + // not what the runtime has. + let opaque = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){e=computed()} + i.ALIAS=e + }),1);"#; + assert!(resolve_named_enum(opaque, "M", "ALIAS").is_none()); + // 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){ diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index ffc0324..50cba76 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -1807,8 +1807,16 @@ fn fold_object<'b, 'a>( } // A helper whose result is a repeated element becomes this key's child list; // one that returns a flat object contributes its fields under their own names. + // + // The OUTER guard has to survive the inlining. `local_call_source` above is + // handed `strip_guard(value)`, so `participants: enabled && buildIt(node)` + // reaches the helper with the guard already gone, and the `mapped_child` inside + // sees an unguarded map — reporting a collection as always present when the + // falsy path produces none. The guard belongs to the key, not to the helper. + let outer_absent = guard_admits_absence(value); for mut c in nested.children { c.name = key.to_string(); + c.required &= !outer_absent; write_key(def, key, KeyValue::Child(c)); } for f in nested.fields { @@ -1983,6 +1991,11 @@ fn value_selection<'b, 'a>( /// opposite: the falsy path yields no value at all. fn guard_admits_absence(e: &Expression) -> bool { match e { + // `(cond ? map(…) : null)` is a paren wrapping the guard, and matching on the + // wrapper would answer "no guard here" for a guard that is plainly there. The + // same wrapper has cost this file a field twice before; `strip_guard` unwraps it + // for exactly this reason, so the two must agree on what they are looking at. + Expression::ParenthesizedExpression(p) => guard_admits_absence(&p.expression), Expression::ConditionalExpression(c) => { is_nullish(strip_guard(&c.consequent)) || is_nullish(strip_guard(&c.alternate)) } @@ -2239,7 +2252,22 @@ fn collect_accessor_fields<'b, 'a>( // parseParticipant)` — is a function too. Rejecting the identifier before looking // at its body emitted the child with an empty field list, which tells a consumer // the element carries nothing. - if let Some(src) = as_identifier(e).and_then(|n| ctx.locals.get(n)) { + // The caller's own bindings come FIRST. A parameter or local of the same name as + // a module function shadows it, and the runtime calls what the caller bound — so + // reaching straight for `ctx.locals` would publish an unrelated function's fields + // as if they were this element's. + // Only when NOTHING in scope binds the name is the module-level function the one + // that runs. A name that is bound but resolves to no expression we can follow is + // refused rather than guessed at. + let resolved = deref_ident(e, outer); + if !std::ptr::eq(resolved, e) { + collect_accessor_fields(resolved, outer, ctx, out); + return; + } + if let Some(src) = as_identifier(e) + .filter(|n| outer.get(n).is_none()) + .and_then(|n| ctx.locals.get(n)) + { collect_named_callback_fields(src, ctx, out); } return; diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index a2874e9..5838fa0 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -621,6 +621,9 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr if (e.hasChild("b")) return {actionType:o("WAWebGroupType").GROUP_ACTIONS.ANNOUNCE, rows:e.mapChildrenWithTag("row", function(x){ return {v:x.attrString("q")}; })}; return {actionType:o("WAWebGroupType").GROUP_ACTIONS.ANNOUNCE, rows:e.mapChildrenWithTag("row", function(x){ return {v:x.attrString("p")}; })}; } + function shadowedCb(x){ return {wrong:x.attrString("wrong")}; } + function helperKids(e){ return e.mapChildrenWithTag("helper_user", function(x){ return {id:x.attrUserJid("jid")}; }); } + function withShadow(e,shadowedCb){ return e.mapChildrenWithTag("shadow_user", shadowedCb); } function y(e,t){ return t.mapChildrenWithTag("participant", function(p){ return { id: p.attrUserJid("jid"), displayName: p.maybeAttrString("display_name"), kind: p.maybeAttrEnum("type", o("WAWebGroupApiConst").GROUP_PARTICIPANT_TYPES) }; }); } @@ -673,7 +676,10 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr return {actionType:o("WAWebGroupType").GROUP_ACTIONS.MEMBERSHIP, picked: t.hasChildren() ? t.mapChildrenWithTag("picked_user", function(x){ return {id:x.attrUserJid("jid")}; }) : [{id:"fallback"}], nulled: t.hasChild("opt") ? t.mapChildrenWithTag("nulled_user", function(x){ return {id:x.attrUserJid("jid")}; }) : null, - anded: t.hasChild("flag") && t.mapChildrenWithTag("anded_user", function(x){ return {id:x.attrUserJid("jid")}; })}; + anded: t.hasChild("flag") && t.mapChildrenWithTag("anded_user", function(x){ return {id:x.attrUserJid("jid")}; }), + parend: (t.hasChild("p") ? t.mapChildrenWithTag("parend_user", function(x){ return {id:x.attrUserJid("jid")}; }) : null), + shadowed: withShadow(t, function(z){ return {right:z.attrString("right")}; }), + viaHelper: t.hasChild("h") && helperKids(t)}; case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.DESC: @@ -825,6 +831,30 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { "`cond ? map(…) : null` may be absent" ); assert!(!child("anded").required, "`cond && map(…)` may be absent"); + assert!( + !child("parend").required, + "a paren around the guard must not hide it" + ); + // A callback name that a caller binding SHADOWS must resolve to what the caller + // passed, not to the module function of the same name. Publishing the module one + // would report an unrelated element's fields as this child's. + let sh = child("shadowed"); + assert_eq!(sh.wire_tag, "shadow_user"); + let names: Vec<&str> = sh.fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!( + names, + ["right"], + "the shadowing argument wins, not `shadowedCb`" + ); + + // A guard around a value-position HELPER belongs to the key, not to the helper: the + // inliner is handed the stripped expression, so the map inside looks unguarded. + assert!( + !child("viaHelper").required, + "`cond && helper(node)` may produce no collection" + ); + assert_eq!(child("viaHelper").wire_tag, "helper_user"); + // The guard must not cost the child's contents. assert_eq!(child("anded").wire_tag, "anded_user"); assert_eq!(child("anded").fields.len(), 1); diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index e1f63ee..931d872 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -2202,11 +2202,23 @@ fn pins_with_prior( wa_version: &str, ) -> Option { let mut pins = pins?; - let prior = fs::read_to_string(lock_path) - .ok() - .and_then(|raw| serde_json::from_str::(&raw).ok()) - .filter(|l| l.wa_version == wa_version) - .and_then(|l| l.bootloader); + // A lock that exists but cannot be parsed is NOT the same as one that is absent: the + // ids it held are about to be dropped, and the whole point of accumulating is that + // they must not be. Say so rather than let a corrupt file look like a first run. + let prior = match fs::read_to_string(lock_path) { + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(l) if l.wa_version == wa_version => l.bootloader, + Ok(_) => None, + Err(e) => { + eprintln!( + " {} is unreadable ({e}); its bootloader handles cannot be carried over", + lock_path.display() + ); + None + } + }, + Err(_) => None, + }; merge_prior_handles(&mut pins, prior.as_ref()); Some(pins) } diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 85b450e..ec776d5 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -116,8 +116,15 @@ def check_field(f, path, errors, counts): if t == "union" and len(f.get("unionVariants") or []) < 2: errors.append(f"{path}: union carries fewer than two variants") + # Each pair is the two bounds of ONE range accessor. Inverted is a contradiction; + # so is half of one — the schema permits either key alone, but a consumer handed + # `intMin` with no `intMax` has a weaker constraint than the wire actually enforces + # and no way to know it lost the other end. for lo, hi in (("byteMin", "byteMax"), ("intMin", "intMax")): - if lo in f and hi in f and f[lo] > f[hi]: + if (lo in f) != (hi in f): + present, missing = (lo, hi) if lo in f else (hi, lo) + errors.append(f"{path}: {present} without {missing} is half a range") + elif lo in f and f[lo] > f[hi]: errors.append(f"{path}: {lo} {f[lo]} exceeds {hi} {f[hi]}") # An echo rule with no path says "this equals something in the request" and @@ -182,8 +189,18 @@ def check_enum_catalog_refs(data, domain, errors): if not isinstance(catalog, list) or not catalog: return by_module = {} - for e in catalog: - if isinstance(e, dict) and "module" in e: + for i, e in enumerate(catalog): + if not isinstance(e, dict): + continue + # Checked directly, not only through a reference: `generated/enums/` is a catalog + # nothing in the document points at, so validating references alone left every one + # of its 326 definitions unexamined. A named enum with no values is the same + # broken promise whether or not this document happens to cite it. + if not e.get("variants"): + errors.append( + f"{domain}/enums/{i}: enum {e.get('name')!r} is defined with no values" + ) + if "module" in e: by_module.setdefault(e["module"], []).append(e) def visit(node, path): @@ -209,6 +226,15 @@ def check_assertion(a, path, errors): errors.append(f"{path}: reference assertion with no referencePath") if a.get("kind") == "attr" and not a.get("name"): errors.append(f"{path}: attr assertion with no attribute name") + # `WapAttrKind::Const` is documented as "fixed literal value (carried in + # `WapAttrDef::value`)", but `value` is optional in the schema. Without it the IR + # tells an emitter the value is fixed and then declines to say to what. + # + # Only this direction. `value` is NOT exclusive to const: `kind` is shared with the + # assertion vocabulary, where `attr` and `content` carry one legitimately (1781 nodes + # today), so rejecting `value` on non-const kinds would flag correct documents. + if a.get("kind") == "const" and a.get("value") is None: + errors.append(f"{path}: const attribute with no value") def main() -> int: From 57a96933f67846fd25b019fd365fd6409408d8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 14:46:18 -0300 Subject: [PATCH 13/46] review: bound the callback recursion, scope parameters, and check what an empty catalog hides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four from Codex on 031a442. One is a crash; the rest are latent. - The callback-alias resolution added last round could not terminate. `deref_ident` caps at four hops, so on a cycle whose length does not divide four (`a → b → c → a`) it hands back a *different* identifier every call, and recursing on that walks the cycle until the stack goes. Reverting the guard makes the new test abort with a stack overflow, so this is a real crash on a shape the extractor could meet, not a theoretical one. It now recurses only into something that is no longer an identifier; an unresolved chain is refused, which is what the hop limit was for. - Function parameters bind their name for the whole body, which neither the declarator nor the assignment path saw, so `function f(e){ extends({}, e, …) }` still composed over the outer `e`. Refusing such names module-wide — the cheap version — was measured against the pinned bundles and LOST a real constraint (`STANZA_MSG_TYPES`), so this tracks a scope stack instead and refuses only inside the body that binds the name. `generated/` is unchanged either way, which is the point: the blunt version was not. - `check_enum_catalog_refs` returned early on an EMPTY catalog, which is the worst case rather than a trivial one: if extraction collapsed the list while the enum-typed fields remained, all 1431 references are dangling and the document was still reported consistent. An empty list is an empty lookup. - A WAM event's `code` is its wire identifier and nothing checked it was unique. A consumer building a dispatch table by code would silently overwrite one of a colliding pair, leaving one event shape unreachable. --- Cargo.lock | 1 + crates/wa-enums/Cargo.toml | 1 + crates/wa-enums/src/lib.rs | 62 +++++++++++++++++++++++++++++++++- crates/wa-notif/src/actions.rs | 9 ++++- crates/wa-notif/src/tests.rs | 18 ++++++++-- scripts/lint-ir.py | 31 ++++++++++++++++- 6 files changed, 117 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5fc46dc..b41b9b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,6 +1881,7 @@ dependencies = [ "oxc_allocator", "oxc_ast", "oxc_ast_visit", + "oxc_syntax", "wa-ir", "wa-oxc", "wa-transform", diff --git a/crates/wa-enums/Cargo.toml b/crates/wa-enums/Cargo.toml index ff044c4..4b754b5 100644 --- a/crates/wa-enums/Cargo.toml +++ b/crates/wa-enums/Cargo.toml @@ -12,6 +12,7 @@ description = "Native tooling: oxc AST extraction of WhatsApp Web's $InternalEnu oxc_allocator = { workspace = true } oxc_ast = { workspace = true } oxc_ast_visit = { workspace = true } +oxc_syntax = { workspace = true } wa-ir = { workspace = true } wa-oxc = { workspace = true } wa-transform = { workspace = true } diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 8af7140..1a3be3b 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -100,6 +100,15 @@ fn extract_from_module(slice: &str, module: &str) -> Vec { out } +/// The identifier names a parameter list binds. +fn param_names(params: &oxc_ast::ast::FormalParameters) -> HashSet { + params + .items + .iter() + .filter_map(|p| p.pattern.get_identifier_name().map(|n| n.to_string())) + .collect() +} + /// Resolve `X.Name = local` bindings against the locals captured by var-init, filling /// `exports` (first binding wins). Shared by [`resolve_named_enum`] and the plain-object /// catalog pass so the two resolution sites can't drift. @@ -214,6 +223,13 @@ struct NamedResolver { /// 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, + /// Parameter names bound by the functions currently being visited, innermost last. + /// + /// A parameter shadows only INSIDE its own body, so unlike [`Self::shadowed`] this is + /// a stack rather than a module-wide set. Refusing such a name everywhere was measured + /// and costs real constraints — `STANZA_MSG_TYPES` resolves at module level in a + /// module that happens to reuse its local's name as a parameter elsewhere. + param_scopes: Vec>, exports: HashMap, pending: Vec<(String, String)>, } @@ -223,6 +239,7 @@ impl NamedResolver { Self { locals: HashMap::new(), shadowed: HashSet::new(), + param_scopes: Vec::new(), exports: HashMap::new(), pending: Vec::new(), } @@ -230,12 +247,18 @@ impl NamedResolver { /// 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) { + if self.shadowed.contains(name) || self.param_shadows(name) { return None; } self.locals.get(name) } + /// Whether an enclosing function binds `name` as a parameter, so a read of it here + /// is that parameter and not the module local this pass recorded. + fn param_shadows(&self, name: &str) -> bool { + self.param_scopes.iter().any(|s| s.contains(name)) + } + fn bind_local(&mut self, name: &str, data: EnumData) { match self.locals.get(name) { Some(prev) if *prev != data => { @@ -337,6 +360,22 @@ impl<'a> Visit<'a> for NamedResolver { walk::walk_variable_declarator(self, d); } + fn visit_function( + &mut self, + f: &oxc_ast::ast::Function<'a>, + flags: oxc_syntax::scope::ScopeFlags, + ) { + self.param_scopes.push(param_names(&f.params)); + walk::walk_function(self, f, flags); + self.param_scopes.pop(); + } + + fn visit_arrow_function_expression(&mut self, f: &oxc_ast::ast::ArrowFunctionExpression<'a>) { + self.param_scopes.push(param_names(&f.params)); + walk::walk_arrow_function_expression(self, f); + self.param_scopes.pop(); + } + fn visit_assignment_expression(&mut self, a: &AssignmentExpression<'a>) { if let Some(m) = a.left.as_member_expression() && let Some(prop) = m.static_property_name() @@ -631,6 +670,27 @@ mod tests { }),1);"#; assert!(resolve_named_enum(opaque, "M", "ALIAS").is_none()); + // A PARAMETER binds the name for its whole body, which neither the declarator nor + // the assignment path sees. + let param = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(e){ var l=babelHelpers.extends({},e,{B:"b"}); i.WITH=l } + f({X:"x"}) + }),1);"#; + assert!(resolve_named_enum(param, "M", "WITH").is_none()); + + // ...but only INSIDE that body. Refusing the name module-wide was measured against + // the pinned bundles and lost a real constraint (`STANZA_MSG_TYPES`), so a merge + // outside the shadowing function must still resolve. + let outside = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(e){ return e } + var l=babelHelpers.extends({},e,{B:"b"}); + i.WITH=l + }),1);"#; + let def = resolve_named_enum(outside, "M", "WITH").expect("resolves outside the body"); + assert_eq!(def.variants.len(), 2); + // 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){ diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index 50cba76..52389c2 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -2261,7 +2261,14 @@ fn collect_accessor_fields<'b, 'a>( // refused rather than guessed at. let resolved = deref_ident(e, outer); if !std::ptr::eq(resolved, e) { - collect_accessor_fields(resolved, outer, ctx, out); + // Recurse ONLY into something that is no longer an identifier. `deref_ident` + // is bounded at four hops, so on an alias cycle whose length does not divide + // four (`a → b → c → a`) it returns a *different* identifier every time — + // and recursing on that walks the cycle forever until the stack goes. An + // unresolved chain is refused, which is what the hop limit was already for. + if as_identifier(resolved).is_none() { + collect_accessor_fields(resolved, outer, ctx, out); + } return; } if let Some(src) = as_identifier(e) diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 5838fa0..24057f4 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -672,14 +672,17 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.UNLINK: return I(t); - case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.MEMBERSHIP: + case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.MEMBERSHIP: { + var cycA=cycB, cycB=cycC, cycC=cycA; return {actionType:o("WAWebGroupType").GROUP_ACTIONS.MEMBERSHIP, picked: t.hasChildren() ? t.mapChildrenWithTag("picked_user", function(x){ return {id:x.attrUserJid("jid")}; }) : [{id:"fallback"}], nulled: t.hasChild("opt") ? t.mapChildrenWithTag("nulled_user", function(x){ return {id:x.attrUserJid("jid")}; }) : null, anded: t.hasChild("flag") && t.mapChildrenWithTag("anded_user", function(x){ return {id:x.attrUserJid("jid")}; }), parend: (t.hasChild("p") ? t.mapChildrenWithTag("parend_user", function(x){ return {id:x.attrUserJid("jid")}; }) : null), shadowed: withShadow(t, function(z){ return {right:z.attrString("right")}; }), - viaHelper: t.hasChild("h") && helperKids(t)}; + viaHelper: t.hasChild("h") && helperKids(t), + cyclic: t.mapChildrenWithTag("cyclic_user", cycA)}; + } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.DESC: @@ -855,6 +858,17 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { ); assert_eq!(child("viaHelper").wire_tag, "helper_user"); + // An alias CYCLE (`cycA → cycB → cycC → cycA`) must terminate. `deref_ident` caps at + // four hops, so on a 3-cycle it hands back a different identifier every time, and + // recursing on that walked the cycle until the stack went — reaching this assertion + // at all is most of the test. + let cyc = child("cyclic"); + assert_eq!(cyc.wire_tag, "cyclic_user"); + assert!( + cyc.fields.is_empty(), + "an unresolved callback contributes no fields" + ); + // The guard must not cost the child's contents. assert_eq!(child("anded").wire_tag, "anded_user"); assert_eq!(child("anded").fields.len(), 1); diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index ec776d5..9eb438f 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -186,8 +186,12 @@ def check_enum_catalog_refs(data, domain, errors): domain adopting the same shape is covered without anyone remembering to add it here. """ catalog = data.get("enums") - if not isinstance(catalog, list) or not catalog: + if not isinstance(catalog, list): return + # An EMPTY catalog is not "nothing to check" — it is the worst case. If extraction + # collapsed the list while the enum-typed fields remained, every reference in the + # document is dangling, and returning early here reported exactly that document as + # internally consistent. An empty list is an empty lookup; the walk still runs. by_module = {} for i, e in enumerate(catalog): if not isinstance(e, dict): @@ -221,6 +225,30 @@ def visit(node, path): walk(data, visit) +def check_event_codes(data, domain, errors): + """A WAM event's `code` is its wire identifier, so two events cannot share one. + + The schema has no way to say "unique across the array". A consumer generating a + dispatch table by code would silently overwrite one of the pair, and one of the two + event shapes would then be unreachable — with nothing in the document indicating it. + """ + events = data.get("events") + if not isinstance(events, list): + return + seen = {} + for e in events: + if not isinstance(e, dict) or "code" not in e: + continue + code = e["code"] + if code in seen: + errors.append( + f"{domain}: events {seen[code]!r} and {e.get('name')!r} " + f"share code {code}, which is the wire identifier" + ) + else: + seen[code] = e.get("name") + + def check_assertion(a, path, errors): if a.get("kind") == "reference" and not a.get("referencePath"): errors.append(f"{path}: reference assertion with no referencePath") @@ -278,6 +306,7 @@ def visit(node, path, domain=domain): # Needs the whole document, not one node: the reference and its definition sit in # different subtrees. check_enum_catalog_refs(data, domain, errors) + check_event_codes(data, domain, errors) ok = True for name, observed in sorted(counts.items()): From 7fd371605e9b3b7d03d36421d41fc4b26b028149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 15:05:53 -0300 Subject: [PATCH 14/46] review: version-scope the cache handles, close two shadow holes, tighten three checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six from Codex on 57a9693. One is a real cross-release bug; the rest are latent. - `BundleCache::wasm_handles` read its manifest with no version check while `wasm_payloads` refuses a stale one through `usable_manifest`. A cache left from the previous release could therefore feed its ids into this release's pins — the same cross-version mixing the lock path already refuses, reached by the one door left open. It now takes the version, like everything else that reads that manifest for a purpose. Two holes in last round's parameter-scope fix, both found by pushing on it: - An unreadable redeclaration (`var e = getEnum()` in a nested scope) shadows just as much as a readable one, and left the outer body in `locals` for a later composition to publish. Refused only when the name is already bound: an unparseable `var` that shadows nothing costs nothing to ignore. - A deferred export alias (`function f(e){ i.OUT = e }`) is resolved AFTER the walk, when the parameter stack is already unwound, so the scope has to be recorded at the assignment rather than looked up later. Linter: - A union with two entries of the SAME name is not a two-alternative union: the name is the discriminator, and the Rust codegen emits it as the variant identifier. - `valueKind` is what tells a consumer how to represent an enum's values, and the schema permits any `Scalar` per variant, so nothing checked the two agreed. - A `content` assertion is the fixed text a marker variant matches on; without the value a consumer cannot tell when the variant applies. Kept narrow — an `attr` assertion may legitimately assert only presence. `generated/` is unchanged by all six. --- crates/wa-enums/src/lib.rs | 36 +++++++++++++++++++++++--- crates/wa-fetch/src/cache.rs | 49 ++++++++++++++++++++++++++++++------ crates/whatspec/src/main.rs | 5 +++- scripts/lint-ir.py | 35 ++++++++++++++++++++++++-- 4 files changed, 112 insertions(+), 13 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 1a3be3b..8da5253 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -353,8 +353,17 @@ impl<'a> Visit<'a> for NamedResolver { // values — 41 lost constraints, the largest single entry in // `dropsByReason`. .or_else(|| d.init.as_ref().and_then(|e| self.merge_extends(e))); - if let Some(data) = data { - self.bind_local(local.as_str(), data); + match data { + Some(data) => self.bind_local(local.as_str(), data), + // A redeclaration this pass cannot READ is as much a shadow as one it + // can: `var e = getEnum()` in a nested scope leaves the outer body in + // `locals`, and a later composition would publish values the runtime + // never has. Only when the name is already bound — an unparseable `var` + // that shadows nothing costs nothing to ignore. + None if self.locals.contains_key(local.as_str()) => { + self.shadowed.insert(local.to_string()); + } + None => {} } } walk::walk_variable_declarator(self, d); @@ -394,7 +403,12 @@ impl<'a> Visit<'a> for NamedResolver { self.pending.push((key.to_string(), id.to_string())); } } - } else if let Some(id) = as_identifier(&a.right) { + } else if let Some(id) = as_identifier(&a.right) + // `pending` is resolved AFTER the walk, when the parameter stack is + // already unwound, so the scope has to be recorded now: `function f(e){ + // i.OUT = e }` would otherwise export the module-local `e`. + && !self.param_shadows(id) + { self.pending.push((prop.to_string(), id.to_string())); } } else if let Some(name) = a.left.get_identifier_name() { @@ -691,6 +705,22 @@ mod tests { let def = resolve_named_enum(outside, "M", "WITH").expect("resolves outside the body"); assert_eq!(def.variants.len(), 2); + // A redeclaration this pass cannot READ shadows just as much as one it can. + let unreadable = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){ var e=getEnum(); var l=babelHelpers.extends({},e,{B:"b"}); i.OUT=l } + }),1);"#; + assert!(resolve_named_enum(unreadable, "M", "OUT").is_none()); + + // A deferred alias (`i.OUT = e`) is resolved after the walk, when the parameter + // stack is already unwound — so the shadow has to be recorded at the assignment. + let deferred = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(e){ i.OUT=e } + f({X:"x"}) + }),1);"#; + assert!(resolve_named_enum(deferred, "M", "OUT").is_none()); + // 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){ diff --git a/crates/wa-fetch/src/cache.rs b/crates/wa-fetch/src/cache.rs index f204f82..a51900e 100644 --- a/crates/wa-fetch/src/cache.rs +++ b/crates/wa-fetch/src/cache.rs @@ -201,11 +201,17 @@ impl BundleCache { (payloads, skipped) } - /// The recorded wasm URL → `bx` handle pairing, empty when nothing is cached (or the - /// cache predates the field). Read separately from [`Self::wasm_payloads`] because the - /// pairing is provenance metadata, not part of the payload integrity contract. - pub fn wasm_handles(&self) -> BTreeMap { + /// The recorded wasm URL → `bx` handle pairing for `wa_version`, empty when nothing is + /// cached, the cache predates the field, or the cache belongs to another release. + /// + /// Read separately from [`Self::wasm_payloads`] because the pairing is provenance + /// metadata, not part of the payload integrity contract — but it is still provenance + /// FOR ONE RELEASE. Reading the manifest unversioned let a cache left over from the + /// previous version contribute its ids to the new version's pins, which is the same + /// cross-release mixing the lock path already refuses, reached by another route. + pub fn wasm_handles(&self, wa_version: &str) -> BTreeMap { self.read_manifest() + .filter(|m| m.wa_version == wa_version) .map(|m| m.wasm_handles) .unwrap_or_default() } @@ -762,7 +768,7 @@ mod tests { .store_wasm("v", &[bundle("https://h/e.wasm", "e.wasm", b"p")], &handles) .unwrap(); - let read = cache.wasm_handles(); + let read = cache.wasm_handles("v"); assert_eq!(read.len(), 1, "only stored payloads: {read:?}"); assert_eq!( read.get("https://h/e.wasm").map(String::as_str), @@ -777,7 +783,36 @@ mod tests { &BTreeMap::new(), ) .unwrap(); - assert!(cache.wasm_handles().is_empty()); + assert!(cache.wasm_handles("v").is_empty()); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn handles_from_another_release_are_not_read() { + // The payload path already refuses a stale manifest via `usable_manifest`; the + // handle path bypassed it, so a cache left from the previous version could feed + // its ids into the new version's bootloader pins. + let dir = tmp_dir("wasm-handles-version"); + let cache = BundleCache::new(&dir); + cache + .store("old", &[bundle("https://h/a.js", "a.js", b"x")]) + .unwrap(); + let handles: BTreeMap = + [("https://h/e.wasm".to_string(), "32180".to_string())] + .into_iter() + .collect(); + cache + .store_wasm( + "old", + &[bundle("https://h/e.wasm", "e.wasm", b"p")], + &handles, + ) + .unwrap(); + assert_eq!(cache.wasm_handles("old").len(), 1, "its own release"); + assert!( + cache.wasm_handles("new").is_empty(), + "another release contributes nothing" + ); fs::remove_dir_all(&dir).ok(); } @@ -792,7 +827,7 @@ mod tests { r#"{"wa_version":"v","complete":true,"bundles":[]}"#, ) .unwrap(); - assert!(cache.wasm_handles().is_empty()); + assert!(cache.wasm_handles("v").is_empty()); assert!( cache.read_manifest().is_some(), "legacy manifest still parses" diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index 931d872..fca64f6 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -1324,7 +1324,10 @@ fn load_wasm( // when this particular run resolved nothing. let mut handles = invert(&discovered.bx_data); if let Some(cache) = &cache { - handles.extend(cache.wasm_handles()); + // Version-scoped: a cache for the previous release must not contribute its ids + // to this one's pins. `wasm_payloads` below already refuses such a manifest, so + // reading the handles unversioned mixed releases through the one door left open. + handles.extend(cache.wasm_handles(remote_version.unwrap_or_default())); } handles.extend(resolution.handle_by_url()); let pins = bootloader_pins(&resolution, &handles); diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 9eb438f..0c5a5cd 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -113,8 +113,20 @@ def check_field(f, path, errors, counts): # A union whose alternatives are absent is not a union — a consumer has # nothing to switch on. - if t == "union" and len(f.get("unionVariants") or []) < 2: - errors.append(f"{path}: union carries fewer than two variants") + if t == "union": + variants = f.get("unionVariants") or [] + if len(variants) < 2: + errors.append(f"{path}: union carries fewer than two variants") + else: + # Two entries are not two ALTERNATIVES if they answer to the same name. The + # name is the documented discriminator and the Rust codegen emits it as the + # variant identifier, so a repeat is either ambiguous or uncompilable. + names = [v.get("name") for v in variants if isinstance(v, dict)] + if len(set(names)) < len(names): + errors.append( + f"{path}: union alternatives repeat a name " + f"({sorted(n for n in set(names) if names.count(n) > 1)})" + ) # Each pair is the two bounds of ONE range accessor. Inverted is a contradiction; # so is half of one — the schema permits either key alone, but a consumer handed @@ -204,6 +216,20 @@ def check_enum_catalog_refs(data, domain, errors): errors.append( f"{domain}/enums/{i}: enum {e.get('name')!r} is defined with no values" ) + # `valueKind` is what tells a consumer how to represent these values; the schema + # permits any `Scalar` per variant, so it cannot check the two agree. A variant + # that disagrees would have the consumer pick an incompatible representation. + # `bool` is excluded explicitly — in Python it IS an int. + expected = {"int": int, "string": str}.get(e.get("valueKind")) + if expected is not None: + for v in e.get("variants") or []: + val = v.get("value") if isinstance(v, dict) else None + if not isinstance(val, expected) or isinstance(val, bool): + errors.append( + f"{domain}/enums/{i}: enum {e.get('name')!r} is valueKind " + f"{e.get('valueKind')!r} but variant " + f"{(v.get('name') or v.get('key'))!r} carries {val!r}" + ) if "module" in e: by_module.setdefault(e["module"], []).append(e) @@ -263,6 +289,11 @@ def check_assertion(a, path, errors): # today), so rejecting `value` on non-const kinds would flag correct documents. if a.get("kind") == "const" and a.get("value") is None: errors.append(f"{path}: const attribute with no value") + # A `content` assertion IS the fixed text a marker union variant matches on. Without + # the value a consumer cannot tell when the variant applies. Narrow on purpose: an + # `attr` assertion may legitimately assert only presence. + if a.get("kind") == "content" and a.get("value") is None: + errors.append(f"{path}: content assertion with no value to match") def main() -> int: From 7cfe9e56eaaa42972c8c456e9d91bc51c1e7b846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 15:23:23 -0300 Subject: [PATCH 15/46] review: keep spans with the buffer they index, and bind every parameter pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four from Codex on 7fd3716. One drops a field today; the rest are latent. - Nested inlining re-parses a SYNTHETIC buffer, but `local_call_source` kept slicing the module source with spans belonging to it. The bounds check never fired — those offsets are in range — so the argument text was simply unrelated, and a value threaded through two helpers (`mk2(v){ return mk(v) }`) lost its field. `ArmCtx` now carries the source its spans index, and `inline_local` rebinds it to the buffer it parsed. `locals` became a borrow so the context is cheap to re-point; there is still exactly one construction of it. - `param_names` saw only plainly-named parameters, so `function f({e})` and a rest element bound nothing as far as the shadow guard was concerned. Uses `get_binding_identifiers` — the same traversal `wa_scan::shadow_params` already uses for its own shadowing — rather than a second hand-rolled walk to drift from. - A WAM field's `id` is its wire identifier within its event, and only the event-level `code` was checked. Scoped per event: ids repeat across events by design. - `fields`, `constantFields` and `children` are three representations of one object-key namespace. Each is internally consistent by construction and nothing compared them, so an action could carry a wire-read `reason` and a constant `reason` at once — a shape runtime cannot produce. --- crates/wa-enums/src/lib.rs | 39 +++++++++++++++++++++++++++++----- crates/wa-notif/src/actions.rs | 38 ++++++++++++++++++++++++++------- crates/wa-notif/src/tests.rs | 15 ++++++++++++- scripts/lint-ir.py | 38 +++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 14 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 8da5253..0f8033b 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -102,11 +102,25 @@ fn extract_from_module(slice: &str, module: &str) -> Vec { /// The identifier names a parameter list binds. fn param_names(params: &oxc_ast::ast::FormalParameters) -> HashSet { - params - .items - .iter() - .filter_map(|p| p.pattern.get_identifier_name().map(|n| n.to_string())) - .collect() + // EVERY identifier a parameter list binds, not just the plainly-named ones. A + // destructured parameter (`function f({e})`) binds `e` exactly as much as + // `function f(e)` does, and the rest element is not in `items` at all — both were + // invisible, so a composition inside such a function still resolved the module-level + // operand. `get_binding_identifiers` is the same traversal `wa_scan::shadow_params` + // uses for its own shadowing, rather than a second hand-rolled walk to drift from. + let mut out = HashSet::new(); + let mut add = |pattern: &oxc_ast::ast::BindingPattern| { + for ident in pattern.get_binding_identifiers() { + out.insert(ident.name.to_string()); + } + }; + for p in ¶ms.items { + add(&p.pattern); + } + if let Some(rest) = ¶ms.rest { + add(&rest.rest.argument); + } + out } /// Resolve `X.Name = local` bindings against the locals captured by var-init, filling @@ -721,6 +735,21 @@ mod tests { }),1);"#; assert!(resolve_named_enum(deferred, "M", "OUT").is_none()); + // A DESTRUCTURED parameter binds its names too, and a rest element is not even + // in `items` — both were invisible to the plain-name lookup. + let destructured = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f({e}){ var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + f({e:{X:"x"}}) + }),1);"#; + assert!(resolve_named_enum(destructured, "M", "OUT").is_none()); + + let rest = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(q, ...e){ var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(rest, "M", "OUT").is_none()); + // 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){ diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index 52389c2..6692eaa 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -404,13 +404,14 @@ pub(crate) fn extract_actions( } } } + let locals: HashMap = by_name + .into_iter() + .filter_map(|(n, src)| src.map(|s| (n, s))) + .collect(); let ctx = ArmCtx { consts, source: handler_slice, - locals: by_name - .into_iter() - .filter_map(|(n, src)| src.map(|s| (n, s))) - .collect(), + locals: &locals, }; let mut finder = SwitchFinder { ctx: &ctx, @@ -425,13 +426,29 @@ pub(crate) fn extract_actions( /// module's local helper functions, as re-parsable source (keyed by name). struct ArmCtx<'c, 'a> { consts: &'c ConstResolver<'a>, - locals: HashMap, - /// The handler module's source, so an inlined helper can be given the TEXT of the - /// arguments it was called with. The helper is re-parsed in its own allocator, so a - /// call-site AST reference cannot cross into it — its source can. + locals: &'c HashMap, + /// The source THIS context's spans index into, so an inlined helper can be given the + /// TEXT of the arguments it was called with. The helper is re-parsed in its own + /// allocator, so a call-site AST reference cannot cross into it — its source can. + /// + /// Borrowed rather than fixed to the module, because nested inlining re-parses a + /// SYNTHETIC buffer: the spans of nodes in it are offsets into that buffer, and + /// slicing the module with them lands on unrelated text — in range, so the bounds + /// check never fires, just wrong. `inline_local` rebinds this to the buffer it parsed. source: &'c str, } +impl<'c, 'a> ArmCtx<'c, 'a> { + /// The same context reading a different source buffer. + fn with_source(&self, source: &'c str) -> ArmCtx<'c, 'a> { + ArmCtx { + consts: self.consts, + locals: self.locals, + source, + } + } +} + /// Collects `function name(…){…}` / `var name = function(…){…}` spans in a module. #[derive(Default)] struct LocalFns { @@ -1641,6 +1658,11 @@ fn inline_local( return; }; let (func, bound) = applied_helper(func); + // From here the spans belong to `wrapped`, not to whatever this context was reading. + // Folding with the outer source would slice the module at this buffer's offsets: in + // range and therefore unguarded, but unrelated text — which cost the field a second + // helper level down (`mk2(v){ return mk(v) }`). + let ctx = &ctx.with_source(&wrapped); // Each of the helper's result branches is folded on its own and then MERGED, not // accumulated: a helper returning `{x: …}` in one branch and `{y: …}` in another // describes two legal shapes, and combining them would make the enclosing action diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 24057f4..3790472 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -622,6 +622,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr return {actionType:o("WAWebGroupType").GROUP_ACTIONS.ANNOUNCE, rows:e.mapChildrenWithTag("row", function(x){ return {v:x.attrString("p")}; })}; } function shadowedCb(x){ return {wrong:x.attrString("wrong")}; } + function mk(v){ return {chained:v}; } + function mk2(v){ return mk(v); } function helperKids(e){ return e.mapChildrenWithTag("helper_user", function(x){ return {id:x.attrUserJid("jid")}; }); } function withShadow(e,shadowedCb){ return e.mapChildrenWithTag("shadow_user", shadowedCb); } function y(e,t){ return t.mapChildrenWithTag("participant", function(p){ @@ -681,7 +683,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr parend: (t.hasChild("p") ? t.mapChildrenWithTag("parend_user", function(x){ return {id:x.attrUserJid("jid")}; }) : null), shadowed: withShadow(t, function(z){ return {right:z.attrString("right")}; }), viaHelper: t.hasChild("h") && helperKids(t), - cyclic: t.mapChildrenWithTag("cyclic_user", cycA)}; + cyclic: t.mapChildrenWithTag("cyclic_user", cycA), + twoDeep: mk2(t.attrString("deep_jid"))}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -869,6 +872,16 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { "an unresolved callback contributes no fields" ); + // A value threaded through TWO helpers: the second inlining re-parses a synthetic + // buffer, so its spans index that buffer — slicing the module with them lands on + // unrelated text, in range and therefore unguarded, and the field vanishes. + let deep = arm + .fields + .iter() + .find(|f| f.name == "chained") + .expect("a field threaded through two helpers survives"); + assert_eq!(deep.wire_name, "deep_jid"); + // The guard must not cost the child's contents. assert_eq!(child("anded").wire_tag, "anded_user"); assert_eq!(child("anded").fields.len(), 1); diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 0c5a5cd..2d8006c 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -273,6 +273,43 @@ def check_event_codes(data, domain, errors): ) else: seen[code] = e.get("name") + # A field's `id` is its wire identifier WITHIN the event, so the same rule applies + # one level down: an encoder handed two fields sharing an id cannot tell them + # apart. Scoped per event — ids repeat across events by design. + by_id = {} + for f in e.get("fields") or []: + if not isinstance(f, dict) or "id" not in f: + continue + fid = f["id"] + if fid in by_id: + errors.append( + f"{domain}: in event {e.get('name')!r}, fields {by_id[fid]!r} and " + f"{f.get('name')!r} share id {fid}" + ) + else: + by_id[fid] = f.get("name") + + +def check_action_keys(node, path, errors): + """`fields`, `constantFields` and `children` are three representations of ONE object + key namespace, so a name may appear in only one of them. + + Runtime cannot produce two values for one key, and a consumer handed both a wire-read + `reason` and a constant `reason` has a shape that contradicts itself. Each array is + internally consistent by construction; nothing compared them to each other. + """ + arrays = [k for k in ("fields", "constantFields", "children") if isinstance(node.get(k), list)] + if len(arrays) < 2: + return + names = [ + it["name"] + for k in arrays + for it in node[k] + if isinstance(it, dict) and "name" in it + ] + repeated = sorted({n for n in names if names.count(n) > 1}) + if repeated: + errors.append(f"{path}: one key filled twice across fields/constantFields/children: {repeated}") def check_assertion(a, path, errors): @@ -332,6 +369,7 @@ def visit(node, path, domain=domain): # Independent of the field gate — see each function's note. check_enum_ref(node, f"{domain}{path}", errors) check_const_bytes(node, f"{domain}{path}", errors) + check_action_keys(node, f"{domain}{path}", errors) walk(data, visit) # Needs the whole document, not one node: the reference and its definition sit in From 273032a7aee42d76b19fcf6a032b71c938c6308d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 15:37:37 -0300 Subject: [PATCH 16/46] review: pin every aliasing handle, refuse a shadowed callee, require two more shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four from Codex on 7cfe9e5, all latent. - The bootloader pins were rebuilt from `handles`, which is URL-keyed and so already lossy: `invert`/`handle_by_url` keep the lowest id where several `bx` ids alias one wasm URL, and reversing that recovers only that one. The field promises every id that resolved, and a JS reference using a dropped alias would find nothing. Built from the two authoritative id -> URL sources instead. (The cache's own record is URL-keyed, so its aliases were lost when it was written; those two are not.) - `local_call_source` resolved the callee straight from the module's function table while using the caller's scope only for the arguments, so `outer(h, node){ return h(node) }` could inline an unrelated module-level `h` and publish its fields — or a different action. Refused, the same rule the named mapped-callback path already applies. - A WAM event's `weights` are `[default, ring1, ring2]`, where the POSITIONS are the meaning: a short array does not lose the last ring, it shifts every ring that remains. The schema permits any length. - A `tag` assertion with no `name` can neither enforce nor dispatch a shape — the incoming and server-request scanners read the tag from that field. --- crates/wa-notif/src/actions.rs | 8 +++++++ crates/wa-notif/src/tests.rs | 13 ++++++++++- crates/whatspec/src/main.rs | 42 +++++++++++++++++++++++++--------- scripts/lint-ir.py | 14 ++++++++++++ 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index 6692eaa..a99d3c8 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -1605,6 +1605,14 @@ fn local_call_source<'b, 'a>( ) -> Option<(String, Vec)> { let call = as_call(e)?; let name = as_identifier(&call.callee)?; + // The CALLEE is subject to the caller's bindings too, not only the arguments below. + // `outer(h, node){ return h(node) }` calls the `h` it was handed; inlining the + // module-level `h` of the same name would publish an unrelated helper's fields, or + // even a different action. Refused rather than guessed at — the same rule the named + // mapped-callback path applies, and for the same reason. + if scope.get(name).is_some() { + return None; + } let src = ctx.locals.get(name).cloned()?; // The argument TEXT, so `inline_local` can bind the helper's formals. A span outside // the module source (a synthesized node) yields nothing for that position rather than diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 3790472..3166c74 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -622,6 +622,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr return {actionType:o("WAWebGroupType").GROUP_ACTIONS.ANNOUNCE, rows:e.mapChildrenWithTag("row", function(x){ return {v:x.attrString("p")}; })}; } function shadowedCb(x){ return {wrong:x.attrString("wrong")}; } + function hh(e){ return {wrongHelper:e.attrString("wrong")}; } + function callsShadowed(e,hh){ return hh(e); } function mk(v){ return {chained:v}; } function mk2(v){ return mk(v); } function helperKids(e){ return e.mapChildrenWithTag("helper_user", function(x){ return {id:x.attrUserJid("jid")}; }); } @@ -684,7 +686,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr shadowed: withShadow(t, function(z){ return {right:z.attrString("right")}; }), viaHelper: t.hasChild("h") && helperKids(t), cyclic: t.mapChildrenWithTag("cyclic_user", cycA), - twoDeep: mk2(t.attrString("deep_jid"))}; + twoDeep: mk2(t.attrString("deep_jid")), + viaShadowedCallee: callsShadowed(t, function(z){ return {rightHelper:z.attrString("ok")}; })}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -882,6 +885,14 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { .expect("a field threaded through two helpers survives"); assert_eq!(deep.wire_name, "deep_jid"); + // A helper CALLEE the caller binds is the one that runs. Inlining the module-level + // function of the same name would publish an unrelated helper's fields. + let names: Vec<&str> = arm.fields.iter().map(|f| f.name.as_str()).collect(); + assert!( + !names.contains(&"wrongHelper"), + "the module-level `hh` must not be inlined over the bound one: {names:?}" + ); + // The guard must not cost the child's contents. assert_eq!(child("anded").wire_tag, "anded_user"); assert_eq!(child("anded").fields.len(), 1); diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index fca64f6..8410944 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -1330,7 +1330,22 @@ fn load_wasm( handles.extend(cache.wasm_handles(remote_version.unwrap_or_default())); } handles.extend(resolution.handle_by_url()); - let pins = bootloader_pins(&resolution, &handles); + + // The PINS are built from the id → URL direction instead, because `handles` is + // URL-keyed and therefore already lossy: `invert`/`handle_by_url` keep the lowest id + // where several `bx` ids alias one wasm URL, and reversing that back recovers only + // that one. `wasm_handles` promises every id that resolved, and a JS reference using + // one of the dropped aliases would find nothing in the map. (The cache's own record + // is URL-keyed too, so its aliases were already lost when it was written; the two + // authoritative sources are not.) + let mut ids: BTreeMap = discovered.bx_data.clone(); + ids.extend(resolution.by_id.clone()); + if let Some(cache) = &cache { + for (url, id) in cache.wasm_handles(remote_version.unwrap_or_default()) { + ids.entry(id).or_insert(url); + } + } + let pins = bootloader_pins(&resolution, &ids); if payloads.is_empty() { // Still sweep: an explicit `--wasm-out` that produced nothing must not leave the @@ -1455,14 +1470,14 @@ fn truncate_on_char_boundary(s: &str, max_bytes: usize) -> &str { #[cfg(feature = "fetch")] fn bootloader_pins( resolution: &wa_fetch::WasmResolution, - handles: &BTreeMap, + ids: &BTreeMap, ) -> lock::BootloaderPins { lock::BootloaderPins { handles_from_page: resolution.from_page, - wasm_handles: handles + wasm_handles: ids .iter() - .filter(|(url, _)| wa_fetch::is_wasm_url(url)) - .map(|(url, id)| (id.clone(), url.clone())) + .filter(|(_, url)| wa_fetch::is_wasm_url(url)) + .map(|(id, url)| (id.clone(), url.clone())) .collect(), requests: resolution.requests, failed_requests: resolution.failed_requests, @@ -3237,13 +3252,17 @@ mod tests { // three sources folded into `handles`. Pinning the lot broke the field's own // contract and swamped its diff signal with images that change far more often // than the wasm set does. + // id -> URL, the direction the page and the resolver actually record: two ids may + // alias one wasm URL, and the URL-keyed index this used to be built from kept only + // the lowest of them. let handles: BTreeMap = [ - ("https://x/a.wasm", "11"), - ("https://x/logo.png", "22"), - ("https://x/b.wasm?v=3", "33"), + ("11", "https://x/a.wasm"), + ("22", "https://x/logo.png"), + ("33", "https://x/b.wasm?v=3"), + ("44", "https://x/a.wasm"), ] .iter() - .map(|(u, i)| (u.to_string(), i.to_string())) + .map(|(i, u)| (i.to_string(), u.to_string())) .collect(); let resolution = wa_fetch::WasmResolution { from_page: 1, @@ -3254,8 +3273,9 @@ mod tests { let pins = bootloader_pins(&resolution, &handles); assert_eq!( pins.wasm_handles.keys().collect::>(), - ["11", "33"], - "the image is dropped and a query string does not hide a .wasm" + ["11", "33", "44"], + "the image is dropped, a query string does not hide a .wasm, \ + and an aliasing id is kept" ); // The diagnostics still come from the resolution, untouched by the filter. assert_eq!((pins.handles_from_page, pins.requests), (1, 2)); diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 2d8006c..9d8da58 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -273,6 +273,16 @@ def check_event_codes(data, domain, errors): ) else: seen[code] = e.get("name") + # `[default, ring1, ring2]` — the positions ARE the meaning, so a short array does + # not lose the last ring, it shifts every ring that remains. The schema permits any + # length. + weights = e.get("weights") + if not isinstance(weights, list) or len(weights) != 3: + errors.append( + f"{domain}: event {e.get('name')!r} carries " + f"{len(weights) if isinstance(weights, list) else 'no'} sampling " + f"weight(s), not the three positions [default, ring1, ring2]" + ) # A field's `id` is its wire identifier WITHIN the event, so the same rule applies # one level down: an encoder handed two fields sharing an id cannot tell them # apart. Scoped per event — ids repeat across events by design. @@ -331,6 +341,10 @@ def check_assertion(a, path, errors): # `attr` assertion may legitimately assert only presence. if a.get("kind") == "content" and a.get("value") is None: errors.append(f"{path}: content assertion with no value to match") + # A `tag` assertion IS the expected tag; the incoming and server-request scanners read + # it from `name`. Without it the assertion can neither enforce nor dispatch a shape. + if a.get("kind") == "tag" and not a.get("name"): + errors.append(f"{path}: tag assertion with no tag name") def main() -> int: From 59d05d6440a13c0e6c76954e24f9b97fe999321c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 15:49:41 -0300 Subject: [PATCH 17/46] review: stop the linter crashing on the malformed IR it exists to catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven findings across a CodeRabbit round on 273032a and a Codex one, all gaps in this branch's own earlier fixes. The linter crashed on two shapes rather than reporting them. A non-dict entry in `variants` guarded the value lookup but not the message beside it, so a bare string aborted the whole run with an `AttributeError` — reproduced before fixing. And two nameless union variants made the duplicate-name report sort `None` against `str`. Both are error paths assuming a shape they had not verified, which is the failure they were written to prevent. Shadowing, continuing to widen: - Destructured `var`/`const` declarations bound nothing as far as the guard was concerned, exactly as parameters did before. Refused on collision only. - The `X.exports = {Name: e}` bag pushed deferred aliases with no parameter-shadow guard, while the single-property branch beside it had just gained one. - An outer guard reached a value-position helper's children but not its FIELDS, so a helper returning both under `cond && helper(node)` left the field required. Worth noting: the first test written for this passed with and without the fix — the field appeared in only one branch, so `BranchFold` was already weakening it. It now uses a single return carrying both. - Enum members must be unique within a definition: JS keeps only the last duplicate property, `uniqueItems` cannot see objects that differ in `value`, and a consumer would get two answers for one member. Declined one: a bare `e = extends({}, e, {…})` rebind is NOT resolved. Which body a read sees depends on whether it precedes the rebind, and this pass has no ordering model for reads — publishing the merged set would over-claim for every earlier read. Refusing also costs nothing, since the merge differs from the bound body and would mark the name shadowed anyway. Recorded as a test. --- crates/wa-enums/src/lib.rs | 54 +++++++++++++++++++++++++++++++++- crates/wa-notif/src/actions.rs | 7 ++++- crates/wa-notif/src/tests.rs | 16 +++++++++- scripts/lint-ir.py | 45 +++++++++++++++++++++++----- 4 files changed, 111 insertions(+), 11 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 0f8033b..02c5683 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -354,6 +354,18 @@ impl NamedResolver { impl<'a> Visit<'a> for NamedResolver { fn visit_variable_declarator(&mut self, d: &VariableDeclarator<'a>) { + if d.id.get_identifier_name().is_none() { + // A DESTRUCTURED declaration (`const {e} = obj`) binds `e` just as much as + // `var e = …` does, and `get_identifier_name` sees none of it — so the whole + // branch below was skipped and the outer body stayed usable. Refused on + // collision only, the same rule an unreadable initializer follows: a + // destructuring that shadows nothing costs nothing to ignore. + for ident in d.id.get_binding_identifiers() { + if self.locals.contains_key(ident.name.as_str()) { + self.shadowed.insert(ident.name.to_string()); + } + } + } if let Some(local) = d.id.get_identifier_name() { let data = d .init @@ -413,7 +425,13 @@ impl<'a> Visit<'a> for NamedResolver { for (key, value) in wa_oxc::obj_props(o) { if let Some(data) = enum_object(value).and_then(parse_enum) { self.exports.entry(key.to_string()).or_insert(data); - } else if let Some(id) = as_identifier(value) { + } else if let Some(id) = as_identifier(value) + // The same deferred-alias guard as the single-property branch + // below: `pending` resolves after the walk, when the parameter + // stack is unwound, so a parameter-shadowed name has to be + // refused HERE or it silently resolves to the module local. + && !self.param_shadows(id) + { self.pending.push((key.to_string(), id.to_string())); } } @@ -431,6 +449,13 @@ impl<'a> Visit<'a> for NamedResolver { // that reassigns an operand before composing it still published the stale // body — the same wrong closed set the shadow guard was added to prevent, // reached through the one form it did not watch. + // Deliberately does NOT fall back to `merge_extends` the way the `var` form + // does. A self-referential rebind (`e = extends({}, e, {B:"b"})`) is only + // resolvable if you know whether a given read of `e` happens before or after + // it, and this pass has no ordering model for reads — so publishing the + // merged set would over-claim for every read that precedes the composition. + // Refusing costs nothing here: the merge yields a body different from the one + // already bound, so `bind_local` would mark the name shadowed anyway. match enum_object(&a.right).and_then(parse_enum) { Some(data) => self.bind_local(name, data), // Rebound to something this pass cannot read: whatever `locals` still @@ -750,6 +775,33 @@ mod tests { }),1);"#; assert!(resolve_named_enum(rest, "M", "OUT").is_none()); + // The exports BAG defers aliases the same way the single-property form does, so + // it needs the same parameter guard. + let bag = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(e){ i.exports={OUT:e} } + f({X:"x"}) + }),1);"#; + assert!(resolve_named_enum(bag, "M", "OUT").is_none()); + + // A bare rebind to a composition is REFUSED, not resolved: which body a read of + // `e` sees depends on whether it precedes the rebind, and nothing here models + // that order. See the note at the assignment branch. + let composed = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + e=babelHelpers.extends({},e,{B:"b"}); + i.OUT=e + }),1);"#; + assert!(resolve_named_enum(composed, "M", "OUT").is_none()); + + // A DESTRUCTURED declaration binds its names too, and `get_identifier_name` sees + // none of them. + let destructured_var = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(obj){ var {e}=obj; var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(destructured_var, "M", "OUT").is_none()); + // 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){ diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index a99d3c8..2577daa 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -1849,7 +1849,12 @@ fn fold_object<'b, 'a>( c.required &= !outer_absent; write_key(def, key, KeyValue::Child(c)); } - for f in nested.fields { + // Fields carry the same guard as children above. A helper whose branches + // yield a mapped collection in one and a flat object in the other contributes + // both kinds under `cond && helper(node)`, and only the collection was being + // weakened — leaving a genuinely optional field marked required. + for mut f in nested.fields { + f.required &= !outer_absent; let fkey = f.name.clone(); write_key(def, &fkey, KeyValue::Field(f)); } diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 3166c74..d8ae666 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -626,6 +626,7 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr function callsShadowed(e,hh){ return hh(e); } function mk(v){ return {chained:v}; } function mk2(v){ return mk(v); } + function mixedKids(e){ return {plainField:e.attrString("plain"), kids:e.mapChildrenWithTag("mixed_user", function(x){ return {id:x.attrUserJid("jid")}; })}; } function helperKids(e){ return e.mapChildrenWithTag("helper_user", function(x){ return {id:x.attrUserJid("jid")}; }); } function withShadow(e,shadowedCb){ return e.mapChildrenWithTag("shadow_user", shadowedCb); } function y(e,t){ return t.mapChildrenWithTag("participant", function(p){ @@ -687,7 +688,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr viaHelper: t.hasChild("h") && helperKids(t), cyclic: t.mapChildrenWithTag("cyclic_user", cycA), twoDeep: mk2(t.attrString("deep_jid")), - viaShadowedCallee: callsShadowed(t, function(z){ return {rightHelper:z.attrString("ok")}; })}; + viaShadowedCallee: callsShadowed(t, function(z){ return {rightHelper:z.attrString("ok")}; }), + mixed: t.hasChild("mx") && mixedKids(t)}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -893,6 +895,18 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { "the module-level `hh` must not be inlined over the bound one: {names:?}" ); + // A guarded value-position helper whose branches yield BOTH a collection and a flat + // field must weaken both. Only the collection was being weakened. + let plain = arm + .fields + .iter() + .find(|f| f.name == "plainField") + .expect("the helper's flat branch contributes a field"); + assert!( + !plain.required, + "`cond && helper(node)` makes the helper's fields optional too, not only its children" + ); + // The guard must not cost the child's contents. assert_eq!(child("anded").wire_tag, "anded_user"); assert_eq!(child("anded").fields.len(), 1); diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 9d8da58..6c26eb7 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -121,12 +121,17 @@ def check_field(f, path, errors, counts): # Two entries are not two ALTERNATIVES if they answer to the same name. The # name is the documented discriminator and the Rust codegen emits it as the # variant identifier, so a repeat is either ambiguous or uncompilable. - names = [v.get("name") for v in variants if isinstance(v, dict)] - if len(set(names)) < len(names): - errors.append( - f"{path}: union alternatives repeat a name " - f"({sorted(n for n in set(names) if names.count(n) > 1)})" - ) + # Only real names: a missing one is `None`, and two of those would both + # count as a duplicate AND make the report `sorted()` a `None` against a + # `str`. Absence is not a repeat. + names = [ + v.get("name") + for v in variants + if isinstance(v, dict) and isinstance(v.get("name"), str) + ] + repeated = sorted({n for n in names if names.count(n) > 1}) + if repeated: + errors.append(f"{path}: union alternatives repeat a name ({repeated})") # Each pair is the two bounds of ONE range accessor. Inverted is a contradiction; # so is half of one — the schema permits either key alone, but a consumer handed @@ -220,10 +225,34 @@ def check_enum_catalog_refs(data, domain, errors): # permits any `Scalar` per variant, so it cannot check the two agree. A variant # that disagrees would have the consumer pick an incompatible representation. # `bool` is excluded explicitly — in Python it IS an int. + # One member name, one value. JS keeps only the last duplicate property, so a + # definition carrying both gives a consumer two contradictory answers for the same + # member — and `uniqueItems` cannot see it, because the objects differ in `value`. + members = [ + v.get("name") or v.get("key") + for v in e.get("variants") or [] + if isinstance(v, dict) and isinstance(v.get("name") or v.get("key"), str) + ] + repeated = sorted({m for m in members if members.count(m) > 1}) + if repeated: + errors.append( + f"{domain}/enums/{i}: enum {e.get('name')!r} defines " + f"{repeated} more than once" + ) + expected = {"int": int, "string": str}.get(e.get("valueKind")) if expected is not None: - for v in e.get("variants") or []: - val = v.get("value") if isinstance(v, dict) else None + for j, v in enumerate(e.get("variants") or []): + # A non-dict entry is malformed IR — exactly what this exists to catch — + # so it must be REPORTED, not crash the run on `.get`. The value guard + # below was written defensively; the message beside it was not. + if not isinstance(v, dict): + errors.append( + f"{domain}/enums/{i}: enum {e.get('name')!r} variant {j} " + f"is {type(v).__name__}, not an object" + ) + continue + val = v.get("value") if not isinstance(val, expected) or isinstance(val, bool): errors.append( f"{domain}/enums/{i}: enum {e.get('name')!r} is valueKind " From d74959e378d3c9b81c7c012be84097dd9978e9aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 16:08:30 -0300 Subject: [PATCH 18/46] review: keep the lockfile inside the CDN allowlist, and stop refusing terminal aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four from Codex on 59d05d6. - The bootloader pins were built from the page's `bxData` behind a filename-suffix check alone. That map is server-supplied and may name any absolute URL; `resolve_wasm_with` already refuses a non-CDN one, so the committed lockfile — which `restore` treats as trusted — could publish `https://attacker.example/payload.wasm` as a resolved handle the fetcher itself had rejected. Now behind the same `is_cdn_payload` the download path uses, not a second copy of it. The test covers the foreign host, the plaintext downgrade and the userinfo form. - Bounding the callback recursion two rounds ago refused EVERY chain ending on an identifier, which threw out the benign case with the dangerous one: a terminal alias (`var cb = parseP`) names a module callback and must resolve, and the child was shipping with an empty field list. Whether the caller still binds the name separates a terminal alias from a cycle. - `catch (e)` binds for the handler body exactly as a parameter binds for a function body — the third form of the same shadow, after parameters and destructured declarations. - A `reference` assertion needs `name` as well as `referencePath`: the path says where in the request the value comes from, `name` says which response attribute has to echo it. With only the path a consumer knows the source of a rule it cannot apply to anything. Note on the catch fix: two revert checks appeared to pass because the reverted code did not COMPILE, and grepping the output for a panic found none. Re-run with the type spelled out, the revert resolves `A,B` and the fix refuses it, as the finding said. A revert that proves nothing has to be distinguishable from a revert that passes. --- crates/wa-enums/src/lib.rs | 40 +++++++++++++++++++++++++++++++ crates/wa-fetch/src/bootloader.rs | 2 +- crates/wa-fetch/src/lib.rs | 2 +- crates/wa-notif/src/actions.rs | 18 ++++++++++++-- crates/wa-notif/src/tests.rs | 20 +++++++++++++++- crates/whatspec/src/main.rs | 30 +++++++++++++++++------ scripts/lint-ir.py | 10 ++++++-- 7 files changed, 108 insertions(+), 14 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 02c5683..7149889 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -405,6 +405,26 @@ impl<'a> Visit<'a> for NamedResolver { self.param_scopes.pop(); } + fn visit_catch_clause(&mut self, c: &oxc_ast::ast::CatchClause<'a>) { + // `catch (e)` binds `e` for the handler body exactly as a parameter binds it for + // a function body — the third form of the same shadow, after parameters and + // destructured declarations. + let names = c + .param + .as_ref() + .map(|p| { + p.pattern + .get_binding_identifiers() + .into_iter() + .map(|i| i.name.to_string()) + .collect() + }) + .unwrap_or_default(); + self.param_scopes.push(names); + walk::walk_catch_clause(self, c); + self.param_scopes.pop(); + } + fn visit_arrow_function_expression(&mut self, f: &oxc_ast::ast::ArrowFunctionExpression<'a>) { self.param_scopes.push(param_names(&f.params)); walk::walk_arrow_function_expression(self, f); @@ -802,6 +822,26 @@ mod tests { }),1);"#; assert!(resolve_named_enum(destructured_var, "M", "OUT").is_none()); + // `catch (e)` binds for the handler body — the third form of the same shadow. + let caught = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + try { risky() } catch(e) { var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(caught, "M", "OUT").is_none()); + // Sanity: the SAME shape without the catch binding must resolve, or the assertion + // above proves nothing about catch. + let no_catch = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + try { risky() } catch(q) { var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert_eq!( + resolve_named_enum(no_catch, "M", "OUT") + .expect("resolves when catch binds another name") + .variants + .len(), + 2 + ); + // 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){ diff --git a/crates/wa-fetch/src/bootloader.rs b/crates/wa-fetch/src/bootloader.rs index 0798fe2..40f1275 100644 --- a/crates/wa-fetch/src/bootloader.rs +++ b/crates/wa-fetch/src/bootloader.rs @@ -226,7 +226,7 @@ pub fn resolve_wasm(discovered: &Discovered, opts: &WasmResolveOptions) -> WasmR /// `https://static.whatsapp.net:443@attacker.example/x.wasm` names `attacker.example` as /// the host a client would connect to, and must not pass as the CDN). Plaintext is refused /// so a downgraded URL can't put a fetched payload on the wire unauthenticated. -fn is_cdn_payload(url: &str) -> bool { +pub fn is_cdn_payload(url: &str) -> bool { url.starts_with("https://") && host_of(url).as_deref() == Some(ALLOWED_HOST) } diff --git a/crates/wa-fetch/src/lib.rs b/crates/wa-fetch/src/lib.rs index 3bf4b38..052fbd8 100644 --- a/crates/wa-fetch/src/lib.rs +++ b/crates/wa-fetch/src/lib.rs @@ -35,7 +35,7 @@ mod native; #[cfg(all(test, feature = "native"))] mod testutil; -pub use bootloader::{WasmResolution, WasmResolveOptions, resolve_wasm_with}; +pub use bootloader::{WasmResolution, WasmResolveOptions, is_cdn_payload, resolve_wasm_with}; pub use discover::{ BootloaderParams, Discovered, Sources, WA_WEB_URL, build_wa_version, discover_bundle_urls_with, discover_from_html, is_wasm_url, diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index 2577daa..c1ed570 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -2301,8 +2301,22 @@ fn collect_accessor_fields<'b, 'a>( // four (`a → b → c → a`) it returns a *different* identifier every time — // and recursing on that walks the cycle forever until the stack goes. An // unresolved chain is refused, which is what the hop limit was already for. - if as_identifier(resolved).is_none() { - collect_accessor_fields(resolved, outer, ctx, out); + // Recurse ONLY into something that is no longer an identifier: on a cycle + // whose length does not divide the hop limit, `deref_ident` hands back a + // different identifier every call and recursing walks it forever. + // + // But a chain ending on an identifier has a second, benign cause: a TERMINAL + // alias naming a module callback (`var cb = parseP`). Refusing those cost the + // child its whole field list. Scope still binding the name is what separates + // the two — a terminal module name is not bound by the caller, a cycle's is. + match as_identifier(resolved) { + None => collect_accessor_fields(resolved, outer, ctx, out), + Some(inner) if outer.get(inner).is_none() => { + if let Some(src) = ctx.locals.get(inner) { + collect_named_callback_fields(src, ctx, out); + } + } + Some(_) => {} } return; } diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index d8ae666..2aca693 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -624,6 +624,7 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr function shadowedCb(x){ return {wrong:x.attrString("wrong")}; } function hh(e){ return {wrongHelper:e.attrString("wrong")}; } function callsShadowed(e,hh){ return hh(e); } + function parseP(x){ return {aliased:x.attrString("via_alias")}; } function mk(v){ return {chained:v}; } function mk2(v){ return mk(v); } function mixedKids(e){ return {plainField:e.attrString("plain"), kids:e.mapChildrenWithTag("mixed_user", function(x){ return {id:x.attrUserJid("jid")}; })}; } @@ -679,6 +680,7 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr return I(t); case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.MEMBERSHIP: { var cycA=cycB, cycB=cycC, cycC=cycA; + var cbAlias=parseP; return {actionType:o("WAWebGroupType").GROUP_ACTIONS.MEMBERSHIP, picked: t.hasChildren() ? t.mapChildrenWithTag("picked_user", function(x){ return {id:x.attrUserJid("jid")}; }) : [{id:"fallback"}], nulled: t.hasChild("opt") ? t.mapChildrenWithTag("nulled_user", function(x){ return {id:x.attrUserJid("jid")}; }) : null, @@ -689,7 +691,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr cyclic: t.mapChildrenWithTag("cyclic_user", cycA), twoDeep: mk2(t.attrString("deep_jid")), viaShadowedCallee: callsShadowed(t, function(z){ return {rightHelper:z.attrString("ok")}; }), - mixed: t.hasChild("mx") && mixedKids(t)}; + mixed: t.hasChild("mx") && mixedKids(t), + aliasedCb: t.mapChildrenWithTag("alias_user", cbAlias)}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -907,6 +910,21 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { "`cond && helper(node)` makes the helper's fields optional too, not only its children" ); + // A TERMINAL alias (`var cbAlias = parseP`) names a module callback and must resolve. + // The cycle guard above refuses any chain ending on an identifier, which threw this + // away too — the child shipped with an empty field list. + let aliased = child("aliasedCb"); + assert_eq!(aliased.wire_tag, "alias_user"); + assert_eq!( + aliased + .fields + .iter() + .map(|f| f.name.as_str()) + .collect::>(), + ["aliased"], + "a terminal alias resolves through the module callbacks" + ); + // The guard must not cost the child's contents. assert_eq!(child("anded").wire_tag, "anded_user"); assert_eq!(child("anded").fields.len(), 1); diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index 8410944..7ed64ee 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -1460,6 +1460,14 @@ fn truncate_on_char_boundary(s: &str, max_bytes: usize) -> &str { /// while their payloads stay in the set — and the supposedly diffable map would differ /// between two runs of an unchanged release. /// +/// Only wasm entries the DOWNLOAD path would accept are pinned. The page's `bxData` is +/// server-supplied and may name any absolute URL; `resolve_wasm_with` already refuses a +/// non-CDN one, but the pins were built from the unfiltered page map behind a +/// filename-suffix check alone, so the committed lockfile — which `restore` treats as +/// trusted — could publish `https://attacker.example/payload.wasm` as a resolved handle +/// that the fetcher itself had rejected. Same predicate as the fetch path, not a second +/// copy of it. +/// /// Only the wasm entries are pinned, as the field name promises. `handles` starts from the /// page's `bxData`, the bootloader's map of EVERY resource — images and the rest — while /// the other two contributors are already wasm-filtered (`by_id` keeps only wasm URLs, and @@ -1476,7 +1484,7 @@ fn bootloader_pins( handles_from_page: resolution.from_page, wasm_handles: ids .iter() - .filter(|(_, url)| wa_fetch::is_wasm_url(url)) + .filter(|(_, url)| wa_fetch::is_wasm_url(url) && wa_fetch::is_cdn_payload(url)) .map(|(id, url)| (id.clone(), url.clone())) .collect(), requests: resolution.requests, @@ -3256,10 +3264,18 @@ mod tests { // alias one wasm URL, and the URL-keyed index this used to be built from kept only // the lowest of them. let handles: BTreeMap = [ - ("11", "https://x/a.wasm"), - ("22", "https://x/logo.png"), - ("33", "https://x/b.wasm?v=3"), - ("44", "https://x/a.wasm"), + ("11", "https://static.whatsapp.net/a.wasm"), + ("22", "https://static.whatsapp.net/logo.png"), + ("33", "https://static.whatsapp.net/b.wasm?v=3"), + ("44", "https://static.whatsapp.net/a.wasm"), + // Server-supplied `bxData` may name any host. The fetcher refuses these; the + // lockfile is trusted by `restore`, so it must refuse them too. + ("55", "https://attacker.example/payload.wasm"), + ("66", "http://static.whatsapp.net/downgraded.wasm"), + ( + "77", + "https://static.whatsapp.net:443@attacker.example/x.wasm", + ), ] .iter() .map(|(i, u)| (i.to_string(), u.to_string())) @@ -3274,8 +3290,8 @@ mod tests { assert_eq!( pins.wasm_handles.keys().collect::>(), ["11", "33", "44"], - "the image is dropped, a query string does not hide a .wasm, \ - and an aliasing id is kept" + "the image is dropped, a query string does not hide a .wasm, an aliasing id \ + is kept, and nothing off the CDN is pinned" ); // The diagnostics still come from the resolution, untouched by the filter. assert_eq!((pins.handles_from_page, pins.requests), (1, 2)); diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 6c26eb7..03b3eec 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -352,8 +352,14 @@ def check_action_keys(node, path, errors): def check_assertion(a, path, errors): - if a.get("kind") == "reference" and not a.get("referencePath"): - errors.append(f"{path}: reference assertion with no referencePath") + if a.get("kind") == "reference": + if not a.get("referencePath"): + errors.append(f"{path}: reference assertion with no referencePath") + # The path says WHERE in the request the value comes from; `name` says which + # response attribute has to echo it. With only the path a consumer knows the + # source of a rule it cannot apply to anything. + if not a.get("name"): + errors.append(f"{path}: reference assertion with no target attribute name") if a.get("kind") == "attr" and not a.get("name"): errors.append(f"{path}: attr assertion with no attribute name") # `WapAttrKind::Const` is documented as "fixed literal value (carried in From 2712fd53b5670a947c35835900a01ee890ae5ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 16:26:31 -0300 Subject: [PATCH 19/46] review: baseline unresolved enums by identity, not by total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four from Codex on d74959e. The ratchet could not see a SUBSTITUTION. One field gaining values while another lost them holds the sum at 155 and reports `ok` — precisely the silent constraint loss the file exists to catch, and the one shape a scalar can never detect. The 155 fields collapse to 30 identities (domain + field name + wire name + accessor), so the baseline is now that multiset. Identity rather than path, because a positional key would churn on every unrelated insertion. Verified by mutating one field in each direction at once: the total holds and both halves are reported. - A nested `function e(){}` hoists a binding over the enclosing body, so an operand read there is the function, not the module local — the fourth form of this shadow, after parameters, destructured declarations and catch bindings. - Resolution can come up short WITHOUT a request failing: a component list capped by `max_components`, a page that deferred nothing, missing endpoint parameters. Those live in `failures`, which the pins discarded, so a capped run whose requests all succeeded pinned zeroes and — with the handle map accumulating — left no diff at all to say the extraction was incomplete. `degradations` now carries the count. - A required `WapVariantGroup` with no variants promises that exactly one alternative applies and offers none: an emitter cannot construct the request, and the generator emits a required field whose enum has no variants. --- crates/wa-enums/src/lib.rs | 17 ++++++++ crates/whatspec/src/lock.rs | 9 ++++ crates/whatspec/src/main.rs | 11 +++++ scripts/lint-ir.py | 87 ++++++++++++++++++++++++++++++++++--- 4 files changed, 118 insertions(+), 6 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 7149889..c4fa262 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -400,6 +400,16 @@ impl<'a> Visit<'a> for NamedResolver { f: &oxc_ast::ast::Function<'a>, flags: oxc_syntax::scope::ScopeFlags, ) { + // A nested `function e(){}` HOISTS a binding named `e` over the enclosing body, so + // an operand read there is the function, not the module local — the fourth form of + // the same shadow, after parameters, destructured declarations and catch bindings. + // Recorded on the module-wide set (that is where the name becomes visible) and + // only on collision, the rule every other unreadable shadow follows. + if let Some(id) = &f.id + && self.locals.contains_key(id.name.as_str()) + { + self.shadowed.insert(id.name.to_string()); + } self.param_scopes.push(param_names(&f.params)); walk::walk_function(self, f, flags); self.param_scopes.pop(); @@ -842,6 +852,13 @@ mod tests { 2 ); + // A nested `function e(){}` hoists a binding over the enclosing body. + let fn_name = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function g(){ function e(){} var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(fn_name, "M", "OUT").is_none()); + // 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){ diff --git a/crates/whatspec/src/lock.rs b/crates/whatspec/src/lock.rs index 0ab1bea..7b72da5 100644 --- a/crates/whatspec/src/lock.rs +++ b/crates/whatspec/src/lock.rs @@ -170,6 +170,14 @@ pub struct BootloaderPins { /// contract moved. pub requests: usize, pub failed_requests: usize, + /// How many ways resolution came up short, INCLUDING the ones that never reached a + /// request — a component list capped by `max_components`, a page that deferred + /// nothing, endpoint parameters the page did not ship. `failed_requests` counts only + /// requests that failed, so a capped run whose requests all succeeded pinned zeroes + /// across the board and, with the handle map accumulating, left no diff at all to say + /// the extraction had been incomplete. + #[serde(default)] + pub degradations: usize, } /// The wasm lockfile (`generated/wasm.lock.json`) — what a fetch run resolved and stored. @@ -451,6 +459,7 @@ mod tests { wasm_handles: BTreeMap::from([("30933".into(), "https://s/a.wasm".into())]), requests: 4, failed_requests: 1, + degradations: 2, }; let lock = WasmLock::with_bootloader( "2.3000.TEST", diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index 7ed64ee..d5a6a02 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -1489,6 +1489,7 @@ fn bootloader_pins( .collect(), requests: resolution.requests, failed_requests: resolution.failed_requests, + degradations: resolution.failures.len(), } } @@ -3148,6 +3149,7 @@ mod tests { .collect(), requests, failed_requests: failed, + degradations: 0, }; let before = lock::WasmLock::with_bootloader( @@ -3214,6 +3216,7 @@ mod tests { .collect(), requests: 4, failed_requests: 0, + degradations: 0, }; let seed = lock::WasmLock::with_bootloader( "2.3000.test", @@ -3284,6 +3287,9 @@ mod tests { from_page: 1, requests: 2, failed_requests: 0, + // Degradation that never reached a request: the pins must still carry it, or + // a capped run whose requests all succeeded looks identical to a clean one. + failures: vec!["component list capped".into()], ..Default::default() }; let pins = bootloader_pins(&resolution, &handles); @@ -3295,6 +3301,11 @@ mod tests { ); // The diagnostics still come from the resolution, untouched by the filter. assert_eq!((pins.handles_from_page, pins.requests), (1, 2)); + assert_eq!( + (pins.failed_requests, pins.degradations), + (0, 1), + "no request failed, but coverage was still short" + ); } #[test] diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 03b3eec..c1c6405 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -24,18 +24,59 @@ """ import json import sys +from collections import defaultdict from pathlib import Path # Counted states, with the value observed when this guard was introduced. Raising # one of these is a deliberate act: it means a constraint the extractor used to # recover is now being lost, and the number has to be updated with a reason. BASELINE = { - # 157 -> 155: appstate's two `protoEnum` fields were never unresolved, only - # unrecognised by the check below. - "enum field with no values": 155, "content integer with no byte width": 0, } +# The enums no extraction path could resolve, by IDENTITY rather than by total. +# +# A single number cannot see a SUBSTITUTION: one field gaining values while another loses +# them holds the sum at 155 and reports `ok`, which is precisely the silent constraint loss +# this file exists to catch. Identity is domain + field name + wire name + accessor, so it +# survives reordering — a positional path would churn on every unrelated insertion. The +# counts are multiplicities: the same wire enum is read at several places. +# +# 155 fields collapse to 30 identities. Adding one, dropping one, or changing a count all +# fail; each is a deliberate act that costs one line here. +UNRESOLVED_ENUMS = { + "incoming|edit|edit|attrEnum": 1, + "iq|automaticBinding|automatic-binding|maybeAttrEnum": 2, + "iq|canAddPayout|can-add-payout|attrEnum": 2, + "iq|canPayout|can-payout|attrEnum": 2, + "iq|canSell|can-sell|attrEnum": 2, + "iq|capabilitiesDefaultEligibleP2m|default-eligible-p2m|maybeAttrEnum": 6, + "iq|capabilitiesDefaultEligibleP2p|default-eligible-p2p|maybeAttrEnum": 6, + "iq|capabilitiesDefaultEligible|default-eligible|attrEnum": 6, + "iq|capabilitiesEditable|editable|attrEnum": 6, + "iq|capabilitiesP2mCreditEligible|p2m-credit-eligible|attrEnum": 2, + "iq|capabilitiesP2mDebitEligible|p2m-debit-eligible|attrEnum": 2, + "iq|capabilitiesVerifiable|verifiable|attrEnum": 6, + "iq|defaultCreditP2m|default-credit-p2m|maybeAttrEnum": 8, + "iq|defaultCreditP2p|default-credit-p2p|maybeAttrEnum": 8, + "iq|defaultCredit|default-credit|attrEnum": 8, + "iq|defaultDebitP2m|default-debit-p2m|maybeAttrEnum": 8, + "iq|defaultDebitP2p|default-debit-p2p|maybeAttrEnum": 8, + "iq|defaultDebit|default-debit|attrEnum": 8, + "iq|error|error|maybeAttrEnum": 7, + "iq|isAadhaarEnabled|is-aadhaar-enabled|maybeAttrEnum": 2, + "iq|isInternationalPayEnabled|is_international_pay_enabled|maybeAttrEnum": 2, + "iq|isMpinSet|is-mpin-set|attrEnum": 2, + "iq|membershipApprovalRequestError|error|maybeAttrEnum": 2, + "iq|needsDeviceBinding|needs-device-binding|attrEnum": 2, + "iq|p2mEligible|p2m-eligible|maybeAttrEnum": 18, + "iq|p2pEligible|p2p-eligible|maybeAttrEnum": 18, + "iq|pinFormatVersion|pin-format-version|attrEnum": 2, + "iq|pixOnboardingState|pix-onboarding-state|maybeAttrEnum": 2, + "iq|verified|verified|attrEnum": 6, + "notif|reason|reason|": 1, +} + # `contentInt` reads DECIMAL TEXT, so it has no byte width and must not be asked # for one; `contentUint(N)` reads N big-endian bytes and must always carry it. WIDTH_BEARING = {"contentUint"} @@ -71,7 +112,7 @@ def walk(node, visit, path=""): walk(v, visit, f"{path}/{i}") -def check_field(f, path, errors, counts): +def check_field(f, path, domain, errors, counts, unresolved): """Invariants of one `ParsedField`-shaped object.""" t = f.get("type") method = f.get("method", "") @@ -90,7 +131,7 @@ def check_field(f, path, errors, counts): and not f.get("enumKeys") and not f.get("protoEnum") ): - counts["enum field with no values"] += 1 + unresolved[f"{domain}|{f.get('name')}|{f.get('wireName')}|{method}"] += 1 if method in WIDTH_BEARING and "byteLength" not in f: counts["content integer with no byte width"] += 1 @@ -351,6 +392,19 @@ def check_action_keys(node, path, errors): errors.append(f"{path}: one key filled twice across fields/constantFields/children: {repeated}") +def check_variant_group(node, path, errors): + """A `WapVariantGroup` says exactly one of its alternatives applies. + + With none listed, a REQUIRED group promises a choice and supplies nothing to choose: + an emitter cannot construct the request, and the Rust generator emits a required field + whose enum has no variants. An optional group with no alternatives is merely empty. + """ + if "variants" not in node or "optional" not in node: + return + if not node.get("optional") and not node.get("variants"): + errors.append(f"{path}: required variant group offers no alternative") + + def check_assertion(a, path, errors): if a.get("kind") == "reference": if not a.get("referencePath"): @@ -386,6 +440,7 @@ def main() -> int: root = Path(sys.argv[1] if len(sys.argv) > 1 else "generated") errors: list[str] = [] counts = dict.fromkeys(BASELINE, 0) + unresolved: dict[str, int] = defaultdict(int) docs = sorted(root.glob("*/index.json")) if not docs: @@ -412,13 +467,14 @@ def visit(node, path, domain=domain): f"{domain}{path}: field type {node.get('type')!r} is not in the " f"ParsedFieldType vocabulary" ) - check_field(node, f"{domain}{path}", errors, counts) + check_field(node, f"{domain}{path}", domain, errors, counts, unresolved) if "kind" in node and "reference_path" not in node: check_assertion(node, f"{domain}{path}", errors) # Independent of the field gate — see each function's note. check_enum_ref(node, f"{domain}{path}", errors) check_const_bytes(node, f"{domain}{path}", errors) check_action_keys(node, f"{domain}{path}", errors) + check_variant_group(node, f"{domain}{path}", errors) walk(data, visit) # Needs the whole document, not one node: the reference and its definition sit in @@ -427,6 +483,25 @@ def visit(node, path, domain=domain): check_event_codes(data, domain, errors) ok = True + # Set difference in BOTH directions, plus the multiplicities. A gain that offsets a + # loss keeps the total at 155 and is exactly what a scalar cannot see. + for ident in sorted(set(unresolved) | set(UNRESOLVED_ENUMS)): + now, was = unresolved.get(ident, 0), UNRESOLVED_ENUMS.get(ident, 0) + if now == was: + continue + ok = False + if was == 0: + print(f"REGRESSION newly unresolved enum: {ident} (x{now})") + elif now == 0: + print(f"IMPROVED enum now resolved: {ident} — drop it from the baseline") + else: + print(f"CHANGED {ident}: {was} -> {now} — update the baseline") + if ok: + print( + f"ok unresolved enums: {sum(unresolved.values())} across " + f"{len(unresolved)} identities, as pinned" + ) + for name, observed in sorted(counts.items()): allowed = BASELINE[name] if observed > allowed: From 065daa9a089c15114f0b1796bfe4c2b6f312e8ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 16:45:26 -0300 Subject: [PATCH 20/46] review: prefer the callee the caller bound, and check three more IR shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five from Codex on 2712fd5 plus three from CodeRabbit. - `guard_admits_absence` examined only each ternary branch's stripped terminal, so a guard nested inside one (`cond ? enabled && map(…) : fallback`) read as no guard at all, though the `!enabled` path yields no collection. Each branch is now examined whole. - The callee-shadow guard depended on the formal being in `Scope`, but `apply_args` declines to substitute unless an argument carries a wire read (doing it unconditionally cost 62 shape elements), so on that path the formal was never bound and the module-level helper won anyway. `ArmCtx` carries the formals so the shadowing survives the skipped substitution. - That guard also REFUSED where it could resolve. CodeRabbit's point stands: the test asserted only that the wrong helper was absent, and the right one was absent too. What the caller bound is now preferred — its text comes out of the buffer `ArmCtx` already tracks — and refusal is left for the case that cannot be read back. Linter: a range pair must sit on the type it constrains (a string field with a numeric range instructs a consumer to do two incompatible things); a WAM event code must fit the 16-bit wire field the reference consumer emits it as; a channel must be one WAM defines, since an unknown one is silently mapped to `Regular` rather than preserved; and a non-string `literalValue` is reported instead of iterated, which would abort the run. Two fixtures were wrong before they were right: the formal-shadow case passed a function literal, whose text contains `.attr`, so `apply_args` DID substitute and the un-substituted path went untested. Both now fail without their fix. --- crates/wa-notif/src/actions.rs | 80 ++++++++++++++++++++++++++++------ crates/wa-notif/src/tests.rs | 28 +++++++++++- scripts/lint-ir.py | 35 ++++++++++++++- 3 files changed, 128 insertions(+), 15 deletions(-) diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index c1ed570..4fe1ffb 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -59,7 +59,7 @@ //! and `dropsByReason` counts what was seen and not recovered. That is the invariant to //! preserve if this grows: a reader that guesses is worse than one that reports a gap. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use oxc_allocator::Allocator; use oxc_ast::ast::{Expression, Statement, SwitchStatement}; @@ -408,10 +408,12 @@ pub(crate) fn extract_actions( .into_iter() .filter_map(|(n, src)| src.map(|s| (n, s))) .collect(); + let no_formals = HashSet::new(); let ctx = ArmCtx { consts, source: handler_slice, locals: &locals, + formals: &no_formals, }; let mut finder = SwitchFinder { ctx: &ctx, @@ -436,15 +438,24 @@ struct ArmCtx<'c, 'a> { /// slicing the module with them lands on unrelated text — in range, so the bounds /// check never fires, just wrong. `inline_local` rebinds this to the buffer it parsed. source: &'c str, + /// Formal parameter names of the helper currently being inlined. + /// + /// They SHADOW module helpers of the same name for the whole body, but they are not + /// in `Scope`: `apply_args` deliberately declines to substitute unless an argument + /// carries a wire read (doing it unconditionally cost 62 shape elements), so the + /// binding that would have recorded them never happens. Carried separately so the + /// shadowing survives even when the substitution is skipped. + formals: &'c HashSet, } impl<'c, 'a> ArmCtx<'c, 'a> { - /// The same context reading a different source buffer. - fn with_source(&self, source: &'c str) -> ArmCtx<'c, 'a> { + /// The same context reading a different source buffer, for the helper's formals. + fn nested(&self, source: &'c str, formals: &'c HashSet) -> ArmCtx<'c, 'a> { ArmCtx { consts: self.consts, locals: self.locals, source, + formals, } } } @@ -1456,6 +1467,28 @@ fn apply_args(fn_src: &str, arg_srcs: &[String]) -> String { } } +/// The parameter names a helper declares, whether or not the call was substituted. +fn helper_formals(e: &Expression) -> HashSet { + let params = match e { + Expression::FunctionExpression(f) => &f.params, + Expression::ArrowFunctionExpression(f) => &f.params, + _ => return HashSet::new(), + }; + let mut out = HashSet::new(); + let mut add = |p: &oxc_ast::ast::BindingPattern| { + for id in p.get_binding_identifiers() { + out.insert(id.name.to_string()); + } + }; + for p in ¶ms.items { + add(&p.pattern); + } + if let Some(rest) = ¶ms.rest { + add(&rest.rest.argument); + } + out +} + /// Split a parsed `((fn)(a, b))` into the function and a scope binding its formals to /// the call-site argument expressions. /// @@ -1606,14 +1639,27 @@ fn local_call_source<'b, 'a>( let call = as_call(e)?; let name = as_identifier(&call.callee)?; // The CALLEE is subject to the caller's bindings too, not only the arguments below. - // `outer(h, node){ return h(node) }` calls the `h` it was handed; inlining the - // module-level `h` of the same name would publish an unrelated helper's fields, or - // even a different action. Refused rather than guessed at — the same rule the named - // mapped-callback path applies, and for the same reason. - if scope.get(name).is_some() { - return None; - } - let src = ctx.locals.get(name).cloned()?; + // `outer(h, node){ return h(node) }` calls the `h` it was handed, so the module-level + // `h` of the same name would publish an unrelated helper's fields, or a different + // action entirely. + // + // What the caller bound is preferred, not merely refused: its text comes out of the + // source these spans index, which is why `ArmCtx` carries that buffer. Refusing + // outright loses a resolvable case — the whole field list of a callback passed as a + // literal. Only when the binding cannot be read back does it fall through to nothing. + let src = match scope.get(name) { + Some(bound) => { + let sp = oxc_span::GetSpan::span(*bound); + ctx.source + .get(sp.start as usize..sp.end as usize)? + .to_string() + } + // A formal whose argument `apply_args` declined to substitute: the name is bound + // at runtime to something this pass never saw, so the module helper is certainly + // not it. + None if ctx.formals.contains(name) => return None, + None => ctx.locals.get(name).cloned()?, + }; // The argument TEXT, so `inline_local` can bind the helper's formals. A span outside // the module source (a synthesized node) yields nothing for that position rather than // a wrong slice. @@ -1670,7 +1716,8 @@ fn inline_local( // Folding with the outer source would slice the module at this buffer's offsets: in // range and therefore unguarded, but unrelated text — which cost the field a second // helper level down (`mk2(v){ return mk(v) }`). - let ctx = &ctx.with_source(&wrapped); + let formals = helper_formals(func); + let ctx = &ctx.nested(&wrapped, &formals); // Each of the helper's result branches is folded on its own and then MERGED, not // accumulated: a helper returning `{x: …}` in one branch and `{y: …}` in another // describes two legal shapes, and combining them would make the enclosing action @@ -2032,7 +2079,14 @@ fn guard_admits_absence(e: &Expression) -> bool { // for exactly this reason, so the two must agree on what they are looking at. Expression::ParenthesizedExpression(p) => guard_admits_absence(&p.expression), Expression::ConditionalExpression(c) => { - is_nullish(strip_guard(&c.consequent)) || is_nullish(strip_guard(&c.alternate)) + // Each branch is examined WHOLE, not just its stripped terminal: a guard can + // sit inside one (`cond ? enabled && map(…) : fallback`), where neither end + // is nullish yet the `!enabled` path still yields no collection. Terminates — + // every step descends into a strictly smaller expression. + is_nullish(strip_guard(&c.consequent)) + || is_nullish(strip_guard(&c.alternate)) + || guard_admits_absence(&c.consequent) + || guard_admits_absence(&c.alternate) } Expression::LogicalExpression(l) => { l.operator == oxc_syntax::operator::LogicalOperator::And diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 2aca693..a4bdd61 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -625,6 +625,9 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr function hh(e){ return {wrongHelper:e.attrString("wrong")}; } function callsShadowed(e,hh){ return hh(e); } function parseP(x){ return {aliased:x.attrString("via_alias")}; } + function hx(e){ return {wrongFormal:e.attrString("nope")}; } + function realCb(e){ return {rightFormal:e.attrString("ok2")}; } + function outerH(node, hx){ return hx(node); } function mk(v){ return {chained:v}; } function mk2(v){ return mk(v); } function mixedKids(e){ return {plainField:e.attrString("plain"), kids:e.mapChildrenWithTag("mixed_user", function(x){ return {id:x.attrUserJid("jid")}; })}; } @@ -692,7 +695,9 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr twoDeep: mk2(t.attrString("deep_jid")), viaShadowedCallee: callsShadowed(t, function(z){ return {rightHelper:z.attrString("ok")}; }), mixed: t.hasChild("mx") && mixedKids(t), - aliasedCb: t.mapChildrenWithTag("alias_user", cbAlias)}; + aliasedCb: t.mapChildrenWithTag("alias_user", cbAlias), + nestedGuard: t.hasChild("ng") ? (t.hasChild("en") && t.mapChildrenWithTag("nested_user", function(x){ return {id:x.attrUserJid("jid")}; })) : [{id:"fb"}], + fromFormal: outerH(t, realCb)}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -897,6 +902,12 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { !names.contains(&"wrongHelper"), "the module-level `hh` must not be inlined over the bound one: {names:?}" ); + // ...and the one that IS bound must still come through. Asserting only the absence + // would pass just as happily if the resolution silently produced nothing. + assert!( + names.contains(&"rightHelper"), + "the callback the caller bound is the one that runs: {names:?}" + ); // A guarded value-position helper whose branches yield BOTH a collection and a flat // field must weaken both. Only the collection was being weakened. @@ -925,6 +936,21 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { "a terminal alias resolves through the module callbacks" ); + // A guard NESTED inside a ternary branch: neither end is nullish, but the `&&` path + // still yields no collection. + assert!( + !child("nestedGuard").required, + "a guard inside a branch counts, not only a nullish terminal" + ); + + // A helper FORMAL shadows a module helper of the same name even when `apply_args` + // declines to substitute — the formal never reaches `Scope` on that path. + let names2: Vec<&str> = arm.fields.iter().map(|f| f.name.as_str()).collect(); + assert!( + !names2.contains(&"wrongFormal"), + "the module-level `hx` must not win over the formal: {names2:?}" + ); + // The guard must not cost the child's contents. assert_eq!(child("anded").wire_tag, "anded_user"); assert_eq!(child("anded").fields.len(), 1); diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index c1c6405..434ec59 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -81,6 +81,10 @@ # for one; `contentUint(N)` reads N big-endian bytes and must always carry it. WIDTH_BEARING = {"contentUint"} +# `WamChannel`. Anything else is mapped to `Regular` by the reference generator rather +# than preserved, so an unknown value loses the channel silently instead of failing. +WAM_CHANNELS = {"regular", "realtime", "private"} + # Keys only a response field carries. They identify a `ParsedField` independently of # whether its `type` is one we know — which is what lets an unrecognized type be # REPORTED instead of skipped. Deliberately excludes `name`/`type` alone: appstate has @@ -137,6 +141,12 @@ def check_field(f, path, domain, errors, counts, unresolved): counts["content integer with no byte width"] += 1 lv = f.get("literalValue") + # The schema types it as a string; anything else would make the hex scan below iterate + # a non-iterable and abort the whole run — the same "error path assumes a shape it did + # not verify" that already crashed this file once. + if lv is not None and not isinstance(lv, str): + errors.append(f"{path}: literalValue is {type(lv).__name__}, not a string") + lv = None if lv is not None: if t == "integer": try: @@ -178,12 +188,18 @@ def check_field(f, path, domain, errors, counts, unresolved): # so is half of one — the schema permits either key alone, but a consumer handed # `intMin` with no `intMax` has a weaker constraint than the wire actually enforces # and no way to know it lost the other end. - for lo, hi in (("byteMin", "byteMax"), ("intMin", "intMax")): + for (lo, hi), owner in ((("byteMin", "byteMax"), "bytes"), (("intMin", "intMax"), "integer")): if (lo in f) != (hi in f): present, missing = (lo, hi) if lo in f else (hi, lo) errors.append(f"{path}: {present} without {missing} is half a range") elif lo in f and f[lo] > f[hi]: errors.append(f"{path}: {lo} {f[lo]} exceeds {hi} {f[hi]}") + # A byte range belongs to a bytes field and an integer range to an integer one. + # On any other type the two halves instruct a consumer to do incompatible things + # — a string with a numeric range — and it either builds a nonsense validator or + # drops the constraint. Today every one of them sits on its own type. + if lo in f and t != owner: + errors.append(f"{path}: {lo}/{hi} on a {t!r} field, not {owner!r}") # An echo rule with no path says "this equals something in the request" and # then does not say what. @@ -346,6 +362,23 @@ def check_event_codes(data, domain, errors): # `[default, ring1, ring2]` — the positions ARE the meaning, so a short array does # not lose the last ring, it shifts every ring that remains. The schema permits any # length. + # The wire format carries the code in 16 bits, and the reference consumer emits it + # as `u16`. The schema permits the whole `u32` range, and the extractor turns a + # negative source literal into a large one — either way the IR would be declared + # usable and then generate metadata that cannot encode. + if isinstance(code, int) and not 0 <= code <= 0xFFFF: + errors.append( + f"{domain}: event {e.get('name')!r} has code {code}, " + f"which does not fit the 16-bit wire field" + ) + # An unrecognised channel is not preserved by the reference generator — it maps + # to `Regular`, so a typo silently uploads on channel 0 instead of failing. + channel = e.get("channel") + if channel is not None and channel not in WAM_CHANNELS: + errors.append( + f"{domain}: event {e.get('name')!r} has channel {channel!r}, " + f"not one of {sorted(WAM_CHANNELS)}" + ) weights = e.get("weights") if not isinstance(weights, list) or len(weights) != 3: errors.append( From 4f5a70d08f6437ec9bb200e57aebf29f9cb3d993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 17:07:27 -0300 Subject: [PATCH 21/46] review: pre-register hoisted bindings, and check five more IR shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six from Codex on 065daa9, all latent. - The function-name shadow was source-ordered, so a declaration written AFTER the composition that reads the name slipped past — and JS hoists it over the whole body regardless. Each function's declarations are now pre-registered before its body is walked. - `NotifActionChild::required` promised in its own docs that a consumer can always tell "required by default" from "unspecified", and the published schema did not keep that promise: `serde(default)` makes schemars drop the property from `required`, so an omission validated clean. `schemars(required)` turned out to be a no-op on a non-`Option` field, so the check lives in `lint-ir.py` and the doc comment now says that rather than implying the schema covers it. - A WAM field's `id` is a 16-bit wire identifier, like the event `code` already checked one level up. - An appstate `protoEnum` was treated as a resolved constraint because the STRING was present. A path resolving to nothing is not a constraint, and the schema accepts any string, so the dotted path is now looked up in the committed `WAProto.proto` — a dangling one falls back into the unresolved baseline, where it belongs. - An integer pin is validated against the ASCII decimal grammar rather than Python's `int()`, which accepts `1_000`, padding and non-ASCII digits that neither the extractor emits nor a Rust parser takes. - An A/B config's `default`/`altDefault` must match its `valueType`. `Scalar` permits every arm and nothing compared them, so `bool` with a string default validated clean. `bool` is tested before `int` because in Python it is one. --- crates/wa-enums/src/lib.rs | 27 ++++++- crates/wa-ir/src/notif.rs | 6 ++ generated/schema/notif.schema.json | 2 +- scripts/lint-ir.py | 112 ++++++++++++++++++++++++++--- 4 files changed, 135 insertions(+), 12 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index c4fa262..a4110d6 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -403,14 +403,27 @@ impl<'a> Visit<'a> for NamedResolver { // A nested `function e(){}` HOISTS a binding named `e` over the enclosing body, so // an operand read there is the function, not the module local — the fourth form of // the same shadow, after parameters, destructured declarations and catch bindings. - // Recorded on the module-wide set (that is where the name becomes visible) and - // only on collision, the rule every other unreadable shadow follows. + // Only on collision, the rule every other unreadable shadow follows. if let Some(id) = &f.id && self.locals.contains_key(id.name.as_str()) { self.shadowed.insert(id.name.to_string()); } - self.param_scopes.push(param_names(&f.params)); + // The declarations INSIDE this body are hoisted over all of it, so one written + // AFTER a composition still binds the operand that composition reads. Recording + // them only as the walk reaches them was source-ordered, and reversing the two + // lines slipped straight past — so they are pre-registered before descending. + let mut hoisted = param_names(&f.params); + if let Some(body) = &f.body { + for stmt in &body.statements { + if let oxc_ast::ast::Statement::FunctionDeclaration(d) = stmt + && let Some(id) = &d.id + { + hoisted.insert(id.name.to_string()); + } + } + } + self.param_scopes.push(hoisted); walk::walk_function(self, f, flags); self.param_scopes.pop(); } @@ -859,6 +872,14 @@ mod tests { }),1);"#; assert!(resolve_named_enum(fn_name, "M", "OUT").is_none()); + // ...and hoisting means the declaration may come AFTER the composition that reads + // the name. A source-ordered check misses exactly this order. + let hoisted_after = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function g(){ var x=babelHelpers.extends({},e,{B:"b"}); function e(){} i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(hoisted_after, "M", "OUT").is_none()); + // 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){ diff --git a/crates/wa-ir/src/notif.rs b/crates/wa-ir/src/notif.rs index 81d8572..e4e2919 100644 --- a/crates/wa-ir/src/notif.rs +++ b/crates/wa-ir/src/notif.rs @@ -260,6 +260,12 @@ pub struct NotifActionChild { /// Always serialized. Skipping the `true` case would leave a non-Rust consumer — /// which is who the IR is for — unable to tell "required by default" from /// "unspecified", and the JSON Schema cannot carry the default either. + /// + /// The JSON Schema cannot say so: `serde(default)` makes schemars drop the property + /// from `required`, and `schemars(required)` is a no-op on a non-`Option` field. A + /// document omitting it therefore validates clean, which is the ambiguity above + /// reappearing through the published contract — so `lint-ir.py` enforces the presence + /// instead. The serde default stays, so an older document still deserializes. #[serde(default = "crate::default_true")] pub required: bool, } diff --git a/generated/schema/notif.schema.json b/generated/schema/notif.schema.json index 7a1156f..d9fcd32 100644 --- a/generated/schema/notif.schema.json +++ b/generated/schema/notif.schema.json @@ -184,7 +184,7 @@ "type": "string" }, "required": { - "description": "Whether EVERY branch producing this action carries the collection.\n\n`if (c) return {actionType: A, participants: …}; return {actionType: A}` yields two\nlegal shapes for one action, and only one of them has `participants`. Without this\nthe metadata claimed the list is always present — the same over-assertion that\n`required` on a scalar field exists to prevent, one level up.\n\nAlways serialized. Skipping the `true` case would leave a non-Rust consumer —\nwhich is who the IR is for — unable to tell \"required by default\" from\n\"unspecified\", and the JSON Schema cannot carry the default either.", + "description": "Whether EVERY branch producing this action carries the collection.\n\n`if (c) return {actionType: A, participants: …}; return {actionType: A}` yields two\nlegal shapes for one action, and only one of them has `participants`. Without this\nthe metadata claimed the list is always present — the same over-assertion that\n`required` on a scalar field exists to prevent, one level up.\n\nAlways serialized. Skipping the `true` case would leave a non-Rust consumer —\nwhich is who the IR is for — unable to tell \"required by default\" from\n\"unspecified\", and the JSON Schema cannot carry the default either.\n\nThe JSON Schema cannot say so: `serde(default)` makes schemars drop the property\nfrom `required`, and `schemars(required)` is a no-op on a non-`Option` field. A\ndocument omitting it therefore validates clean, which is the ambiguity above\nreappearing through the published contract — so `lint-ir.py` enforces the presence\ninstead. The serde default stays, so an older document still deserializes.", "type": "boolean", "default": true }, diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 434ec59..4c5220f 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -23,6 +23,7 @@ Usage: scripts/lint-ir.py [generated-dir] (default: ./generated) """ import json +import re import sys from collections import defaultdict from pathlib import Path @@ -85,6 +86,9 @@ # than preserved, so an unknown value loses the channel silently instead of failing. WAM_CHANNELS = {"regular", "realtime", "private"} +# What the extractor emits for an integer pin, and what a consumer must be able to parse. +DECIMAL = re.compile(r"-?[0-9]+") + # Keys only a response field carries. They identify a `ParsedField` independently of # whether its `type` is one we know — which is what lets an unrecognized type be # REPORTED instead of skipped. Deliberately excludes `name`/`type` alone: appstate has @@ -105,6 +109,33 @@ } +def collect_proto_enums(path): + """`Outer.Inner` for every enum in the committed `.proto`, by brace depth. + + Cheap and text-based on purpose: the linter must stay dependency-free, and the file is + generated by this same repo. A path naming a message that has no such enum, or naming + nothing at all, is a dangling reference the schema cannot catch. + """ + try: + text = path.read_text() + except OSError: + return set() + out, stack = set(), [] + for line in text.splitlines(): + line = line.strip() + m = re.match(r"(message|enum)\s+(\w+)", line) + if m: + if m.group(1) == "enum" and stack: + out.add(f"{stack[-1]}.{m.group(2)}") + elif m.group(1) == "enum": + out.add(m.group(2)) + stack.append(m.group(2)) + continue + if line.startswith("}") and stack: + stack.pop() + return out + + def walk(node, visit, path=""): """Depth-first over every dict in the document, with a readable path.""" if isinstance(node, dict): @@ -116,7 +147,7 @@ def walk(node, visit, path=""): walk(v, visit, f"{path}/{i}") -def check_field(f, path, domain, errors, counts, unresolved): +def check_field(f, path, domain, errors, counts, unresolved, proto_enums): """Invariants of one `ParsedField`-shaped object.""" t = f.get("type") method = f.get("method", "") @@ -133,7 +164,7 @@ def check_field(f, path, domain, errors, counts, unresolved): t == "enum" and not f.get("enumRef") and not f.get("enumKeys") - and not f.get("protoEnum") + and not (f.get("protoEnum") and f["protoEnum"] in proto_enums) ): unresolved[f"{domain}|{f.get('name')}|{f.get('wireName')}|{method}"] += 1 @@ -148,11 +179,12 @@ def check_field(f, path, domain, errors, counts, unresolved): errors.append(f"{path}: literalValue is {type(lv).__name__}, not a string") lv = None if lv is not None: - if t == "integer": - try: - int(lv) - except (TypeError, ValueError): - errors.append(f"{path}: literalValue {lv!r} is not an integer") + if t == "integer" and not DECIMAL.fullmatch(lv): + # NOT `int()`: Python accepts `1_000`, surrounding whitespace and non-ASCII + # digits, none of which the extractor emits and none of which a Rust (or most + # other) integer parser will take. The grammar is the contract, not what one + # language happens to tolerate. + errors.append(f"{path}: literalValue {lv!r} is not a canonical decimal integer") if t == "bytes": if any(c not in "0123456789abcdef" for c in lv) or len(lv) % 2: errors.append(f"{path}: literalValue {lv!r} is not lowercase hex") @@ -394,6 +426,11 @@ def check_event_codes(data, domain, errors): if not isinstance(f, dict) or "id" not in f: continue fid = f["id"] + if isinstance(fid, int) and not 0 <= fid <= 0xFFFF: + errors.append( + f"{domain}: in event {e.get('name')!r}, field {f.get('name')!r} has " + f"id {fid}, which does not fit the 16-bit wire field" + ) if fid in by_id: errors.append( f"{domain}: in event {e.get('name')!r}, fields {by_id[fid]!r} and " @@ -425,6 +462,56 @@ def check_action_keys(node, path, errors): errors.append(f"{path}: one key filled twice across fields/constantFields/children: {repeated}") +def check_abprops(data, domain, errors): + """An A/B config's `default`/`altDefault` are typed by its `valueType`. + + `Scalar` permits every arm and nothing compared the two, so `valueType: "bool"` with a + string default validated clean and the reference generator would emit contradictory + `AbPropType::Bool` / `AbDefault::Str` metadata. + """ + configs = data.get("configs") + if not isinstance(configs, list): + return + for i, c in enumerate(configs): + if not isinstance(c, dict): + continue + vt = c.get("valueType") + for key in ("default", "altDefault"): + if key not in c: + continue + v = c[key] + # `bool` first: in Python it IS an int, so an unordered check would let `True` + # satisfy `valueType: "int"`. + if vt == "bool": + ok = isinstance(v, bool) + elif vt == "int": + ok = isinstance(v, int) and not isinstance(v, bool) + elif vt == "float": + ok = isinstance(v, (int, float)) and not isinstance(v, bool) + elif vt == "string": + ok = isinstance(v, str) + else: + continue + if not ok: + errors.append( + f"{domain}/configs/{i}: {c.get('name')!r} is valueType {vt!r} " + f"but its {key} is {v!r}" + ) + + +def check_child_requiredness(node, path, errors): + """A `NotifActionChild` must SAY whether it is always present. + + Rust reads a missing `required` as `true` through a serde default, but the schema + cannot carry that: the default makes schemars drop the property from its `required` + list, so an omission validates clean and a non-Rust consumer — who the IR is for — + cannot tell "required by default" from "unspecified". The field's own documentation + forbids exactly that, and this is the only place the contract can be enforced. + """ + if "wireTag" in node and "fields" in node and "name" in node and "required" not in node: + errors.append(f"{path}: mapped child does not say whether it is required") + + def check_variant_group(node, path, errors): """A `WapVariantGroup` says exactly one of its alternatives applies. @@ -475,6 +562,11 @@ def main() -> int: counts = dict.fromkeys(BASELINE, 0) unresolved: dict[str, int] = defaultdict(int) + # The protobuf enums an appstate `protoEnum` may name. A path that resolves to + # nothing is not a constraint — it only looks like one because the string is present, + # and the schema accepts any string. + proto_enums = collect_proto_enums(root / "proto" / "WAProto.proto") + docs = sorted(root.glob("*/index.json")) if not docs: sys.exit(f"no domain documents under {root}") @@ -500,7 +592,9 @@ def visit(node, path, domain=domain): f"{domain}{path}: field type {node.get('type')!r} is not in the " f"ParsedFieldType vocabulary" ) - check_field(node, f"{domain}{path}", domain, errors, counts, unresolved) + check_field( + node, f"{domain}{path}", domain, errors, counts, unresolved, proto_enums + ) if "kind" in node and "reference_path" not in node: check_assertion(node, f"{domain}{path}", errors) # Independent of the field gate — see each function's note. @@ -508,12 +602,14 @@ def visit(node, path, domain=domain): check_const_bytes(node, f"{domain}{path}", errors) check_action_keys(node, f"{domain}{path}", errors) check_variant_group(node, f"{domain}{path}", errors) + check_child_requiredness(node, f"{domain}{path}", errors) walk(data, visit) # Needs the whole document, not one node: the reference and its definition sit in # different subtrees. check_enum_catalog_refs(data, domain, errors) check_event_codes(data, domain, errors) + check_abprops(data, domain, errors) ok = True # Set difference in BOTH directions, plus the multiplicities. A gain that offsets a From 1e75bd3c830d11322a432fc72bc6053972cd0a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 17:26:37 -0300 Subject: [PATCH 22/46] review: rebind the context on the other expansion path, and fix the proto scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven from Codex on 4f5a70d. Two are defects in last round's own fixes. - `expand_helper` re-parses into a synthetic buffer exactly as `inline_local` does, and only `inline_local` rebound the context to it. At the second level a nested `local_call_source` therefore sliced the module with this buffer's offsets — in range, unguarded, unrelated — and the field vanished. That is the same bug as before, surviving its own fix by living on the sibling path. - The `.proto` scan popped its stack on EVERY closing brace, so a `oneof` (there are 25) closed the enclosing message and a nested enum came out unqualified: a real `Outer.E` reference then read as dangling. It now tracks all brace depth and qualifies by the nearest named scope. - The variant-group check required `optional` to be PRESENT, but `false` is skipped in serialization, so a required group never carries it and the check never ran on the groups it was written for. Driven from the `variantGroups` array instead. - `constBytes` is the fixed value of byte content; on `dynamic` or `const` it hands two consumers different answers depending on which field they dispatch on. - A `reference` assertion takes its expected value FROM the request, so a `value` beside it is a second, conflicting rule; the reference codegen ignores it and another consumer would not. - WAM field names must be unique within an event: JS keeps only the last repeated property, and the codegen silently invents a suffixed name. Not applied: bootstrapping the committed lock's `bootloader` section. It can only be produced by a live fetch run, and inventing a handle map would put values in a trusted artifact that no run ever resolved. The update workflow writes it on its next run; `degradations` and the accumulation rules are what make that first write meaningful. --- crates/wa-notif/src/actions.rs | 6 +++ crates/wa-notif/src/tests.rs | 24 +++++++++- scripts/lint-ir.py | 81 +++++++++++++++++++++++++++------- 3 files changed, 93 insertions(+), 18 deletions(-) diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index 4fe1ffb..640c92b 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -1556,6 +1556,12 @@ fn expand_helper( return Vec::new(); }; let (func, bound) = applied_helper(func); + // Same rebinding `inline_local` does, and for the same reason: from here the spans + // index `wrapped`, so a nested `local_call_source` slicing the module with them lands + // on unrelated text — in range, so nothing catches it. Only one of the two whole-body + // expansion paths had it, which is exactly how the first bug survived its own fix. + let formals = helper_formals(func); + let ctx = &ctx.nested(&wrapped, &formals); let mut merged: Vec = Vec::new(); for (shape, scope) in fn_result_shapes(func, &bound) { for action in expand_shape(wire_tag, shape, &scope, ctx, depth) { diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index a4bdd61..827f0c8 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -598,7 +598,7 @@ __d("WAWebHandleDeviceNotification",["WADeprecatedWapParser"],(function(t,n,r,o, __d("WAWebHandleGroupNotificationConst",[],(function(t,n,r,o,a,i,l){ var cache={}; cache.GROUP_NOTIFICATION_TAG={ADD:"WRONG_add",SUBJECT:"WRONG_subject"}; - var e=Object.freeze({ADD:"add",SUBJECT:"subject",EPHEMERAL:"ephemeral",NOT_EPHEMERAL:"not_ephemeral",MODIFY:"modify",GROWTH_LOCKED:"growth_locked",INVITE:"invite",LINK:"link",ANNOUNCE:"announcement",LOCKED:"locked",REVOKE_INVITE:"revoke",DESC:"description",UNLINK:"unlink",MEMBERSHIP:"membership"}); + var e=Object.freeze({ADD:"add",SUBJECT:"subject",EPHEMERAL:"ephemeral",NOT_EPHEMERAL:"not_ephemeral",MODIFY:"modify",GROWTH_LOCKED:"growth_locked",INVITE:"invite",LINK:"link",ANNOUNCE:"announcement",LOCKED:"locked",REVOKE_INVITE:"revoke",DESC:"description",UNLINK:"unlink",MEMBERSHIP:"membership",GROWTH_LOCKED2:"growth_locked2"}); l.GROUP_NOTIFICATION_TAG=e; }), 1); __d("WAWebGroupApiConst",[],(function(t,n,r,o,a,i,l){ @@ -636,6 +636,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr function y(e,t){ return t.mapChildrenWithTag("participant", function(p){ return { id: p.attrUserJid("jid"), displayName: p.maybeAttrString("display_name"), kind: p.maybeAttrEnum("type", o("WAWebGroupApiConst").GROUP_PARTICIPANT_TYPES) }; }); } + function innerWhole(e,v){ return {actionType:o("WAWebGroupType").GROUP_ACTIONS.RESTRICT, deepWhole:v}; } + function outerWhole(e,v){ return innerWhole(e,v); } function I(e){ var t=e.attrString("unlink_type"); if(t==="parent_group") return {actionType:o("WAWebGroupType").GROUP_ACTIONS.DESC_REMOVE, descId:e.attrString("id")}; @@ -679,6 +681,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr var n; return {actionType:o("WAWebGroupType").GROUP_ACTIONS.RESTRICT, value:!0, threshold:(n=t.maybeAttrString("threshold"))!=null?n:void 0, seeded:thr}; } + case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.GROWTH_LOCKED2: + return outerWhole(t, t.attrString("deep_whole")); case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.UNLINK: return I(t); case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.MEMBERSHIP: { @@ -956,6 +960,24 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { assert_eq!(child("anded").fields.len(), 1); } +#[test] +fn a_two_level_whole_result_helper_keeps_its_fields() { + // `expand_helper` re-parses into a synthetic buffer just as `inline_local` does, but + // only one of the two rebound the context to it — so at the second level the nested + // `local_call_source` sliced the module source with this buffer's offsets: in range, + // unguarded, and unrelated. + let ir = extract_notif(GROUP_ACTIONS_BUNDLE, "2.3000.test"); + let arm = group_actions(&ir) + .into_iter() + .find(|a| a.wire_tag == "growth_locked2") + .expect("the delegating arm"); + let names: Vec<&str> = arm.fields.iter().map(|f| f.name.as_str()).collect(); + assert!( + names.contains(&"deepWhole"), + "the field survives two whole-result helpers: {names:?}" + ); +} + #[test] fn handler_without_a_const_keyed_switch_has_no_actions() { // Most handlers forward straight to a `WADeprecatedWapParser` and carry no action diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 4c5220f..d233643 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -121,18 +121,31 @@ def collect_proto_enums(path): except OSError: return set() out, stack = set(), [] + + def enclosing(): + for name in reversed(stack): + if name is not None: + return name + return None + for line in text.splitlines(): - line = line.strip() + line = line.split("//")[0].strip() + opens, closes = line.count("{"), line.count("}") m = re.match(r"(message|enum)\s+(\w+)", line) - if m: - if m.group(1) == "enum" and stack: - out.add(f"{stack[-1]}.{m.group(2)}") - elif m.group(1) == "enum": - out.add(m.group(2)) - stack.append(m.group(2)) - continue - if line.startswith("}") and stack: - stack.pop() + if m and opens: + name = m.group(2) + if m.group(1) == "enum": + parent = enclosing() + out.add(f"{parent}.{name}" if parent else name) + stack.append(name) + opens -= 1 + # `oneof`, options, anything else braced. Popping on their `}` as if it closed the + # enclosing MESSAGE was what made a nested enum come out unqualified — and a real + # `Outer.E` reference then read as dangling. + stack.extend([None] * opens) + for _ in range(closes): + if stack: + stack.pop() return out @@ -270,6 +283,12 @@ def check_const_bytes(node, path, errors): cb = node.get("constBytes") if not isinstance(cb, str): return + # It IS the fixed value of byte content. The emitter honours it whatever `kind` says, + # so a `dynamic` or `const` node carrying one hands two consumers two different + # answers depending on which field they dispatch on. + kind = node.get("kind") + if kind is not None and kind != "bytes": + errors.append(f"{path}: constBytes on {kind!r} content, which is not bytes") if any(c not in "0123456789abcdef" for c in cb) or len(cb) % 2: errors.append(f"{path}: constBytes {cb!r} is not lowercase hex") elif "byteLength" in node and len(cb) != node["byteLength"] * 2: @@ -438,6 +457,19 @@ def check_event_codes(data, domain, errors): ) else: by_id[fid] = f.get("name") + # JS keeps only the LAST property of a repeated name, so two fields sharing one + # means the catalog no longer describes the runtime event — and the codegen + # silently invents a suffixed name rather than failing. + fnames = [ + f.get("name") + for f in e.get("fields") or [] + if isinstance(f, dict) and isinstance(f.get("name"), str) + ] + repeated = sorted({n for n in fnames if fnames.count(n) > 1}) + if repeated: + errors.append( + f"{domain}: event {e.get('name')!r} defines field(s) {repeated} twice" + ) def check_action_keys(node, path, errors): @@ -512,17 +544,27 @@ def check_child_requiredness(node, path, errors): errors.append(f"{path}: mapped child does not say whether it is required") -def check_variant_group(node, path, errors): - """A `WapVariantGroup` says exactly one of its alternatives applies. +def check_variant_groups(node, path, errors): + """Each `WapVariantGroup` says exactly one of its alternatives applies. With none listed, a REQUIRED group promises a choice and supplies nothing to choose: - an emitter cannot construct the request, and the Rust generator emits a required field + an emitter cannot construct the request, and the generator emits a required field whose enum has no variants. An optional group with no alternatives is merely empty. + + Driven from the `variantGroups` ARRAY rather than a group's own keys. `optional` is + skipped when false, so a required group never carries it — and the first version of + this, gated on that key being present, never ran on the very groups it was for. """ - if "variants" not in node or "optional" not in node: + groups = node.get("variantGroups") + if not isinstance(groups, list): return - if not node.get("optional") and not node.get("variants"): - errors.append(f"{path}: required variant group offers no alternative") + for i, g in enumerate(groups): + if not isinstance(g, dict): + errors.append(f"{path}/variantGroups/{i}: not an object") + elif not g.get("optional") and not g.get("variants"): + errors.append( + f"{path}/variantGroups/{i}: required variant group offers no alternative" + ) def check_assertion(a, path, errors): @@ -534,6 +576,11 @@ def check_assertion(a, path, errors): # source of a rule it cannot apply to anything. if not a.get("name"): errors.append(f"{path}: reference assertion with no target attribute name") + # Its expected value comes FROM THE REQUEST, so it has none of its own. The + # reference codegen ignores `value`; another consumer could enforce it, and the + # two would disagree about the same response rule. + if a.get("value") is not None: + errors.append(f"{path}: reference assertion also pins a fixed value") if a.get("kind") == "attr" and not a.get("name"): errors.append(f"{path}: attr assertion with no attribute name") # `WapAttrKind::Const` is documented as "fixed literal value (carried in @@ -601,7 +648,7 @@ def visit(node, path, domain=domain): check_enum_ref(node, f"{domain}{path}", errors) check_const_bytes(node, f"{domain}{path}", errors) check_action_keys(node, f"{domain}{path}", errors) - check_variant_group(node, f"{domain}{path}", errors) + check_variant_groups(node, f"{domain}{path}", errors) check_child_requiredness(node, f"{domain}{path}", errors) walk(data, visit) From dd0e6a89e9d601c7e2ef41f9fe1f0750aff91f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 17:46:06 -0300 Subject: [PATCH 23/46] review: key unresolved enums by semantic path, and hoist var bindings too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four from Codex on 1e75bd3 plus four from CodeRabbit; the two reviews converged on the `var` hoisting independently. The identity baseline still aggregated: one key stood for as many as 18 fields, so a substitution WITHIN a key kept its count and passed — the same blind spot as the scalar it replaced, one level down. The key is now the semantic path (tags and names from the document root to the field), under which all 155 are distinct. Verified by resolving one of the 18 and introducing a same-signature field elsewhere: the aggregate holds at 18 and both halves are now reported. Shadowing, made lexical and complete: - `var` is function-scoped, so one written after a composition — or nested in a block — still binds the operand it reads. The whole body is pre-scanned. - A first attempt swept every hoisted name unconditionally and every enum in the catalog refused itself: the module body IS a function, so its own `var e = {…}` shadowed the local it declares. Only names that collide with an existing binding are recorded. - The named-function shadow no longer poisons the module-wide set. What a nested `function e(){}` shadows is its enclosing scope, and a composition written outside that body never sees the inner binding at all. - `enabled ? map(…) : false` yields a boolean on the disabled path, exactly as `enabled && map(…)` does. Both spellings are covered: `!1` is a unary expression, `false` a boolean literal, and they take different arms — the first test hit only one and left the other unguarded. Linter: an empty union alternative name is not a name (the codegen turns it into the reserved `_` and emits invalid Rust), and a range pair is compared only once both ends are integers — `>` between a str and an int raises, which is the crash this file has now hit twice. --- crates/wa-enums/src/lib.rs | 105 ++++++++++--- crates/wa-notif/src/actions.rs | 15 +- crates/wa-notif/src/tests.rs | 17 +- scripts/lint-ir.py | 273 ++++++++++++++++++++++++++------- 4 files changed, 336 insertions(+), 74 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index a4110d6..c4a93ba 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -100,6 +100,59 @@ fn extract_from_module(slice: &str, module: &str) -> Vec { out } +/// Every name a function body hoists: its `var` declarations and its nested function +/// declarations, wherever in the body they are written. +/// +/// `var` is FUNCTION-scoped, so one inside an `if` or a loop binds for the whole body too; +/// nested functions are not descended into, because their own `var`s belong to them. +fn collect_hoisted(stmts: &[oxc_ast::ast::Statement], out: &mut HashSet) { + use oxc_ast::ast::Statement as S; + for stmt in stmts { + match stmt { + S::FunctionDeclaration(d) => { + if let Some(id) = &d.id { + out.insert(id.name.to_string()); + } + } + S::VariableDeclaration(d) => { + for decl in &d.declarations { + for id in decl.id.get_binding_identifiers() { + out.insert(id.name.to_string()); + } + } + } + S::BlockStatement(b) => collect_hoisted(&b.body, out), + S::IfStatement(i) => { + collect_hoisted(std::slice::from_ref(&i.consequent), out); + if let Some(alt) = &i.alternate { + collect_hoisted(std::slice::from_ref(alt), out); + } + } + S::ForStatement(f) => collect_hoisted(std::slice::from_ref(&f.body), out), + S::ForInStatement(f) => collect_hoisted(std::slice::from_ref(&f.body), out), + S::ForOfStatement(f) => collect_hoisted(std::slice::from_ref(&f.body), out), + S::WhileStatement(w) => collect_hoisted(std::slice::from_ref(&w.body), out), + S::DoWhileStatement(w) => collect_hoisted(std::slice::from_ref(&w.body), out), + S::TryStatement(t) => { + collect_hoisted(&t.block.body, out); + if let Some(h) = &t.handler { + collect_hoisted(&h.body.body, out); + } + if let Some(fin) = &t.finalizer { + collect_hoisted(&fin.body, out); + } + } + S::SwitchStatement(sw) => { + for case in &sw.cases { + collect_hoisted(&case.consequent, out); + } + } + S::LabeledStatement(l) => collect_hoisted(std::slice::from_ref(&l.body), out), + _ => {} + } + } +} + /// The identifier names a parameter list binds. fn param_names(params: &oxc_ast::ast::FormalParameters) -> HashSet { // EVERY identifier a parameter list binds, not just the plainly-named ones. A @@ -400,28 +453,27 @@ impl<'a> Visit<'a> for NamedResolver { f: &oxc_ast::ast::Function<'a>, flags: oxc_syntax::scope::ScopeFlags, ) { - // A nested `function e(){}` HOISTS a binding named `e` over the enclosing body, so - // an operand read there is the function, not the module local — the fourth form of - // the same shadow, after parameters, destructured declarations and catch bindings. - // Only on collision, the rule every other unreadable shadow follows. - if let Some(id) = &f.id - && self.locals.contains_key(id.name.as_str()) - { - self.shadowed.insert(id.name.to_string()); - } - // The declarations INSIDE this body are hoisted over all of it, so one written + // Everything this body binds is HOISTED over all of it, so a declaration written // AFTER a composition still binds the operand that composition reads. Recording - // them only as the walk reaches them was source-ordered, and reversing the two - // lines slipped straight past — so they are pre-registered before descending. + // them as the walk reached them was source-ordered and reversing the two lines + // slipped straight past, so the whole body is pre-scanned before descending. + // + // Lexical, not module-wide: the enclosing scope is what a nested `function e(){}` + // shadows, and poisoning `e` for the entire module would refuse a composition + // written outside this body that never sees the inner binding at all. let mut hoisted = param_names(&f.params); if let Some(body) = &f.body { - for stmt in &body.statements { - if let oxc_ast::ast::Statement::FunctionDeclaration(d) = stmt - && let Some(id) = &d.id - { - hoisted.insert(id.name.to_string()); - } - } + let mut declared = HashSet::new(); + collect_hoisted(&body.statements, &mut declared); + // Only the ones that SHADOW something already bound. The module body is itself + // a function, so an unconditional sweep made its own `var e = {…}` shadow the + // local it declares — every enum in the catalog refused itself. Parameters + // stay unconditional: they can never BE the module local. + hoisted.extend( + declared + .into_iter() + .filter(|n| self.locals.contains_key(n.as_str())), + ); } self.param_scopes.push(hoisted); walk::walk_function(self, f, flags); @@ -880,6 +932,21 @@ mod tests { }),1);"#; assert!(resolve_named_enum(hoisted_after, "M", "OUT").is_none()); + // `var` is hoisted over the whole function too, so one written AFTER the + // composition still binds the operand it reads. + let var_after = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function g(){ var x=babelHelpers.extends({},e,{B:"b"}); var e={X:"x"}; i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(var_after, "M", "OUT").is_none()); + + // ...and a `var` nested in a block is function-scoped all the same. + let var_in_block = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function g(){ var x=babelHelpers.extends({},e,{B:"b"}); if (c) { var e={X:"x"} } i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(var_in_block, "M", "OUT").is_none()); + // 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){ diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index 640c92b..2bbb1b9 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -2102,12 +2102,23 @@ fn guard_admits_absence(e: &Expression) -> bool { } /// Whether the expression is a literal absence — the far side of a presence guard. +/// +/// `false` counts. `enabled ? map(…) : !1` yields a boolean rather than a collection on +/// the disabled path, exactly as `enabled && map(…)` does, and only the `&&` form was +/// recognised. `!1` is how the minifier writes it. fn is_nullish(e: &Expression) -> bool { match e { Expression::NullLiteral(_) => true, + Expression::BooleanLiteral(b) => !b.value, Expression::Identifier(i) => i.name == "undefined", - // The minifier writes `undefined` as `void 0`. - Expression::UnaryExpression(u) => u.operator == oxc_ast::ast::UnaryOperator::Void, + Expression::UnaryExpression(u) => match u.operator { + // The minifier writes `undefined` as `void 0`. + oxc_ast::ast::UnaryOperator::Void => true, + // ...and as . + oxc_ast::ast::UnaryOperator::LogicalNot => as_int(&u.argument) == Some(1), + // ...and `false` as `!1`. + _ => false, + }, _ => false, } } diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 827f0c8..12c5376 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -701,7 +701,9 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr mixed: t.hasChild("mx") && mixedKids(t), aliasedCb: t.mapChildrenWithTag("alias_user", cbAlias), nestedGuard: t.hasChild("ng") ? (t.hasChild("en") && t.mapChildrenWithTag("nested_user", function(x){ return {id:x.attrUserJid("jid")}; })) : [{id:"fb"}], - fromFormal: outerH(t, realCb)}; + fromFormal: outerH(t, realCb), + falsyFallback: t.hasChild("ff") ? t.mapChildrenWithTag("falsy_user", function(x){ return {id:x.attrUserJid("jid")}; }) : !1, + bareFalse: t.hasChild("bf") ? t.mapChildrenWithTag("bare_user", function(x){ return {id:x.attrUserJid("jid")}; }) : false}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -955,6 +957,19 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { "the module-level `hx` must not win over the formal: {names2:?}" ); + // `: !1` is how the minifier writes `false`, and a boolean is no more a collection + // than `null` is — the `&&` form of the same falsy path was already handled. + assert!( + !child("falsyFallback").required, + "a falsy literal fallback is an absent collection" + ); + // Both spellings: `!1` is a unary expression, `false` a boolean literal, and they are + // recognised by different arms — a test hitting only one leaves the other unguarded. + assert!( + !child("bareFalse").required, + "the unminified `false` is the same absence" + ); + // The guard must not cost the child's contents. assert_eq!(child("anded").wire_tag, "anded_user"); assert_eq!(child("anded").fields.len(), 1); diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index d233643..7a73f4e 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -25,7 +25,6 @@ import json import re import sys -from collections import defaultdict from pathlib import Path # Counted states, with the value observed when this guard was introduced. Raising @@ -46,38 +45,164 @@ # 155 fields collapse to 30 identities. Adding one, dropping one, or changing a count all # fail; each is a deliberate act that costs one line here. UNRESOLVED_ENUMS = { - "incoming|edit|edit|attrEnum": 1, - "iq|automaticBinding|automatic-binding|maybeAttrEnum": 2, - "iq|canAddPayout|can-add-payout|attrEnum": 2, - "iq|canPayout|can-payout|attrEnum": 2, - "iq|canSell|can-sell|attrEnum": 2, - "iq|capabilitiesDefaultEligibleP2m|default-eligible-p2m|maybeAttrEnum": 6, - "iq|capabilitiesDefaultEligibleP2p|default-eligible-p2p|maybeAttrEnum": 6, - "iq|capabilitiesDefaultEligible|default-eligible|attrEnum": 6, - "iq|capabilitiesEditable|editable|attrEnum": 6, - "iq|capabilitiesP2mCreditEligible|p2m-credit-eligible|attrEnum": 2, - "iq|capabilitiesP2mDebitEligible|p2m-debit-eligible|attrEnum": 2, - "iq|capabilitiesVerifiable|verifiable|attrEnum": 6, - "iq|defaultCreditP2m|default-credit-p2m|maybeAttrEnum": 8, - "iq|defaultCreditP2p|default-credit-p2p|maybeAttrEnum": 8, - "iq|defaultCredit|default-credit|attrEnum": 8, - "iq|defaultDebitP2m|default-debit-p2m|maybeAttrEnum": 8, - "iq|defaultDebitP2p|default-debit-p2p|maybeAttrEnum": 8, - "iq|defaultDebit|default-debit|attrEnum": 8, - "iq|error|error|maybeAttrEnum": 7, - "iq|isAadhaarEnabled|is-aadhaar-enabled|maybeAttrEnum": 2, - "iq|isInternationalPayEnabled|is_international_pay_enabled|maybeAttrEnum": 2, - "iq|isMpinSet|is-mpin-set|attrEnum": 2, - "iq|membershipApprovalRequestError|error|maybeAttrEnum": 2, - "iq|needsDeviceBinding|needs-device-binding|attrEnum": 2, - "iq|p2mEligible|p2m-eligible|maybeAttrEnum": 18, - "iq|p2pEligible|p2p-eligible|maybeAttrEnum": 18, - "iq|pinFormatVersion|pin-format-version|attrEnum": 2, - "iq|pixOnboardingState|pix-onboarding-state|maybeAttrEnum": 2, - "iq|verified|verified|attrEnum": 6, - "notif|reason|reason|": 1, + "incoming|ack|deprecatedEditMixin|edit|attrEnum", + "iq|AddParticipantsResponseSuccess|participant|addParticipant|addParticipantsParticipantAddedOrNonRegisteredWaUserParticipantErrorLidResponseMixinGroup|AddParticipantsParticipantAddedResponse|addParticipantsParticipantMixins|ParticipantGroupJoinRequest|membershipApprovalRequestError|maybeAttrEnum", + "iq|CreateCustomPaymentMethodResponseSuccess|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2mEligible|maybeAttrEnum", + "iq|CreateCustomPaymentMethodResponseSuccess|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2pEligible|maybeAttrEnum", + "iq|CreateResponseSuccess|description|groupDescription|error|maybeAttrEnum", + "iq|PromoteDemoteAdminResponseSuccessMultiAdmin|participant|adminParticipant|error|maybeAttrEnum", + "iq|PromoteDemoteResponseSuccessDemote|participant|demoteParticipant|error|maybeAttrEnum", + "iq|PromoteDemoteResponseSuccessPromote|participant|promoteParticipant|error|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2m|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2p|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultCredit|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2m|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2p|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultDebit|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|isAadhaarEnabled|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|isInternationalPayEnabled|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|isMpinSet|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|p2mEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|p2pEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|pinFormatVersion|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|automaticBinding|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligible|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesEditable|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesVerifiable|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mCreditEligible|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mDebitEligible|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2m|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2p|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCredit|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2m|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2p|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebit|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|needsDeviceBinding|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2mEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2pEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|verified|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligible|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesEditable|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesVerifiable|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2m|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2p|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCredit|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2m|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2p|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebit|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2mEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2pEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|verified|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligible|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesEditable|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesVerifiable|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2m|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2p|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCredit|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2m|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2p|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebit|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2mEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2pEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|verified|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2mEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2pEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|canAddPayout|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|canPayout|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|canSell|attrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|p2mEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|p2pEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2mEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2pEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2mEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2pEligible|maybeAttrEnum", + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|pixOnboardingState|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2m|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2p|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultCredit|attrEnum", + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2m|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2p|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultDebit|attrEnum", + "iq|account|accountPaymentMethodsMixin|bank|bank|isAadhaarEnabled|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|bank|bank|isInternationalPayEnabled|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|bank|bank|isMpinSet|attrEnum", + "iq|account|accountPaymentMethodsMixin|bank|bank|p2mEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|bank|bank|p2pEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|bank|bank|pinFormatVersion|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|automaticBinding|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligible|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesEditable|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesVerifiable|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mCreditEligible|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mDebitEligible|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2m|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2p|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCredit|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2m|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2p|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebit|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|needsDeviceBinding|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2mEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2pEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|verified|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligible|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesEditable|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesVerifiable|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2m|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2p|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCredit|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2m|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2p|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebit|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2mEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2pEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|verified|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligible|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesEditable|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesVerifiable|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2m|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2p|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCredit|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2m|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2p|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebit|attrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2mEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2pEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|verified|attrEnum", + "iq|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2mEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2pEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|merchant|merchant|canAddPayout|attrEnum", + "iq|account|accountPaymentMethodsMixin|merchant|merchant|canPayout|attrEnum", + "iq|account|accountPaymentMethodsMixin|merchant|merchant|canSell|attrEnum", + "iq|account|accountPaymentMethodsMixin|merchant|merchant|p2mEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|merchant|merchant|p2pEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2mEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2pEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2mEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2pEligible|maybeAttrEnum", + "iq|account|accountPaymentMethodsMixin|merchant|merchant|pixOnboardingState|maybeAttrEnum", + "iq|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2mEligible|maybeAttrEnum", + "iq|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2pEligible|maybeAttrEnum", + "iq|description|groupDescription|error|maybeAttrEnum", + "iq|participant|addParticipant|addParticipantsParticipantAddedOrNonRegisteredWaUserParticipantErrorLidResponseMixinGroup|AddParticipantsParticipantAddedResponse|addParticipantsParticipantMixins|ParticipantGroupJoinRequest|membershipApprovalRequestError|maybeAttrEnum", + "iq|participant|adminParticipant|error|maybeAttrEnum", + "iq|participant|promoteParticipant|error|maybeAttrEnum", + "notif|create|reason|", } + # `contentInt` reads DECIMAL TEXT, so it has no byte width and must not be asked # for one; `contentUint(N)` reads N big-endian bytes and must always carry it. WIDTH_BEARING = {"contentUint"} @@ -109,6 +234,44 @@ } +def collect_unresolved_enums(data, domain, proto_enums, out): + """Every enum field no extraction path could resolve, keyed by SEMANTIC path. + + The key is the chain of tags and names from the document root down to the field, so it + survives reordering — a positional path would churn on every unrelated insertion. + Aggregating by `domain|name|wireName|method` was not enough: one key stood for as many + as 18 distinct fields, and a substitution WITHIN a key kept its count and passed. Along + this path all 155 are distinct, so any substitution changes the set. + """ + + def walk_(node, trail): + if isinstance(node, dict): + step = [] + if node.get("tag"): + step.append(str(node["tag"])) + elif node.get("wireTag"): + step.append(str(node["wireTag"])) + if node.get("name"): + step.append(str(node["name"])) + here = trail + step + if ( + node.get("type") == "enum" + and not node.get("enumRef") + and not node.get("enumKeys") + and not (node.get("protoEnum") and node["protoEnum"] in proto_enums) + ): + out.add("|".join([domain, *here, str(node.get("method", ""))])) + for k, v in node.items(): + if k in ("tag", "wireTag", "name"): + continue + walk_(v, here) + elif isinstance(node, list): + for v in node: + walk_(v, trail) + + walk_(data, []) + + def collect_proto_enums(path): """`Outer.Inner` for every enum in the committed `.proto`, by brace depth. @@ -160,7 +323,7 @@ def walk(node, visit, path=""): walk(v, visit, f"{path}/{i}") -def check_field(f, path, domain, errors, counts, unresolved, proto_enums): +def check_field(f, path, domain, errors, counts, proto_enums): """Invariants of one `ParsedField`-shaped object.""" t = f.get("type") method = f.get("method", "") @@ -179,7 +342,7 @@ def check_field(f, path, domain, errors, counts, unresolved, proto_enums): and not f.get("enumKeys") and not (f.get("protoEnum") and f["protoEnum"] in proto_enums) ): - unresolved[f"{domain}|{f.get('name')}|{f.get('wireName')}|{method}"] += 1 + pass # counted by `collect_unresolved_enums`, which can see the semantic path if method in WIDTH_BEARING and "byteLength" not in f: counts["content integer with no byte width"] += 1 @@ -220,10 +383,17 @@ def check_field(f, path, domain, errors, counts, unresolved, proto_enums): # Only real names: a missing one is `None`, and two of those would both # count as a duplicate AND make the report `sorted()` a `None` against a # `str`. Absence is not a repeat. + # An EMPTY name is not a name. The codegen runs it through `pascal_case`, + # which yields the reserved `_` and emits `enum E { _ }` — invalid Rust — so + # treating "" as a valid unique discriminator certifies something that cannot + # be generated. + for i, v in enumerate(variants): + if isinstance(v, dict) and v.get("name") == "": + errors.append(f"{path}: union alternative {i} has an empty name") names = [ v.get("name") for v in variants - if isinstance(v, dict) and isinstance(v.get("name"), str) + if isinstance(v, dict) and isinstance(v.get("name"), str) and v["name"] ] repeated = sorted({n for n in names if names.count(n) > 1}) if repeated: @@ -237,8 +407,14 @@ def check_field(f, path, domain, errors, counts, unresolved, proto_enums): if (lo in f) != (hi in f): present, missing = (lo, hi) if lo in f else (hi, lo) errors.append(f"{path}: {present} without {missing} is half a range") - elif lo in f and f[lo] > f[hi]: - errors.append(f"{path}: {lo} {f[lo]} exceeds {hi} {f[hi]}") + elif lo in f: + # Compared only once both are numbers: `>` between a str and an int raises, + # and an error path that crashes on malformed input is the failure this file + # has already hit twice. + if not all(isinstance(f[k], int) and not isinstance(f[k], bool) for k in (lo, hi)): + errors.append(f"{path}: {lo}/{hi} are {f[lo]!r}/{f[hi]!r}, not integers") + elif f[lo] > f[hi]: + errors.append(f"{path}: {lo} {f[lo]} exceeds {hi} {f[hi]}") # A byte range belongs to a bytes field and an integer range to an integer one. # On any other type the two halves instruct a consumer to do incompatible things # — a string with a numeric range — and it either builds a nonsense validator or @@ -607,7 +783,7 @@ def main() -> int: root = Path(sys.argv[1] if len(sys.argv) > 1 else "generated") errors: list[str] = [] counts = dict.fromkeys(BASELINE, 0) - unresolved: dict[str, int] = defaultdict(int) + unresolved: set[str] = set() # The protobuf enums an appstate `protoEnum` may name. A path that resolves to # nothing is not a constraint — it only looks like one because the string is present, @@ -640,7 +816,7 @@ def visit(node, path, domain=domain): f"ParsedFieldType vocabulary" ) check_field( - node, f"{domain}{path}", domain, errors, counts, unresolved, proto_enums + node, f"{domain}{path}", domain, errors, counts, proto_enums ) if "kind" in node and "reference_path" not in node: check_assertion(node, f"{domain}{path}", errors) @@ -657,26 +833,19 @@ def visit(node, path, domain=domain): check_enum_catalog_refs(data, domain, errors) check_event_codes(data, domain, errors) check_abprops(data, domain, errors) + collect_unresolved_enums(data, domain, proto_enums, unresolved) ok = True # Set difference in BOTH directions, plus the multiplicities. A gain that offsets a # loss keeps the total at 155 and is exactly what a scalar cannot see. - for ident in sorted(set(unresolved) | set(UNRESOLVED_ENUMS)): - now, was = unresolved.get(ident, 0), UNRESOLVED_ENUMS.get(ident, 0) - if now == was: - continue + for ident in sorted(unresolved - UNRESOLVED_ENUMS): + print(f"REGRESSION newly unresolved enum: {ident}") + ok = False + for ident in sorted(UNRESOLVED_ENUMS - unresolved): + print(f"IMPROVED enum now resolved: {ident} — drop it from the baseline") ok = False - if was == 0: - print(f"REGRESSION newly unresolved enum: {ident} (x{now})") - elif now == 0: - print(f"IMPROVED enum now resolved: {ident} — drop it from the baseline") - else: - print(f"CHANGED {ident}: {was} -> {now} — update the baseline") if ok: - print( - f"ok unresolved enums: {sum(unresolved.values())} across " - f"{len(unresolved)} identities, as pinned" - ) + print(f"ok unresolved enums: {len(unresolved)}, exactly as pinned") for name, observed in sorted(counts.items()): allowed = BASELINE[name] From ab0f020bc39bcb2cc2921e34744d2eb0cd41da37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 17:50:43 -0300 Subject: [PATCH 24/46] review: keep parameter-local writes local, and let scoping give the lexical answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from CodeRabbit's round on dd0e6a8 (its other three had already landed there). - A write to a name an enclosing parameter or hoisted binding owns was going into the module-wide `locals`/`shadowed`, so a local write inside one function could invalidate a valid module-level read elsewhere. - A block-bodied arrow hoists exactly as a function does; only its parameters were recorded. This reverses an expectation set earlier in this same branch, deliberately. `function f(){var e={X:"x"}} … extends({}, e, {B:"b"})` used to assert REFUSAL on both the composition and the alias. That was the conservative approximation of a resolver that could not tell an inner `e` from an outer one. With parameter, hoisted and catch scopes now modelled it can, and the lexical answers — `A,B` for the composition, `A` for the alias — are simply correct; keeping the refusal would throw away two resolvable enums to no purpose. What still refuses is ambiguity the pass genuinely cannot order, and that case keeps its own test. Also dropped a vestigial `"reference_path" not in node` guard: the IR emits `referencePath`, so it excluded nothing (13138 of 13138 nodes passed it) and only read as though it did. The accompanying suggestion to key content pins on `const_bytes` was measured and declined — that spelling appears zero times; `constBytes` is the emitted key. `generated/` is unchanged throughout. --- crates/wa-enums/src/lib.rs | 56 +++++++++++++++++++++++++++++++------- scripts/lint-ir.py | 7 ++++- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index c4a93ba..10e0d9e 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -432,6 +432,13 @@ impl<'a> Visit<'a> for NamedResolver { // values — 41 lost constraints, the largest single entry in // `dropsByReason`. .or_else(|| d.init.as_ref().and_then(|e| self.merge_extends(e))); + // A name bound by an enclosing parameter (or hoisted binding) is LOCAL to that + // body, so writing it into the module-wide `locals`/`shadowed` would let a + // write inside one function invalidate a valid module-level read elsewhere. + if self.param_shadows(&local) { + walk::walk_variable_declarator(self, d); + return; + } match data { Some(data) => self.bind_local(local.as_str(), data), // A redeclaration this pass cannot READ is as much a shadow as one it @@ -501,7 +508,17 @@ impl<'a> Visit<'a> for NamedResolver { } fn visit_arrow_function_expression(&mut self, f: &oxc_ast::ast::ArrowFunctionExpression<'a>) { - self.param_scopes.push(param_names(&f.params)); + // A block-bodied arrow hoists exactly as a function does; only its parameters + // were being recorded. + let mut hoisted = param_names(&f.params); + let mut declared = HashSet::new(); + collect_hoisted(&f.body.statements, &mut declared); + hoisted.extend( + declared + .into_iter() + .filter(|n| self.locals.contains_key(n.as_str())), + ); + self.param_scopes.push(hoisted); walk::walk_arrow_function_expression(self, f); self.param_scopes.pop(); } @@ -551,6 +568,11 @@ impl<'a> Visit<'a> for NamedResolver { // merged set would over-claim for every read that precedes the composition. // Refusing costs nothing here: the merge yields a body different from the one // already bound, so `bind_local` would mark the name shadowed anyway. + if self.param_shadows(name) { + // Local to the body that binds it — see the declarator path. + walk::walk_assignment_expression(self, a); + return; + } match enum_object(&a.right).and_then(parse_enum) { Some(data) => self.bind_local(name, data), // Rebound to something this pass cannot read: whatever `locals` still @@ -780,22 +802,36 @@ mod tests { #[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. + // function must not leak into the outer binding the composition below reads. This + // once asserted REFUSAL on both, which was the conservative approximation of a + // resolver that could not tell an inner `e` from an outer one; now that parameter, + // hoisted and catch scopes are modelled, the honest answer is the lexical one — + // and keeping a refusal here would throw away two resolvable enums. What still + // refuses is ambiguity the pass genuinely cannot order, below. 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" + // Now that the pass models scopes, the honest answer here is to RESOLVE: the inner + // `var e` is local to `f`, and both reads below are outer ones that never see it. + // The refusal this once asserted was the conservative approximation of a resolver + // that could not tell the two apart — worth keeping only while that was true. + assert_eq!( + resolve_named_enum(module, "M", "WITH") + .expect("the outer composition is unambiguous") + .variants + .len(), + 2, + "outer `e` plus `B`, not the inner `{{X}}`" ); - assert!( - resolve_named_enum(module, "M", "ALIAS").is_none(), - "`resolve_pending` reads the same rebound name" + assert_eq!( + resolve_named_enum(module, "M", "ALIAS") + .expect("the outer alias is unambiguous") + .variants + .len(), + 1 ); // A bare `e = …` rebinding is invisible to `visit_variable_declarator`, so only // the `var` form reached `shadowed` at first — the same stale-body bug through diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 7a73f4e..79b2573 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -818,7 +818,12 @@ def visit(node, path, domain=domain): check_field( node, f"{domain}{path}", domain, errors, counts, proto_enums ) - if "kind" in node and "reference_path" not in node: + # No `reference_path` guard: the IR emits `referencePath`, so that condition + # excluded nothing (13138 of 13138 nodes passed it) and only read as though it + # did. Every `kind` value these checks name — `reference`, `attr`, `tag`, + # `content`, `const` — belongs to exactly one of the two vocabularies, so + # running them on any node carrying a `kind` is correct as well as honest. + if "kind" in node: check_assertion(node, f"{domain}{path}", errors) # Independent of the field gate — see each function's note. check_enum_ref(node, f"{domain}{path}", errors) From 9afdbf7bb9c64e026a40fc56306ae06178575e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 18:10:18 -0300 Subject: [PATCH 25/46] review: cross-reference appstate collections, and close two gaps in this file's own checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six from Codex on ab0f020; each was verified by mutating the committed IR, which is how two of them turned out to be gaps in checks added earlier this week. - `check_child_requiredness` keyed on the child's `fields`, but that array is `skip_serializing_if = "Vec::is_empty"`, so a fieldless child was not recognised as a child at all and could drop `required` too. Driven from the `children` array now. - A named function EXPRESSION binds its own name inside its body, and that name lives on `f.id` rather than among the enclosing body's declarations — the one binding form the prescan could not see. New cross-document checks, none of which fire today: - An appstate action's `collection` must be one the document declares. The schema is only a string, and the generator builds the `Collection` enum from the top-level list alone, so an unknown name emits a variant that does not exist. An omitted field means `regular`, which must be declared too. - `(module, name)` is an A/B flag's identity; two records under one leave a consumer nothing to choose between. - An integer WAM enum's values become `#[repr(i64)]` discriminants, where a repeat is E0081. Declined one, with the data. Widening the duplicate-key check to catch repeats WITHIN a single array is right in principle and wrong here: it fires 15 times on the committed IR, and the cases are not contradictions. `iq/stanzas/49/response` carries ten `value` fields whose `enumRef`s differ per privacy category — alternatives the extractor models deliberately. A guard that fails CI on correct data is worse than the gap it closes. Whether that flattening is the right shape is a real modelling question, and it belongs in its own change rather than being forced by a linter. --- crates/wa-enums/src/lib.rs | 16 +++++++++ scripts/lint-ir.py | 74 +++++++++++++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 10e0d9e..3c76b21 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -469,6 +469,15 @@ impl<'a> Visit<'a> for NamedResolver { // shadows, and poisoning `e` for the entire module would refuse a composition // written outside this body that never sees the inner binding at all. let mut hoisted = param_names(&f.params); + // A NAMED function expression binds its own name inside its body (`var g = + // function e(){ … e … }` sees the function, not an outer `e`). That name lives on + // `f.id`, not among the declarations collected from the enclosing body, so it was + // the one binding form the prescan could not see. + if let Some(id) = &f.id + && self.locals.contains_key(id.name.as_str()) + { + hoisted.insert(id.name.to_string()); + } if let Some(body) = &f.body { let mut declared = HashSet::new(); collect_hoisted(&body.statements, &mut declared); @@ -983,6 +992,13 @@ mod tests { }),1);"#; assert!(resolve_named_enum(var_in_block, "M", "OUT").is_none()); + // A named function EXPRESSION binds its own name inside its body. + let self_named = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + var f=function e(){ var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x }; + }),1);"#; + assert!(resolve_named_enum(self_named, "M", "OUT").is_none()); + // 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){ diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 79b2573..b6bf7e9 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -543,6 +543,18 @@ def check_enum_catalog_refs(data, domain, errors): f"{e.get('valueKind')!r} but variant " f"{(v.get('name') or v.get('key'))!r} carries {val!r}" ) + # Integer members become discriminants of a `#[repr(i64)]` Rust enum, where a + # repeat is E0081 — so a catalog the linter certifies would not compile. + vals = [ + v.get("value") + for v in e.get("variants") or [] + if isinstance(v, dict) and isinstance(v.get("value"), int) + ] + dup_vals = sorted({v for v in vals if vals.count(v) > 1}) + if dup_vals: + errors.append( + f"{domain}/enums/{i}: enum {e.get('name')!r} reuses value(s) {dup_vals}" + ) if "module" in e: by_module.setdefault(e["module"], []).append(e) @@ -657,6 +669,13 @@ def check_action_keys(node, path, errors): internally consistent by construction; nothing compared them to each other. """ arrays = [k for k in ("fields", "constantFields", "children") if isinstance(node.get(k), list)] + # Deliberately ACROSS arrays only. Widening this to catch a repeat within a single + # array was tried and reverted: it fires 15 times on the committed IR, and the cases + # are not contradictions. `iq/stanzas/49/response` carries ten `value` fields whose + # `enumRef`s differ per privacy category — alternatives the extractor models on + # purpose, not one key answered twice. A guard that fails CI on correct data is worse + # than the gap it closes; the shape those ten represent is a modelling question, and + # it is recorded in the PR rather than silently enforced here. if len(arrays) < 2: return names = [ @@ -680,9 +699,20 @@ def check_abprops(data, domain, errors): configs = data.get("configs") if not isinstance(configs, list): return + seen_ids = {} for i, c in enumerate(configs): if not isinstance(c, dict): continue + # `(module, name)` IS the flag identity. Two records under one identity leave a + # consumer no way to pick; the reference generator just suffixes the second and + # puts both contradictory entries in `ALL`. + ident = (c.get("module"), c.get("name")) + if ident in seen_ids: + errors.append( + f"{domain}/configs/{i}: {ident} already defined at index {seen_ids[ident]}" + ) + else: + seen_ids[ident] = i vt = c.get("valueType") for key in ("default", "altDefault"): if key not in c: @@ -707,17 +737,50 @@ def check_abprops(data, domain, errors): ) +def check_appstate_collections(data, domain, errors): + """An action's `collection` must be one the document declares. + + The schema is only a string. `generate_appstate_schemas` builds the `Collection` enum + from the top-level list alone, so a name absent from it emits a variant that does not + exist — the certified IR generates uncompilable Rust. An omitted field means `regular`, + which must therefore be declared too. + """ + declared = data.get("collections") + actions = data.get("actions") + if not isinstance(declared, list) or not isinstance(actions, dict): + return + known = {c for c in declared if isinstance(c, str)} + for name, a in sorted(actions.items()): + if not isinstance(a, dict): + continue + used = a.get("collection", "regular") + if used not in known: + errors.append( + f"{domain}/actions/{name}: collection {used!r} is not among " + f"{sorted(known)}" + ) + + def check_child_requiredness(node, path, errors): - """A `NotifActionChild` must SAY whether it is always present. + """Every `NotifActionChild` must SAY whether it is always present. Rust reads a missing `required` as `true` through a serde default, but the schema cannot carry that: the default makes schemars drop the property from its `required` list, so an omission validates clean and a non-Rust consumer — who the IR is for — - cannot tell "required by default" from "unspecified". The field's own documentation - forbids exactly that, and this is the only place the contract can be enforced. + cannot tell "required by default" from "unspecified". + + Driven from the `children` ARRAY. Keying on the child's own `fields` missed a child + that has none, which `serde(skip_serializing_if = "Vec::is_empty")` makes a perfectly + ordinary serialized shape. """ - if "wireTag" in node and "fields" in node and "name" in node and "required" not in node: - errors.append(f"{path}: mapped child does not say whether it is required") + children = node.get("children") + if not isinstance(children, list): + return + for i, c in enumerate(children): + if isinstance(c, dict) and "wireTag" in c and "required" not in c: + errors.append( + f"{path}/children/{i}: mapped child does not say whether it is required" + ) def check_variant_groups(node, path, errors): @@ -838,6 +901,7 @@ def visit(node, path, domain=domain): check_enum_catalog_refs(data, domain, errors) check_event_codes(data, domain, errors) check_abprops(data, domain, errors) + check_appstate_collections(data, domain, errors) collect_unresolved_enums(data, domain, proto_enums, unresolved) ok = True From 9f456e62c3260618a725c0b2e449e077550f5aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 18:31:13 -0300 Subject: [PATCH 26/46] review: keep a destructured shadow lexical, and drop a rebound handle's stale alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five from Codex on 9afdbf7. The destructured-declarator branch still wrote into the module-wide `shadowed` set, without the `param_shadows` guard the simple-identifier path had just gained. The hoisted prescan already marks those names lexically, so the extra write outlived the body that binds them and refused a later, unrelated outer composition — losing a valid enum. Same guard, same reason. `handles` could hold `A -> 11` from the cache beside a fresh `B -> 11` from the live response. `wasm_entries` would then stamp `bxId: 11` on both payloads while the pins, built from the id side where the live value wins, say 11 is B: a lock contradicting itself. The live map now invalidates any other URL claiming an id it rebound; an id this run never saw is left alone, being still the best record there is. Three linter checks, none firing today: `(module, name)` is an enum definition's catalog identity, as it is a flag's; two collection names that `pascal_case` collapses to one variant will not compile; and position zero of a sync index IS the wire action name, so anything else has the encoder identify an action by one name and build its index under another. The `drop_stale_aliases` extraction was made for the test to exercise production rather than restate it — the first version replicated the retain inline and would have passed with the logic removed. --- crates/wa-enums/src/lib.rs | 24 ++++++++++++++++- crates/whatspec/src/main.rs | 54 +++++++++++++++++++++++++++++++++++++ scripts/lint-ir.py | 35 ++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 3c76b21..3a972a3 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -413,8 +413,14 @@ impl<'a> Visit<'a> for NamedResolver { // branch below was skipped and the outer body stayed usable. Refused on // collision only, the same rule an unreadable initializer follows: a // destructuring that shadows nothing costs nothing to ignore. + // + // The hoisted prescan already marks these LEXICALLY, so writing them into the + // module-wide set as well outlived the body that binds them and refused a + // later, unrelated outer composition. Same guard the simple-identifier path + // takes, for the same reason. for ident in d.id.get_binding_identifiers() { - if self.locals.contains_key(ident.name.as_str()) { + if !self.param_shadows(&ident.name) && self.locals.contains_key(ident.name.as_str()) + { self.shadowed.insert(ident.name.to_string()); } } @@ -999,6 +1005,22 @@ mod tests { }),1);"#; assert!(resolve_named_enum(self_named, "M", "OUT").is_none()); + // A destructured shadow is LEXICAL: after the body that binds it, an unrelated + // outer composition must still resolve. + let outer_after_destructure = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(obj){ var {e}=obj; return e } + var l=babelHelpers.extends({},e,{B:"b"}); + i.OUT=l + }),1);"#; + assert_eq!( + resolve_named_enum(outer_after_destructure, "M", "OUT") + .expect("the outer composition never sees the inner binding") + .variants + .len(), + 2 + ); + // 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){ diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index d5a6a02..6c46e7c 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -1330,6 +1330,11 @@ fn load_wasm( handles.extend(cache.wasm_handles(remote_version.unwrap_or_default())); } handles.extend(resolution.handle_by_url()); + // A handle the live response REBOUND now names a different URL, and the cache's old + // `A -> 11` would otherwise sit beside the new `B -> 11`: `wasm_entries` would stamp + // `bxId: 11` on both payloads while the pins say 11 resolves to B — a lock that + // contradicts itself. The live map wins, so any other URL claiming a live id goes. + drop_stale_aliases(&mut handles, &resolution.by_id); // The PINS are built from the id → URL direction instead, because `handles` is // URL-keyed and therefore already lossy: `invert`/`handle_by_url` keep the lowest id @@ -1493,6 +1498,23 @@ fn bootloader_pins( } } +#[cfg(feature = "fetch")] +/// Remove URL → id entries whose id the live response has REBOUND to another URL. +/// +/// The cache's `A -> 11` would otherwise sit beside a fresh `B -> 11`: `wasm_entries` +/// stamps `bxId: 11` on both payloads while the pins, built from the id side where the +/// live value wins, say 11 is B — a lock that contradicts itself. An id this run never +/// saw is left alone; it is still the best record there is. +fn drop_stale_aliases( + handles: &mut BTreeMap, + live_by_id: &BTreeMap, +) { + handles.retain(|url, id| match live_by_id.get(id) { + Some(live_url) => live_url == url, + None => true, + }); +} + /// `bx` id → URI inverted into URI → `bx` id, lowest id winning on a shared URI (the /// input is sorted, so the choice is deterministic). #[cfg(feature = "fetch")] @@ -3256,6 +3278,38 @@ mod tests { fs::remove_dir_all(&dir).unwrap(); } + #[cfg(feature = "fetch")] + #[test] + fn a_rebound_handle_drops_its_stale_url() { + // The cache says 11 resolved A; the live response says 11 resolves B. Keeping both + // `A -> 11` and `B -> 11` in the URL-keyed map would stamp `bxId: 11` on two + // payloads while the pins — built from the id side, where the live value wins — + // say 11 is B. The lock would contradict itself. + let live: BTreeMap = [( + "11".to_string(), + "https://static.whatsapp.net/b.wasm".to_string(), + )] + .into_iter() + .collect(); + let mut handles: BTreeMap = [ + ("https://static.whatsapp.net/a.wasm", "11"), + ("https://static.whatsapp.net/b.wasm", "11"), + ("https://static.whatsapp.net/c.wasm", "22"), + ] + .iter() + .map(|(u, i)| (u.to_string(), i.to_string())) + .collect(); + drop_stale_aliases(&mut handles, &live); + assert_eq!( + handles.keys().collect::>(), + [ + "https://static.whatsapp.net/b.wasm", + "https://static.whatsapp.net/c.wasm" + ], + "the stale alias for a rebound id goes; an id the run never saw stays" + ); + } + #[cfg(feature = "fetch")] #[test] fn only_wasm_handles_are_pinned() { diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index b6bf7e9..1bf013c 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -494,6 +494,7 @@ def check_enum_catalog_refs(data, domain, errors): # document is dangling, and returning early here reported exactly that document as # internally consistent. An empty list is an empty lookup; the walk still runs. by_module = {} + seen_defs = {} for i, e in enumerate(catalog): if not isinstance(e, dict): continue @@ -555,6 +556,15 @@ def check_enum_catalog_refs(data, domain, errors): errors.append( f"{domain}/enums/{i}: enum {e.get('name')!r} reuses value(s) {dup_vals}" ) + # `(module, name)` is the catalog identity — the extractor dedups on it, and a + # consumer has no other way to select one definition of a repeated pair. + ident = (e.get("module"), e.get("name")) + if ident in seen_defs: + errors.append( + f"{domain}/enums/{i}: {ident} already defined at index {seen_defs[ident]}" + ) + else: + seen_defs[ident] = i if "module" in e: by_module.setdefault(e["module"], []).append(e) @@ -750,6 +760,18 @@ def check_appstate_collections(data, domain, errors): if not isinstance(declared, list) or not isinstance(actions, dict): return known = {c for c in declared if isinstance(c, str)} + # `render_collection_enum` runs each through `pascal_case` without deduplicating, so + # two wire names that normalise alike emit the same variant twice and will not compile. + by_variant = {} + for c in sorted(known): + variant = "".join(part.capitalize() for part in c.replace("-", "_").split("_")) + if variant in by_variant: + errors.append( + f"{domain}/collections: {c!r} and {by_variant[variant]!r} both become " + f"{variant!r}" + ) + else: + by_variant[variant] = c for name, a in sorted(actions.items()): if not isinstance(a, dict): continue @@ -759,6 +781,19 @@ def check_appstate_collections(data, domain, errors): f"{domain}/actions/{name}: collection {used!r} is not among " f"{sorted(known)}" ) + # Position zero of the sync index IS the wire action name. Anything else has the + # encoder identify the action by one name and build its index under another. + parts = a.get("indexParts") + first = parts[0] if isinstance(parts, list) and parts else None + if not isinstance(first, dict) or first.get("type") != "literal": + errors.append( + f"{domain}/actions/{name}: indexParts does not begin with a literal" + ) + elif first.get("value") != a.get("name"): + errors.append( + f"{domain}/actions/{name}: index begins with {first.get('value')!r}, " + f"not the action name {a.get('name')!r}" + ) def check_child_requiredness(node, path, errors): From 37f53f901c0c50a57156b0a77977fc6ad4ff171a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 18:52:12 -0300 Subject: [PATCH 27/46] review: model the mutation extends performs, and tombstone rejected handle ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four from Codex on 9f456e6 plus five from CodeRabbit. A test of mine described a hazard and then asserted it. `a_composition_that_mutates_its_target_is_refused` said in its own comment that recovering `extends(e, {B})`'s target would "publish a `BASE` export missing a member the runtime has — an enum that claims to reject a value it accepts", and the assertion under it checked exactly that `BASE` still resolved with one member. `extends` writes into its first argument, so the target is now marked unusable and the test asserts refusal. - `({e} = obj)` rebinds without declaring, so no declarator ran and the cached body survived a write that replaced it. Assignment patterns are enumerated now. - A loop HEADER declares too, and its `var` hoists across the function exactly as one in the body does; the prescan read only bodies. - An id the run OBSERVED but whose new binding the CDN filter dropped was being restored from the prior lock, because absence from the pins was read as "never seen". `live_handle_ids` is the tombstone that tells the two apart. Robustness, the same class for the fourth time in this file: a `bool` is not an integer discriminant; a non-string `collection`, or a `module` that is a dict, made a membership test or a dict key raise and took the whole run down. It is always the error path that assumes a shape, never the check. --- crates/wa-enums/src/lib.rs | 139 ++++++++++++++++++++++++++++++++---- crates/whatspec/src/main.rs | 70 +++++++++++++++--- scripts/lint-ir.py | 23 ++++-- 3 files changed, 202 insertions(+), 30 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 3a972a3..981b138 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -114,13 +114,7 @@ fn collect_hoisted(stmts: &[oxc_ast::ast::Statement], out: &mut HashSet) out.insert(id.name.to_string()); } } - S::VariableDeclaration(d) => { - for decl in &d.declarations { - for id in decl.id.get_binding_identifiers() { - out.insert(id.name.to_string()); - } - } - } + S::VariableDeclaration(d) => collect_declared(d, out), S::BlockStatement(b) => collect_hoisted(&b.body, out), S::IfStatement(i) => { collect_hoisted(std::slice::from_ref(&i.consequent), out); @@ -128,9 +122,27 @@ fn collect_hoisted(stmts: &[oxc_ast::ast::Statement], out: &mut HashSet) collect_hoisted(std::slice::from_ref(alt), out); } } - S::ForStatement(f) => collect_hoisted(std::slice::from_ref(&f.body), out), - S::ForInStatement(f) => collect_hoisted(std::slice::from_ref(&f.body), out), - S::ForOfStatement(f) => collect_hoisted(std::slice::from_ref(&f.body), out), + // The HEADER declares too, and `var` there is hoisted across the function + // exactly as one in the body is — scanning only the body left + // `for (var e of xs)` invisible. + S::ForStatement(f) => { + if let Some(oxc_ast::ast::ForStatementInit::VariableDeclaration(d)) = &f.init { + collect_declared(d, out); + } + collect_hoisted(std::slice::from_ref(&f.body), out); + } + S::ForInStatement(f) => { + if let oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) = &f.left { + collect_declared(d, out); + } + collect_hoisted(std::slice::from_ref(&f.body), out); + } + S::ForOfStatement(f) => { + if let oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) = &f.left { + collect_declared(d, out); + } + collect_hoisted(std::slice::from_ref(&f.body), out); + } S::WhileStatement(w) => collect_hoisted(std::slice::from_ref(&w.body), out), S::DoWhileStatement(w) => collect_hoisted(std::slice::from_ref(&w.body), out), S::TryStatement(t) => { @@ -153,6 +165,51 @@ fn collect_hoisted(stmts: &[oxc_ast::ast::Statement], out: &mut HashSet) } } +/// Every plain identifier an assignment TARGET writes, however the pattern is spelled. +fn assignment_target_names(target: &oxc_ast::ast::AssignmentTarget) -> Vec { + let mut out = Vec::new(); + // Cheap and textual on the AST: only the simple names matter here, because only a + // simple name can be an enum local this pass recorded. + if let Some(pattern) = target.as_assignment_target_pattern() { + use oxc_ast::ast::AssignmentTargetPattern as P; + match pattern { + P::ArrayAssignmentTarget(a) => { + for el in a.elements.iter().flatten() { + if let Some(id) = el.identifier() { + out.push(id.name.to_string()); + } + } + } + P::ObjectAssignmentTarget(o) => { + for prop in &o.properties { + match prop { + oxc_ast::ast::AssignmentTargetProperty::AssignmentTargetPropertyIdentifier( + p, + ) => out.push(p.binding.name.to_string()), + oxc_ast::ast::AssignmentTargetProperty::AssignmentTargetPropertyProperty( + p, + ) => { + if let Some(id) = p.binding.identifier() { + out.push(id.name.to_string()); + } + } + } + } + } + } + } + out +} + +/// Every identifier one `var`/`let`/`const` declaration binds. +fn collect_declared(d: &oxc_ast::ast::VariableDeclaration, out: &mut HashSet) { + for decl in &d.declarations { + for id in decl.id.get_binding_identifiers() { + out.insert(id.name.to_string()); + } + } +} + /// The identifier names a parameter list binds. fn param_names(params: &oxc_ast::ast::FormalParameters) -> HashSet { // EVERY identifier a parameter list binds, not just the plainly-named ones. A @@ -348,6 +405,21 @@ impl NamedResolver { /// /// 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. + /// The local an `extends(target, …)` MUTATES, when the target is a bare name. + /// + /// `extends` writes into its first argument, so after `extends(e, {B:"b"})` the + /// runtime's `e` has `B` too. Recovering the pre-merge body would publish an enum + /// that rejects a value it accepts — which the refusal in `merge_extends` prevents + /// for the RESULT, and which this exists to prevent for the target itself. + fn mutated_extends_target<'e>(e: &'e Expression) -> Option<&'e str> { + 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; + } + as_identifier(arg_expr(call.arguments.first()?)?) + } + fn merge_extends(&self, e: &Expression) -> Option { let call = as_call(e)?; let callee = wa_oxc::as_member(&call.callee)?; @@ -407,6 +479,15 @@ impl NamedResolver { impl<'a> Visit<'a> for NamedResolver { fn visit_variable_declarator(&mut self, d: &VariableDeclarator<'a>) { + // `var x = extends(e, {B:"b"})` MUTATES `e`. `merge_extends` already refuses to + // publish `x`, but `e` itself kept its pre-merge body and a later `i.BASE = e` + // published an enum missing a member the runtime accepts. + if let Some(init) = &d.init + && let Some(target) = Self::mutated_extends_target(init) + && !self.param_shadows(target) + { + self.shadowed.insert(target.to_string()); + } if d.id.get_identifier_name().is_none() { // A DESTRUCTURED declaration (`const {e} = obj`) binds `e` just as much as // `var e = …` does, and `get_identifier_name` sees none of it — so the whole @@ -570,6 +651,15 @@ impl<'a> Visit<'a> for NamedResolver { { self.pending.push((prop.to_string(), id.to_string())); } + } else if a.left.get_identifier_name().is_none() && a.left.as_member_expression().is_none() + { + // A destructuring ASSIGNMENT (`({e} = obj)`) rebinds without declaring, so no + // declarator ever runs and the cached body survived a write that replaced it. + for name in assignment_target_names(&a.left) { + if !self.param_shadows(&name) && self.locals.contains_key(name.as_str()) { + self.shadowed.insert(name); + } + } } else if let Some(name) = a.left.get_identifier_name() { // A bare `e = …` rebinding, which `visit_variable_declarator` never sees. // Without this, only `var`-form rebindings reached `shadowed`, so a module @@ -808,10 +898,15 @@ mod tests { 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); + // ...and the TARGET is refused too. This once asserted that `BASE` still resolved + // with its one written member — which is precisely the enum "claiming to reject a + // value it accepts" that the paragraph above calls out. The comment described the + // hazard and the assertion below it enshrined the hazard; refusing is the only + // answer consistent with both. + assert!( + resolve_named_enum(module, "M", "BASE").is_none(), + "`extends` wrote `B` into `e`, so the pre-merge body is not what the runtime has" + ); } #[test] @@ -1021,6 +1116,22 @@ mod tests { 2 ); + // A loop HEADER declares too, and `var` there hoists across the function. + let loop_header = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(xs){ var x=babelHelpers.extends({},e,{B:"b"}); for (var e of xs) {} i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(loop_header, "M", "OUT").is_none()); + + // A destructuring ASSIGNMENT rebinds without declaring, so no declarator runs. + let destructure_assign = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + ({e}=obj); + var x=babelHelpers.extends({},e,{B:"b"}); + i.OUT=x + }),1);"#; + assert!(resolve_named_enum(destructure_assign, "M", "OUT").is_none()); + // 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){ diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index 6c46e7c..5bdd291 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -281,6 +281,7 @@ fn update(args: &[String]) -> Result<()> { bundles, wasm, boot_pins, + live_handle_ids, authoritative, } = load_source(&opts)?; eprintln!("WhatsApp version: {wa_version}"); @@ -366,7 +367,7 @@ fn update(args: &[String]) -> Result<()> { // configuration `cargo test --workspace` never exercises. #[cfg(feature = "fetch")] if let Some(pins) = &boot_pins - && let Err(e) = update_lock_bootloader(&lock_path, pins, &wa_version) + && let Err(e) = update_lock_bootloader(&lock_path, pins, &wa_version, &live_handle_ids) { eprintln!(" wasm lock not updated with the bootloader map: {e:#}"); } @@ -378,7 +379,7 @@ fn update(args: &[String]) -> Result<()> { // endpoint subset still succeeds, and replacing the section with its own sample // would delete ids an earlier run resolved — the same shrink, just reached by a // productive run instead of a blocked one. - let boot_pins = pins_with_prior(&lock_path, boot_pins, &wa_version); + let boot_pins = pins_with_prior(&lock_path, boot_pins, &wa_version, &live_handle_ids); let lock = WasmLock::with_bootloader(&wa_version, wasm, boot_pins); fs::write(&lock_path, lock.to_pretty_json()) .with_context(|| format!("write {}", lock_path.display()))?; @@ -963,6 +964,10 @@ struct Loaded { /// [`lock::BootloaderPins`]. `None` for a local `--bundles` regen, which never /// asks the bootloader anything and must not clobber a recorded map with nothing. boot_pins: Option, + /// `bx` ids this run actually observed live, whether or not their binding survived + /// the CDN filter. Absence from the pins then means "rejected", not "never seen", and + /// the prior lock must not restore it. + live_handle_ids: BTreeSet, /// `true` only for the fetch path, which knows every bundle's origin URL and is /// therefore the *authoritative* source of a full lockfile. The offline `--bundles` /// path fingerprints the same inputs (for `--check`) but never rewrites the lock, @@ -989,6 +994,7 @@ fn load_source(opts: &Options) -> Result { // lock is left untouched — see `authoritative`). wasm: Vec::new(), boot_pins: None, + live_handle_ids: BTreeSet::new(), authoritative: false, }) } @@ -1102,7 +1108,7 @@ fn fetch_source(opts: &Options) -> Result { bundles.len() ); maybe_save_bundles(opts, &bundles)?; - let (wasm, boot_pins) = + let (wasm, boot_pins, live_handle_ids) = load_wasm(opts, &discovered, Some(remote_version.as_str()))?; return Ok(Loaded { wa_version: version, @@ -1110,6 +1116,7 @@ fn fetch_source(opts: &Options) -> Result { bundles: bundle_ids(&bundles), wasm, boot_pins, + live_handle_ids, authoritative: true, }); } @@ -1154,13 +1161,15 @@ fn fetch_source(opts: &Options) -> Result { // must never be able to cost the (expensive) JS download a retry. // The discovered version keys the cache; it does not gate the fetch. A run stamped // with `--wa-version` over an unreadable page still collects its payloads. - let (wasm, boot_pins) = load_wasm(opts, &discovered, discovered.wa_version.as_deref())?; + let (wasm, boot_pins, live_handle_ids) = + load_wasm(opts, &discovered, discovered.wa_version.as_deref())?; Ok(Loaded { wa_version: version, source: concat_bundles(&outcome.bundles), bundles: bundle_ids(&outcome.bundles), wasm, boot_pins, + live_handle_ids, authoritative: true, }) } @@ -1212,9 +1221,13 @@ fn load_wasm( opts: &Options, discovered: &wa_fetch::Discovered, remote_version: Option<&str>, -) -> Result<(Vec, Option)> { +) -> Result<( + Vec, + Option, + BTreeSet, +)> { if !opts.wasm { - return Ok((Vec::new(), None)); + return Ok((Vec::new(), None, BTreeSet::new())); } // The cache is keyed by the *discovered* version. Without one it can't be used at all @@ -1350,6 +1363,9 @@ fn load_wasm( ids.entry(id).or_insert(url); } } + // Every id the run OBSERVED, before the CDN/wasm filter drops any of them: absence + // from the pins then distinguishes "rejected" from "never seen". + let live_ids: BTreeSet = resolution.by_id.keys().cloned().collect(); let pins = bootloader_pins(&resolution, &ids); if payloads.is_empty() { @@ -1357,7 +1373,7 @@ fn load_wasm( // previous run's payloads behind, or a runner would consume a set no lock // describes. Not an error — a blocked resolution must not fail a spec update. maybe_save_wasm(opts, &payloads)?; - return Ok((Vec::new(), Some(pins))); + return Ok((Vec::new(), Some(pins), live_ids)); } // The wasm cache attaches to the JS cache for the same version; if that isn't there @@ -1371,7 +1387,7 @@ fn load_wasm( let entries = wasm_entries(&payloads, &handles); maybe_save_wasm(opts, &payloads)?; - Ok((entries, Some(pins))) + Ok((entries, Some(pins), live_ids)) } /// Give every payload a file name that is unique across the set and safe on disk, deriving @@ -2191,6 +2207,7 @@ fn update_lock_bootloader( lock_path: &Path, pins: &lock::BootloaderPins, wa_version: &str, + seen_live: &BTreeSet, ) -> Result<()> { let raw = fs::read_to_string(lock_path)?; let mut lock: WasmLock = serde_json::from_str(&raw)?; @@ -2203,7 +2220,7 @@ fn update_lock_bootloader( return Ok(()); } let mut pins = pins.clone(); - merge_prior_handles(&mut pins, lock.bootloader.as_ref()); + merge_prior_handles(&mut pins, lock.bootloader.as_ref(), seen_live); let pins = &pins; if lock.bootloader.as_ref() == Some(pins) { return Ok(()); @@ -2228,9 +2245,20 @@ fn update_lock_bootloader( /// to decide the whole map. Its ids are provenance, not identity — they do not enter /// `wasmSetHash` — so keeping one whose payload this run did not re-resolve costs nothing, /// while dropping it makes an unchanged release diff against itself. -fn merge_prior_handles(pins: &mut lock::BootloaderPins, prior: Option<&lock::BootloaderPins>) { +fn merge_prior_handles( + pins: &mut lock::BootloaderPins, + prior: Option<&lock::BootloaderPins>, + seen_live: &BTreeSet, +) { let Some(prior) = prior else { return }; for (id, url) in &prior.wasm_handles { + // An id the run SAW but whose new binding the pins filtered out (rebound to a + // non-CDN or non-wasm URL) is absent from `pins` for a reason. Reading that + // absence as "never observed" restored the stale entry, so the lock kept + // provenance the page had explicitly replaced. `seen_live` is the tombstone. + if seen_live.contains(id) { + continue; + } pins.wasm_handles.entry(id.clone()).or_insert(url.clone()); } } @@ -2249,6 +2277,7 @@ fn pins_with_prior( lock_path: &Path, pins: Option, wa_version: &str, + seen_live: &BTreeSet, ) -> Option { let mut pins = pins?; // A lock that exists but cannot be parsed is NOT the same as one that is absent: the @@ -2268,7 +2297,7 @@ fn pins_with_prior( }, Err(_) => None, }; - merge_prior_handles(&mut pins, prior.as_ref()); + merge_prior_handles(&mut pins, prior.as_ref(), seen_live); Some(pins) } @@ -3186,6 +3215,7 @@ mod tests { &path, &pins(&[("22", "https://x/b.wasm")], 1, 1), "2.3000.test", + &BTreeSet::new(), ) .expect("rewrite the section"); @@ -3207,6 +3237,7 @@ mod tests { &path, &pins(&[("33", "https://x/c.wasm")], 9, 9), "2.3000.next", + &BTreeSet::new(), ) .expect("a stale lock is not an error"); let untouched: lock::WasmLock = @@ -3258,6 +3289,7 @@ mod tests { ("33", "https://x/c.wasm"), ])), "2.3000.test", + &BTreeSet::new(), ) .expect("pins were supplied"); assert_eq!( @@ -3272,9 +3304,25 @@ mod tests { &path, Some(pins(&[("33", "https://x/c.wasm")])), "2.3000.next", + &BTreeSet::new(), ) .expect("pins were supplied"); assert_eq!(isolated.wasm_handles.keys().collect::>(), ["33"]); + + // An id the run SAW but whose new binding the CDN filter dropped must not be + // restored from the prior lock: absent-from-pins means rejected, not unseen. + let tombstoned = pins_with_prior( + &path, + Some(pins(&[("22", "https://x/b.wasm")])), + "2.3000.test", + &BTreeSet::from(["11".to_string()]), + ) + .expect("pins were supplied"); + assert_eq!( + tombstoned.wasm_handles.keys().collect::>(), + ["22"], + "the page rebound 11 to something the filter refused; the stale URL stays gone" + ); fs::remove_dir_all(&dir).unwrap(); } diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 1bf013c..6690720 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -549,7 +549,9 @@ def check_enum_catalog_refs(data, domain, errors): vals = [ v.get("value") for v in e.get("variants") or [] - if isinstance(v, dict) and isinstance(v.get("value"), int) + if isinstance(v, dict) + and isinstance(v.get("value"), int) + and not isinstance(v.get("value"), bool) ] dup_vals = sorted({v for v in vals if vals.count(v) > 1}) if dup_vals: @@ -559,13 +561,17 @@ def check_enum_catalog_refs(data, domain, errors): # `(module, name)` is the catalog identity — the extractor dedups on it, and a # consumer has no other way to select one definition of a repeated pair. ident = (e.get("module"), e.get("name")) - if ident in seen_defs: + if not all(isinstance(part, str) for part in ident): + errors.append(f"{domain}/enums/{i}: identity {ident!r} is not two strings") + elif ident in seen_defs: errors.append( f"{domain}/enums/{i}: {ident} already defined at index {seen_defs[ident]}" ) else: seen_defs[ident] = i - if "module" in e: + # Keyed only when it can BE a key: a dict or list `module` is unhashable and + # `setdefault` raises. The identity check above already reported it. + if isinstance(e.get("module"), str): by_module.setdefault(e["module"], []).append(e) def visit(node, path): @@ -717,7 +723,9 @@ def check_abprops(data, domain, errors): # consumer no way to pick; the reference generator just suffixes the second and # puts both contradictory entries in `ALL`. ident = (c.get("module"), c.get("name")) - if ident in seen_ids: + if not all(isinstance(part, str) for part in ident): + errors.append(f"{domain}/configs/{i}: identity {ident!r} is not two strings") + elif ident in seen_ids: errors.append( f"{domain}/configs/{i}: {ident} already defined at index {seen_ids[ident]}" ) @@ -776,7 +784,12 @@ def check_appstate_collections(data, domain, errors): if not isinstance(a, dict): continue used = a.get("collection", "regular") - if used not in known: + # Type-checked before the membership test. `known` holds strings; an unhashable + # value (a list, a dict) makes `in` raise and takes the whole run down. Fourth time + # this class has come up here, and it is always the error path, never the check. + if not isinstance(used, str): + errors.append(f"{domain}/actions/{name}: collection is {used!r}, not a string") + elif used not in known: errors.append( f"{domain}/actions/{name}: collection {used!r} is not among " f"{sorted(known)}" From 62af5ac38f62500687dbd1b0edde174f1f57a604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 18:57:38 -0300 Subject: [PATCH 28/46] test: cover all three loop-header forms, not just for-of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit asked whether the loop-header prescan had regression coverage for each form. It did not — only `for-of` was exercised, while `for` init and `for-in` left take different arms. Each of the three now fails on its own when its arm is removed. --- crates/wa-enums/src/lib.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 981b138..3353b7b 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -1116,12 +1116,24 @@ mod tests { 2 ); - // A loop HEADER declares too, and `var` there hoists across the function. - let loop_header = r#"__d("M",[],(function(t,n,r,o,a,i){ - var e={A:"a"}; - function f(xs){ var x=babelHelpers.extends({},e,{B:"b"}); for (var e of xs) {} i.OUT=x } - }),1);"#; - assert!(resolve_named_enum(loop_header, "M", "OUT").is_none()); + // A loop HEADER declares too, and `var` there hoists across the function. All + // three forms, because each takes a different arm of the prescan. + for header in [ + "for (var e = 0; e < 1; e++) {}", + "for (var e in xs) {}", + "for (var e of xs) {}", + ] { + let src = format!( + r#"__d("M",[],(function(t,n,r,o,a,i){{ + var e={{A:"a"}}; + function f(xs){{ var x=babelHelpers.extends({{}},e,{{B:"b"}}); {header} i.OUT=x }} + }}),1);"# + ); + assert!( + resolve_named_enum(&src, "M", "OUT").is_none(), + "the header's `var e` binds for the whole function: {header}" + ); + } // A destructuring ASSIGNMENT rebinds without declaring, so no declarator runs. let destructure_assign = r#"__d("M",[],(function(t,n,r,o,a,i){ From b5dd0c2eec5ffacb1505c9ec5f97b4958235f66e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 19:23:15 -0300 Subject: [PATCH 29/46] review: build the tombstone from pre-filter ids, and hoist only what var hoists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight from Codex on 62af5ac; two are defects in the round that introduced them. - The tombstone was built from `WasmResolution::by_id`, which is populated AFTER both URL filters — so an id rebound to something the filter rejects was absent from it, and the merge restored the stale URL. That is precisely the case the tombstone was added for, and it did not work. `observed_ids` records every id the response mentioned, before any filtering. - `collect_declared` hoisted `let`/`const` function-wide alongside `var`. Only `var` reaches beyond its block, so a `let` in a nested block was refusing an outer composition written where the binding is not in scope — the over-refusal this mechanism keeps having to avoid. The unreadable-initializer rule needed the same distinction, which the declarator could not see, so the declaration's kind is tracked. - `assignment_target_names` looked one level deep, missing `({x: {e}} = obj)`, `({...e} = obj)`, nested arrays and defaults. Linter: exact duplicate collection names were collapsed by the membership set before the collision check could see them; the normalization was an approximation that missed `fooBar` vs `foo-bar`, so it now mirrors `naming::pascal_case`; an A/B `code` must be unique within its module, being what the `` IQ sends; an appstate `chatJidIndex` must address a JID part that exists (`null` is legitimate and is 47 of the 67); and `singleByte[0]` is the reserved slot, without which every single-byte token shifts by one. --- crates/wa-enums/src/lib.rs | 139 +++++++++++++++++++++++------- crates/wa-fetch/src/bootloader.rs | 38 +++++++- crates/whatspec/src/main.rs | 8 +- scripts/lint-ir.py | 85 +++++++++++++++++- 4 files changed, 236 insertions(+), 34 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 3353b7b..9699a99 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -114,7 +114,11 @@ fn collect_hoisted(stmts: &[oxc_ast::ast::Statement], out: &mut HashSet) out.insert(id.name.to_string()); } } - S::VariableDeclaration(d) => collect_declared(d, out), + // ONLY `var` hoists to the function. A `let`/`const` in a nested block is + // scoped to that block, so treating it as a function-wide binding refused an + // outer composition written where the binding is not even in scope — the + // over-refusal this whole mechanism keeps having to avoid. + S::VariableDeclaration(d) if d.kind.is_var() => collect_declared(d, out), S::BlockStatement(b) => collect_hoisted(&b.body, out), S::IfStatement(i) => { collect_hoisted(std::slice::from_ref(&i.consequent), out); @@ -126,19 +130,25 @@ fn collect_hoisted(stmts: &[oxc_ast::ast::Statement], out: &mut HashSet) // exactly as one in the body is — scanning only the body left // `for (var e of xs)` invisible. S::ForStatement(f) => { - if let Some(oxc_ast::ast::ForStatementInit::VariableDeclaration(d)) = &f.init { + if let Some(oxc_ast::ast::ForStatementInit::VariableDeclaration(d)) = &f.init + && d.kind.is_var() + { collect_declared(d, out); } collect_hoisted(std::slice::from_ref(&f.body), out); } S::ForInStatement(f) => { - if let oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) = &f.left { + if let oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) = &f.left + && d.kind.is_var() + { collect_declared(d, out); } collect_hoisted(std::slice::from_ref(&f.body), out); } S::ForOfStatement(f) => { - if let oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) = &f.left { + if let oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) = &f.left + && d.kind.is_var() + { collect_declared(d, out); } collect_hoisted(std::slice::from_ref(&f.body), out); @@ -168,37 +178,58 @@ fn collect_hoisted(stmts: &[oxc_ast::ast::Statement], out: &mut HashSet) /// Every plain identifier an assignment TARGET writes, however the pattern is spelled. fn assignment_target_names(target: &oxc_ast::ast::AssignmentTarget) -> Vec { let mut out = Vec::new(); - // Cheap and textual on the AST: only the simple names matter here, because only a - // simple name can be an enum local this pass recorded. - if let Some(pattern) = target.as_assignment_target_pattern() { - use oxc_ast::ast::AssignmentTargetPattern as P; - match pattern { - P::ArrayAssignmentTarget(a) => { - for el in a.elements.iter().flatten() { - if let Some(id) = el.identifier() { - out.push(id.name.to_string()); - } + collect_target_names(target, &mut out); + out +} + +/// Recursive: a nested pattern (`({x: {e}} = obj)`) or a rest (`({...e} = obj)`) writes +/// just as much as a direct element does, and the first version of this looked only one +/// level deep. +fn collect_target_names(target: &oxc_ast::ast::AssignmentTarget, out: &mut Vec) { + use oxc_ast::ast::AssignmentTargetPattern as P; + if let Some(id) = target.get_identifier_name() { + out.push(id.to_string()); + return; + } + let Some(pattern) = target.as_assignment_target_pattern() else { + return; + }; + fn maybe_default(d: &oxc_ast::ast::AssignmentTargetMaybeDefault, out: &mut Vec) { + use oxc_ast::ast::AssignmentTargetMaybeDefault as D; + match d { + D::AssignmentTargetWithDefault(w) => collect_target_names(&w.binding, out), + _ => { + if let Some(t) = d.as_assignment_target() { + collect_target_names(t, out); } } - P::ObjectAssignmentTarget(o) => { - for prop in &o.properties { - match prop { - oxc_ast::ast::AssignmentTargetProperty::AssignmentTargetPropertyIdentifier( - p, - ) => out.push(p.binding.name.to_string()), - oxc_ast::ast::AssignmentTargetProperty::AssignmentTargetPropertyProperty( - p, - ) => { - if let Some(id) = p.binding.identifier() { - out.push(id.name.to_string()); - } - } + } + } + match pattern { + P::ArrayAssignmentTarget(a) => { + for el in a.elements.iter().flatten() { + maybe_default(el, out); + } + if let Some(rest) = &a.rest { + collect_target_names(&rest.target, out); + } + } + P::ObjectAssignmentTarget(o) => { + for prop in &o.properties { + match prop { + oxc_ast::ast::AssignmentTargetProperty::AssignmentTargetPropertyIdentifier( + p, + ) => out.push(p.binding.name.to_string()), + oxc_ast::ast::AssignmentTargetProperty::AssignmentTargetPropertyProperty(p) => { + maybe_default(&p.binding, out) } } } + if let Some(rest) = &o.rest { + collect_target_names(&rest.target, out); + } } } - out } /// Every identifier one `var`/`let`/`const` declaration binds. @@ -354,6 +385,12 @@ struct NamedResolver { /// and costs real constraints — `STANZA_MSG_TYPES` resolves at module level in a /// module that happens to reuse its local's name as a parameter elsewhere. param_scopes: Vec>, + /// Whether the declaration currently being visited is a `var`. + /// + /// Only `var` reaches beyond its block, so only `var` may invalidate a module-level + /// name. A `let`/`const` in a nested block is invisible to a read written outside it, + /// and treating it as a shadow dropped a resolvable enum. + in_var_decl: bool, exports: HashMap, pending: Vec<(String, String)>, } @@ -364,6 +401,7 @@ impl NamedResolver { locals: HashMap::new(), shadowed: HashSet::new(), param_scopes: Vec::new(), + in_var_decl: false, exports: HashMap::new(), pending: Vec::new(), } @@ -478,6 +516,12 @@ impl NamedResolver { } impl<'a> Visit<'a> for NamedResolver { + fn visit_variable_declaration(&mut self, d: &oxc_ast::ast::VariableDeclaration<'a>) { + let was = std::mem::replace(&mut self.in_var_decl, d.kind.is_var()); + walk::walk_variable_declaration(self, d); + self.in_var_decl = was; + } + fn visit_variable_declarator(&mut self, d: &VariableDeclarator<'a>) { // `var x = extends(e, {B:"b"})` MUTATES `e`. `merge_extends` already refuses to // publish `x`, but `e` itself kept its pre-merge body and a later `i.BASE = e` @@ -533,7 +577,9 @@ impl<'a> Visit<'a> for NamedResolver { // `locals`, and a later composition would publish values the runtime // never has. Only when the name is already bound — an unparseable `var` // that shadows nothing costs nothing to ignore. - None if self.locals.contains_key(local.as_str()) => { + // `var` only: a block-scoped `let`/`const` cannot be what a read written + // outside its block sees. + None if self.in_var_decl && self.locals.contains_key(local.as_str()) => { self.shadowed.insert(local.to_string()); } None => {} @@ -1144,6 +1190,41 @@ mod tests { }),1);"#; assert!(resolve_named_enum(destructure_assign, "M", "OUT").is_none()); + // Nested and rest patterns write just as much as a direct element does. + for pat in [ + "({x: {e}} = obj)", + "({...e} = obj)", + "([[e]] = xs)", + "({e = 1} = obj)", + ] { + let src = format!( + r#"__d("M",[],(function(t,n,r,o,a,i){{ + var e={{A:"a"}}; + {pat}; + var x=babelHelpers.extends({{}},e,{{B:"b"}}); + i.OUT=x + }}),1);"# + ); + assert!( + resolve_named_enum(&src, "M", "OUT").is_none(), + "the pattern rebinds `e`: {pat}" + ); + } + + // A `let` in a NESTED BLOCK is scoped to that block, so a composition written + // outside it must still resolve — hoisting it function-wide was over-refusal. + let block_let = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){ { let e = 1; } var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert_eq!( + resolve_named_enum(block_let, "M", "OUT") + .expect("a block-scoped `let` does not shadow the outer read") + .variants + .len(), + 2 + ); + // 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){ diff --git a/crates/wa-fetch/src/bootloader.rs b/crates/wa-fetch/src/bootloader.rs index 40f1275..3dad693 100644 --- a/crates/wa-fetch/src/bootloader.rs +++ b/crates/wa-fetch/src/bootloader.rs @@ -24,7 +24,7 @@ //! //! WASM-safe: everything here goes through the [`HttpClient`] port. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use serde_json::Value; @@ -78,6 +78,10 @@ impl Default for WasmResolveOptions { pub struct WasmResolution { /// `bx` id → wasm URL, for every wasm handle resolved (page + endpoint). pub by_id: BTreeMap, + /// Every `bx` id the response MENTIONED, before the wasm/CDN filters. `by_id` holds + /// only the survivors, so absence from it means either "rejected" or "never seen" — + /// and a merge against a prior map needs to distinguish the two. + pub observed_ids: BTreeSet, /// Wasm URLs, deduped and sorted — what the downloader consumes. Includes any URL /// discovered by text scan that no `bx` id claimed. pub urls: Vec, @@ -193,6 +197,10 @@ pub fn resolve_wasm_with( let mut urls: Vec = Vec::new(); for (id, uri) in bx { + // Recorded whether or not the binding survives the filters below: a caller + // merging a PRIOR map has to tell "this id was rebound to something we refuse" + // from "this id was never mentioned", and only the pre-filter set can say that. + out.observed_ids.insert(id.clone()); if is_wasm_url(&uri) && is_cdn_payload(&uri) { urls.push(uri.clone()); out.by_id.insert(id, uri); @@ -467,6 +475,34 @@ mod tests { const ENDPOINT: &str = "https://web.whatsapp.com/ajax/bootloader-endpoint/"; + #[test] + fn a_rejected_binding_is_still_recorded_as_observed() { + // `by_id` keeps only what survives the wasm/CDN filters, so a caller merging a + // PRIOR handle map cannot tell an id rebound to something refused from one the + // response never mentioned. `observed_ids` is what makes that distinction — + // and building a tombstone from `by_id` instead failed for exactly the case + // the tombstone exists to cover. + let client = CannedClient::always(200, payload(&[])); + let d = discovered_with( + ENDPOINT, + &[ + ("11", "https://attacker.example/x.wasm"), + ("22", "https://static.whatsapp.net/a.wasm"), + ], + ); + let r = resolve_wasm_with(&client, &d, &WasmResolveOptions::default()); + assert_eq!( + r.by_id.keys().collect::>(), + ["22"], + "only the CDN binding survives" + ); + assert_eq!( + r.observed_ids.iter().collect::>(), + ["11", "22"], + "but both were mentioned" + ); + } + #[test] fn merges_page_and_endpoint_handles_and_filters_non_wasm() { let client = CannedClient::always( diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index 5bdd291..0baa55d 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -1364,8 +1364,12 @@ fn load_wasm( } } // Every id the run OBSERVED, before the CDN/wasm filter drops any of them: absence - // from the pins then distinguishes "rejected" from "never seen". - let live_ids: BTreeSet = resolution.by_id.keys().cloned().collect(); + // from the pins then distinguishes "rejected" from "never seen". Taken from + // `observed_ids`, NOT `by_id` — the latter holds only the survivors, so building the + // tombstone from it failed for exactly the case the tombstone exists to cover. Taken from + // , NOT — the latter holds only the survivors, so building the + // tombstone from it failed for precisely the case the tombstone exists to cover. + let live_ids: BTreeSet = resolution.observed_ids.clone(); let pins = bootloader_pins(&resolution, &ids); if payloads.is_empty() { diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 6690720..1480dfc 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -716,12 +716,24 @@ def check_abprops(data, domain, errors): if not isinstance(configs, list): return seen_ids = {} + seen_codes = {} for i, c in enumerate(configs): if not isinstance(c, dict): continue # `(module, name)` IS the flag identity. Two records under one identity leave a # consumer no way to pick; the reference generator just suffixes the second and # puts both contradictory entries in `ALL`. + # The `code` is what the `` IQ sends, so two flags sharing one inside a + # module leave a consumer unable to associate a returned value with either. + code_key = (c.get("module"), c.get("code")) + if all(isinstance(part, (str, int)) and not isinstance(part, bool) for part in code_key): + if code_key in seen_codes: + errors.append( + f"{domain}/configs/{i}: code {c.get('code')!r} in " + f"{c.get('module')!r} already used at index {seen_codes[code_key]}" + ) + else: + seen_codes[code_key] = i ident = (c.get("module"), c.get("name")) if not all(isinstance(part, str) for part in ident): errors.append(f"{domain}/configs/{i}: identity {ident!r} is not two strings") @@ -755,6 +767,47 @@ def check_abprops(data, domain, errors): ) +def pascal_case(name): + """`naming::pascal_case`, which is what actually names the generated variants. + + Splitting only on `-`/`_` was an approximation: `fooBar` and `foo-bar` both become + `FooBar` in the generator but came out `Foobar` and `FooBar` here, so a pair that + cannot compile was certified. Case boundaries count as separators too. + """ + parts, cur = [], "" + for ch in name: + if ch in "-_ ": + if cur: + parts.append(cur) + cur = "" + elif ch.isupper() and cur and not cur[-1].isupper(): + parts.append(cur) + cur = ch + else: + cur += ch + if cur: + parts.append(cur) + return "".join(p[:1].upper() + p[1:].lower() for p in parts) + + +def check_tokens(data, domain, errors): + """`singleByte[tag]` is a direct wire lookup with index zero reserved. + + The IR promises that, and nothing checked it: a table that lost its leading empty + string shifts every single-byte token by one, so the generated consumer encodes and + decodes different strings than the wire carries — silently, and for everything. + """ + table = data.get("singleByte") + if not isinstance(table, list): + return + if not table: + errors.append(f"{domain}/singleByte: the table is empty") + elif table[0] != "": + errors.append( + f"{domain}/singleByte[0] is {table[0]!r}; index zero is the reserved slot" + ) + + def check_appstate_collections(data, domain, errors): """An action's `collection` must be one the document declares. @@ -767,12 +820,18 @@ def check_appstate_collections(data, domain, errors): actions = data.get("actions") if not isinstance(declared, list) or not isinstance(actions, dict): return - known = {c for c in declared if isinstance(c, str)} + names = [c for c in declared if isinstance(c, str)] + # Exact repeats first: the set below collapses them, so checking after it would never + # see a pair that `render_collection_enum` happily emits twice. + exact = sorted({c for c in names if names.count(c) > 1}) + if exact: + errors.append(f"{domain}/collections: {exact} listed more than once") + known = set(names) # `render_collection_enum` runs each through `pascal_case` without deduplicating, so # two wire names that normalise alike emit the same variant twice and will not compile. by_variant = {} for c in sorted(known): - variant = "".join(part.capitalize() for part in c.replace("-", "_").split("_")) + variant = pascal_case(c) if variant in by_variant: errors.append( f"{domain}/collections: {c!r} and {by_variant[variant]!r} both become " @@ -802,6 +861,27 @@ def check_appstate_collections(data, domain, errors): errors.append( f"{domain}/actions/{name}: indexParts does not begin with a literal" ) + # `chatJidIndex` is the POSITION holding the chat JID, passed through unchanged to + # the encoder — so an out-of-range one indexes past `indexParts`, and one pointing + # at a non-JID part encodes the wrong component. `null` means "no chat JID", which + # 47 of the 67 actions legitimately are. + cji = a.get("chatJidIndex") + if cji is not None: + parts_list = parts if isinstance(parts, list) else [] + if not isinstance(cji, int) or isinstance(cji, bool) or not 0 <= cji < len(parts_list): + errors.append( + f"{domain}/actions/{name}: chatJidIndex {cji!r} is not a position in " + f"its {len(parts_list)} index part(s)" + ) + elif not ( + isinstance(parts_list[cji], dict) and parts_list[cji].get("type") == "jid" + ): + errors.append( + f"{domain}/actions/{name}: chatJidIndex {cji} points at a " + f"{parts_list[cji].get('type') if isinstance(parts_list[cji], dict) else parts_list[cji]!r} part, not a jid" + ) + if not isinstance(first, dict) or first.get("type") != "literal": + pass elif first.get("value") != a.get("name"): errors.append( f"{domain}/actions/{name}: index begins with {first.get('value')!r}, " @@ -950,6 +1030,7 @@ def visit(node, path, domain=domain): check_event_codes(data, domain, errors) check_abprops(data, domain, errors) check_appstate_collections(data, domain, errors) + check_tokens(data, domain, errors) collect_unresolved_enums(data, domain, proto_enums, unresolved) ok = True From 41c9d58fd8fb1dd38f5c021f819fbdd153f835a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 19:43:51 -0300 Subject: [PATCH 30/46] review: restrict exports to the module object, and reconcile aliases against observed ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven from Codex on b5dd0c2. Two follow the round that introduced them; one found two live defects. - `X.Name = {…}` was recorded as an export whatever `X` was, so a private `tmp.Target = {A:"a"}` could be published as a named enum and attached as a CLOSED set to protocol fields the runtime never validates against it. Accepted only on a parameter of the module factory now; when the factory cannot be identified nothing is narrowed, because guessing would drop real exports. - `drop_stale_aliases` still reconciled against the post-filter `by_id`, so a cached alias for an id the live response rebound to something the filter rejects survived — and `wasm_entries` stamped it on a payload while the pins tombstoned the same id. Both sides read `observed_ids` now. - Limiting the hoist prescan to `var` was right; leaving `let`/`const` untracked was not. An unreadable `let e = get()` still shadows for reads written INSIDE its block, so a scope is pushed for the block and popped with it. The appstate `valueEnumFields` check found two REAL dangling paths on its first run — but the fault was mine: `collect_proto_enums` qualified an enum by its immediate parent only, so a doubly nested one (`WASARootSecretAction. RootSecretEntry.Status`) never matched. It now emits every qualification. `pascal_case` is a faithful port at the third attempt. The first split on `-`/`_` only, the second added case boundaries, and both still missed `foo.bar` — the generator replaces EVERY non-alphanumeric, and uppercases the first character without lowering the rest. Also: an inline `enumRef` follows the same member-uniqueness rule as the catalog, and a token table cannot exceed the 256 positions a one-byte index addresses. --- crates/wa-enums/src/lib.rs | 104 +++++++++++++++++++++++++++++++++ crates/whatspec/src/main.rs | 29 +++++++++- scripts/lint-ir.py | 111 ++++++++++++++++++++++++------------ 3 files changed, 205 insertions(+), 39 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 9699a99..1f63944 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -298,6 +298,7 @@ pub fn resolve_named_enum(module_slice: &str, module: &str, name: &str) -> Optio return None; } let mut r = NamedResolver::new(); + r.factory_params = module_factory_params(&ret.program); r.visit_program(&ret.program); resolve_pending(&mut r); let (value_kind, variants) = r.exports.get(name)?.clone(); @@ -309,6 +310,36 @@ pub fn resolve_named_enum(module_slice: &str, module: &str, name: &str) -> Optio }) } +/// The parameter names of the `__d("Name", deps, factory, id)` factory, if it is there. +/// +/// The export object is one of them, so a member assignment on anything else writes to a +/// private object rather than exporting. Which parameter it is varies by bundle arity, so +/// all of them are accepted — the point is to exclude module LOCALS. +fn module_factory_params(program: &oxc_ast::ast::Program) -> HashSet { + for stmt in &program.body { + let oxc_ast::ast::Statement::ExpressionStatement(es) = stmt else { + continue; + }; + let Some(call) = as_call(&es.expression) else { + continue; + }; + if as_identifier(&call.callee) != Some("__d") { + continue; + } + for arg in &call.arguments { + let Some(e) = arg_expr(arg) else { continue }; + let e = match e { + Expression::ParenthesizedExpression(p) => &p.expression, + other => other, + }; + if let Expression::FunctionExpression(f) = e { + return param_names(&f.params); + } + } + } + HashSet::new() +} + /// An enum-body object: either `$InternalEnum({…})` or a bare object literal. fn enum_object<'b, 'a>(e: &'b Expression<'a>) -> Option<&'b ObjectExpression<'a>> { internal_enum_object(e).or_else(|| as_object(e)) @@ -346,6 +377,7 @@ fn extract_plain_object_enums(slice: &str, module: &str) -> Vec return Vec::new(); } let mut r = NamedResolver::new(); + r.factory_params = module_factory_params(&ret.program); r.visit_program(&ret.program); resolve_pending(&mut r); let mut out: Vec = Vec::new(); @@ -391,6 +423,13 @@ struct NamedResolver { /// name. A `let`/`const` in a nested block is invisible to a read written outside it, /// and treating it as a shadow dropped a resolvable enum. in_var_decl: bool, + /// The module factory's parameter names, when they could be identified. + /// + /// `X.Name = {…}` is only an EXPORT when `X` is one of them. Accepting it on any + /// object let a private `tmp.Target = {A:"a"}` be published as a named enum and + /// attached as a closed `enumRef` to protocol fields the runtime never validates + /// against it. Empty means "could not tell", and then nothing is narrowed. + factory_params: HashSet, exports: HashMap, pending: Vec<(String, String)>, } @@ -402,6 +441,7 @@ impl NamedResolver { shadowed: HashSet::new(), param_scopes: Vec::new(), in_var_decl: false, + factory_params: HashSet::new(), exports: HashMap::new(), pending: Vec::new(), } @@ -415,6 +455,18 @@ impl NamedResolver { self.locals.get(name) } + /// Whether `e` names the module's export object — one of the factory's parameters. + /// + /// When the parameters could not be identified this accepts anything, which is the + /// behaviour that predates the check: narrowing on a guess would silently drop real + /// exports, and a private object being published is the rarer failure. + fn is_export_object(&self, e: &Expression) -> bool { + if self.factory_params.is_empty() { + return true; + } + as_identifier(e).is_some_and(|n| self.factory_params.contains(n)) + } + /// Whether an enclosing function binds `name` as a parameter, so a read of it here /// is that parameter and not the module local this pass recorded. fn param_shadows(&self, name: &str) -> bool { @@ -629,6 +681,30 @@ impl<'a> Visit<'a> for NamedResolver { self.param_scopes.pop(); } + fn visit_block_statement(&mut self, b: &oxc_ast::ast::BlockStatement<'a>) { + // `let`/`const` bind for THIS block only. Excluding them from the function-wide + // prescan was right; leaving them untracked entirely was not — an unreadable + // `let e = get()` inside a block still shadows the outer `e` for reads written in + // that block. A scope active only while the block is visited says both at once. + let mut lexical = HashSet::new(); + for stmt in &b.body { + if let oxc_ast::ast::Statement::VariableDeclaration(d) = stmt + && !d.kind.is_var() + { + collect_declared(d, &mut lexical); + } + } + lexical.retain(|n| self.locals.contains_key(n.as_str())); + let pushed = !lexical.is_empty(); + if pushed { + self.param_scopes.push(lexical); + } + walk::walk_block_statement(self, b); + if pushed { + self.param_scopes.pop(); + } + } + fn visit_catch_clause(&mut self, c: &oxc_ast::ast::CatchClause<'a>) { // `catch (e)` binds `e` for the handler body exactly as a parameter binds it for // a function body — the third form of the same shadow, after parameters and @@ -668,6 +744,7 @@ impl<'a> Visit<'a> for NamedResolver { fn visit_assignment_expression(&mut self, a: &AssignmentExpression<'a>) { if let Some(m) = a.left.as_member_expression() && let Some(prop) = m.static_property_name() + && self.is_export_object(m.object()) { if let Some(data) = enum_object(&a.right).and_then(parse_enum) { self.exports.entry(prop.to_string()).or_insert(data); @@ -1225,6 +1302,33 @@ mod tests { 2 ); + // A `let` shadows INSIDE its block, even though it does not hoist to the function. + let block_let_inside = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + { let e=get(); var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(block_let_inside, "M", "OUT").is_none()); + + // `X.Name = {…}` is an EXPORT only when `X` is the module's export object. A + // private local assigned the same way would otherwise be published as a named + // enum and attached as a closed set to fields the runtime never checks against it. + let private_obj = r#"__d("M",[],(function(t,n,r,o,a,i){ + var tmp={}; + tmp.Target={A:"a",B:"b"}; + }),1);"#; + assert!(resolve_named_enum(private_obj, "M", "Target").is_none()); + // ...and the real export object still works. + let real_export = r#"__d("M",[],(function(t,n,r,o,a,i){ + i.Target={A:"a",B:"b"}; + }),1);"#; + assert_eq!( + resolve_named_enum(real_export, "M", "Target") + .expect("an assignment on the factory's export param resolves") + .variants + .len(), + 2 + ); + // 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){ diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index 0baa55d..c5d87a8 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -1347,7 +1347,7 @@ fn load_wasm( // `A -> 11` would otherwise sit beside the new `B -> 11`: `wasm_entries` would stamp // `bxId: 11` on both payloads while the pins say 11 resolves to B — a lock that // contradicts itself. The live map wins, so any other URL claiming a live id goes. - drop_stale_aliases(&mut handles, &resolution.by_id); + drop_stale_aliases(&mut handles, &resolution.by_id, &resolution.observed_ids); // The PINS are built from the id → URL direction instead, because `handles` is // URL-keyed and therefore already lossy: `invert`/`handle_by_url` keep the lowest id @@ -1528,10 +1528,15 @@ fn bootloader_pins( fn drop_stale_aliases( handles: &mut BTreeMap, live_by_id: &BTreeMap, + observed: &BTreeSet, ) { handles.retain(|url, id| match live_by_id.get(id) { Some(live_url) => live_url == url, - None => true, + // Seen but NOT in `by_id`: the live binding was rejected by the wasm/CDN filter. + // Keeping the cached alias would stamp `bxId` on a payload while the pins — which + // tombstone the same id — say it resolves to nothing. Reconciling against the + // post-filter map alone left exactly that contradiction. + None => !observed.contains(id), }); } @@ -3351,7 +3356,7 @@ mod tests { .iter() .map(|(u, i)| (u.to_string(), i.to_string())) .collect(); - drop_stale_aliases(&mut handles, &live); + drop_stale_aliases(&mut handles, &live, &BTreeSet::from(["11".to_string()])); assert_eq!( handles.keys().collect::>(), [ @@ -3360,6 +3365,24 @@ mod tests { ], "the stale alias for a rebound id goes; an id the run never saw stays" ); + + // ...and an id the run SAW but whose live binding the filter rejected loses its + // cached alias too: absence from `by_id` is not absence from the response. + let mut cached: BTreeMap = [( + "https://static.whatsapp.net/a.wasm".to_string(), + "11".to_string(), + )] + .into_iter() + .collect(); + drop_stale_aliases( + &mut cached, + &BTreeMap::new(), + &BTreeSet::from(["11".to_string()]), + ); + assert!( + cached.is_empty(), + "the page rebound 11 to something refused; its cached URL must not survive" + ); } #[cfg(feature = "fetch")] diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 1480dfc..9df90fb 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -298,8 +298,13 @@ def enclosing(): if m and opens: name = m.group(2) if m.group(1) == "enum": - parent = enclosing() - out.add(f"{parent}.{name}" if parent else name) + # Every qualification, not just the immediate parent: a doubly nested enum + # is written `Outer.Inner.E` in the IR, and emitting only `Inner.E` made a + # real reference read as dangling (`WASARootSecretAction.RootSecretEntry. + # Status` was the case that surfaced it). + named = [n for n in stack if n is not None] + for i in range(len(named) + 1): + out.add(".".join([*named[i:], name])) stack.append(name) opens -= 1 # `oneof`, options, anything else braced. Popping on their `}` as if it closed the @@ -448,8 +453,22 @@ def check_enum_ref(node, path, errors): surfaces unchecked. """ ref = node.get("enumRef") - if isinstance(ref, dict) and not ref.get("variants"): + if not isinstance(ref, dict): + return + if not ref.get("variants"): errors.append(f"{path}: enumRef {ref.get('name')!r} has no variants") + return + # The same member-name rule the top-level catalog follows: JS keeps only the last + # repeated property, so two members under one name mean the reference no longer + # describes what the runtime validates against. + members = [ + v.get("name") + for v in ref["variants"] + if isinstance(v, dict) and isinstance(v.get("name"), str) + ] + repeated = sorted({m for m in members if members.count(m) > 1}) + if repeated: + errors.append(f"{path}: enumRef {ref.get('name')!r} defines {repeated} more than once") def check_const_bytes(node, path, errors): @@ -768,47 +787,58 @@ def check_abprops(data, domain, errors): def pascal_case(name): - """`naming::pascal_case`, which is what actually names the generated variants. - - Splitting only on `-`/`_` was an approximation: `fooBar` and `foo-bar` both become - `FooBar` in the generator but came out `Foobar` and `FooBar` here, so a pair that - cannot compile was certified. Case boundaries count as separators too. + """A faithful port of `wa_codegen::naming::pascal_case`, which is what actually names + the generated variants. + + Two approximations preceded this and both certified pairs that cannot compile: the + first split only on `-`/`_`, missing `fooBar` vs `foo-bar`; the second still missed + `foo.bar`, because the generator replaces EVERY non-alphanumeric, not three chosen + ones. It also uppercases the first character without lowering the rest, which + `.capitalize()` does not do. """ - parts, cur = [], "" - for ch in name: - if ch in "-_ ": - if cur: - parts.append(cur) - cur = "" - elif ch.isupper() and cur and not cur[-1].isupper(): - parts.append(cur) - cur = ch - else: - cur += ch - if cur: - parts.append(cur) - return "".join(p[:1].upper() + p[1:].lower() for p in parts) + s2 = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name) + s2 = re.sub(r"([A-Z])([A-Z][a-z])", r"\1 \2", s2) + s2 = re.sub(r"[^a-zA-Z0-9]", " ", s2) + return "".join(w[:1].upper() + w[1:] for w in s2.split() if w) def check_tokens(data, domain, errors): - """`singleByte[tag]` is a direct wire lookup with index zero reserved. + """`singleByte[tag]` and `doubleByte[dict][index]` are direct lookups by a wire BYTE. - The IR promises that, and nothing checked it: a table that lost its leading empty - string shifts every single-byte token by one, so the generated consumer encodes and - decodes different strings than the wire carries — silently, and for everything. + Index zero of the single-byte table is reserved: a table that lost its leading empty + string shifts every token by one, so the consumer encodes and decodes different + strings than the wire carries — silently, and for everything. And a position past 255 + is unaddressable, so publishing it advertises a token that can be neither encoded nor + decoded. """ table = data.get("singleByte") - if not isinstance(table, list): - return - if not table: - errors.append(f"{domain}/singleByte: the table is empty") - elif table[0] != "": - errors.append( - f"{domain}/singleByte[0] is {table[0]!r}; index zero is the reserved slot" - ) + if isinstance(table, list): + if not table: + errors.append(f"{domain}/singleByte: the table is empty") + else: + if table[0] != "": + errors.append( + f"{domain}/singleByte[0] is {table[0]!r}; index zero is the reserved slot" + ) + if len(table) > 256: + errors.append( + f"{domain}/singleByte holds {len(table)} tokens; a one-byte tag " + f"addresses at most 256" + ) + dicts = data.get("doubleByte") + if isinstance(dicts, list): + if len(dicts) > 256: + errors.append( + f"{domain}/doubleByte has {len(dicts)} dictionaries; the selector is one byte" + ) + for i, sub in enumerate(dicts): + if isinstance(sub, list) and len(sub) > 256: + errors.append( + f"{domain}/doubleByte/{i} holds {len(sub)} tokens; the index is one byte" + ) -def check_appstate_collections(data, domain, errors): +def check_appstate_collections(data, domain, errors, proto_enums): """An action's `collection` must be one the document declares. The schema is only a string. `generate_appstate_schemas` builds the `Collection` enum @@ -861,6 +891,15 @@ def check_appstate_collections(data, domain, errors): errors.append( f"{domain}/actions/{name}: indexParts does not begin with a literal" ) + # Each `valueEnumFields` value is a protobuf enum PATH, published unchanged for a + # consumer to interpret. A typo or a removed enum leaves mutation tooling unable + # to resolve what the IR claims — and the schema is only a string. + for field, path_ in sorted((a.get("valueEnumFields") or {}).items()): + if isinstance(path_, str) and path_ not in proto_enums: + errors.append( + f"{domain}/actions/{name}: valueEnumFields[{field!r}] names " + f"{path_!r}, which is in no committed protobuf enum" + ) # `chatJidIndex` is the POSITION holding the chat JID, passed through unchanged to # the encoder — so an out-of-range one indexes past `indexParts`, and one pointing # at a non-JID part encodes the wrong component. `null` means "no chat JID", which @@ -1029,7 +1068,7 @@ def visit(node, path, domain=domain): check_enum_catalog_refs(data, domain, errors) check_event_codes(data, domain, errors) check_abprops(data, domain, errors) - check_appstate_collections(data, domain, errors) + check_appstate_collections(data, domain, errors, proto_enums) check_tokens(data, domain, errors) collect_unresolved_enums(data, domain, proto_enums, unresolved) From d3c92e081a3e0ad8c782c1df617c51dd600b5eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 19:57:38 -0300 Subject: [PATCH 31/46] review: give every lexical construct a scope, and stop the linter crashing on four more shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine from CodeRabbit on 41c9d58 plus two from Codex. Lexical bindings were modelled only where they are direct statements of a `BlockStatement`, which left three constructs with no scope at all: loop headers (`for (let e = …)`), `switch` case consequents, and a function body's own top-level `let`/`const` — the last has no enclosing block, so nothing saw it. Each now pushes a scope for exactly its own extent, and each visitor fails its test on its own when removed. Linter robustness, the same class again: `valueEnumFields` was assumed dict-shaped; `byteLength` entered arithmetic untyped; the enum-reference side hashed a `module` it had not checked; and an unparseable `index.json` aborted with a traceback rather than naming the file. A `pass` branch left where the begins-with-a-literal check used to live is now the check itself, and its duplicate below is gone. An A/B `float` default must be a float literal, not a bare integer: the generator emits the value as written, so `AbDefault::Float` would carry an int. Not applied: pinning a shared pascal_case table against the Rust implementation. The port is now faithful and covered by collision cases, and a second fixture list in another language is one more thing to drift — the real fix is generating the linter's copy from the Rust, which belongs in its own change. --- crates/wa-enums/src/lib.rs | 123 ++++++++++++++++++++++++++++++++++--- scripts/lint-ir.py | 42 ++++++++++--- 2 files changed, 150 insertions(+), 15 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 1f63944..c783c80 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -467,6 +467,38 @@ impl NamedResolver { as_identifier(e).is_some_and(|n| self.factory_params.contains(n)) } + /// Push a lexical scope for `stmts`' own `let`/`const`, if any collide with a local. + fn with_lexical<'a, R>( + &mut self, + stmts: &[oxc_ast::ast::Statement<'a>], + extra: &[&oxc_ast::ast::VariableDeclaration<'a>], + f: impl FnOnce(&mut Self) -> R, + ) -> R { + let mut lexical = HashSet::new(); + for d in extra { + if !d.kind.is_var() { + collect_declared(d, &mut lexical); + } + } + for stmt in stmts { + if let oxc_ast::ast::Statement::VariableDeclaration(d) = stmt + && !d.kind.is_var() + { + collect_declared(d, &mut lexical); + } + } + lexical.retain(|n| self.locals.contains_key(n.as_str())); + let pushed = !lexical.is_empty(); + if pushed { + self.param_scopes.push(lexical); + } + let out = f(self); + if pushed { + self.param_scopes.pop(); + } + out + } + /// Whether an enclosing function binds `name` as a parameter, so a read of it here /// is that parameter and not the module local this pass recorded. fn param_shadows(&self, name: &str) -> bool { @@ -654,6 +686,24 @@ impl<'a> Visit<'a> for NamedResolver { // shadows, and poisoning `e` for the entire module would refuse a composition // written outside this body that never sees the inner binding at all. let mut hoisted = param_names(&f.params); + // A body's own top-level `let`/`const` binds for the WHOLE body — it does not + // hoist across blocks, but nothing in this body is outside it either. The + // block-scope visitor only sees nested blocks, so these needed collecting here. + if let Some(body) = &f.body { + let mut lexical = HashSet::new(); + for stmt in &body.statements { + if let oxc_ast::ast::Statement::VariableDeclaration(d) = stmt + && !d.kind.is_var() + { + collect_declared(d, &mut lexical); + } + } + hoisted.extend( + lexical + .into_iter() + .filter(|n| self.locals.contains_key(n.as_str())), + ); + } // A NAMED function expression binds its own name inside its body (`var g = // function e(){ … e … }` sees the function, not an outer `e`). That name lives on // `f.id`, not among the declarations collected from the enclosing body, so it was @@ -686,12 +736,31 @@ impl<'a> Visit<'a> for NamedResolver { // prescan was right; leaving them untracked entirely was not — an unreadable // `let e = get()` inside a block still shadows the outer `e` for reads written in // that block. A scope active only while the block is visited says both at once. + self.with_lexical(&b.body, &[], |me| walk::walk_block_statement(me, b)); + } + + fn visit_for_in_statement(&mut self, f: &oxc_ast::ast::ForInStatement<'a>) { + let left = match &f.left { + oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) => vec![&**d], + _ => vec![], + }; + self.with_lexical(&[], &left, |me| walk::walk_for_in_statement(me, f)); + } + + fn visit_switch_statement(&mut self, sw: &oxc_ast::ast::SwitchStatement<'a>) { + // A `case` consequent is not a `BlockStatement`, so its `let` was invisible — + // the same untracked-lexical hole as the loop headers below. The binding is + // scoped to the whole switch body, which is what the runtime does too. + let stmts: Vec> = Vec::new(); + let _ = &stmts; let mut lexical = HashSet::new(); - for stmt in &b.body { - if let oxc_ast::ast::Statement::VariableDeclaration(d) = stmt - && !d.kind.is_var() - { - collect_declared(d, &mut lexical); + for case in &sw.cases { + for stmt in &case.consequent { + if let oxc_ast::ast::Statement::VariableDeclaration(d) = stmt + && !d.kind.is_var() + { + collect_declared(d, &mut lexical); + } } } lexical.retain(|n| self.locals.contains_key(n.as_str())); @@ -699,12 +768,25 @@ impl<'a> Visit<'a> for NamedResolver { if pushed { self.param_scopes.push(lexical); } - walk::walk_block_statement(self, b); + walk::walk_switch_statement(self, sw); if pushed { self.param_scopes.pop(); } } - + fn visit_for_statement(&mut self, f: &oxc_ast::ast::ForStatement<'a>) { + let init = match &f.init { + Some(oxc_ast::ast::ForStatementInit::VariableDeclaration(d)) => vec![&**d], + _ => vec![], + }; + self.with_lexical(&[], &init, |me| walk::walk_for_statement(me, f)); + } + fn visit_for_of_statement(&mut self, f: &oxc_ast::ast::ForOfStatement<'a>) { + let left = match &f.left { + oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) => vec![&**d], + _ => vec![], + }; + self.with_lexical(&[], &left, |me| walk::walk_for_of_statement(me, f)); + } fn visit_catch_clause(&mut self, c: &oxc_ast::ast::CatchClause<'a>) { // `catch (e)` binds `e` for the handler body exactly as a parameter binds it for // a function body — the third form of the same shadow, after parameters and @@ -1329,6 +1411,33 @@ mod tests { 2 ); + // A lexical binding in a loop HEADER or a `switch` case shadows inside it too — + // neither is a `BlockStatement`, so both were invisible. + for shape in [ + "for (let e = get(); ; ) { var x=babelHelpers.extends({},e,{B:\"b\"}); i.OUT=x }", + "for (const e of xs) { var x=babelHelpers.extends({},e,{B:\"b\"}); i.OUT=x }", + "switch (k) { case 1: let e=get(); var x=babelHelpers.extends({},e,{B:\"b\"}); i.OUT=x }", + ] { + let src = format!( + r#"__d("M",[],(function(t,n,r,o,a,i){{ + var e={{A:"a"}}; + function f(xs,k){{ {shape} }} + }}),1);"# + ); + assert!( + resolve_named_enum(&src, "M", "OUT").is_none(), + "the lexical binding shadows inside its construct: {shape}" + ); + } + + // A body's own top-level `let` binds for the whole body, including a read written + // before it — no block encloses it, so the block visitor never sees it. + let body_let = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){ var x=babelHelpers.extends({},e,{B:"b"}); let e=get(); i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(body_let, "M", "OUT").is_none()); + // 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){ diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 9df90fb..ce82aa4 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -369,6 +369,10 @@ def check_field(f, path, domain, errors, counts, proto_enums): if t == "bytes": if any(c not in "0123456789abcdef" for c in lv) or len(lv) % 2: errors.append(f"{path}: literalValue {lv!r} is not lowercase hex") + elif "byteLength" in f and not ( + isinstance(f["byteLength"], int) and not isinstance(f["byteLength"], bool) + ): + errors.append(f"{path}: byteLength is {f['byteLength']!r}, not an integer") elif "byteLength" in f and len(lv) != f["byteLength"] * 2: errors.append( f"{path}: literalValue is {len(lv) // 2} bytes, " @@ -486,6 +490,10 @@ def check_const_bytes(node, path, errors): errors.append(f"{path}: constBytes on {kind!r} content, which is not bytes") if any(c not in "0123456789abcdef" for c in cb) or len(cb) % 2: errors.append(f"{path}: constBytes {cb!r} is not lowercase hex") + elif "byteLength" in node and not ( + isinstance(node["byteLength"], int) and not isinstance(node["byteLength"], bool) + ): + errors.append(f"{path}: byteLength is {node['byteLength']!r}, not an integer") elif "byteLength" in node and len(cb) != node["byteLength"] * 2: errors.append( f"{path}: constBytes is {len(cb) // 2} bytes, " @@ -597,6 +605,12 @@ def visit(node, path): if node.get("kind") != "enum" or "module" not in node: return module = node["module"] + # Hashable check on the REFERENCE side too, not just where definitions are + # indexed: `by_module.get` on a list or dict raises, and this is a reference + # walker over arbitrary JSON. + if not isinstance(module, str): + errors.append(f"{domain}{path}: enum reference module is {module!r}, not a string") + return found = by_module.get(module, []) if not found: errors.append(f"{domain}{path}: enum reference {module!r} is in no definition") @@ -774,7 +788,10 @@ def check_abprops(data, domain, errors): elif vt == "int": ok = isinstance(v, int) and not isinstance(v, bool) elif vt == "float": - ok = isinstance(v, (int, float)) and not isinstance(v, bool) + # A bare integer is not a float literal: the generator emits the value as + # written, so `AbDefault::Float` would carry an int and a consumer reading + # the declared type gets a mismatch the schema cannot see. + ok = isinstance(v, float) elif vt == "string": ok = isinstance(v, str) else: @@ -887,14 +904,14 @@ def check_appstate_collections(data, domain, errors, proto_enums): # encoder identify the action by one name and build its index under another. parts = a.get("indexParts") first = parts[0] if isinstance(parts, list) and parts else None - if not isinstance(first, dict) or first.get("type") != "literal": - errors.append( - f"{domain}/actions/{name}: indexParts does not begin with a literal" - ) # Each `valueEnumFields` value is a protobuf enum PATH, published unchanged for a # consumer to interpret. A typo or a removed enum leaves mutation tooling unable # to resolve what the IR claims — and the schema is only a string. - for field, path_ in sorted((a.get("valueEnumFields") or {}).items()): + vef = a.get("valueEnumFields") + if vef is not None and not isinstance(vef, dict): + errors.append(f"{domain}/actions/{name}: valueEnumFields is {vef!r}, not a map") + vef = None + for field, path_ in sorted((vef or {}).items()): if isinstance(path_, str) and path_ not in proto_enums: errors.append( f"{domain}/actions/{name}: valueEnumFields[{field!r}] names " @@ -920,7 +937,9 @@ def check_appstate_collections(data, domain, errors, proto_enums): f"{parts_list[cji].get('type') if isinstance(parts_list[cji], dict) else parts_list[cji]!r} part, not a jid" ) if not isinstance(first, dict) or first.get("type") != "literal": - pass + errors.append( + f"{domain}/actions/{name}: indexParts does not begin with a literal" + ) elif first.get("value") != a.get("name"): errors.append( f"{domain}/actions/{name}: index begins with {first.get('value')!r}, " @@ -1025,7 +1044,14 @@ def main() -> int: sys.exit(f"no domain documents under {root}") for doc in docs: - data = json.loads(doc.read_text()) + try: + data = json.loads(doc.read_text()) + except (OSError, json.JSONDecodeError) as e: + # Reported, not raised: an unreadable document is exactly the malformed input + # this exists to report, and a traceback tells a CI reader far less than a + # line naming the file. + errors.append(f"{doc}: cannot be read as JSON ({e})") + continue domain = doc.parent.name def visit(node, path, domain=domain): From 33ac17661f28275b02642f4358d429d28fccffe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 20:15:59 -0300 Subject: [PATCH 32/46] review: pin the proto path the IR actually writes, and count unresolved enums again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four from Codex on d3c92e0; two are over-corrections from the round before. - Registering EVERY suffix of a nested protobuf enum made a shortened or typo'd reference match — a bare `Status` resolved. Registering only the absolute path was wrong the other way: the IR never spells `SyncActionValue`, the message every appstate action nests under, so all fourteen references failed. Exactly two forms are registered now, the full path and that one elided prefix, and a shortened path is rejected again. - Switching the unresolved-enum baseline to a SET lost multiplicity. The semantic trail omits array indices for reorder stability, so two sibling fields can share an identity, and one of them losing its `enumRef` left the set unchanged. It counts per identity again — which is what the version before the semantic trail did, and the trail is what made the identities precise enough for counting to mean something. - An arrow's `FunctionBody` is not a `BlockStatement`, so its own top-level `let`/`const` was as invisible as a function body's had been. - A `privateStatsId` on a `regular`/`realtime` event is metadata the WAM codec defines only for the private channel. 50 events carry one today, all private. --- crates/wa-enums/src/lib.rs | 19 +- scripts/lint-ir.py | 362 ++++++++++++++++++++----------------- 2 files changed, 215 insertions(+), 166 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index c783c80..cb54970 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -809,9 +809,18 @@ impl<'a> Visit<'a> for NamedResolver { fn visit_arrow_function_expression(&mut self, f: &oxc_ast::ast::ArrowFunctionExpression<'a>) { // A block-bodied arrow hoists exactly as a function does; only its parameters - // were being recorded. + // were being recorded. Its top-level `let`/`const` need the same treatment as a + // function body's: an arrow's `FunctionBody` is not a `BlockStatement`, so the + // block visitor never sees them either. let mut hoisted = param_names(&f.params); let mut declared = HashSet::new(); + for stmt in &f.body.statements { + if let oxc_ast::ast::Statement::VariableDeclaration(d) = stmt + && !d.kind.is_var() + { + collect_declared(d, &mut declared); + } + } collect_hoisted(&f.body.statements, &mut declared); hoisted.extend( declared @@ -1438,6 +1447,14 @@ mod tests { }),1);"#; assert!(resolve_named_enum(body_let, "M", "OUT").is_none()); + // An arrow's body is not a `BlockStatement` either, so its own top-level `let` + // was as invisible as a function body's was. + let arrow_let = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + var f=()=>{ let e=get(); var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x }; + }),1);"#; + assert!(resolve_named_enum(arrow_let, "M", "OUT").is_none()); + // 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){ diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index ce82aa4..9af4a74 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -25,6 +25,7 @@ import json import re import sys +from collections import defaultdict from pathlib import Path # Counted states, with the value observed when this guard was introduced. Raising @@ -45,161 +46,161 @@ # 155 fields collapse to 30 identities. Adding one, dropping one, or changing a count all # fail; each is a deliberate act that costs one line here. UNRESOLVED_ENUMS = { - "incoming|ack|deprecatedEditMixin|edit|attrEnum", - "iq|AddParticipantsResponseSuccess|participant|addParticipant|addParticipantsParticipantAddedOrNonRegisteredWaUserParticipantErrorLidResponseMixinGroup|AddParticipantsParticipantAddedResponse|addParticipantsParticipantMixins|ParticipantGroupJoinRequest|membershipApprovalRequestError|maybeAttrEnum", - "iq|CreateCustomPaymentMethodResponseSuccess|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2mEligible|maybeAttrEnum", - "iq|CreateCustomPaymentMethodResponseSuccess|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2pEligible|maybeAttrEnum", - "iq|CreateResponseSuccess|description|groupDescription|error|maybeAttrEnum", - "iq|PromoteDemoteAdminResponseSuccessMultiAdmin|participant|adminParticipant|error|maybeAttrEnum", - "iq|PromoteDemoteResponseSuccessDemote|participant|demoteParticipant|error|maybeAttrEnum", - "iq|PromoteDemoteResponseSuccessPromote|participant|promoteParticipant|error|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2m|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2p|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultCredit|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2m|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2p|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultDebit|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|isAadhaarEnabled|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|isInternationalPayEnabled|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|isMpinSet|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|p2mEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|p2pEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|pinFormatVersion|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|automaticBinding|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligible|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesEditable|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesVerifiable|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mCreditEligible|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mDebitEligible|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2m|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2p|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCredit|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2m|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2p|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebit|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|needsDeviceBinding|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2mEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2pEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|verified|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligible|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesEditable|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesVerifiable|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2m|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2p|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCredit|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2m|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2p|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebit|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2mEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2pEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|verified|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligible|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesEditable|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesVerifiable|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2m|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2p|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCredit|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2m|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2p|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebit|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2mEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2pEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|verified|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2mEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2pEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|canAddPayout|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|canPayout|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|canSell|attrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|p2mEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|p2pEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2mEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2pEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2mEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2pEligible|maybeAttrEnum", - "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|pixOnboardingState|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2m|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2p|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|bank|bank|defaultCredit|attrEnum", - "iq|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2m|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2p|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|bank|bank|defaultDebit|attrEnum", - "iq|account|accountPaymentMethodsMixin|bank|bank|isAadhaarEnabled|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|bank|bank|isInternationalPayEnabled|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|bank|bank|isMpinSet|attrEnum", - "iq|account|accountPaymentMethodsMixin|bank|bank|p2mEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|bank|bank|p2pEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|bank|bank|pinFormatVersion|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|automaticBinding|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligible|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesEditable|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesVerifiable|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mCreditEligible|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mDebitEligible|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2m|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2p|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCredit|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2m|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2p|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebit|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|needsDeviceBinding|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2mEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2pEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|verified|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligible|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesEditable|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesVerifiable|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2m|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2p|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCredit|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2m|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2p|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebit|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2mEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2pEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|verified|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligible|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesEditable|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesVerifiable|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2m|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2p|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCredit|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2m|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2p|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebit|attrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2mEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2pEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|verified|attrEnum", - "iq|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2mEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2pEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|merchant|merchant|canAddPayout|attrEnum", - "iq|account|accountPaymentMethodsMixin|merchant|merchant|canPayout|attrEnum", - "iq|account|accountPaymentMethodsMixin|merchant|merchant|canSell|attrEnum", - "iq|account|accountPaymentMethodsMixin|merchant|merchant|p2mEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|merchant|merchant|p2pEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2mEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2pEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2mEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2pEligible|maybeAttrEnum", - "iq|account|accountPaymentMethodsMixin|merchant|merchant|pixOnboardingState|maybeAttrEnum", - "iq|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2mEligible|maybeAttrEnum", - "iq|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2pEligible|maybeAttrEnum", - "iq|description|groupDescription|error|maybeAttrEnum", - "iq|participant|addParticipant|addParticipantsParticipantAddedOrNonRegisteredWaUserParticipantErrorLidResponseMixinGroup|AddParticipantsParticipantAddedResponse|addParticipantsParticipantMixins|ParticipantGroupJoinRequest|membershipApprovalRequestError|maybeAttrEnum", - "iq|participant|adminParticipant|error|maybeAttrEnum", - "iq|participant|promoteParticipant|error|maybeAttrEnum", - "notif|create|reason|", + "incoming|ack|deprecatedEditMixin|edit|attrEnum": 1, + "iq|AddParticipantsResponseSuccess|participant|addParticipant|addParticipantsParticipantAddedOrNonRegisteredWaUserParticipantErrorLidResponseMixinGroup|AddParticipantsParticipantAddedResponse|addParticipantsParticipantMixins|ParticipantGroupJoinRequest|membershipApprovalRequestError|maybeAttrEnum": 1, + "iq|CreateCustomPaymentMethodResponseSuccess|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2mEligible|maybeAttrEnum": 1, + "iq|CreateCustomPaymentMethodResponseSuccess|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2pEligible|maybeAttrEnum": 1, + "iq|CreateResponseSuccess|description|groupDescription|error|maybeAttrEnum": 1, + "iq|PromoteDemoteAdminResponseSuccessMultiAdmin|participant|adminParticipant|error|maybeAttrEnum": 1, + "iq|PromoteDemoteResponseSuccessDemote|participant|demoteParticipant|error|maybeAttrEnum": 1, + "iq|PromoteDemoteResponseSuccessPromote|participant|promoteParticipant|error|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultCredit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultDebit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|isAadhaarEnabled|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|isInternationalPayEnabled|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|isMpinSet|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|pinFormatVersion|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|automaticBinding|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligible|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesEditable|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesVerifiable|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mCreditEligible|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mDebitEligible|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCredit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|needsDeviceBinding|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|verified|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligible|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesEditable|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesVerifiable|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCredit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|verified|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligible|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesEditable|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesVerifiable|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCredit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|verified|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|canAddPayout|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|canPayout|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|canSell|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|pixOnboardingState|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultCredit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultDebit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|isAadhaarEnabled|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|isInternationalPayEnabled|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|isMpinSet|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|pinFormatVersion|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|automaticBinding|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligible|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesEditable|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesVerifiable|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mCreditEligible|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mDebitEligible|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCredit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|needsDeviceBinding|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|verified|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligible|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesEditable|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesVerifiable|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCredit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|verified|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligible|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesEditable|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesVerifiable|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCredit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|verified|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|canAddPayout|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|canPayout|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|canSell|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|pixOnboardingState|maybeAttrEnum": 1, + "iq|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2mEligible|maybeAttrEnum": 1, + "iq|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2pEligible|maybeAttrEnum": 1, + "iq|description|groupDescription|error|maybeAttrEnum": 1, + "iq|participant|addParticipant|addParticipantsParticipantAddedOrNonRegisteredWaUserParticipantErrorLidResponseMixinGroup|AddParticipantsParticipantAddedResponse|addParticipantsParticipantMixins|ParticipantGroupJoinRequest|membershipApprovalRequestError|maybeAttrEnum": 1, + "iq|participant|adminParticipant|error|maybeAttrEnum": 1, + "iq|participant|promoteParticipant|error|maybeAttrEnum": 1, + "notif|create|reason|": 1, } @@ -211,6 +212,10 @@ # than preserved, so an unknown value loses the channel silently instead of failing. WAM_CHANNELS = {"regular", "realtime", "private"} +# The protobuf message appstate actions nest under. Their `protoEnum`/`valueEnumFields` +# paths are written relative to it, so it is the one prefix that may be elided. +APPSTATE_ROOT = "SyncActionValue" + # What the extractor emits for an integer pin, and what a consumer must be able to parse. DECIMAL = re.compile(r"-?[0-9]+") @@ -260,7 +265,7 @@ def walk_(node, trail): and not node.get("enumKeys") and not (node.get("protoEnum") and node["protoEnum"] in proto_enums) ): - out.add("|".join([domain, *here, str(node.get("method", ""))])) + out["|".join([domain, *here, str(node.get("method", ""))])] += 1 for k, v in node.items(): if k in ("tag", "wireTag", "name"): continue @@ -302,9 +307,17 @@ def enclosing(): # is written `Outer.Inner.E` in the IR, and emitting only `Inner.E` made a # real reference read as dangling (`WASARootSecretAction.RootSecretEntry. # Status` was the case that surfaced it). + # The full path, plus the one form the IR actually writes: appstate paths + # are relative to `SyncActionValue`, the message every action nests under. + # + # Registering EVERY suffix instead was permissive — a bare `Status` + # resolved — and registering only the absolute path was wrong the other + # way, since the IR never spells `SyncActionValue`. Exactly these two. named = [n for n in stack if n is not None] - for i in range(len(named) + 1): - out.add(".".join([*named[i:], name])) + full = [*named, name] + out.add(".".join(full)) + if full[0] == APPSTATE_ROOT and len(full) > 1: + out.add(".".join(full[1:])) stack.append(name) opens -= 1 # `oneof`, options, anything else braced. Popping on their `}` as if it closed the @@ -667,6 +680,14 @@ def check_event_codes(data, domain, errors): f"{domain}: event {e.get('name')!r} has channel {channel!r}, " f"not one of {sorted(WAM_CHANNELS)}" ) + # The WAM codec defines this global only for the private channel, so an id on a + # `regular`/`realtime` event is metadata that cannot be applied where the event + # says it is sent. + if e.get("privateStatsId") is not None and channel != "private": + errors.append( + f"{domain}: event {e.get('name')!r} declares a privateStatsId on the " + f"{channel!r} channel" + ) weights = e.get("weights") if not isinstance(weights, list) or len(weights) != 3: errors.append( @@ -1032,7 +1053,7 @@ def main() -> int: root = Path(sys.argv[1] if len(sys.argv) > 1 else "generated") errors: list[str] = [] counts = dict.fromkeys(BASELINE, 0) - unresolved: set[str] = set() + unresolved: dict[str, int] = defaultdict(int) # The protobuf enums an appstate `protoEnum` may name. A path that resolves to # nothing is not a constraint — it only looks like one because the string is present, @@ -1101,14 +1122,25 @@ def visit(node, path, domain=domain): ok = True # Set difference in BOTH directions, plus the multiplicities. A gain that offsets a # loss keeps the total at 155 and is exactly what a scalar cannot see. - for ident in sorted(unresolved - UNRESOLVED_ENUMS): - print(f"REGRESSION newly unresolved enum: {ident}") - ok = False - for ident in sorted(UNRESOLVED_ENUMS - unresolved): - print(f"IMPROVED enum now resolved: {ident} — drop it from the baseline") + # COUNTS, not just membership: the semantic trail omits array indices for reorder + # stability, so two sibling fields can share an identity — and with a set, one of them + # losing its `enumRef` left the set unchanged and passed. + for ident in sorted(set(unresolved) | set(UNRESOLVED_ENUMS)): + now, was = unresolved.get(ident, 0), UNRESOLVED_ENUMS.get(ident, 0) + if now == was: + continue ok = False + if was == 0: + print(f"REGRESSION newly unresolved enum: {ident} (x{now})") + elif now == 0: + print(f"IMPROVED enum now resolved: {ident} — drop it from the baseline") + else: + print(f"CHANGED {ident}: {was} -> {now} — update the baseline") if ok: - print(f"ok unresolved enums: {len(unresolved)}, exactly as pinned") + print( + f"ok unresolved enums: {sum(unresolved.values())} across " + f"{len(unresolved)} identities, exactly as pinned" + ) for name, observed in sorted(counts.items()): allowed = BASELINE[name] From 4a30b7387c41aecf4ad5de5058aebae624351881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 20:31:08 -0300 Subject: [PATCH 33/46] review: one lexical collector for all four scopes, and scalar fallbacks count as absence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four from Codex on 33ac176; two applied, two answered. The lexical-binding collection had been hand-written in four places — a function body, an arrow body, a block and a `switch` — and had already drifted: adding the `class` arm to two of them left the third resolving over a class binding, which the test caught. One `collect_lexical` now serves all four, and `class e {}` binds lexically over its scope exactly as `let` does. A scalar sentinel is no more a collection than `null` is: `enabled ? map(…) : 0` yields a number on the disabled path, and only null/undefined/false counted. Two are recorded on the PR rather than applied, both for the same reason — the narrowing they ask for needs a signal I do not have, and guessing it drops real data silently: - Restricting exports to the `module`/`exports` receiver specifically, rather than to any factory parameter. Which minified parameter that is varies by bundle arity, and a wrong guess silently drops real exports; the current rule already excludes module LOCALS, which was the reported failure. - Accepting a named callback only where it is lexically visible. `LocalFns` has no owner information today, and adding it is a change to how every helper is resolved — too broad to land at the end of this sequence without its own measurement. --- crates/wa-enums/src/lib.rs | 73 ++++++++++++++++++++-------------- crates/wa-notif/src/actions.rs | 5 +++ crates/wa-notif/src/tests.rs | 8 +++- 3 files changed, 55 insertions(+), 31 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index cb54970..61edcb3 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -232,6 +232,28 @@ fn collect_target_names(target: &oxc_ast::ast::AssignmentTarget, out: &mut Vec) { + use oxc_ast::ast::Statement as S; + for stmt in stmts { + match stmt { + S::VariableDeclaration(d) if !d.kind.is_var() => collect_declared(d, out), + // `class e {}` binds lexically over its whole scope exactly as `let` does. + S::ClassDeclaration(c) => { + if let Some(id) = &c.id { + out.insert(id.name.to_string()); + } + } + // `class e {}` binds lexically over its whole scope exactly as `let` does. + _ => {} + } + } +} + /// Every identifier one `var`/`let`/`const` declaration binds. fn collect_declared(d: &oxc_ast::ast::VariableDeclaration, out: &mut HashSet) { for decl in &d.declarations { @@ -480,13 +502,7 @@ impl NamedResolver { collect_declared(d, &mut lexical); } } - for stmt in stmts { - if let oxc_ast::ast::Statement::VariableDeclaration(d) = stmt - && !d.kind.is_var() - { - collect_declared(d, &mut lexical); - } - } + collect_lexical(stmts, &mut lexical); lexical.retain(|n| self.locals.contains_key(n.as_str())); let pushed = !lexical.is_empty(); if pushed { @@ -691,13 +707,7 @@ impl<'a> Visit<'a> for NamedResolver { // block-scope visitor only sees nested blocks, so these needed collecting here. if let Some(body) = &f.body { let mut lexical = HashSet::new(); - for stmt in &body.statements { - if let oxc_ast::ast::Statement::VariableDeclaration(d) = stmt - && !d.kind.is_var() - { - collect_declared(d, &mut lexical); - } - } + collect_lexical(&body.statements, &mut lexical); hoisted.extend( lexical .into_iter() @@ -751,17 +761,9 @@ impl<'a> Visit<'a> for NamedResolver { // A `case` consequent is not a `BlockStatement`, so its `let` was invisible — // the same untracked-lexical hole as the loop headers below. The binding is // scoped to the whole switch body, which is what the runtime does too. - let stmts: Vec> = Vec::new(); - let _ = &stmts; let mut lexical = HashSet::new(); for case in &sw.cases { - for stmt in &case.consequent { - if let oxc_ast::ast::Statement::VariableDeclaration(d) = stmt - && !d.kind.is_var() - { - collect_declared(d, &mut lexical); - } - } + collect_lexical(&case.consequent, &mut lexical); } lexical.retain(|n| self.locals.contains_key(n.as_str())); let pushed = !lexical.is_empty(); @@ -814,13 +816,7 @@ impl<'a> Visit<'a> for NamedResolver { // block visitor never sees them either. let mut hoisted = param_names(&f.params); let mut declared = HashSet::new(); - for stmt in &f.body.statements { - if let oxc_ast::ast::Statement::VariableDeclaration(d) = stmt - && !d.kind.is_var() - { - collect_declared(d, &mut declared); - } - } + collect_lexical(&f.body.statements, &mut declared); collect_hoisted(&f.body.statements, &mut declared); hoisted.extend( declared @@ -1455,6 +1451,23 @@ mod tests { }),1);"#; assert!(resolve_named_enum(arrow_let, "M", "OUT").is_none()); + // `class e {}` binds lexically over its scope exactly as `let` does. + for shape in [ + "function g(){ class e {} var x=babelHelpers.extends({},e,{B:\"b\"}); i.OUT=x }", + "function g(){ { class e {} var x=babelHelpers.extends({},e,{B:\"b\"}); i.OUT=x } }", + ] { + let src = format!( + r#"__d("M",[],(function(t,n,r,o,a,i){{ + var e={{A:"a"}}; + {shape} + }}),1);"# + ); + assert!( + resolve_named_enum(&src, "M", "OUT").is_none(), + "the class binding shadows: {shape}" + ); + } + // 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){ diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index 2bbb1b9..421862e 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -2111,6 +2111,11 @@ fn is_nullish(e: &Expression) -> bool { Expression::NullLiteral(_) => true, Expression::BooleanLiteral(b) => !b.value, Expression::Identifier(i) => i.name == "undefined", + // A scalar sentinel is no more a collection than `null` is: `enabled ? map(…) : 0` + // yields a number on the disabled path. Any literal that cannot be a list counts. + Expression::NumericLiteral(_) | Expression::StringLiteral(_) => true, + // A scalar sentinel is no more a collection than `null` is: `enabled ? map(…) : 0` + // yields a number on the disabled path. Any literal that cannot be a list counts. Expression::UnaryExpression(u) => match u.operator { // The minifier writes `undefined` as `void 0`. oxc_ast::ast::UnaryOperator::Void => true, diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 12c5376..02de3d1 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -703,7 +703,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr nestedGuard: t.hasChild("ng") ? (t.hasChild("en") && t.mapChildrenWithTag("nested_user", function(x){ return {id:x.attrUserJid("jid")}; })) : [{id:"fb"}], fromFormal: outerH(t, realCb), falsyFallback: t.hasChild("ff") ? t.mapChildrenWithTag("falsy_user", function(x){ return {id:x.attrUserJid("jid")}; }) : !1, - bareFalse: t.hasChild("bf") ? t.mapChildrenWithTag("bare_user", function(x){ return {id:x.attrUserJid("jid")}; }) : false}; + bareFalse: t.hasChild("bf") ? t.mapChildrenWithTag("bare_user", function(x){ return {id:x.attrUserJid("jid")}; }) : false, + scalarFb: t.hasChild("sf") ? t.mapChildrenWithTag("scalar_user", function(x){ return {id:x.attrUserJid("jid")}; }) : 0}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -969,6 +970,11 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { !child("bareFalse").required, "the unminified `false` is the same absence" ); + // A scalar sentinel is no more a collection than `null` is. + assert!( + !child("scalarFb").required, + "`: 0` on the far side is an absent collection too" + ); // The guard must not cost the child's contents. assert_eq!(child("anded").wire_tag, "anded_user"); From c9e1f2bdae302c1f3f205ab0c3505c58bd17f3f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 20:50:12 -0300 Subject: [PATCH 34/46] review: resolve the value payload against the proto, and tombstone the last cache path Six from Codex on 4a30b73; five applied, one answered again. - The cache's second read fed `ids` directly and ran after `drop_stale_aliases`, so an id the endpoint rebound to a rejected URL was re-inserted from the cache and reached the pins unchallenged. `live_handle_ids` can only suppress the prior LOCK merge. Fourth and last path where a stale binding could outlive the response that replaced it. - `valueProtoType` names the `SyncActionValue` payload message and nothing resolved it; the proto scan now collects messages alongside enums, in the same two path forms. All 62 resolve today. - Action scopes become a generated enum through the same `pascal_case` as collections, so they need the same normalized-collision check. - The `doubleByte` outer dimension is FOUR, not 256: the wire exposes `DICTIONARY_0`..`DICTIONARY_3` and entries beyond have no selector tag. - A WAM event's `(module, name)` is an identity like everything else's; the extractor dedups on `(code, name)`, so a repeat under one name survives while JS keeps only the last. Declining the lock bootstrap a third time, and stating the reason once more rather than letting repetition wear it down: the pins can only be produced by a live fetch run, this PR does not run fetch, and writing a handle map no run resolved puts invented values in an artifact `restore` treats as trusted. The update workflow writes it on its next run. --- crates/whatspec/src/main.rs | 7 +++++ scripts/lint-ir.py | 63 +++++++++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index c5d87a8..7dc5726 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -1360,6 +1360,13 @@ fn load_wasm( ids.extend(resolution.by_id.clone()); if let Some(cache) = &cache { for (url, id) in cache.wasm_handles(remote_version.unwrap_or_default()) { + // Skipped when the run SAW this id: `by_id` above already inserted the live + // binding if it survived, and if it did not, the cache's URL is the one the + // response replaced. `live_handle_ids` can only suppress the prior-LOCK + // merge, so an entry inserted here would reach the pins unchallenged. + if resolution.observed_ids.contains(&id) { + continue; + } ids.entry(id).or_insert(url); } } diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 9af4a74..a366843 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -277,13 +277,27 @@ def walk_(node, trail): walk_(data, []) +def collect_proto_messages(path): + """`Outer.Inner` for every protobuf MESSAGE, in the same two forms as the enums. + + `valueProtoType` names one of these, and nothing resolved it — a typo or a removed + message left mutation tooling unable to build the `SyncActionValue` payload the IR + claims, while the linter certified the document. + """ + return _collect_proto(path, "message") + + def collect_proto_enums(path): - """`Outer.Inner` for every enum in the committed `.proto`, by brace depth. + """`Outer.Inner` for every ENUM in the committed `.proto`, by brace depth. Cheap and text-based on purpose: the linter must stay dependency-free, and the file is generated by this same repo. A path naming a message that has no such enum, or naming nothing at all, is a dangling reference the schema cannot catch. """ + return _collect_proto(path, "enum") + + +def _collect_proto(path, want): try: text = path.read_text() except OSError: @@ -302,7 +316,7 @@ def enclosing(): m = re.match(r"(message|enum)\s+(\w+)", line) if m and opens: name = m.group(2) - if m.group(1) == "enum": + if m.group(1) == want: # Every qualification, not just the immediate parent: a doubly nested enum # is written `Outer.Inner.E` in the IR, and emitting only `Inner.E` made a # real reference read as dangling (`WASARootSecretAction.RootSecretEntry. @@ -649,10 +663,20 @@ def check_event_codes(data, domain, errors): if not isinstance(events, list): return seen = {} + seen_events = {} for e in events: if not isinstance(e, dict) or "code" not in e: continue code = e["code"] + ident = (e.get("module"), e.get("name")) + if all(isinstance(x, str) for x in ident): + if ident in seen_events: + errors.append( + f"{domain}: event identity {ident} already defined — JS keeps only " + f"the last property under that name" + ) + else: + seen_events[ident] = True if code in seen: errors.append( f"{domain}: events {seen[code]!r} and {e.get('name')!r} " @@ -865,9 +889,12 @@ def check_tokens(data, domain, errors): ) dicts = data.get("doubleByte") if isinstance(dicts, list): - if len(dicts) > 256: + # FOUR, not 256: the wire exposes `DICTIONARY_0`..`DICTIONARY_3` and nothing + # else, so entries 4 and up have no selector tag and are unreachable. + if len(dicts) > 4: errors.append( - f"{domain}/doubleByte has {len(dicts)} dictionaries; the selector is one byte" + f"{domain}/doubleByte has {len(dicts)} dictionaries; the wire has four " + f"selector tags" ) for i, sub in enumerate(dicts): if isinstance(sub, list) and len(sub) > 256: @@ -876,7 +903,7 @@ def check_tokens(data, domain, errors): ) -def check_appstate_collections(data, domain, errors, proto_enums): +def check_appstate_collections(data, domain, errors, proto_enums, proto_messages): """An action's `collection` must be one the document declares. The schema is only a string. `generate_appstate_schemas` builds the `Collection` enum @@ -907,6 +934,20 @@ def check_appstate_collections(data, domain, errors, proto_enums): ) else: by_variant[variant] = c + # Action SCOPES become a generated enum the same way collections do, through the same + # `pascal_case` — so two spellings that normalise alike emit the variant twice. + by_scope = {} + for name, a in sorted(actions.items()): + if not isinstance(a, dict) or not isinstance(a.get("scope"), str): + continue + variant = pascal_case(a["scope"]) + prev = by_scope.setdefault(variant, a["scope"]) + if prev != a["scope"]: + errors.append( + f"{domain}/actions/{name}: scopes {a['scope']!r} and {prev!r} both " + f"become {variant!r}" + ) + for name, a in sorted(actions.items()): if not isinstance(a, dict): continue @@ -925,6 +966,15 @@ def check_appstate_collections(data, domain, errors, proto_enums): # encoder identify the action by one name and build its index under another. parts = a.get("indexParts") first = parts[0] if isinstance(parts, list) and parts else None + # `valueProtoType` names the `SyncActionValue` payload message. A typo or a + # removed message leaves mutation tooling unable to build what the IR declares, + # and the generator publishes the string unchanged. + vpt = a.get("valueProtoType") + if isinstance(vpt, str) and vpt not in proto_messages: + errors.append( + f"{domain}/actions/{name}: valueProtoType {vpt!r} is in no committed " + f"protobuf message" + ) # Each `valueEnumFields` value is a protobuf enum PATH, published unchanged for a # consumer to interpret. A typo or a removed enum leaves mutation tooling unable # to resolve what the IR claims — and the schema is only a string. @@ -1059,6 +1109,7 @@ def main() -> int: # nothing is not a constraint — it only looks like one because the string is present, # and the schema accepts any string. proto_enums = collect_proto_enums(root / "proto" / "WAProto.proto") + proto_messages = collect_proto_messages(root / "proto" / "WAProto.proto") docs = sorted(root.glob("*/index.json")) if not docs: @@ -1115,7 +1166,7 @@ def visit(node, path, domain=domain): check_enum_catalog_refs(data, domain, errors) check_event_codes(data, domain, errors) check_abprops(data, domain, errors) - check_appstate_collections(data, domain, errors, proto_enums) + check_appstate_collections(data, domain, errors, proto_enums, proto_messages) check_tokens(data, domain, errors) collect_unresolved_enums(data, domain, proto_enums, unresolved) From c09ba9e75cf726e73fbf8c1b069a528008d5bc72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 21:10:15 -0300 Subject: [PATCH 35/46] review: stop rejecting a legal enum alias, and check the payload field with its type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven from Codex on c9e1f2b; six applied, one reverted after measuring. The duplicate-value check was going to fail CI on correct data. `#[repr(i64)]` justifies it for WAM, where members become discriminants — but it also ran on `generated/enums/`, which is emitted as an untyped `(name, value)` slice where an upstream ALIAS is legal and preserved. Scoped to WAM. This is the second time a guard of mine would have rejected valid upstream data, and both times the reviewer caught it before WA shipped the shape. - `valueField` was entirely unverified: it must name a real `SyncActionValue` field AND that field must be declared as the `valueProtoType` message. All 62 pairs agree today. My first extraction reported all 62 as broken — a non-greedy regex stopped at the first nested `}`; the fields are read by brace depth now. - Union alternative names are compared after `pascal_case`, as collections and scopes already are. - A fixed `byteLength` outside a declared `byteMin`/`byteMax` is two claims no payload satisfies. - Two fields carrying one `enumRef` identity must carry the same table: `stanza_export` dedups by that identity with the first table winning. - Every boolean literal is a scalar fallback, not only `false`/`!1`. Reverted: dropping the `locals` filter so a nested shadow found BEFORE the outer enum still counts. The naive version broke two tests and changed `generated/` — the module body is itself a function, so its own `var`s shadowed the locals they declare, which is the over-refusal this filter exists to prevent. The finding is real; moving the collision test from collection time to lookup time is the shape of the fix, and it needs its own measurement. --- crates/wa-notif/src/actions.rs | 7 ++- scripts/lint-ir.py | 103 ++++++++++++++++++++++++++++++--- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index 421862e..a6a4066 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -2109,7 +2109,9 @@ fn guard_admits_absence(e: &Expression) -> bool { fn is_nullish(e: &Expression) -> bool { match e { Expression::NullLiteral(_) => true, - Expression::BooleanLiteral(b) => !b.value, + // EVERY boolean, not just `false`: `enabled ? map(…) : true` yields a boolean on + // the disabled path exactly as `: false` does. Neither is a collection. + Expression::BooleanLiteral(_) => true, Expression::Identifier(i) => i.name == "undefined", // A scalar sentinel is no more a collection than `null` is: `enabled ? map(…) : 0` // yields a number on the disabled path. Any literal that cannot be a list counts. @@ -2120,7 +2122,8 @@ fn is_nullish(e: &Expression) -> bool { // The minifier writes `undefined` as `void 0`. oxc_ast::ast::UnaryOperator::Void => true, // ...and as . - oxc_ast::ast::UnaryOperator::LogicalNot => as_int(&u.argument) == Some(1), + // `!0` and `!1` are how the minifier writes `true` and `false`. + oxc_ast::ast::UnaryOperator::LogicalNot => as_int(&u.argument).is_some(), // ...and `false` as `!1`. _ => false, }, diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index a366843..7d564f0 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -277,6 +277,34 @@ def walk_(node, trail): walk_(data, []) +def collect_sync_action_fields(path): + """`fieldName -> MessageType` for the direct fields of `message SyncActionValue`. + + Scanned by brace DEPTH, not by a non-greedy match: the message contains nested ones, + and a regex stopping at the first `}` reported all 62 fields as missing. + """ + try: + text = path.read_text() + except OSError: + return {} + out, depth, inside = {}, 0, False + for line in text.splitlines(): + stripped = line.split("//")[0].strip() + if not inside and re.match(r"message\s+SyncActionValue\s*\{", stripped): + inside, depth = True, 1 + continue + if not inside: + continue + if depth == 1: + m = re.match(r"(?:optional|required|repeated)\s+([\w.]+)\s+(\w+)\s*=", stripped) + if m: + out[m.group(2)] = m.group(1) + depth += stripped.count("{") - stripped.count("}") + if depth <= 0: + break + return out + + def collect_proto_messages(path): """`Outer.Inner` for every protobuf MESSAGE, in the same two forms as the enums. @@ -426,14 +454,19 @@ def check_field(f, path, domain, errors, counts, proto_enums): for i, v in enumerate(variants): if isinstance(v, dict) and v.get("name") == "": errors.append(f"{path}: union alternative {i} has an empty name") + # NORMALIZED, as the generator sees them: `collect_union` runs each through + # `pascal_case`, so `fooBar` and `foo-bar` emit one variant twice. Same rule + # already applied to appstate collections and scopes. names = [ - v.get("name") + pascal_case(v["name"]) for v in variants if isinstance(v, dict) and isinstance(v.get("name"), str) and v["name"] ] repeated = sorted({n for n in names if names.count(n) > 1}) if repeated: - errors.append(f"{path}: union alternatives repeat a name ({repeated})") + errors.append( + f"{path}: union alternative names collide once normalized ({repeated})" + ) # Each pair is the two bounds of ONE range accessor. Inverted is a contradiction; # so is half of one — the schema permits either key alone, but a consumer handed @@ -458,6 +491,15 @@ def check_field(f, path, domain, errors, counts, proto_enums): if lo in f and t != owner: errors.append(f"{path}: {lo}/{hi} on a {t!r} field, not {owner!r}") + # A fixed length outside the declared range is two claims no payload can satisfy. + bl = f.get("byteLength") + if isinstance(bl, int) and not isinstance(bl, bool): + lo, hi = f.get("byteMin"), f.get("byteMax") + if isinstance(lo, int) and not isinstance(lo, bool) and bl < lo: + errors.append(f"{path}: byteLength {bl} is below byteMin {lo}") + if isinstance(hi, int) and not isinstance(hi, bool) and bl > hi: + errors.append(f"{path}: byteLength {bl} is above byteMax {hi}") + # An echo rule with no path says "this equals something in the request" and # then does not say what. if "referencePath" in f and not f["referencePath"]: @@ -475,7 +517,7 @@ def check_field(f, path, domain, errors, counts, proto_enums): ) -def check_enum_ref(node, path, errors): +def check_enum_ref(node, path, errors, seen_refs=None): """An `enumRef` anywhere, not only on a `ParsedField`. Request-side and stanza attributes carry one too, keyed by `kind` rather than `type`, @@ -501,6 +543,25 @@ def check_enum_ref(node, path, errors): if repeated: errors.append(f"{path}: enumRef {ref.get('name')!r} defines {repeated} more than once") + # `(module, name)` denotes ONE exported enum. `stanza_export` dedups references by it + # with the first table winning, so two fields carrying that identity with different + # variants get different constraints depending on traversal order. + if seen_refs is None: + return + ident = (ref.get("module"), ref.get("name")) + if not all(isinstance(x, str) for x in ident): + return + table = tuple( + (v.get("name"), repr(v.get("value"))) + for v in ref["variants"] + if isinstance(v, dict) + ) + prev = seen_refs.setdefault(ident, (table, path)) + if prev[0] != table: + errors.append( + f"{path}: enumRef {ident} disagrees with the table at {prev[1]}" + ) + def check_const_bytes(node, path, errors): """A request-side `constBytes` pin: the same invariant as a response `literalValue` @@ -598,9 +659,12 @@ def check_enum_catalog_refs(data, domain, errors): f"{e.get('valueKind')!r} but variant " f"{(v.get('name') or v.get('key'))!r} carries {val!r}" ) - # Integer members become discriminants of a `#[repr(i64)]` Rust enum, where a - # repeat is E0081 — so a catalog the linter certifies would not compile. - vals = [ + # WAM ONLY. There, integer members become discriminants of a `#[repr(i64)]` enum + # and a repeat is E0081. `generated/enums/` is emitted as an untyped `(name, + # value)` slice, where an upstream ALIAS — two names for one value — is legal and + # preserved; rejecting it would have failed CI on correct data the next time WA + # shipped one. The `repr` argument belongs to the WAM generator, not to both. + vals = [] if domain != "wam" else [ v.get("value") for v in e.get("variants") or [] if isinstance(v, dict) @@ -903,7 +967,7 @@ def check_tokens(data, domain, errors): ) -def check_appstate_collections(data, domain, errors, proto_enums, proto_messages): +def check_appstate_collections(data, domain, errors, proto_enums, proto_messages, sync_fields): """An action's `collection` must be one the document declares. The schema is only a string. `generate_appstate_schemas` builds the `Collection` enum @@ -975,6 +1039,23 @@ def check_appstate_collections(data, domain, errors, proto_enums, proto_messages f"{domain}/actions/{name}: valueProtoType {vpt!r} is in no committed " f"protobuf message" ) + # The FIELD too, and that the two agree: a consumer places the message into the + # `SyncActionValue` field this names, so a wrong name — or a real name typed as a + # different message — leaves the payload unbuildable. Checking only that the + # message exists somewhere left `valueField` entirely unverified. + vf = a.get("valueField") + if isinstance(vf, str) and isinstance(vpt, str): + declared = sync_fields.get(vf) + if declared is None: + errors.append( + f"{domain}/actions/{name}: valueField {vf!r} is not a field of " + f"SyncActionValue" + ) + elif declared.split(".")[-1] != vpt.split(".")[-1]: + errors.append( + f"{domain}/actions/{name}: valueField {vf!r} is declared " + f"{declared!r}, not {vpt!r}" + ) # Each `valueEnumFields` value is a protobuf enum PATH, published unchanged for a # consumer to interpret. A typo or a removed enum leaves mutation tooling unable # to resolve what the IR claims — and the schema is only a string. @@ -1104,12 +1185,14 @@ def main() -> int: errors: list[str] = [] counts = dict.fromkeys(BASELINE, 0) unresolved: dict[str, int] = defaultdict(int) + seen_refs: dict[tuple, tuple] = {} # The protobuf enums an appstate `protoEnum` may name. A path that resolves to # nothing is not a constraint — it only looks like one because the string is present, # and the schema accepts any string. proto_enums = collect_proto_enums(root / "proto" / "WAProto.proto") proto_messages = collect_proto_messages(root / "proto" / "WAProto.proto") + sync_fields = collect_sync_action_fields(root / "proto" / "WAProto.proto") docs = sorted(root.glob("*/index.json")) if not docs: @@ -1154,7 +1237,7 @@ def visit(node, path, domain=domain): if "kind" in node: check_assertion(node, f"{domain}{path}", errors) # Independent of the field gate — see each function's note. - check_enum_ref(node, f"{domain}{path}", errors) + check_enum_ref(node, f"{domain}{path}", errors, seen_refs) check_const_bytes(node, f"{domain}{path}", errors) check_action_keys(node, f"{domain}{path}", errors) check_variant_groups(node, f"{domain}{path}", errors) @@ -1166,7 +1249,9 @@ def visit(node, path, domain=domain): check_enum_catalog_refs(data, domain, errors) check_event_codes(data, domain, errors) check_abprops(data, domain, errors) - check_appstate_collections(data, domain, errors, proto_enums, proto_messages) + check_appstate_collections( + data, domain, errors, proto_enums, proto_messages, sync_fields + ) check_tokens(data, domain, errors) collect_unresolved_enums(data, domain, proto_enums, unresolved) From ee948bc62781e26a883e1c15a06e50dcc384db4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 21:16:44 -0300 Subject: [PATCH 36/46] review: read loop and switch headers in the enclosing scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A header expression is evaluated before the construct's own binding exists, so it resolves where the construct is written. The switch discriminant and the `for-in` /`for-of` right-hand side were being visited under the pushed lexical scope, which could only refuse a resolvable composition — safe, but the point of modelling scopes is not to guess in either direction. A `for` init is left inside, being the binding itself. Shipped without a regression test, deliberately. The one written for it passed with the fix REMOVED: reaching the header path needs the composition to be the header expression and its result to be exported, and the resolver's supported export forms make that awkward to express. A test verified to prove nothing is worse than none, so this says so instead. --- crates/wa-enums/src/lib.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 61edcb3..a64b81b 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -754,7 +754,10 @@ impl<'a> Visit<'a> for NamedResolver { oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) => vec![&**d], _ => vec![], }; - self.with_lexical(&[], &left, |me| walk::walk_for_in_statement(me, f)); + // The RIGHT-hand expression is evaluated before the loop binding exists, so it + // reads in the enclosing scope; only the body sees the binding. + self.visit_expression(&f.right); + self.with_lexical(&[], &left, |me| me.visit_statement(&f.body)); } fn visit_switch_statement(&mut self, sw: &oxc_ast::ast::SwitchStatement<'a>) { @@ -766,11 +769,23 @@ impl<'a> Visit<'a> for NamedResolver { collect_lexical(&case.consequent, &mut lexical); } lexical.retain(|n| self.locals.contains_key(n.as_str())); + // The DISCRIMINANT is read where the switch is written, before any case binding + // exists, so it belongs to the enclosing scope. Visiting it under the pushed one + // only refused a resolvable composition — safe, but the point of modelling scopes + // is not to guess in either direction. + self.visit_expression(&sw.discriminant); let pushed = !lexical.is_empty(); if pushed { self.param_scopes.push(lexical); } - walk::walk_switch_statement(self, sw); + for case in &sw.cases { + if let Some(test) = &case.test { + self.visit_expression(test); + } + for stmt in &case.consequent { + self.visit_statement(stmt); + } + } if pushed { self.param_scopes.pop(); } @@ -787,7 +802,10 @@ impl<'a> Visit<'a> for NamedResolver { oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) => vec![&**d], _ => vec![], }; - self.with_lexical(&[], &left, |me| walk::walk_for_of_statement(me, f)); + // The RIGHT-hand expression is evaluated before the loop binding exists, so it + // reads in the enclosing scope; only the body sees the binding. + self.visit_expression(&f.right); + self.with_lexical(&[], &left, |me| me.visit_statement(&f.body)); } fn visit_catch_clause(&mut self, c: &oxc_ast::ast::CatchClause<'a>) { // `catch (e)` binds `e` for the handler body exactly as a parameter binds it for From 1987c5ae12d97869f38f390c2a8646b3ec527c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 21:30:06 -0300 Subject: [PATCH 37/46] review: read a mapped collection from either ternary branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mapped_child` calls `strip_guard`, which always follows the consequent, so `disabled ? 0 : node.mapChildrenWithTag(…)` — the same shape written the other way round — dropped the child ENTIRELY. The absence check beside it already inspects both branches, so the two disagreed about which shapes exist. Reversing an equivalent ternary must not erase the collection. Notification types and stanza tags become generated Rust identifiers through the same `pascal_case` as collections, scopes and union alternatives, so two wire spellings that normalise alike emit one variant twice. Not applied: resolving each `valueEnumFields` KEY against its payload message's fields. The pair check landed last round for `valueField`/`valueProtoType`; extending it to the enum keys means walking nested field paths inside an arbitrary message, which the brace-depth scanner does not do and which I would rather build with its own measurement than bolt on. Recorded on the thread. --- crates/wa-notif/src/actions.rs | 14 ++++++++++---- crates/wa-notif/src/tests.rs | 8 +++++++- scripts/lint-ir.py | 25 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index a6a4066..56d7239 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -1945,10 +1945,16 @@ fn mapped_child<'b, 'a>( scope: &Scope<'b, 'a>, ctx: &ArmCtx, ) -> Option { - let call = as_call(strip_guard(e))?; - if callee_method(call)? != wap::MAP_CHILDREN_WITH_TAG { - return None; - } + // Either branch may carry the map. `strip_guard` always follows the consequent, so + // `disabled ? 0 : node.mapChildrenWithTag(…)` — the same shape written the other way + // round — dropped the child entirely, while the absence check beside it already looks + // at both. Reversing an equivalent ternary must not erase the collection. + let call = as_call(strip_guard(e)) + .or_else(|| match e { + Expression::ConditionalExpression(c) => as_call(strip_guard(&c.alternate)), + _ => None, + }) + .filter(|c| callee_method(c) == Some(wap::MAP_CHILDREN_WITH_TAG))?; let wire_tag = arg_expr(call.arguments.first()?).and_then(as_string_lit)?; let mut fields = Vec::new(); for arg in &call.arguments { diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 02de3d1..254f556 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -704,7 +704,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr fromFormal: outerH(t, realCb), falsyFallback: t.hasChild("ff") ? t.mapChildrenWithTag("falsy_user", function(x){ return {id:x.attrUserJid("jid")}; }) : !1, bareFalse: t.hasChild("bf") ? t.mapChildrenWithTag("bare_user", function(x){ return {id:x.attrUserJid("jid")}; }) : false, - scalarFb: t.hasChild("sf") ? t.mapChildrenWithTag("scalar_user", function(x){ return {id:x.attrUserJid("jid")}; }) : 0}; + scalarFb: t.hasChild("sf") ? t.mapChildrenWithTag("scalar_user", function(x){ return {id:x.attrUserJid("jid")}; }) : 0, + reversed_: t.hasChild("rv") ? 0 : t.mapChildrenWithTag("reversed_user", function(x){ return {id:x.attrUserJid("jid")}; })}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -975,6 +976,11 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { !child("scalarFb").required, "`: 0` on the far side is an absent collection too" ); + // The same ternary written the other way round must yield the same child, not none: + // `strip_guard` follows the consequent, so the map in the ALTERNATE was invisible. + let rev = child("reversed_"); + assert_eq!(rev.wire_tag, "reversed_user"); + assert!(!rev.required, "the scalar consequent is the absent path"); // The guard must not cost the child's contents. assert_eq!(child("anded").wire_tag, "anded_user"); diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 7d564f0..73c6aca 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -928,6 +928,30 @@ def pascal_case(name): return "".join(w[:1].upper() + w[1:] for w in s2.split() if w) +def check_notif_identifiers(data, domain, errors): + """Notification types and stanza tags become generated Rust identifiers. + + `emit_notification_type_enum` and `StanzaTag` run each through `pascal_case`, so two + wire spellings that normalise alike emit one variant — and one match arm — twice. + Same rule already applied to appstate collections, scopes and union alternatives. + """ + for key, label in (("notifications", "type"), ("stanzaTags", "tag")): + items = data.get(key) + if not isinstance(items, list): + continue + by_variant = {} + for it in items: + raw = it.get(label) if label and isinstance(it, dict) else it + if not isinstance(raw, str): + continue + variant = pascal_case(raw) + prev = by_variant.setdefault(variant, raw) + if prev != raw: + errors.append( + f"{domain}/{key}: {raw!r} and {prev!r} both become {variant!r}" + ) + + def check_tokens(data, domain, errors): """`singleByte[tag]` and `doubleByte[dict][index]` are direct lookups by a wire BYTE. @@ -1253,6 +1277,7 @@ def visit(node, path, domain=domain): data, domain, errors, proto_enums, proto_messages, sync_fields ) check_tokens(data, domain, errors) + check_notif_identifiers(data, domain, errors) collect_unresolved_enums(data, domain, proto_enums, unresolved) ok = True From ab74276317af14df09e3fefa8d7d49697bdfea18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 21:39:51 -0300 Subject: [PATCH 38/46] review: require the export receiver to be unshadowed at the assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restricting exports to the factory's parameters compared SPELLING only, so a nested `function f(i) { i.OUT = {…} }` — writing to its own parameter — was still read as a module export and could publish a private object as a closed protocol enum. Frame 0 of the scope stack is the module factory itself, whose parameters ARE the receivers, so a shadowing is any frame after it that carries the name. Checking `param_shadows` directly would have refused every real export. --- crates/wa-enums/src/lib.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index a64b81b..a083cc5 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -486,7 +486,17 @@ impl NamedResolver { if self.factory_params.is_empty() { return true; } - as_identifier(e).is_some_and(|n| self.factory_params.contains(n)) + let Some(name) = as_identifier(e) else { + return false; + }; + if !self.factory_params.contains(name) { + return false; + } + // Spelling is not enough: `function f(i) { i.OUT = {…} }` writes to the NESTED + // parameter, not to the module's export object. Frame 0 is the module factory + // itself — its parameters ARE the receivers — so a rebinding is any frame after + // it that carries the name. + !self.param_scopes.iter().skip(1).any(|s| s.contains(name)) } /// Push a lexical scope for `stmts`' own `let`/`const`, if any collide with a local. @@ -1486,6 +1496,23 @@ mod tests { ); } + // A nested function that SHADOWS the export receiver writes to its own parameter. + let shadowed_receiver = r#"__d("M",[],(function(t,n,r,o,a,i){ + function f(i){ i.OUT={A:"a",B:"b"} } + }),1);"#; + assert!(resolve_named_enum(shadowed_receiver, "M", "OUT").is_none()); + // ...while the module's own receiver still exports. + let real = r#"__d("M",[],(function(t,n,r,o,a,i){ + function f(){ i.OUT={A:"a",B:"b"} } + }),1);"#; + assert_eq!( + resolve_named_enum(real, "M", "OUT") + .expect("the module's export object still works from a nested function") + .variants + .len(), + 2 + ); + // 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){ From 7c1d92c6af6e6945f8bf8c9f21ddc51a88561091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 21:54:22 -0300 Subject: [PATCH 39/46] review: unwrap the paren, widen the receiver shadow, watch every extends call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from Codex on ab74276, each a gap in the fix that preceded it. - The alternate-branch search matched on the expression directly, so `(cond ? 0 : map(…))` answered "not a conditional". Third time this wrapper has cost this file something; it is peeled up front now. - The receiver-shadow check reads the scope stack, whose frames are filtered to names that collide with an ENUM local — and a receiver name is not an enum, so `function f(){ var i = {}; i.OUT = … }` walked straight through. The filter now keeps a name that collides with either. - `extends(e, {B:"b"})` mutates `e` wherever it appears, and only a declarator's initializer was watched; a bare expression statement does it too. --- crates/wa-enums/src/lib.rs | 41 +++++++++++++++++++++++++++++----- crates/wa-notif/src/actions.rs | 16 +++++++++++-- crates/wa-notif/src/tests.rs | 5 ++++- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index a083cc5..7ecb1da 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -496,6 +496,8 @@ impl NamedResolver { // parameter, not to the module's export object. Frame 0 is the module factory // itself — its parameters ARE the receivers — so a rebinding is any frame after // it that carries the name. + // `param_scopes` frames beyond the module factory carry parameters AND hoisted + // `var`s, so `function f(){ var i = {}; i.OUT = … }` is caught by the same test. !self.param_scopes.iter().skip(1).any(|s| s.contains(name)) } @@ -632,6 +634,18 @@ impl<'a> Visit<'a> for NamedResolver { self.in_var_decl = was; } + fn visit_expression_statement(&mut self, st: &oxc_ast::ast::ExpressionStatement<'a>) { + // `extends(e, {B:"b"})` mutates `e` wherever it is written, not only as a + // declarator's initializer — a bare expression statement does it too, and the + // declarator path below could not see that. + if let Some(target) = Self::mutated_extends_target(&st.expression) + && !self.param_shadows(target) + { + self.shadowed.insert(target.to_string()); + } + walk::walk_expression_statement(self, st); + } + fn visit_variable_declarator(&mut self, d: &VariableDeclarator<'a>) { // `var x = extends(e, {B:"b"})` MUTATES `e`. `merge_extends` already refuses to // publish `x`, but `e` itself kept its pre-merge body and a later `i.BASE = e` @@ -740,11 +754,14 @@ impl<'a> Visit<'a> for NamedResolver { // a function, so an unconditional sweep made its own `var e = {…}` shadow the // local it declares — every enum in the catalog refused itself. Parameters // stay unconditional: they can never BE the module local. - hoisted.extend( - declared - .into_iter() - .filter(|n| self.locals.contains_key(n.as_str())), - ); + // Kept when the name collides with an enum local — the shadow that matters + // for operand lookup — OR with a factory parameter, which is what + // `is_export_object` consults: `function f(){ var i = {}; i.OUT = … }` writes + // to a private object, and filtering on `locals` alone let it through because + // a receiver name is not an enum. + hoisted.extend(declared.into_iter().filter(|n| { + self.locals.contains_key(n.as_str()) || self.factory_params.contains(n) + })); } self.param_scopes.push(hoisted); walk::walk_function(self, f, flags); @@ -1513,6 +1530,20 @@ mod tests { 2 ); + // A `var` that shadows the export receiver is the same hazard as a parameter. + let var_receiver = r#"__d("M",[],(function(t,n,r,o,a,i){ + function f(){ var i={}; i.OUT={A:"a",B:"b"} } + }),1);"#; + assert!(resolve_named_enum(var_receiver, "M", "OUT").is_none()); + + // `extends(e, …)` mutates `e` as a bare STATEMENT too, not only as an initializer. + let stmt_mutation = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + babelHelpers.extends(e,{B:"b"}); + i.BASE=e + }),1);"#; + assert!(resolve_named_enum(stmt_mutation, "M", "BASE").is_none()); + // 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){ diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index 56d7239..3e4ffba 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -1949,8 +1949,12 @@ fn mapped_child<'b, 'a>( // `disabled ? 0 : node.mapChildrenWithTag(…)` — the same shape written the other way // round — dropped the child entirely, while the absence check beside it already looks // at both. Reversing an equivalent ternary must not erase the collection. - let call = as_call(strip_guard(e)) - .or_else(|| match e { + // Unwrapped first: `(cond ? 0 : map(…))` hides the conditional behind a paren, and + // matching on the wrapper answers "not a conditional". Third time this wrapper has + // cost this file something. + let unwrapped = strip_parens(e); + let call = as_call(strip_guard(unwrapped)) + .or_else(|| match unwrapped { Expression::ConditionalExpression(c) => as_call(strip_guard(&c.alternate)), _ => None, }) @@ -2107,6 +2111,14 @@ fn guard_admits_absence(e: &Expression) -> bool { } } +/// Peel `(…)` wrappers, which the minifier and `babelHelpers` output both produce. +fn strip_parens<'b, 'a>(mut e: &'b Expression<'a>) -> &'b Expression<'a> { + while let Expression::ParenthesizedExpression(p) = e { + e = &p.expression; + } + e +} + /// Whether the expression is a literal absence — the far side of a presence guard. /// /// `false` counts. `enabled ? map(…) : !1` yields a boolean rather than a collection on diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 254f556..5abc63a 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -705,7 +705,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr falsyFallback: t.hasChild("ff") ? t.mapChildrenWithTag("falsy_user", function(x){ return {id:x.attrUserJid("jid")}; }) : !1, bareFalse: t.hasChild("bf") ? t.mapChildrenWithTag("bare_user", function(x){ return {id:x.attrUserJid("jid")}; }) : false, scalarFb: t.hasChild("sf") ? t.mapChildrenWithTag("scalar_user", function(x){ return {id:x.attrUserJid("jid")}; }) : 0, - reversed_: t.hasChild("rv") ? 0 : t.mapChildrenWithTag("reversed_user", function(x){ return {id:x.attrUserJid("jid")}; })}; + reversed_: t.hasChild("rv") ? 0 : t.mapChildrenWithTag("reversed_user", function(x){ return {id:x.attrUserJid("jid")}; }), + parenRev: (t.hasChild("pr") ? 0 : t.mapChildrenWithTag("paren_rev_user", function(x){ return {id:x.attrUserJid("jid")}; }))}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -981,6 +982,8 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { let rev = child("reversed_"); assert_eq!(rev.wire_tag, "reversed_user"); assert!(!rev.required, "the scalar consequent is the absent path"); + // ...and a paren around it must not hide the conditional from the branch search. + assert_eq!(child("parenRev").wire_tag, "paren_rev_user"); // The guard must not cost the child's contents. assert_eq!(child("anded").wire_tag, "anded_user"); From ef41dfd15edced5a0ff592bdf5a84d84f634ed35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 22:06:44 -0300 Subject: [PATCH 40/46] review: mirror the receiver filter into arrows, and refuse a contradicting export Three from Codex on 7c1d92c, each a place the preceding fix did not reach. - The two-way receiver filter (enum local OR factory parameter) went into the function prescan and not the arrow one. - `tmp = extends(e, {B:"b"})` is an expression statement wrapping an ASSIGNMENT, so reading the statement's expression found the assignment rather than the call that mutates. - An export written twice with different bodies published whichever write came first. Which one a consumer sees is not something this pass can determine, so it now determines nothing; an identical repeat is still resolved. --- crates/wa-enums/src/lib.rs | 70 +++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 7ecb1da..100897a 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -323,6 +323,9 @@ pub fn resolve_named_enum(module_slice: &str, module: &str, name: &str) -> Optio r.factory_params = module_factory_params(&ret.program); r.visit_program(&ret.program); resolve_pending(&mut r); + if r.conflicting.contains(name) { + return None; + } let (value_kind, variants) = r.exports.get(name)?.clone(); Some(InternalEnumDef { name: name.to_string(), @@ -453,6 +456,9 @@ struct NamedResolver { /// against it. Empty means "could not tell", and then nothing is narrowed. factory_params: HashSet, exports: HashMap, + /// Exports written more than once with DIFFERENT bodies — refused rather than + /// resolved to whichever write this pass happened to see first. + conflicting: HashSet, pending: Vec<(String, String)>, } @@ -465,6 +471,7 @@ impl NamedResolver { in_var_decl: false, factory_params: HashSet::new(), exports: HashMap::new(), + conflicting: HashSet::new(), pending: Vec::new(), } } @@ -638,7 +645,14 @@ impl<'a> Visit<'a> for NamedResolver { // `extends(e, {B:"b"})` mutates `e` wherever it is written, not only as a // declarator's initializer — a bare expression statement does it too, and the // declarator path below could not see that. - if let Some(target) = Self::mutated_extends_target(&st.expression) + // Under an assignment too: `tmp = extends(e, {B:"b"})` is an expression statement + // wrapping an assignment, so looking at the statement's expression alone found + // the assignment and not the call that does the mutating. + let inner = match &st.expression { + Expression::AssignmentExpression(a) => &a.right, + other => other, + }; + if let Some(target) = Self::mutated_extends_target(inner) && !self.param_shadows(target) { self.shadowed.insert(target.to_string()); @@ -863,10 +877,12 @@ impl<'a> Visit<'a> for NamedResolver { let mut declared = HashSet::new(); collect_lexical(&f.body.statements, &mut declared); collect_hoisted(&f.body.statements, &mut declared); + // Same two-way filter the function body uses: an enum local OR a factory + // parameter, the latter because a receiver name is never an enum. hoisted.extend( - declared - .into_iter() - .filter(|n| self.locals.contains_key(n.as_str())), + declared.into_iter().filter(|n| { + self.locals.contains_key(n.as_str()) || self.factory_params.contains(n) + }), ); self.param_scopes.push(hoisted); walk::walk_arrow_function_expression(self, f); @@ -879,7 +895,17 @@ impl<'a> Visit<'a> for NamedResolver { && self.is_export_object(m.object()) { if let Some(data) = enum_object(&a.right).and_then(parse_enum) { - self.exports.entry(prop.to_string()).or_insert(data); + // First write wins, but a SECOND write with a different body means the + // module publishes one name for two value sets and this pass cannot say + // which a consumer will see. Refusing beats picking. + match self.exports.get(prop) { + Some(prev) if *prev != data => { + self.conflicting.insert(prop.to_string()); + } + _ => { + self.exports.entry(prop.to_string()).or_insert(data); + } + } } else if prop == "exports" && let Some(o) = as_object(&a.right) { @@ -1544,6 +1570,40 @@ mod tests { }),1);"#; assert!(resolve_named_enum(stmt_mutation, "M", "BASE").is_none()); + // An ARROW shadowing the receiver is the same hazard as a function doing it. + let arrow_receiver = r#"__d("M",[],(function(t,n,r,o,a,i){ + var f=()=>{ var i={}; i.OUT={A:"a",B:"b"} }; + }),1);"#; + assert!(resolve_named_enum(arrow_receiver, "M", "OUT").is_none()); + + // `tmp = extends(e, …)` mutates `e` just as the bare call does. + let assigned_mutation = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"},tmp; + tmp=babelHelpers.extends(e,{B:"b"}); + i.BASE=e + }),1);"#; + assert!(resolve_named_enum(assigned_mutation, "M", "BASE").is_none()); + + // Two writes to one export with DIFFERENT bodies: which one a consumer sees is + // not something this pass can say, so it says nothing. + let conflicting = r#"__d("M",[],(function(t,n,r,o,a,i){ + i.OUT={A:"a"}; + i.OUT={X:"x"}; + }),1);"#; + assert!(resolve_named_enum(conflicting, "M", "OUT").is_none()); + // ...but an identical repeat is not a conflict. + let same_twice = r#"__d("M",[],(function(t,n,r,o,a,i){ + i.OUT={A:"a",B:"b"}; + i.OUT={A:"a",B:"b"}; + }),1);"#; + assert_eq!( + resolve_named_enum(same_twice, "M", "OUT") + .expect("an identical repeat still resolves") + .variants + .len(), + 2 + ); + // 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){ From 322fd44f2c31c02ebe73973b39d1a12a2ea9956f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 22:15:19 -0300 Subject: [PATCH 41/46] review: filter each mapped-child candidate before falling back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.filter()` ran on the COMBINED result, so `or_else` fired only when the consequent was not a call at all. `cond ? otherCall() : map(…)` therefore discarded the wrong call and never looked at the alternate — and both fixtures used `0` as the consequent, where `as_call` already returns `None`, so the tests could not see the gap they were written for. Each candidate is filtered on its own now, with a call-consequent case added. `parenRev` asserted only its `wire_tag`, which would have passed with the collection wrongly marked always-present. It asserts the absence too. --- crates/wa-notif/src/actions.rs | 16 ++++++++++++---- crates/wa-notif/src/tests.rs | 16 ++++++++++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index 3e4ffba..f481ee9 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -1953,12 +1953,20 @@ fn mapped_child<'b, 'a>( // matching on the wrapper answers "not a conditional". Third time this wrapper has // cost this file something. let unwrapped = strip_parens(e); + // Each candidate is filtered BEFORE falling back. Filtering the combined result made + // `or_else` fire only when the consequent was not a call at all, so + // `cond ? otherCall() : map(…)` discarded the wrong call and never looked at the + // alternate — and the tests, whose consequent was `0`, could not see it. + let is_map = + |c: &&oxc_ast::ast::CallExpression| callee_method(c) == Some(wap::MAP_CHILDREN_WITH_TAG); let call = as_call(strip_guard(unwrapped)) + .filter(is_map) .or_else(|| match unwrapped { - Expression::ConditionalExpression(c) => as_call(strip_guard(&c.alternate)), + Expression::ConditionalExpression(c) => { + as_call(strip_guard(&c.alternate)).filter(is_map) + } _ => None, - }) - .filter(|c| callee_method(c) == Some(wap::MAP_CHILDREN_WITH_TAG))?; + })?; let wire_tag = arg_expr(call.arguments.first()?).and_then(as_string_lit)?; let mut fields = Vec::new(); for arg in &call.arguments { @@ -2131,9 +2139,9 @@ fn is_nullish(e: &Expression) -> bool { // the disabled path exactly as `: false` does. Neither is a collection. Expression::BooleanLiteral(_) => true, Expression::Identifier(i) => i.name == "undefined", + Expression::NumericLiteral(_) | Expression::StringLiteral(_) => true, // A scalar sentinel is no more a collection than `null` is: `enabled ? map(…) : 0` // yields a number on the disabled path. Any literal that cannot be a list counts. - Expression::NumericLiteral(_) | Expression::StringLiteral(_) => true, // A scalar sentinel is no more a collection than `null` is: `enabled ? map(…) : 0` // yields a number on the disabled path. Any literal that cannot be a list counts. Expression::UnaryExpression(u) => match u.operator { diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 5abc63a..499d5d8 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -627,6 +627,7 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr function parseP(x){ return {aliased:x.attrString("via_alias")}; } function hx(e){ return {wrongFormal:e.attrString("nope")}; } function realCb(e){ return {rightFormal:e.attrString("ok2")}; } + function other(e){ return null; } function outerH(node, hx){ return hx(node); } function mk(v){ return {chained:v}; } function mk2(v){ return mk(v); } @@ -706,7 +707,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr bareFalse: t.hasChild("bf") ? t.mapChildrenWithTag("bare_user", function(x){ return {id:x.attrUserJid("jid")}; }) : false, scalarFb: t.hasChild("sf") ? t.mapChildrenWithTag("scalar_user", function(x){ return {id:x.attrUserJid("jid")}; }) : 0, reversed_: t.hasChild("rv") ? 0 : t.mapChildrenWithTag("reversed_user", function(x){ return {id:x.attrUserJid("jid")}; }), - parenRev: (t.hasChild("pr") ? 0 : t.mapChildrenWithTag("paren_rev_user", function(x){ return {id:x.attrUserJid("jid")}; }))}; + parenRev: (t.hasChild("pr") ? 0 : t.mapChildrenWithTag("paren_rev_user", function(x){ return {id:x.attrUserJid("jid")}; })), + callRev: t.hasChild("cr") ? other(t) : t.mapChildrenWithTag("call_rev_user", function(x){ return {id:x.attrUserJid("jid")}; })}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -983,7 +985,17 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { assert_eq!(rev.wire_tag, "reversed_user"); assert!(!rev.required, "the scalar consequent is the absent path"); // ...and a paren around it must not hide the conditional from the branch search. - assert_eq!(child("parenRev").wire_tag, "paren_rev_user"); + // A CALL consequent is the case the `0` fixtures could not reach: filtering the + // combined result made the fallback fire only when the consequent was not a call. + assert_eq!(child("callRev").wire_tag, "call_rev_user"); + + let paren_rev = child("parenRev"); + assert_eq!(paren_rev.wire_tag, "paren_rev_user"); + assert!( + !paren_rev.required, + "the paren must not cost the absence either — asserting only the tag would pass \ + with the collection wrongly marked always-present" + ); // The guard must not cost the child's contents. assert_eq!(child("anded").wire_tag, "anded_user"); From 4e7d5c58ba6d374f5099de0f9632e76dc0bfdc83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 22:30:38 -0300 Subject: [PATCH 42/46] review: search nested branches for the mapped call, and pin accessor semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from Codex on 322fd44. The branch search looked one level down, so `a ? x : (b ? y : map(…))` found the inner conditional rather than the map inside it. `find_mapped_call` now recurses through both sides; each step descends into a strictly smaller node. Two linter checks, both of which had to be built around what the data actually holds rather than the obvious rule: - The accessor must agree with the declared type — but as a SET per accessor, not one type: `attrInt` legitimately backs `integer`, `timestamp` and `timestamp_millis`, and a one-to-one map would have failed CI on 27 correct fields. - A `byteLength` belongs to an accessor that has one — checked only where a `method` is present, because 20 request-side `constBytes` pins carry the length with no accessor at all. That is the third and fourth time a guard here would have rejected valid data if written to the obvious rule; measuring the committed IR first is what caught both. --- crates/wa-notif/src/actions.rs | 40 +++++++++++++++++++++------------- crates/wa-notif/src/tests.rs | 6 ++++- scripts/lint-ir.py | 30 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index f481ee9..75aea79 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -1952,21 +1952,11 @@ fn mapped_child<'b, 'a>( // Unwrapped first: `(cond ? 0 : map(…))` hides the conditional behind a paren, and // matching on the wrapper answers "not a conditional". Third time this wrapper has // cost this file something. - let unwrapped = strip_parens(e); - // Each candidate is filtered BEFORE falling back. Filtering the combined result made - // `or_else` fire only when the consequent was not a call at all, so - // `cond ? otherCall() : map(…)` discarded the wrong call and never looked at the - // alternate — and the tests, whose consequent was `0`, could not see it. - let is_map = - |c: &&oxc_ast::ast::CallExpression| callee_method(c) == Some(wap::MAP_CHILDREN_WITH_TAG); - let call = as_call(strip_guard(unwrapped)) - .filter(is_map) - .or_else(|| match unwrapped { - Expression::ConditionalExpression(c) => { - as_call(strip_guard(&c.alternate)).filter(is_map) - } - _ => None, - })?; + // The search filters each candidate on its own and descends into nested branches — + // see `find_mapped_call`. Filtering a combined result made the fallback fire only + // when the consequent was not a call at all, and looking one level down found an + // inner conditional rather than the map inside it. + let call = find_mapped_call(e)?; let wire_tag = arg_expr(call.arguments.first()?).and_then(as_string_lit)?; let mut fields = Vec::new(); for arg in &call.arguments { @@ -2119,6 +2109,26 @@ fn guard_admits_absence(e: &Expression) -> bool { } } +/// The `mapChildrenWithTag` call reachable from `e` through guards and ternary branches. +/// +/// Both sides, RECURSIVELY: `a ? x : (b ? y : map(…))` nests, and looking one level down +/// found the inner conditional rather than the map. Each step descends into a strictly +/// smaller node, so it terminates on the expression's own depth. +fn find_mapped_call<'b, 'a>(e: &'b Expression<'a>) -> Option<&'b oxc_ast::ast::CallExpression<'a>> { + let is_map = + |c: &&oxc_ast::ast::CallExpression| callee_method(c) == Some(wap::MAP_CHILDREN_WITH_TAG); + let e = strip_parens(e); + if let Some(c) = as_call(strip_guard(e)).filter(is_map) { + return Some(c); + } + match e { + Expression::ConditionalExpression(c) => { + find_mapped_call(&c.alternate).or_else(|| find_mapped_call(&c.consequent)) + } + _ => None, + } +} + /// Peel `(…)` wrappers, which the minifier and `babelHelpers` output both produce. fn strip_parens<'b, 'a>(mut e: &'b Expression<'a>) -> &'b Expression<'a> { while let Expression::ParenthesizedExpression(p) = e { diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 499d5d8..3d30ffc 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -708,7 +708,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr scalarFb: t.hasChild("sf") ? t.mapChildrenWithTag("scalar_user", function(x){ return {id:x.attrUserJid("jid")}; }) : 0, reversed_: t.hasChild("rv") ? 0 : t.mapChildrenWithTag("reversed_user", function(x){ return {id:x.attrUserJid("jid")}; }), parenRev: (t.hasChild("pr") ? 0 : t.mapChildrenWithTag("paren_rev_user", function(x){ return {id:x.attrUserJid("jid")}; })), - callRev: t.hasChild("cr") ? other(t) : t.mapChildrenWithTag("call_rev_user", function(x){ return {id:x.attrUserJid("jid")}; })}; + callRev: t.hasChild("cr") ? other(t) : t.mapChildrenWithTag("call_rev_user", function(x){ return {id:x.attrUserJid("jid")}; }), + nestedRev: t.hasChild("n1") ? other(t) : (t.hasChild("n2") ? other(t) : t.mapChildrenWithTag("nested_rev_user", function(x){ return {id:x.attrUserJid("jid")}; }))}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -988,6 +989,9 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { // A CALL consequent is the case the `0` fixtures could not reach: filtering the // combined result made the fallback fire only when the consequent was not a call. assert_eq!(child("callRev").wire_tag, "call_rev_user"); + // ...and nested one level deeper: looking at a single alternate found the inner + // conditional, not the map inside it. + assert_eq!(child("nestedRev").wire_tag, "nested_rev_user"); let paren_rev = child("parenRev"); assert_eq!(paren_rev.wire_tag, "paren_rev_user"); diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 73c6aca..4481cb6 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -212,6 +212,24 @@ # than preserved, so an unknown value loses the channel silently instead of failing. WAM_CHANNELS = {"regular", "realtime", "private"} +# What each accessor may decode to. Deliberately a SET per accessor, not one type: an +# `attrInt` legitimately backs `integer`, `timestamp` and `timestamp_millis`, and a +# one-to-one map would have failed CI on 27 correct fields. Only accessors whose vocabulary +# is settled are listed; anything else is left alone rather than guessed at. +ACCESSOR_TYPES = { + "attrInt": {"integer", "timestamp", "timestamp_millis"}, + "attrString": {"string"}, + "contentUint": {"integer"}, + "contentBytes": {"bytes"}, + "attrEnum": {"enum"}, + "maybeAttrEnum": {"enum"}, +} + +# `contentUint(N)` reads N big-endian bytes and `contentBytes` a fixed run; nothing else +# has a byte width. A node with NO accessor is request-side (a `constBytes` pin), which +# carries the length legitimately — 20 of them do. +WIDTH_ACCESSORS = {"contentUint", "contentBytes"} + # The protobuf message appstate actions nest under. Their `protoEnum`/`valueEnumFields` # paths are written relative to it, so it is the one prefix that may be elided. APPSTATE_ROOT = "SyncActionValue" @@ -500,6 +518,18 @@ def check_field(f, path, domain, errors, counts, proto_enums): if isinstance(hi, int) and not isinstance(hi, bool) and bl > hi: errors.append(f"{path}: byteLength {bl} is above byteMax {hi}") + # The accessor and the declared type have to agree: `attrInt` with `type: "string"` + # tells a consumer to read the wire one way and represent it another. + allowed = ACCESSOR_TYPES.get(method) + if allowed is not None and t not in allowed: + errors.append( + f"{path}: {method!r} decodes {sorted(allowed)}, not {t!r}" + ) + + # A byte width belongs to an accessor that HAS one. + if "byteLength" in f and method and method not in WIDTH_ACCESSORS: + errors.append(f"{path}: byteLength on {method!r}, which has no byte width") + # An echo rule with no path says "this equals something in the request" and # then does not say what. if "referencePath" in f and not f["referencePath"]: From fc2f43e0959a6f706775d7d6ac1b8c360ea8ab42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 22:50:28 -0300 Subject: [PATCH 43/46] review: catch a mutating extends anywhere, honour the TDZ, reconcile both branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from Codex on 4e7d5c5. - A mutating `extends` was detected at the statement and declarator level only, so one nested in a sequence, a logical operand or a condition test was missed. Detected in a call visitor now, which the generic walk takes to every position. - For a LEXICAL `for-in`/`for-of` head the bound name is already in its temporal dead zone while the right-hand side runs, so a read there throws rather than reaching an outer enum. Last round moved the RHS out of the scope for both kinds; that is right for `var` and wrong for `let`/`const`, and the two are now distinguished. - `cond ? map("p", …) : map("q", …)` returned whichever branch was reached first, publishing one wire tag for a shape that produces two. Equal tags still resolve; differing ones refuse. The conditional is reconciled BEFORE the `strip_guard` shortcut, which follows the consequent and would otherwise answer with it. --- crates/wa-enums/src/lib.rs | 87 ++++++++++++++++++++++++++++++---- crates/wa-notif/src/actions.rs | 27 ++++++++++- crates/wa-notif/src/tests.rs | 9 +++- 3 files changed, 111 insertions(+), 12 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 100897a..1a2aa34 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -569,7 +569,11 @@ impl NamedResolver { /// that rejects a value it accepts — which the refusal in `merge_extends` prevents /// for the RESULT, and which this exists to prevent for the target itself. fn mutated_extends_target<'e>(e: &'e Expression) -> Option<&'e str> { - let call = as_call(e)?; + Self::mutated_extends_target_of(as_call(e)?) + } + + /// The local an `extends(target, …)` mutates, given the call itself. + fn mutated_extends_target_of<'e>(call: &'e oxc_ast::ast::CallExpression) -> Option<&'e str> { let callee = wa_oxc::as_member(&call.callee)?; if callee.1 != "extends" || as_identifier(callee.0) != Some("babelHelpers") { return None; @@ -641,6 +645,18 @@ impl<'a> Visit<'a> for NamedResolver { self.in_var_decl = was; } + fn visit_call_expression(&mut self, c: &oxc_ast::ast::CallExpression<'a>) { + // Wherever the call appears — a sequence, a logical operand, a condition test, a + // return value. Detecting it at the statement or declarator level missed every + // nested position, and the generic walk reaches all of them. + if let Some(target) = Self::mutated_extends_target_of(c) + && !self.param_shadows(target) + { + self.shadowed.insert(target.to_string()); + } + walk::walk_call_expression(self, c); + } + fn visit_expression_statement(&mut self, st: &oxc_ast::ast::ExpressionStatement<'a>) { // `extends(e, {B:"b"})` mutates `e` wherever it is written, not only as a // declarator's initializer — a bare expression statement does it too, and the @@ -795,10 +811,23 @@ impl<'a> Visit<'a> for NamedResolver { oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) => vec![&**d], _ => vec![], }; - // The RIGHT-hand expression is evaluated before the loop binding exists, so it - // reads in the enclosing scope; only the body sees the binding. - self.visit_expression(&f.right); - self.with_lexical(&[], &left, |me| me.visit_statement(&f.body)); + // For `var`, the RHS is evaluated with no new binding in place, so it reads in the + // enclosing scope. For `let`/`const` the bound name is already in its TEMPORAL + // DEAD ZONE while the RHS runs — a read there throws rather than reaching an outer + // enum — so the scope covers the RHS too. + let lexical_head = matches!( + &f.left, + oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) if !d.kind.is_var() + ); + if lexical_head { + self.with_lexical(&[], &left, |me| { + me.visit_expression(&f.right); + me.visit_statement(&f.body); + }); + } else { + self.visit_expression(&f.right); + self.with_lexical(&[], &left, |me| me.visit_statement(&f.body)); + } } fn visit_switch_statement(&mut self, sw: &oxc_ast::ast::SwitchStatement<'a>) { @@ -843,10 +872,23 @@ impl<'a> Visit<'a> for NamedResolver { oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) => vec![&**d], _ => vec![], }; - // The RIGHT-hand expression is evaluated before the loop binding exists, so it - // reads in the enclosing scope; only the body sees the binding. - self.visit_expression(&f.right); - self.with_lexical(&[], &left, |me| me.visit_statement(&f.body)); + // For `var`, the RHS is evaluated with no new binding in place, so it reads in the + // enclosing scope. For `let`/`const` the bound name is already in its TEMPORAL + // DEAD ZONE while the RHS runs — a read there throws rather than reaching an outer + // enum — so the scope covers the RHS too. + let lexical_head = matches!( + &f.left, + oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) if !d.kind.is_var() + ); + if lexical_head { + self.with_lexical(&[], &left, |me| { + me.visit_expression(&f.right); + me.visit_statement(&f.body); + }); + } else { + self.visit_expression(&f.right); + self.with_lexical(&[], &left, |me| me.visit_statement(&f.body)); + } } fn visit_catch_clause(&mut self, c: &oxc_ast::ast::CatchClause<'a>) { // `catch (e)` binds `e` for the handler body exactly as a parameter binds it for @@ -1604,6 +1646,33 @@ mod tests { 2 ); + // A mutating `extends` NESTED in another expression mutates all the same. + for shape in [ + "(babelHelpers.extends(e,{B:\"b\"}), 0);", + "c && babelHelpers.extends(e,{B:\"b\"});", + "if (babelHelpers.extends(e,{B:\"b\"})) {}", + ] { + let src = format!( + r#"__d("M",[],(function(t,n,r,o,a,i){{ + var e={{A:"a"}}; + {shape} + i.BASE=e + }}),1);"# + ); + assert!( + resolve_named_enum(&src, "M", "BASE").is_none(), + "the nested call mutates `e`: {shape}" + ); + } + + // A LEXICAL loop head puts its name in the TDZ while the RHS runs, so a read there + // throws rather than reaching the outer enum — `var` is the opposite. + let tdz = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){ for (let e of (i.OUT=e, [])) {} } + }),1);"#; + assert!(resolve_named_enum(tdz, "M", "OUT").is_none()); + // 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){ diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index 75aea79..e37cf09 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -2118,12 +2118,35 @@ fn find_mapped_call<'b, 'a>(e: &'b Expression<'a>) -> Option<&'b oxc_ast::ast::C let is_map = |c: &&oxc_ast::ast::CallExpression| callee_method(c) == Some(wap::MAP_CHILDREN_WITH_TAG); let e = strip_parens(e); - if let Some(c) = as_call(strip_guard(e)).filter(is_map) { + // A conditional is reconciled BEFORE the `strip_guard` shortcut: that shortcut follows + // the consequent, so `cond ? map("p") : map("q")` would return the first map and never + // reach the comparison below. + if !matches!(e, Expression::ConditionalExpression(_)) + && let Some(c) = as_call(strip_guard(e)).filter(is_map) + { return Some(c); } match e { Expression::ConditionalExpression(c) => { - find_mapped_call(&c.alternate).or_else(|| find_mapped_call(&c.consequent)) + // BOTH branches, reconciled. Taking the first one reached published one wire + // tag for a shape that produces two: `cond ? map("p", …) : map("q", …)` is + // two different collections, and picking either is a guess. Same tag on both + // sides is one collection written twice and keeps working. + match ( + find_mapped_call(&c.consequent), + find_mapped_call(&c.alternate), + ) { + (Some(a), Some(b)) => { + fn tag<'x>(c: &'x oxc_ast::ast::CallExpression) -> Option<&'x str> { + c.arguments + .first() + .and_then(arg_expr) + .and_then(as_string_lit) + } + (tag(a) == tag(b)).then_some(a) + } + (found, None) | (None, found) => found, + } } _ => None, } diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 3d30ffc..082e116 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -709,7 +709,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr reversed_: t.hasChild("rv") ? 0 : t.mapChildrenWithTag("reversed_user", function(x){ return {id:x.attrUserJid("jid")}; }), parenRev: (t.hasChild("pr") ? 0 : t.mapChildrenWithTag("paren_rev_user", function(x){ return {id:x.attrUserJid("jid")}; })), callRev: t.hasChild("cr") ? other(t) : t.mapChildrenWithTag("call_rev_user", function(x){ return {id:x.attrUserJid("jid")}; }), - nestedRev: t.hasChild("n1") ? other(t) : (t.hasChild("n2") ? other(t) : t.mapChildrenWithTag("nested_rev_user", function(x){ return {id:x.attrUserJid("jid")}; }))}; + nestedRev: t.hasChild("n1") ? other(t) : (t.hasChild("n2") ? other(t) : t.mapChildrenWithTag("nested_rev_user", function(x){ return {id:x.attrUserJid("jid")}; })), + twoMaps: t.hasChild("tm") ? t.mapChildrenWithTag("tm_p", function(x){ return {id:x.attrUserJid("jid")}; }) : t.mapChildrenWithTag("tm_q", function(x){ return {id:x.attrUserJid("jid")}; })}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -992,6 +993,12 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { // ...and nested one level deeper: looking at a single alternate found the inner // conditional, not the map inside it. assert_eq!(child("nestedRev").wire_tag, "nested_rev_user"); + // Two DIFFERENT collections, one per branch: publishing either tag would describe a + // shape the runtime produces only half the time, so the child is refused instead. + assert!( + !arm.children.iter().any(|c| c.name == "twoMaps"), + "conflicting tags in the two branches must not resolve to one of them" + ); let paren_rev = child("parenRev"); assert_eq!(paren_rev.wire_tag, "paren_rev_user"); From f80ea5615915b67f59b092ace3fa34f0f227d7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 22:58:05 -0300 Subject: [PATCH 44/46] lint: record the flattened discriminated shapes instead of certifying them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `privacyParser` reads ``, where the legal `value` enum depends on `name` and each category produces a differently named output (`lastSeen`, `callAdd`, `readReceipts`, …). The IR publishes ten sibling `value` fields at the response root: the nesting, the discriminator and the output names are all gone, so a consumer sees one key with ten contradictory constraints. This predates the PR — `main` has the identical shape and `generated/iq` is not among the files changed here. What is mine is the handling: the duplicate-key check added in this PR found these 15, and I switched it off after reading them as "alternatives the extractor models on purpose". Reading the source showed that was wrong, and a linter whose stated purpose is "diagnostics over silence" should not have certified them in the meantime. Held to a baseline, like `dropsByReason` and the unresolved enums: the loss is visible, cannot grow unnoticed, and CI does not fail on data this PR cannot fix. Repairing the shape is an extractor change and belongs in its own PR. --- scripts/lint-ir.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 4481cb6..4b6acd0 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -33,6 +33,12 @@ # recover is now being lost, and the number has to be updated with a reason. BASELINE = { "content integer with no byte width": 0, + # The extractor flattens a shape discriminated by an attribute — `privacyParser` reads + # `` into ten sibling `value` fields, losing both the + # discriminator and the runtime's output names (`lastSeen`, `callAdd`, …). A consumer + # gets one key with ten contradictory constraints. Recorded rather than certified; + # fixing the shape is an extractor change, not a linter one. + "object key filled twice in one array": 15, } # The enums no extraction path could resolve, by IDENTITY rather than by total. @@ -848,7 +854,7 @@ def check_event_codes(data, domain, errors): ) -def check_action_keys(node, path, errors): +def check_action_keys(node, path, errors, counts): """`fields`, `constantFields` and `children` are three representations of ONE object key namespace, so a name may appear in only one of them. @@ -857,13 +863,21 @@ def check_action_keys(node, path, errors): internally consistent by construction; nothing compared them to each other. """ arrays = [k for k in ("fields", "constantFields", "children") if isinstance(node.get(k), list)] - # Deliberately ACROSS arrays only. Widening this to catch a repeat within a single - # array was tried and reverted: it fires 15 times on the committed IR, and the cases - # are not contradictions. `iq/stanzas/49/response` carries ten `value` fields whose - # `enumRef`s differ per privacy category — alternatives the extractor models on - # purpose, not one key answered twice. A guard that fails CI on correct data is worse - # than the gap it closes; the shape those ten represent is a modelling question, and - # it is recorded in the PR rather than silently enforced here. + # Two questions, two answers. ACROSS arrays a repeat is a contradiction and fails. + # WITHIN one array it is the extractor FLATTENING a discriminated shape — ten `value` + # fields in `privacyParser`, one per ``, with the discriminator and + # the output names dropped — so it is a known loss held to a baseline, not an error. + # + # It was briefly checked as an error, found these 15, and switched off as "alternatives + # modelled on purpose". Reading the source showed that was wrong. Recording the loss is + # what this file is for; silently certifying it is what it exists to prevent. + for key in arrays: + names_in = [ + it["name"] for it in node[key] if isinstance(it, dict) and "name" in it + ] + if len(set(names_in)) < len(names_in): + counts["object key filled twice in one array"] += 1 + if len(arrays) < 2: return names = [ @@ -1293,7 +1307,7 @@ def visit(node, path, domain=domain): # Independent of the field gate — see each function's note. check_enum_ref(node, f"{domain}{path}", errors, seen_refs) check_const_bytes(node, f"{domain}{path}", errors) - check_action_keys(node, f"{domain}{path}", errors) + check_action_keys(node, f"{domain}{path}", errors, counts) check_variant_groups(node, f"{domain}{path}", errors) check_child_requiredness(node, f"{domain}{path}", errors) From 7c837fac546c51d5c8cc7e5fd82606ae20db706b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 22:59:05 -0300 Subject: [PATCH 45/46] lint: count the flattening per extra entry, at its measured value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counting ARRAYS with a repeat meant an eleventh `value` in an array already holding ten changed nothing — the same count-vs-identity blind spot the unresolved-enum baseline had twice before. Counted per extra entry now, and the baseline is the measured 42 rather than the 24 I first guessed. Both a new repeat and an extra one inside an existing array trip it. --- scripts/lint-ir.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 4b6acd0..52024bc 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -38,7 +38,11 @@ # discriminator and the runtime's output names (`lastSeen`, `callAdd`, …). A consumer # gets one key with ten contradictory constraints. Recorded rather than certified; # fixing the shape is an extractor change, not a linter one. - "object key filled twice in one array": 15, + # + # 42 is the number of EXTRA entries across all such arrays, measured — an earlier + # guess of 24 was wrong, and counting arrays instead let an eleventh duplicate in an + # array already holding ten pass unnoticed. + "object key filled twice in one array": 42, } # The enums no extraction path could resolve, by IDENTITY rather than by total. @@ -875,8 +879,10 @@ def check_action_keys(node, path, errors, counts): names_in = [ it["name"] for it in node[key] if isinstance(it, dict) and "name" in it ] - if len(set(names_in)) < len(names_in): - counts["object key filled twice in one array"] += 1 + # Counted per EXTRA entry, not per array: counting arrays meant an eleventh + # `value` in an array already carrying ten changed nothing, which is the same + # count-vs-identity blind spot the unresolved-enum baseline had twice. + counts["object key filled twice in one array"] += len(names_in) - len(set(names_in)) if len(arrays) < 2: return From e332de1a2c364aa7aee5ccc801b5b0d2c90e2b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Jul 2026 23:15:21 -0300 Subject: [PATCH 46/46] review: pin the flattening by identity, and refuse two more ambiguous shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four from Codex on 7c837fa. The flattening baseline I added two commits ago was an AGGREGATE, so one known loss disappearing while a new one appeared elsewhere kept the total at 42 and passed. That is the third time this exact blind spot has been found here and the second in a baseline I wrote — after citing the lesson in the commit that introduced it. Keyed by semantic path and repeated names now; a substitution reports both halves. - A deferred alias that disagrees with an inline write to the same export (`i.OUT = {A:"a"}; i.OUT = e`) kept the first body through `or_insert`, while the runtime exports the second. Same refusal the immediate path already makes. - Two branches mapping the SAME tag with different callbacks published one of them, asserting `jid` for a branch that reads `lid`. Equal tag is not equal shape; the arguments must match too. - A `*Source` is provenance for an inferred value: alone it says where a constraint came from without saying what to enforce. Both pairs are validated together, and an empty source string is rejected. --- crates/wa-enums/src/lib.rs | 23 ++++++++-- crates/wa-notif/src/actions.rs | 10 ++++- crates/wa-notif/src/tests.rs | 9 +++- scripts/lint-ir.py | 80 +++++++++++++++++++++++++++------- 4 files changed, 101 insertions(+), 21 deletions(-) diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index 1a2aa34..067018d 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -298,9 +298,18 @@ fn resolve_pending(r: &mut NamedResolver) { continue; } if let Some(data) = r.locals.get(local) { - r.exports - .entry(export.clone()) - .or_insert_with(|| data.clone()); + // A deferred alias that DISAGREES with an inline write to the same export is + // the same conflict the immediate path already refuses — `i.OUT = {A:"a"}; + // i.OUT = e` exports `e` at runtime, and `or_insert` kept `{A}`. + match r.exports.get(export) { + Some(prev) if prev != data => { + r.conflicting.insert(export.clone()); + } + Some(_) => {} + None => { + r.exports.insert(export.clone(), data.clone()); + } + } } } } @@ -1673,6 +1682,14 @@ mod tests { }),1);"#; assert!(resolve_named_enum(tdz, "M", "OUT").is_none()); + // A DEFERRED alias that disagrees with an inline write to the same export. + let alias_conflict = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={B:"b"}; + i.OUT={A:"a"}; + i.OUT=e + }),1);"#; + assert!(resolve_named_enum(alias_conflict, "M", "OUT").is_none()); + // 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){ diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index e37cf09..fab680c 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -2143,7 +2143,15 @@ fn find_mapped_call<'b, 'a>(e: &'b Expression<'a>) -> Option<&'b oxc_ast::ast::C .and_then(arg_expr) .and_then(as_string_lit) } - (tag(a) == tag(b)).then_some(a) + // Same tag is not the same shape: callbacks reading `jid` and + // `lid` would publish an always-required `jid` for a branch that + // carries only `lid`. The whole call must match, arguments included. + let same_text = + |x: &oxc_ast::ast::CallExpression, y: &oxc_ast::ast::CallExpression| { + x.span == y.span + || format!("{:?}", x.arguments) == format!("{:?}", y.arguments) + }; + (tag(a) == tag(b) && same_text(a, b)).then_some(a) } (found, None) | (None, found) => found, } diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 082e116..2a0c8b7 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -710,7 +710,8 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr parenRev: (t.hasChild("pr") ? 0 : t.mapChildrenWithTag("paren_rev_user", function(x){ return {id:x.attrUserJid("jid")}; })), callRev: t.hasChild("cr") ? other(t) : t.mapChildrenWithTag("call_rev_user", function(x){ return {id:x.attrUserJid("jid")}; }), nestedRev: t.hasChild("n1") ? other(t) : (t.hasChild("n2") ? other(t) : t.mapChildrenWithTag("nested_rev_user", function(x){ return {id:x.attrUserJid("jid")}; })), - twoMaps: t.hasChild("tm") ? t.mapChildrenWithTag("tm_p", function(x){ return {id:x.attrUserJid("jid")}; }) : t.mapChildrenWithTag("tm_q", function(x){ return {id:x.attrUserJid("jid")}; })}; + twoMaps: t.hasChild("tm") ? t.mapChildrenWithTag("tm_p", function(x){ return {id:x.attrUserJid("jid")}; }) : t.mapChildrenWithTag("tm_q", function(x){ return {id:x.attrUserJid("jid")}; }), + sameTagDiffCb: t.hasChild("st") ? t.mapChildrenWithTag("same_tag", function(x){ return {id:x.attrUserJid("jid")}; }) : t.mapChildrenWithTag("same_tag", function(x){ return {id:x.attrUserJid("lid")}; })}; } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; @@ -999,6 +1000,12 @@ fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { !arm.children.iter().any(|c| c.name == "twoMaps"), "conflicting tags in the two branches must not resolve to one of them" ); + // Same tag, DIFFERENT callbacks: publishing one would assert `jid` for a branch that + // reads `lid`. Equal tag is not equal shape. + assert!( + !arm.children.iter().any(|c| c.name == "sameTagDiffCb"), + "same tag with different callback reads is still ambiguous" + ); let paren_rev = child("parenRev"); assert_eq!(paren_rev.wire_tag, "paren_rev_user"); diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py index 52024bc..52e732a 100755 --- a/scripts/lint-ir.py +++ b/scripts/lint-ir.py @@ -33,16 +33,6 @@ # recover is now being lost, and the number has to be updated with a reason. BASELINE = { "content integer with no byte width": 0, - # The extractor flattens a shape discriminated by an attribute — `privacyParser` reads - # `` into ten sibling `value` fields, losing both the - # discriminator and the runtime's output names (`lastSeen`, `callAdd`, …). A consumer - # gets one key with ten contradictory constraints. Recorded rather than certified; - # fixing the shape is an extractor change, not a linter one. - # - # 42 is the number of EXTRA entries across all such arrays, measured — an earlier - # guess of 24 was wrong, and counting arrays instead let an eleventh duplicate in an - # array already holding ten pass unnoticed. - "object key filled twice in one array": 42, } # The enums no extraction path could resolve, by IDENTITY rather than by total. @@ -55,6 +45,32 @@ # # 155 fields collapse to 30 identities. Adding one, dropping one, or changing a count all # fail; each is a deliberate act that costs one line here. +# Arrays where the extractor FLATTENED a shape discriminated by an attribute, keyed by +# semantic path and the repeated names. `privacyParser` reads `` into ten sibling `value` fields, losing the discriminator linkage and the +# runtime's output names (`lastSeen`, `callAdd`, …); the same ten also appear correctly +# nested under `category`, so the flat copy is a duplicate rather than the only record. +# +# By identity, not by total: an aggregate let one loss disappear while another appeared. +# Fixing the shape is an extractor change and belongs in its own PR. +FLATTENED_KEYS = { + "incoming/incoming/3/shape/fields/5/children/0/children|type": 1, + "incoming/incoming/4/shape/fields|id": 1, + "iq/stanzas/22/response/fields|id": 1, + "iq/stanzas/49/response/fields/0/children/0/children|value": 9, + "iq/stanzas/49/response/fields|value": 9, + "iq/stanzas/50/response/fields/0/children/0/children|value": 1, + "iq/stanzas/50/response/fields|value": 1, + "notif/notifications/0/content/fields|id": 1, + "notif/notifications/12/content/fields|id": 1, + "notif/notifications/17/content/fields|from,t": 3, + "notif/notifications/19/content/fields|id": 1, + "notif/notifications/23/content/fields|from": 2, + "notif/notifications/24/content/fields|jid,participant,t": 8, + "notif/notifications/3/content/fields/7/children|new_lid,old_lid": 2, + "notif/notifications/3/content/fields|t": 1, +} + UNRESOLVED_ENUMS = { "incoming|ack|deprecatedEditMixin|edit|attrEnum": 1, "iq|AddParticipantsResponseSuccess|participant|addParticipant|addParticipantsParticipantAddedOrNonRegisteredWaUserParticipantErrorLidResponseMixinGroup|AddParticipantsParticipantAddedResponse|addParticipantsParticipantMixins|ParticipantGroupJoinRequest|membershipApprovalRequestError|maybeAttrEnum": 1, @@ -540,6 +556,22 @@ def check_field(f, path, domain, errors, counts, proto_enums): if "byteLength" in f and method and method not in WIDTH_ACCESSORS: errors.append(f"{path}: byteLength on {method!r}, which has no byte width") + # A `*Source` is provenance FOR an inferred value. Alone it tells a consumer where a + # constraint allegedly came from and not what to enforce. + for src_key, value_keys in ( + ("byteLengthSource", ("byteLength",)), + ("valueSource", ("value", "constBytes")), + ): + if src_key not in f: + continue + if not isinstance(f[src_key], str) or not f[src_key]: + errors.append(f"{path}: {src_key} is {f[src_key]!r}, not a non-empty string") + if not any(k in f for k in value_keys): + errors.append( + f"{path}: {src_key} without {' or '.join(value_keys)} — provenance for " + f"a value that is not there" + ) + # An echo rule with no path says "this equals something in the request" and # then does not say what. if "referencePath" in f and not f["referencePath"]: @@ -858,7 +890,7 @@ def check_event_codes(data, domain, errors): ) -def check_action_keys(node, path, errors, counts): +def check_action_keys(node, path, errors, flattened): """`fields`, `constantFields` and `children` are three representations of ONE object key namespace, so a name may appear in only one of them. @@ -879,10 +911,13 @@ def check_action_keys(node, path, errors, counts): names_in = [ it["name"] for it in node[key] if isinstance(it, dict) and "name" in it ] - # Counted per EXTRA entry, not per array: counting arrays meant an eleventh - # `value` in an array already carrying ten changed nothing, which is the same - # count-vs-identity blind spot the unresolved-enum baseline had twice. - counts["object key filled twice in one array"] += len(names_in) - len(set(names_in)) + extra = len(names_in) - len(set(names_in)) + if extra: + # By IDENTITY, not by total: one loss disappearing while another appears + # elsewhere kept the aggregate at 42 and passed — the third time this exact + # blind spot has been found in this file, twice in baselines I wrote. + repeated = sorted({n for n in names_in if names_in.count(n) > 1}) + flattened[f"{path}/{key}|{','.join(repeated)}"] += extra if len(arrays) < 2: return @@ -1259,6 +1294,7 @@ def main() -> int: errors: list[str] = [] counts = dict.fromkeys(BASELINE, 0) unresolved: dict[str, int] = defaultdict(int) + flattened: dict[str, int] = defaultdict(int) seen_refs: dict[tuple, tuple] = {} # The protobuf enums an appstate `protoEnum` may name. A path that resolves to @@ -1313,7 +1349,7 @@ def visit(node, path, domain=domain): # Independent of the field gate — see each function's note. check_enum_ref(node, f"{domain}{path}", errors, seen_refs) check_const_bytes(node, f"{domain}{path}", errors) - check_action_keys(node, f"{domain}{path}", errors, counts) + check_action_keys(node, f"{domain}{path}", errors, flattened) check_variant_groups(node, f"{domain}{path}", errors) check_child_requiredness(node, f"{domain}{path}", errors) @@ -1336,6 +1372,18 @@ def visit(node, path, domain=domain): # COUNTS, not just membership: the semantic trail omits array indices for reorder # stability, so two sibling fields can share an identity — and with a set, one of them # losing its `enumRef` left the set unchanged and passed. + for ident in sorted(set(flattened) | set(FLATTENED_KEYS)): + now, was = flattened.get(ident, 0), FLATTENED_KEYS.get(ident, 0) + if now == was: + continue + ok = False + if was == 0: + print(f"REGRESSION newly flattened shape: {ident} (x{now})") + elif now == 0: + print(f"IMPROVED shape no longer flattened: {ident} — drop it") + else: + print(f"CHANGED {ident}: {was} -> {now}") + for ident in sorted(set(unresolved) | set(UNRESOLVED_ENUMS)): now, was = unresolved.get(ident, 0), UNRESOLVED_ENUMS.get(ident, 0) if now == was: