Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Things that look correct and are not:
- **Locks.** `session_locks` serializes Signal encrypt/decrypt per protocol address; `chat_lanes` (`ChatLane::enqueue_lock` in `src/client.rs`) serializes *incoming* processing per chat. Outgoing sends are deliberately not per-chat locked — WA Web doesn't lock them either.
- **Wire-tagged enums.** Every protocol enum derives `WireEnum`, and its `#[wire = ...]` attribute is the single source of truth for the wire value. Do not also derive `serde::Serialize`/`Deserialize` or add `#[serde(rename_all)]` — the derive owns both. In tagged mode it generates a sibling `<Name>Tag`; parsers must dispatch on `<Name>Tag::try_from(node.tag.as_ref())` rather than string literals, so renaming a tag stays a one-attribute change. Modes and attributes: `agent_docs/protocol_architecture.md`.
- **Event payloads are a frozen API.** Sealed with `#[non_exhaustive]` + `#[derive(bon::Builder)]` and constructed via `Type::builder()…build()`; a maybe-absent field is `Option<T>`, never an empty-string or zero sentinel. The full stability policy is the `Event` doc comment in `wacore/src/types/events.rs`.
- **Generated files are generated, not edited.** `wacore/src/iq/abprops.rs`, `wacore/src/iq/mex_operations.rs`, `wacore/appstate/src/schemas.rs`, `wacore/src/types/wire_enums.rs`, `wacore/binary/src/tokens.json`, `waproto/src/whatsapp.proto` and `wacore/src/version/generated.rs` all come out of `cargo run -p whatspec-codegen`, together, from one pinned whatspec commit. An action or flag the protocol carries but the bundle no longer builds goes in a hand-written sibling (`wacore/appstate/src/schemas_unlisted.rs`, `props::stale`), never in the generated file. `wire_enums.rs` binds only the catalog entries listed in the emitter's `WANTED`, because 88 of the 403 have a synthetic name and names repeat across modules; the variants themselves always come from the bundle.
- **Generated files are generated, not edited.** `wacore/src/iq/abprops.rs`, `wacore/src/iq/mex_operations.rs`, `wacore/appstate/src/schemas.rs`, `wacore/src/types/wire_enums.rs`, `wacore/src/iq/targets.rs`, `wacore/binary/src/tokens.json`, `waproto/src/whatsapp.proto` and `wacore/src/version/generated.rs` all come out of `cargo run -p whatspec-codegen`, together, from one pinned whatspec commit. An action or flag the protocol carries but the bundle no longer builds goes in a hand-written sibling (`wacore/appstate/src/schemas_unlisted.rs`, `props::stale`), never in the generated file. `wire_enums.rs` binds only the catalog entries listed in the emitter's `WANTED`, because 88 of the 403 have a synthetic name and names repeat across modules; the variants themselves always come from the bundle. A candidate is found by its variant set but decided by its module: two enums agreeing on every value are not the same enum unless the module owns the wire format we parse. `targets.rs` binds the same way and covers `w:g2` only, the one namespace where a request's target is not implied by its namespace.
- **`whatsapp.proto` is not the whole persisted schema.** It comes from whatspec and is regenerated wholesale, so fields we persist but upstream does not declare live in `LOCAL_FIELDS` in `waproto/build.rs`, spliced into the descriptor at build time, and whole retained messages in `LOCAL_BLOCKS` in the codegen's proto emitter. Never hand-edit the `.proto` or `.desc` to add one — the next sync would drop it.
- **Blocking work** — `ureq`, heavy CPU — belongs in `tokio::task::spawn_blocking`; it shares a runtime with the read loop.
- **let-chains**, never nested `if let`. Clippy's `collapsible_if` is denied in CI.
Expand Down
106 changes: 106 additions & 0 deletions tests/ab_prop_watch_coverage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! Every A/B gate this crate reads must be watched.
//!
//! `AbPropsCache::apply_props` keeps only codes in its interest set, seeded from
//! `iq::props::WATCHED`. A prop read without being watched is therefore not a
//! prop that reads stale -- it is one whose server value was thrown away during
//! parsing, so the read returns the registry default now and on every future
//! connect. Nothing errors and nothing logs.
//!
//! That is worth a scan rather than a runtime check alone. The cache does
//! `debug_assert` on read, but only a test that actually exercises the gated
//! path can trip it, and a gate is usually added precisely because the path is
//! hard to reach. Three shipped gates were dead this way before anyone noticed:
//! `receipt_mode_bitmask_enabled`, `enable_spam_report_iq_with_privacy_token`
//! and `profile_scraping_privacy_token_in_about_usync`.
//!
//! The scan is textual on purpose. Resolving these paths properly would mean
//! running the compiler, and the failure being guarded is a name appearing in
//! one file and not another -- exactly what text sees.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use wacore::iq::props::WATCHED;

fn manifest_path(relative: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join(relative)
}

/// Every `.rs` file under `src/`, as (display path, contents).
fn sources() -> Vec<(String, String)> {
fn walk(dir: &Path, out: &mut Vec<(String, String)>) {
let entries = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read {dir:?}: {e}"));
for entry in entries {
let path = entry.expect("dir entry").path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
let text =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}"));
out.push((path.display().to_string(), text));
}
}
}
let mut out = Vec::new();
walk(&manifest_path("src"), &mut out);
assert!(!out.is_empty(), "no sources found under src/");
out
}

/// Screaming-snake identifiers qualified by a prop registry module, as
/// (identifier, file). The emitter names each constant after the flag it
/// carries, so `web::FOO_ENABLED` is the constant for `foo_enabled`.
fn referenced_props(sources: &[(String, String)]) -> Vec<(String, String)> {
let mut found = Vec::new();
for (file, text) in sources {
for module in ["web::", "stale::"] {
let mut rest = text.as_str();
while let Some(at) = rest.find(module) {
rest = &rest[at + module.len()..];
let ident: String = rest
.chars()
.take_while(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || *c == '_')
.collect();
// Lowercase or mixed means this was some other `web::` path,
// not a flag constant.
if ident.len() > 1
&& !rest[ident.len()..].starts_with(|c: char| c.is_alphanumeric())
{
found.push((ident, file.clone()));
}
}
}
}
found
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

#[test]
fn every_ab_prop_this_crate_reads_is_watched() {
let watched: HashMap<String, u32> = WATCHED
.iter()
.map(|p| (p.name.to_uppercase(), p.code))
.collect();

let sources = sources();
let referenced = referenced_props(&sources);
assert!(
!referenced.is_empty(),
"the scan found no prop constants at all, so it is no longer guarding anything"
);

let mut unwatched: Vec<String> = referenced
.iter()
.filter(|(ident, _)| !watched.contains_key(ident))
.map(|(ident, file)| format!("{ident} (read in {file})"))
.collect();
unwatched.sort();
unwatched.dedup();

assert!(
unwatched.is_empty(),
"these A/B props are read but absent from `WATCHED` in \
wacore/src/iq/props.rs, so the server's value is discarded and each \
read yields the registry default forever:\n {}",
unwatched.join("\n "),
);
}
Loading
Loading