Skip to content

P6.x: thread per-unit Callee data forward; convert unit_table_uses_emit and called_cross_context_services - #1202

Merged
accuser merged 2 commits into
mainfrom
p6-project-callee-plumbing
Aug 14, 2026
Merged

P6.x: thread per-unit Callee data forward; convert unit_table_uses_emit and called_cross_context_services#1202
accuser merged 2 commits into
mainfrom
p6-project-callee-plumbing

Conversation

@accuser

@accuser accuser commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Summary

  • Scope: project.rs's 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 PR adds the missing plumbing and converts the two functions it unblocks.
  • Addresses: the plumbing gap a companion PR (P6.x: convert instantiate_provider_expr to bynk-emit::ir's CapRefIr (Provider given/deps wiring) #1200) found blocking project.rs's own eventual
    slice 6 cleanup — both unit_table_uses_emit and called_cross_context_services had an exact,
    already-resolved Callee classification available in principle, but no per-unit Callee map
    reachable at their own call sites.
  • Realises: byte-identical output confirmed across the entire fixture suite, including every fixture
    that exercises the two converted code paths for real.

The 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_buildbuild_output
emit_composition_root.

The two conversions

  • unit_table_uses_emitEvents.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.

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 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 this repo's own greenfield_status_table_is_current test caught during this change (8 → 9
on ast_importers), not assumed safe. Fixed by having callers precompute the bool once and pass it
down, which also simplified all three signatures.

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 by this, not just
structurally unchanged. ast_importers unaffected (still 8, after fixing the regression above).

cargo build --workspace / cargo clippy --workspace --all-targets / cargo fmt --all -- --check /
cargo test --workspace all clean (164 test-result blocks, 0 failures — including
greenfield_status_table_is_current, which caught the regression before this reached review).

What's still out of scope

own_contract_hashes and plan_agent_given_deps remain untouched, per #1200's own findings: the
former hashes syntactic TypeRefs on purpose (deploy-time contract/manifest compatibility — swapping
its data source risks silently changing a stable hash), the latter is blocked on Agent's own
still-deferred given-with-context IR work (IrHandler::given is bare names only).

Done when

  • RunChecks::Checked carries per-unit Callee data, correctly merged across a unit's own files.
  • unit_table_uses_emit/called_cross_context_services read Callee, not raw AST syntax.
  • The wrangler.rs ast_importers regression found and fixed — emit_worker_compose/
    emit_worker_entry/emit_wrangler_toml take a bool, not the Callee map type.
  • Byte-identical output confirmed across the fixture suite, including the specific fixtures that
    exercise both converted paths.
  • ast_importers unaffected (still 8) — stated explicitly.
  • design/pending/p6-project-callee-plumbing.md added (patch level — internal only, no language
    surface change).
  • A new ADR records this decision if the reviewing bot calls for one; its number is assigned at merge.
    Part of Phase 6 — The IR (spine) #1137 — a further step toward project.rs's own slice 6 cleanup, not slice 6 itself.

…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>
@github-actions
github-actions Bot marked this pull request as ready for review August 14, 2026 17:47
@accuser
accuser marked this pull request as draft August 14, 2026 20:12
@accuser
accuser marked this pull request as ready for review August 14, 2026 20:12
Comment thread bynk-emit/src/project.rs
table: &UnitTable,
callees: Option<&HashMap<ExprId, bynk_check::checker::Callee>>,
) -> bool {
let Some(callees) = callees else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread bynk-emit/src/project.rs
&& 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread bynk-emit/src/project.rs Outdated
// 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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review: sound conversion, well-evidenced — three notes, none blocking

I verified the load-bearing claims against the checker rather than taking the description on trust:

  • ExprId keys really are safe to merge per unit. phase_parse (bynk-check/src/project_model.rs:354) threads one next_expr_id across every tree, and injected first-party units get disjoint FIRSTPARTY_ID_BASE blocks — so unit_callees.extend(...) per file cannot collide, and the UnitTable handler bodies the two readers walk carry the same ids the checker keyed its callees map with.
  • The Events.emit match is the right one. Events.emit[E](...) reaches check_static_call's capability branch and records Callee::Capability { cap: type_name.name, op: method.name } (checker/calls.rs:1088/:1140), with cap being the written name — the same string the old Ident("Events") receiver match tested. The flattened consumes bynk { Events } path lands in the same branch, which is why the 5 emit fixtures are byte-identical.
  • Callee::Cross.unit is the resolved unit, not the alias (check_cross_context_call receives cross_context_prefix's output, which goes through resolve_prefix) — so expects keys still match unit_tables keys.
  • The providers arm of called_cross_context_services is still covered: provider op bodies go through check_handler_body with callees: &mut typed.callees (context_checks.rs:687), so converting that arm doesn't silently lose call sites.
  • All four Workers-target gates share one table/ctx_name, so hoisting ctx_uses_emit out of emit_worker_compose/emit_worker_entry/emit_wrangler_toml/the fan-out DO gate is a pure hoist. The bool-not-map choice for wrangler.rs is the right call and the reasoning behind it is worth having in the log.

Findings

  1. project.rs:2774 — the None arm is currently unreachable on the build path (I traced it), but the new failure mode is a silent false that disables four emission gates at once. A debug_assert! makes the invariant enforced rather than documented.
  2. project.rs:3398 — the conversion is not behaviour-preserving: it fixes a latent bug (a local shadowing a consumed-context name used to produce a bogus expects entry, because the old string match had no shadowing check while the checker does). The byte-identical fixture run is silent on exactly this case. One fixture would pin the improvement.
  3. project.rs:1373 — minor: the full per-file Callee map is now retained project-wide for the rest of the build to answer two narrow questions. Fine if later slices will read the rest of it.

Nit

emitter/workers.rs: let ctx_uses_emit = uses_emit; is a leftover rebinding now that the parameter is passed in — the if ctx_uses_emit below can just read uses_emit.

Release discipline

Exactly one increment (design/pending/p6-project-callee-plumbing.md), level: patch with a changelog — correct for an internal refactor with no language-surface change, and correct that it's present at all given this is a non-trivial code change.

- 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>
@accuser
accuser merged commit b003b4c into main Aug 14, 2026
25 checks passed
@accuser
accuser deleted the p6-project-callee-plumbing branch August 14, 2026 20:58
accuser added a commit that referenced this pull request Aug 14, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant