P6.x: thread per-unit Callee data forward; convert unit_table_uses_emit and called_cross_context_services - #1202
Conversation
…it and called_cross_context_services
Scope: project.rs's own check-then-emit pipeline previously built each
file's TypedCommons/CheckedProgram (and the Callee classification it
carries) inside check_unit_files, then dropped it at the end of that same
loop iteration -- before the later, project-wide Workers-target loop
(build_output/emit_composition_root) that needs project-wide facts about
every unit ever runs. This added the missing plumbing: RunChecks::Checked
gains a new unit_callees: HashMap<String, HashMap<ExprId, Callee>> field,
merged per unit (across that unit's own files -- ExprId is a single
project-wide counter, so merging never collides) inside check_unit_files,
threaded through finish_build -> build_output -> emit_composition_root.
On top of that plumbing, two functions convert from re-deriving a
classification the checker already resolved, to reading it directly:
- unit_table_uses_emit: Events.emit detection now matches the checker's
own Callee::Capability{cap:"Events",op:"emit"} (Events.emit dispatches
through the ordinary capability-call path, bynk-check/src/checker/
calls.rs) instead of emitter::block_uses_emit's bare-Ident("Events")-
receiver name match.
- called_cross_context_services: cross-context call detection now reads
Callee::Cross{unit,service} directly instead of flattening a receiver's
own ident chain and string-matching it against consumed/aliases -- the
identical resolution CrossContextInfo::resolve_prefix already did once,
at check time, per call site.
emit_worker_compose/emit_worker_entry/emit_wrangler_toml (emitter/
workers.rs, emitter/workers_entry.rs, emitter/wrangler.rs) each take a
precomputed uses_emit: bool now, not the Callee map itself -- passing the
map's own element type into wrangler.rs would have reintroduced the
literal `bynk_syntax::ast` spelling slice 2 (#1191) specifically removed
from that file (a real regression caught by this repo's own
greenfield_status_table_is_current test during this change, not assumed
safe).
Correctness evidence: BYNK_BLESS=1 cargo test -p bynkc --test e2e
bless_positive_fixtures touched zero files across the whole positive
fixture suite, including the 5 fixtures that use Events.emit and the 72
that use consumes (cross-context) -- both converted code paths are
exercised for real, not just structurally unchanged. ast_importers
unaffected (still 8, after fixing the wrangler.rs regression above).
Part of #1137. A further step toward project.rs's own eventual slice 6
cleanup (design/tracks/the-ir.md section 6) -- own_contract_hashes and
plan_agent_given_deps remain out of scope, named in an earlier PR
(#1200) as, respectively, too risky to touch (a stable contract hash used
for deploy-time compatibility) and blocked on Agent's own still-deferred
given-with-context IR work.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| table: &UnitTable, | ||
| callees: Option<&HashMap<ExprId, bynk_check::checker::Callee>>, | ||
| ) -> bool { | ||
| let Some(callees) = callees else { |
There was a problem hiding this comment.
A missing map now silently disables events emission, where the old signature couldn't be wrong this way.
I traced the build path and agree the None arm is currently unreachable: every unit that reaches build_output certified all of its files (check_file_core returning None, or the group_error_baseline continue in run_checks, both leave errors non-empty, and finish_build's Checked { .. } if !errors.is_empty() bails before build_output), and Mode::Analyse never reaches finish_build. So this isn't a live bug.
What changed is the failure mode. unit_table_uses_emit(table) took only data that was structurally guaranteed to be there; the new form is keyed on a map whose population is now a separate invariant maintained a thousand lines away. If that invariant ever slips — a new early-continue in the unit loop, a new caller in a non-build mode, a key-normalisation drift between unit_info and unit_tables — the result is not a crash or a diagnostic but false, which flows into four gates at once: no events_fanout.ts, no dispatchToEventsFanout import, no EVENTS_FANOUT DO class in wrangler.toml, and no __eventsDispatch field in compose. A publishing context would compile, typecheck, and deploy clean while silently dropping every emitted event.
Cheap insurance, given the invariant is real today:
let Some(callees) = callees else {
debug_assert!(false, "unit_table_uses_emit: no Callee map for a checked unit");
return false;
};Same argument applies to the None arm at line 3394 in called_cross_context_services, where the consequence is a silently thinner expects in the contracts manifest — a skew gate that stops firing rather than one that fires wrongly.
| && let Some(target) = resolve(&chain) | ||
| { | ||
| out.entry(target).or_default().insert(method.name.clone()); | ||
| if let Some(bynk_check::checker::Callee::Cross { unit, service }) = callees.get(&e.id) { |
There was a problem hiding this comment.
This conversion is not behaviour-preserving — it fixes a latent bug — and nothing in the suite pins the fix.
The old resolve closure matched a flattened receiver chain against consumed/aliases with no shadowing check. Callee::Cross is only recorded by check_cross_context_call, which is gated on ctx.lookup_root_ident(receiver).is_none() and cross_context_prefix's own if ctx.lookup(head).is_some() { return None } (bynk-check/src/checker/calls.rs). So for a context with consumes payment, a handler doing:
let payment = ... // a local whose type has an `authorise` method
payment.authorise(...)
previously recorded expects: { payment: { authorise: <hash> } } in the contracts manifest — a skew gate over a service this caller never calls, exactly the deployment tax this function's own doc comment argues against. After this change it correctly records nothing.
That's a genuine improvement, and it's the strongest argument for the conversion — but the byte-identical fixture run is byte-identical because no fixture shadows a consumed-context name, so the suite proves the unchanged paths unchanged and says nothing about the one case where old and new disagree. Worth one fixture (or a unit test over called_cross_context_services) with a local binding shadowing a consumed context, asserting it produces no expects entry — otherwise a future revert to string matching reintroduces the bug with a green suite.
Secondary case with the same shape, on the other converted function: a locally declared type Events with a static emit used to flip unit_table_uses_emit to true via the bare-Ident name match; it now lands as Callee::Static and correctly doesn't. Also untested.
| // classification into the unit's accumulator before `program` (and | ||
| // the `TypedCommons` it wraps) is dropped at the end of this | ||
| // iteration — the only point in this pipeline that ever holds it. | ||
| unit_callees.extend(program.program().callees.clone()); |
There was a problem hiding this comment.
Minor, non-blocking: this now retains every call site's Callee for every file of every unit for the whole remaining build, where previously the per-file map died with program at the end of the iteration. Two consumers need exactly two variants (Capability{cap:"Events",op:"emit"} and Cross), so on a large project this is a fair amount of live String/Arc per call site kept alive to answer two boolean-ish questions.
I'd leave it as-is if the intent is genuinely to reuse the full map in later slices (the doc comment reads that way) — that's a reasonable trade. If not, filtering at merge time (.filter(|(_, c)| matches!(c, Callee::Cross{..} | Callee::Capability{..}))) costs nothing and keeps the retained set proportional to what's read. The .clone() itself also looks avoidable if CheckedProgram can yield its Program by value after emit_unit is done borrowing it.
Review: sound conversion, well-evidenced — three notes, none blockingI verified the load-bearing claims against the checker rather than taking the description on trust:
Findings
Nit
Release disciplineExactly one increment ( |
- unit_table_uses_emit and called_cross_context_services: debug_assert
when callees is unexpectedly None for a checked unit, rather than
silently returning false/empty. Traced live: unreachable on the build
path today, but a missing map would otherwise silently disable four
emission gates at once (or thin the contracts manifest) with no
diagnostic.
- New fixture 1203_cross_context_call_shadowed_by_local: pins the real
correctness improvement the conversion isn't just "behaviour-preserving"
on -- a local binding shadowing a consumed-context alias, whose own type
has a same-named method, now correctly produces an empty `expects` in
the contracts manifest instead of a bogus cross-context entry (the old
string-matching walk had no shadowing check).
- unit_callees is now filtered at merge time to only the two Callee
variants either reader matches on (Cross, Capability{Events,emit}),
rather than retaining every call site's full classification
project-wide for the rest of the build.
- emitter/workers.rs nit: drop the redundant `let ctx_uses_emit = uses_emit`
rebinding, read the parameter directly.
A second fixture (a locally-declared `Events` type with its own static
`emit`, testing unit_table_uses_emit's matching same-shape improvement)
was written, found to fail `tsc --strict`, and deliberately not included --
see the new doc comment on unit_table_uses_emit for why: it exposed a real,
separate, pre-existing inconsistency between this function (now Callee-
based) and emitter::block_uses_emit (still AST-driven, decides individual
handlers' own deps.__eventsDispatch parameter threading). Before this PR
both used the same syntactic match and agreed, if wrongly, on a shadowed
Events type; now they can disagree, which is a real narrow regression this
PR does not fix. Named as a separate, larger follow-on (block_uses_emit's
several call sites across emit.rs/emitter.rs/ir/lower.rs), not attempted
here -- confirmed by hand rather than landed as a broken fixture.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lved Callee Closes the real inconsistency #1202's own review found and documented (in a code comment on unit_table_uses_emit, not silently): before this commit, unit_table_uses_emit (project.rs, converted in #1202) read the checker's resolved Callee::Capability{cap:"Events",op:"emit"}, while its per-handler twin block_uses_emit (deciding emit_service/emit_agent's own deps.__eventsDispatch parameter threading) still matched a bare Ident("Events") receiver by name -- the same syntactic approach both used before #1202, and both accepted as an "approximation" per this function's own prior doc comment (matching block_uses_send's sibling precedent). That approximation stopped being harmless the moment only one of the two checks became precise: a locally-declared type also named `Events` with its own static `emit` method (legal Bynk, resolves to Callee::Static, not Callee::Capability) made the two disagree -- unit_table_uses_emit correctly skips compose/fan-out generation, while block_uses_emit still threads a deps.__eventsDispatch parameter with nothing left to supply it. Confirmed with a real fixture: 1204_events_emit_shadowed_by_local_type failed `tsc --strict` under #1202 alone (found during that PR's own review, documented, deliberately not landed broken) and passes clean now that both checks agree. block_uses_emit's own callers all already had a TypedCommons/CheckedProgram in scope (emit_service/emit_agent take `commons`, the ir::lower callers take `program`), so this is purely a signature change plus call-site threading -- no new plumbing needed, unlike #1202's own project.rs-level fix. block_uses_send needs no matching conversion: a `~>` send is a real Statement::Send AST variant, not a method call that could be shadowed, so it was never approximate the way block_uses_emit's method-name match was. Correctness evidence: BYNK_BLESS=1 cargo test -p bynkc --test e2e bless_positive_fixtures touched zero existing files. `cargo test -p bynkc --test tsc_verify` (tsc --strict over every fixture's real emitted output) is clean, including the new fixture. ast_importers unaffected (still 8). Part of #1137. A further step toward project.rs's own slice 6 cleanup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
project.rs's check-then-emit pipeline previously built each file'sTypedCommons/CheckedProgram(and theCalleeclassification it carries) insidecheck_unit_files, then droppedit at the end of that same loop iteration — before the later, project-wide Workers-target loop
(
build_output/emit_composition_root) that needs project-wide facts about every unit ever runs.This PR adds the missing plumbing and converts the two functions it unblocks.
project.rs's own eventualslice 6 cleanup — both
unit_table_uses_emitandcalled_cross_context_serviceshad an exact,already-resolved
Calleeclassification available in principle, but no per-unitCalleemapreachable at their own call sites.
that exercises the two converted code paths for real.
The plumbing
RunChecks::Checkedgains a newunit_callees: HashMap<String, HashMap<ExprId, Callee>>field, mergedper unit (across that unit's own files —
ExprIdis a single project-wide counter, so merging nevercollides) inside
check_unit_files, threaded throughfinish_build→build_output→emit_composition_root.The two conversions
unit_table_uses_emit—Events.emitdetection now matches the checker's ownCallee::Capability{cap:"Events",op:"emit"}(Events.emitdispatches through the ordinarycapability-call path,
bynk-check/src/checker/calls.rs) instead ofemitter::block_uses_emit'sbare-
Ident("Events")-receiver name match.called_cross_context_services— cross-context call detection now readsCallee::Cross{unit,service}directly instead of flattening a receiver's own ident chain andstring-matching it against
consumed/aliases— the identical resolutionCrossContextInfo::resolve_prefixalready did once, at check time, per call site.A regression found and fixed along the way
emit_worker_compose/emit_worker_entry/emit_wrangler_toml(emitter/workers.rs,emitter/workers_entry.rs,emitter/wrangler.rs) each take a precomputeduses_emit: boolnow, notthe
Calleemap itself. Passing the map's own element type intowrangler.rswould have reintroducedthe literal
bynk_syntax::astspelling slice 2 (#1191) specifically removed from that file — a realregression this repo's own
greenfield_status_table_is_currenttest caught during this change (8 → 9on
ast_importers), not assumed safe. Fixed by having callers precompute the bool once and pass itdown, which also simplified all three signatures.
Correctness evidence
BYNK_BLESS=1 cargo test -p bynkc --test e2e bless_positive_fixturestouched zero files across thewhole positive fixture suite — including the 5 fixtures that use
Events.emitand the 72 that useconsumes(cross-context). Both converted code paths are exercised for real by this, not juststructurally unchanged.
ast_importersunaffected (still 8, after fixing the regression above).cargo build --workspace/cargo clippy --workspace --all-targets/cargo fmt --all -- --check/cargo test --workspaceall clean (164 test-result blocks, 0 failures — includinggreenfield_status_table_is_current, which caught the regression before this reached review).What's still out of scope
own_contract_hashesandplan_agent_given_depsremain untouched, per #1200's own findings: theformer hashes syntactic
TypeRefs on purpose (deploy-time contract/manifest compatibility — swappingits data source risks silently changing a stable hash), the latter is blocked on Agent's own
still-deferred given-with-context IR work (
IrHandler::givenis bare names only).Done when
RunChecks::Checkedcarries per-unitCalleedata, correctly merged across a unit's own files.unit_table_uses_emit/called_cross_context_servicesreadCallee, not raw AST syntax.wrangler.rsast_importersregression found and fixed —emit_worker_compose/emit_worker_entry/emit_wrangler_tomltake abool, not theCalleemap type.exercise both converted paths.
ast_importersunaffected (still 8) — stated explicitly.design/pending/p6-project-callee-plumbing.mdadded (patch level — internal only, no languagesurface change).
Part of Phase 6 — The IR (spine) #1137 — a further step toward
project.rs's own slice 6 cleanup, not slice 6 itself.