Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1a537a2
feat(ir): carry argument paths, element values, cardinality and union…
claude Aug 14, 2026
eeb3624
fix(scan): keep an argument path off any frame it cannot name
claude Aug 14, 2026
4faad42
fix(scan): tell an empty argument prefix apart from an unresolvable one
claude Aug 14, 2026
2a623a1
fix(scan): hold the argument-object boundary at the request root
claude Aug 14, 2026
827564e
feat(scan): address values inside map callbacks and on a stanza root
claude Aug 14, 2026
a5b6225
fix(ir): record an optional literal's value instead of addressing its…
claude Aug 14, 2026
df35ffe
fix(scan): rebase a mixin contribution like every other handed frame
claude Aug 15, 2026
42a9045
test(scan): make the coverage guard look at variant-group attributes
claude Aug 15, 2026
c1ab69a
fix(scan): close three latent misreads at the builder boundary
claude Aug 15, 2026
c0cf801
feat(scan): rebase a mixin contribution onto the frame that named it
claude Aug 15, 2026
c33e3cf
fix(ir): keep a pinned payload's address, and refuse two addresses fo…
claude Aug 15, 2026
20206f2
Merge origin/main into the request-builder batch
claude Aug 15, 2026
2cc395d
fix(scan): shadow per scope, and withhold a sibling builder's fragment
claude Aug 15, 2026
b9007ce
feat(ir): address the request's addressee, and stop a wrapper claimin…
claude Aug 15, 2026
60e5e55
fix(scan): key a mixin reach by its frame, and dedupe stanzas by addr…
claude Aug 15, 2026
43a7e86
fix(scan): bound the mixin walk by module, and let a local `to` own i…
claude Aug 15, 2026
03d67e2
fix(ir): withdraw an addressee's key when the chain disagrees about t…
claude Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,18 @@ Note the contract version: this raised `schemaVersion` to **2.0.0**, and it is a

Additive in 3.0.0, so a 2.x consumer can ignore them: `unknownValue` on enum-accessor fields; `syntheticName` / `bitPosition` in the enum catalog; and the catalog itself growing from 328 to 403 entries so every `enumRef` resolves against it — 75 of the 87 referenced `(module, name)` pairs were in no catalog before.

Anything the extractor sees but cannot resolve structurally is counted under `manifest.diagnostics.iq.dropsByReason` rather than omitted, so "no constraint here" and "a constraint we failed to extract" never look alike. `manifest.diagnostics.iq.constraints`, `diagnostics.iq.targets.resolved` and `diagnostics.notif.actions` are floor-guarded: a WA refactor that hides one of these constructs fails the update instead of silently emptying a field. The unresolved states are guarded the other way — `scripts/lint-ir.py` pins the count of unaddressed requests and of unjudged accessors to an exact baseline. A rise means a constraint is being lost; a fall means extraction improved and the baseline owes an update. Either way the lint fails, so neither direction passes unnoticed.
### Enough to *call* WhatsApp's own builder, not only to encode the stanza yourself

Everything above describes the wire. That serves a client that encodes the stanza itself — it needs to know a group create carries a `<participant jid=…>` of type `user_jid`. It does not serve a client that **runs WhatsApp's own modules**, which needs the other half: that the value goes in `args.participantArgs[].participantJid`. Neither half implies the other — WA picks the argument key independently of the attribute it lands in (`subjectElementValue` becomes the *text* of `<subject>`) — and almost every request is composed out of mixins, so the argument key is usually defined in a different module from the tag it fills. So the request side also carries the builder:

- **Argument paths** (`argPath` on a request node, an attribute, or an element content) — the absolute path from the builder's argument object, as segments. `list` marks each segment a repeated combinator iterates: `REPEATED_CHILD(template, list, min, max)` calls the template once per element, while `OPTIONAL_CHILD`/`HAS_OPTIONAL_CHILD` hand the object over whole. Nested repeats mark more than one — `userArgs[] → deviceArgs[] → deviceId` is a key read off an element of an element. The same suffix in the wrong place writes the value where the vendor builder never reads it, and the stanza goes out without it. Recovered structurally — from the function's single argument parameter and the `var x = <param>.<key>` destructure — never from a name: `…Args`, `has…` and `any…` are WA conventions that make the IR readable, not evidence. A path that isn't structurally recoverable is absent and counted under `manifest.diagnostics.iq.builder`, never guessed. The legacy `WAWeb*Job` builders take positional parameters rather than one options object, so they get no path at all and are counted as such.
- **Cardinality of a request child** (`presence`, plus `repeatMin`/`repeatMax`) — the three states `WASmaxChildren` distinguishes, of which the wire shows one. `<locked/>` on a group create is a *presence marker*: its template takes no arguments, its whole meaning is being there, and a consumer can model it as a `bool` — which it must not do for an optional child and cannot do for an empty required one, all three of which are the same empty element on the wire. The repeat bounds are the ones a server enforces (`add/participant` 1..1024, `query/group` 1..10000, `media_list` 0..10); a `repeatMin` with no `repeatMax` is WA's explicit `1/0`, i.e. unbounded above, which stays distinguishable from a child that states no bound at all and has neither.
- **The addressee's argument key** (`targetArgPath` on a request) — `target` says a request is sent to one group's own JID rather than to a server, and a consumer running the vendor builder still has to know which argument supplies it. 30 of the 31 runtime-addressed requests carry it (`iqTo`, and one composed through a mixin group as `baseGetGroupOrServerMixinGroupArgs → baseGetGroup → iqTo`); the one that does not is a legacy `WAWeb*Job` builder, which takes positional parameters and so has no argument object for a path to point into.
- **Element values that survive the mixin boundary** (`content`) — `smax("subject", null, subjectElementValue)` is the entire payload of a group rename. WA's builders bind that payload to a local before writing it, and a bare local used to be ignored outright in case it was a node variable. It is now told apart structurally: a local that resolves to an argument path is content, one that resolves to a `smax(…)` call is a child.

**`schemaVersion` is now 4.0.0.** Nearly all of the above is additive — every new property is optional and skipped at its default, and each committed `*/index.json` validates clean against its own **3.0.0** schema (0 errors across all 12 domains). One change is not, and it is the whole reason for the major: `value` on a request attribute was documented as present only for `kind: "const"`, and now also carries the fixed literal of a `WASmaxAttrs.OPTIONAL_LITERAL(lit, flag)` attribute, whose `kind` is `optional`. Migration: read `value` as "what this attribute says **when** it is written" rather than as an unconditional constant — on an `optional` attribute the builder writes it only when its boolean gate is set, which is the attribute analogue of a presence marker. Eight committed IQ attributes are in that state, and the old JSON Schema accepts every one of them, which is precisely why the version has to say what the schema cannot. Additive alongside it: `argPath`, `presence`, `repeatMin`/`repeatMax`, `targetArgPath`, and `content` populated on 45 request nodes that previously carried none.

Anything the extractor sees but cannot resolve structurally is counted under `manifest.diagnostics.iq.dropsByReason` rather than omitted, so "no constraint here" and "a constraint we failed to extract" never look alike. `manifest.diagnostics.iq.constraints`, `diagnostics.iq.targets.resolved`, `diagnostics.iq.builder` and `diagnostics.notif.actions` are floor-guarded: a WA refactor that hides one of these constructs fails the update instead of silently emptying a field. The unresolved states are guarded the other way — `scripts/lint-ir.py` pins the count of unaddressed requests and of unjudged accessors to an exact baseline. A rise means a constraint is being lost; a fall means extraction improved and the baseline owes an update. Either way the lint fails, so neither direction passes unnoticed.

## Quick start

Expand Down
24 changes: 24 additions & 0 deletions crates/wa-codegen/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2465,6 +2465,7 @@ mod tests {
value: value.map(|v| v.into()),
required: false,
enum_ref: None,
arg_path: None,
}
}
fn leaf(tag: &str) -> WapChildNode {
Expand All @@ -2475,6 +2476,7 @@ mod tests {
content: None,
repeats: false,
variant_groups: vec![],
..Default::default()
}
}

Expand Down Expand Up @@ -2563,6 +2565,7 @@ mod tests {
content: None,
repeats: true,
variant_groups: vec![],
..Default::default()
};
let node = WapChildNode {
tag: "config".into(),
Expand All @@ -2577,6 +2580,7 @@ mod tests {
children: vec![item],
}],
}],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -2887,6 +2891,7 @@ mod tests {
content: None,
repeats: false,
variant_groups: groups,
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -3005,6 +3010,7 @@ mod tests {
content: None,
repeats: true,
variant_groups: vec![],
..Default::default()
};
let node = WapChildNode {
tag: "config".into(),
Expand All @@ -3019,6 +3025,7 @@ mod tests {
children: vec![kid("type"), kid("2fa")],
}],
}],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -3056,6 +3063,7 @@ mod tests {
children: vec![kid("type")],
}],
}],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -3089,6 +3097,7 @@ mod tests {
children: vec![kid("my-tag")],
}],
}],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -3121,6 +3130,7 @@ mod tests {
content: None,
repeats: true,
variant_groups: vec![],
..Default::default()
};
let node = WapChildNode {
tag: "config".into(),
Expand All @@ -3135,6 +3145,7 @@ mod tests {
children: vec![kid("foo-bar"), kid("foo_bar")],
}],
}],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -3173,6 +3184,7 @@ mod tests {
children: vec![kid("item")],
}],
}],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let mut ctx = VariantCtx {
Expand Down Expand Up @@ -3205,6 +3217,7 @@ mod tests {
content: None,
repeats: true,
variant_groups: vec![],
..Default::default()
};
let node = WapChildNode {
tag: "config".into(),
Expand All @@ -3219,6 +3232,7 @@ mod tests {
children: vec![item],
}],
}],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -3265,6 +3279,7 @@ mod tests {
children: vec![nested],
}],
}],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -3310,6 +3325,7 @@ mod tests {
children: vec![nested],
}],
}],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -3486,6 +3502,7 @@ mod tests {
],
},
],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -3567,6 +3584,7 @@ mod tests {
optional: false,
variants: vec![mk("appid"), mk("voip"), mk("endpoint")],
}],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -3788,6 +3806,7 @@ mod tests {
content: None,
repeats: false,
variant_groups: vec![],
..Default::default()
};
let (lines, var, _) = build1(&child);
let code = lines.join("\n");
Expand Down Expand Up @@ -3820,6 +3839,7 @@ mod tests {
content: None,
repeats: false,
variant_groups: vec![],
..Default::default()
};
let (lines, _, _) = build1(&parent);
let code = lines.join("\n");
Expand Down Expand Up @@ -3850,6 +3870,7 @@ mod tests {
}),
repeats: false,
variant_groups: vec![],
..Default::default()
};
let (lines, _, _) = build1(&node);
let code = lines.join("\n");
Expand Down Expand Up @@ -3878,6 +3899,7 @@ mod tests {
}),
repeats: false,
variant_groups: vec![],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -3953,6 +3975,7 @@ mod tests {
}),
repeats: false,
variant_groups: vec![],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down Expand Up @@ -3984,6 +4007,7 @@ mod tests {
}),
repeats: false,
variant_groups: vec![],
..Default::default()
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let reserved = std::collections::HashSet::new();
Expand Down
13 changes: 13 additions & 0 deletions crates/wa-codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ mod tests {
value: None,
required: true,
enum_ref: None,
arg_path: None,
}
}

Expand Down Expand Up @@ -291,6 +292,7 @@ mod tests {
exported_function: Some("title".into()),
all_exports: vec!["title".into()],
request: IqRequestDef {
target_arg_path: None,
namespace: "fb:thrift_iq".into(),
iq_type: IqType::Get,
target: IqTarget::Server,
Expand Down Expand Up @@ -347,6 +349,7 @@ mod tests {
exported_function: Some("detail".into()),
all_exports: vec!["detail".into()],
request: IqRequestDef {
target_arg_path: None,
namespace: "fb:thrift_iq".into(),
iq_type: IqType::Get,
target: IqTarget::Server,
Expand Down Expand Up @@ -404,6 +407,7 @@ mod tests {
exported_function: Some("weights".into()),
all_exports: vec!["weights".into()],
request: IqRequestDef {
target_arg_path: None,
namespace: "fb:thrift_iq".into(),
iq_type: IqType::Get,
target: IqTarget::Server,
Expand Down Expand Up @@ -455,6 +459,7 @@ mod tests {
exported_function: Some("scores".into()),
all_exports: vec!["scores".into()],
request: IqRequestDef {
target_arg_path: None,
namespace: "fb:thrift_iq".into(),
iq_type: IqType::Get,
target: IqTarget::Server,
Expand Down Expand Up @@ -516,6 +521,7 @@ mod tests {
exported_function: Some("keys".into()),
all_exports: vec!["keys".into()],
request: IqRequestDef {
target_arg_path: None,
namespace: "encrypt".into(),
iq_type: IqType::Get,
target: IqTarget::Server,
Expand Down Expand Up @@ -569,6 +575,7 @@ mod tests {
exported_function: Some("digest".into()),
all_exports: vec!["digest".into()],
request: IqRequestDef {
target_arg_path: None,
namespace: "encrypt".into(),
iq_type: IqType::Get,
target: IqTarget::Server,
Expand Down Expand Up @@ -614,6 +621,7 @@ mod tests {
exported_function: Some("pn".into()),
all_exports: vec!["pn".into()],
request: IqRequestDef {
target_arg_path: None,
namespace: "w:g2".into(),
iq_type: IqType::Get,
target: IqTarget::Server,
Expand Down Expand Up @@ -688,6 +696,7 @@ mod tests {
exported_function: Some("blob".into()),
all_exports: vec!["blob".into()],
request: IqRequestDef {
target_arg_path: None,
namespace: "encrypt".into(),
iq_type: IqType::Get,
target: IqTarget::Server,
Expand Down Expand Up @@ -723,6 +732,7 @@ mod tests {
exported_function: Some("queryTest".into()),
all_exports: vec!["queryTest".into()],
request: IqRequestDef {
target_arg_path: None,
namespace: "w:test".into(),
iq_type: IqType::Get,
target: IqTarget::Server,
Expand All @@ -736,6 +746,7 @@ mod tests {
content: None,
repeats: false,
variant_groups: vec![],
..Default::default()
}],
},
response: ParsedResponse {
Expand Down Expand Up @@ -787,6 +798,7 @@ mod tests {
exported_function: Some("queryMode".into()),
all_exports: vec!["queryMode".into()],
request: IqRequestDef {
target_arg_path: None,
namespace: "w:mode".into(),
iq_type: IqType::Get,
target: IqTarget::Server,
Expand Down Expand Up @@ -826,6 +838,7 @@ mod tests {
exported_function: None,
all_exports: vec![],
request: IqRequestDef {
target_arg_path: None,
namespace: "w:x".into(),
iq_type: IqType::Set,
target: IqTarget::GroupServer,
Expand Down
3 changes: 3 additions & 0 deletions crates/wa-codegen/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1143,11 +1143,13 @@ mod tests {
value: None,
required: true,
enum_ref: None,
arg_path: None,
}],
children: vec![],
content: None,
repeats: false,
variant_groups: vec![],
..Default::default()
};
let children = vec![
node("participant", WapAttrKind::UserJid),
Expand Down Expand Up @@ -1292,6 +1294,7 @@ mod tests {
exported_function: exported.map(str::to_string),
all_exports: vec![],
request: IqRequestDef {
target_arg_path: None,
namespace: "w:test".into(),
iq_type: IqType::Get,
target: IqTarget::Server,
Expand Down
2 changes: 2 additions & 0 deletions crates/wa-codegen/src/stanza_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ mod tests {
module: module.into(),
variants: vars,
}),
arg_path: None,
}
}

Expand All @@ -292,6 +293,7 @@ mod tests {
value: None,
required: true,
enum_ref: None,
arg_path: None,
}
}

Expand Down
Loading
Loading