diff --git a/bynk-emit/src/emitter/workers.rs b/bynk-emit/src/emitter/workers.rs index 51132b615..6fd1beeb6 100644 --- a/bynk-emit/src/emitter/workers.rs +++ b/bynk-emit/src/emitter/workers.rs @@ -17,7 +17,7 @@ use crate::emitter::{ BOUNDARY_CODEC_RUNTIME_IMPORTS, BYTES_RUNTIME_IMPORTS, JSON_CODEC_RUNTIME_IMPORTS, RuntimeUse, inject_runtime_imports, }; -use crate::project::{ImportExt, LocaleNegotiationArgs, UnitTable, unit_table_uses_emit}; +use crate::project::{ImportExt, LocaleNegotiationArgs, UnitTable}; use bynk_check::symbols::MessageBundleInfo; use bynk_syntax::ast::*; @@ -57,6 +57,14 @@ pub(crate) fn emit_worker_compose( // the caller already resolved the cardinality, this function only acts). locale_bundle: Option<&MessageBundleInfo>, import_ext: ImportExt, + // #1187's slice 6 plumbing: `unit_table_uses_emit(table, callees)`, + // precomputed by the caller (`crate::project::RunChecks::Checked:: + // unit_callees`'s own doc comment has the full grounding for what feeds + // it) — reads the checker's own already-resolved `Callee` classification + // instead of re-deriving `Events.emit` detection from raw AST syntax. A + // bare `bool`, not the `Callee` map itself: this function has exactly + // one use for it. + uses_emit: bool, ) -> (String, bool) { let mut out = String::new(); let _ = writeln!(out, "// Generated by bynkc — do not edit by hand."); @@ -199,8 +207,7 @@ pub(crate) fn emit_worker_compose( // `deps.__eventsDispatch` that calls into it — mirrors `unit_table_uses_ // emit`'s Bundle-mode gate on `composeApp`'s `__eventsDispatch` closure, // so the two targets agree on when the field exists. - let ctx_uses_emit = unit_table_uses_emit(table); - if ctx_uses_emit { + if uses_emit { runtime_imports.push("dispatchToEventsFanout"); } let _ = writeln!( @@ -258,14 +265,14 @@ pub(crate) fn emit_worker_compose( let bind = agent_binding_name(a); let _ = writeln!(out, " {bind}: DurableObjectNamespace;"); } - if ctx_uses_emit { + if uses_emit { let bind = agent_binding_name(EVENTS_FANOUT_CLASS_NAME); let _ = writeln!(out, " {bind}: DurableObjectNamespace;"); } let _ = writeln!(out, "}}"); writeln!(out).unwrap(); - if !agent_names.is_empty() || ctx_uses_emit { + if !agent_names.is_empty() || uses_emit { let _ = writeln!( out, "type DurableObjectNamespace = {{ idFromName(name: string): {{ toString(): string }}; get(id: any): any }};" @@ -351,7 +358,7 @@ pub(crate) fn emit_worker_compose( // release-at-commit event batch is handed to this context's own fan-out // DO — `env.` is typed by the `Env` interface built above, one // instance per publishing context. - if ctx_uses_emit { + if uses_emit { let bind = agent_binding_name(EVENTS_FANOUT_CLASS_NAME); deps_entries.push(format!( "__eventsDispatch: (events: Array<{}>) => dispatchToEventsFanout(env.{bind}, events)", diff --git a/bynk-emit/src/emitter/workers_entry.rs b/bynk-emit/src/emitter/workers_entry.rs index ef10f4814..f9d71a17f 100644 --- a/bynk-emit/src/emitter/workers_entry.rs +++ b/bynk-emit/src/emitter/workers_entry.rs @@ -27,6 +27,9 @@ pub(crate) fn emit_worker_entry( // `scheduled`/`queue`, so the two entry points need distinct compose // calls, not one shared string. needs_locale_request: bool, + // #1187's slice 6 plumbing — see `emit_worker_compose`'s own matching + // parameter (`emitter/workers.rs`) for the full grounding. + uses_emit: bool, ) -> String { let mut out = String::new(); // Which conditional runtime helpers the entry's own inbound/outbound codecs @@ -280,7 +283,7 @@ pub(crate) fn emit_worker_entry( // requirement, for the fan-out DO — it lives in its own file // (`events_fanout.ts`, not `handlers.ts`; a fan-out DO has no backing // `AgentDecl` for `emit_agent` to emit it from). - if crate::project::unit_table_uses_emit(table) { + if uses_emit { let _ = writeln!( out, "export {{ {} }} from \"./events_fanout.js\";", diff --git a/bynk-emit/src/emitter/wrangler.rs b/bynk-emit/src/emitter/wrangler.rs index 939323b4e..e1670a12f 100644 --- a/bynk-emit/src/emitter/wrangler.rs +++ b/bynk-emit/src/emitter/wrangler.rs @@ -6,7 +6,7 @@ use std::fmt::Write as _; -use crate::project::{UnitTable, unit_table_uses_emit, worker_dir_name}; +use crate::project::{UnitTable, worker_dir_name}; /// Compile-time pinned compatibility date. Cloudflare uses this to lock /// Workers runtime behaviour. Bump cautiously when changing the runtime @@ -61,6 +61,12 @@ pub(crate) fn emit_wrangler_toml( // v0.10b/v0.44: every `from queue("name")` service's bound queue name, // sorted+deduped (same reproducibility requirement as `crons`). queues: &[String], + // #1187's slice 6 plumbing: `unit_table_uses_emit(table, callees)`, + // precomputed by the caller — passing a bare `bool` rather than the + // `Callee` map itself keeps this file's own hard-won zero `bynk_syntax:: + // ast` footprint (#1191) intact; the map's own element type would have + // reintroduced exactly the literal spelling that slice removed. + uses_emit: bool, ) -> String { let name = worker_dir_name(context); let mut out = String::new(); @@ -101,7 +107,7 @@ pub(crate) fn emit_wrangler_toml( // only cares that `index.ts` (this Worker's `main`) exports a class with // this name, not which generated file it came from. let mut class_names: Vec = table.agents.keys().cloned().collect(); - if unit_table_uses_emit(table) { + if uses_emit { class_names.push(EVENTS_FANOUT_CLASS_NAME.to_string()); } class_names.sort(); diff --git a/bynk-emit/src/project.rs b/bynk-emit/src/project.rs index 78bf964f3..8f7b12196 100644 --- a/bynk-emit/src/project.rs +++ b/bynk-emit/src/project.rs @@ -687,6 +687,7 @@ fn finish_build(run: RunChecks, import_ext: ImportExt) -> Result Result, tys: &Arc, + // #1187's slice 6 plumbing — this unit's own accumulator; merged into + // per file below, from each file's own certified `CheckedProgram` + // (`RunChecks::Checked::unit_callees`'s own doc comment has the full + // grounding for why this exists). + unit_callees: &mut HashMap, ) { // Emit-prologue tables invariant across every file of this unit — built // once here rather than once per file (see `EmitUnitCtx`). @@ -1359,6 +1366,26 @@ fn check_unit_files( let program = checker::certify(typed, Vec::new()).unwrap_or_else(|_| { panic!("bynk internal error: unit already passed every per-unit gate above") }); + // #1187's slice 6 plumbing: merge this file's own resolved `Callee` + // 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. + // Filtered to the two variants either reader actually matches on + // (review of #1202): every other `Callee` variant would otherwise + // sit retained project-wide, for the rest of the build, to answer + // two boolean-ish questions — a real `String`/`Arc` cost on a large + // project with nothing reading the rest yet. Widen this filter (or + // drop it) the moment a future reader needs a different variant. + unit_callees.extend(program.program().callees.iter().filter_map(|(id, c)| { + let keep = match c { + bynk_check::checker::Callee::Cross { .. } => true, + bynk_check::checker::Callee::Capability { cap, op } => { + cap == "Events" && op == "emit" + } + _ => false, + }; + keep.then(|| (*id, c.clone())) + })); emit_unit( name, kind, @@ -1417,6 +1444,25 @@ enum RunChecks { unit_consumes: HashMap>, unit_consumes_aliases: HashMap>, unit_tables: HashMap, + // #1187's slice 6 plumbing: each unit's own `Callee` classification, + // merged across its files (`ExprId` is a single project-wide + // counter, `project_model.rs`'s `next_expr_id`, so merging different + // files' maps never collides) — checked, resolved data the pre-check + // `unit_tables` above cannot carry. Exists so a later, project-wide + // pass (`build_output`/`emit_composition_root`) can read an + // already-resolved `Callee::Capability`/`Callee::Cross` instead of + // re-deriving the same fact by walking raw AST method-call syntax — + // `check_unit_files`'s own per-file `CheckedProgram` was previously + // built and dropped before any such later pass ever ran. Filtered at + // merge time (`check_unit_files`'s own `unit_callees.extend` call, + // review of #1202) to only `Callee::Cross` and + // `Callee::Capability{cap:"Events",op:"emit"}` — the two variants + // `unit_table_uses_emit`/`called_cross_context_services` actually + // read today; widen the filter (or drop it) the moment a future + // reader needs a different variant, rather than paying to retain + // every call site's full classification project-wide for the rest + // of the build on spec. + unit_callees: HashMap>, unit_flattened: HashMap>, adapter_bindings: HashMap, npm_deps: std::collections::BTreeMap, @@ -1752,6 +1798,11 @@ fn run_checks( // -- 8. For each unit, build the combined symbol space and run // resolve+check per source file. -- let mut compiled: Vec = Vec::new(); + // #1187's slice 6 plumbing (see `RunChecks::Checked::unit_callees`'s own + // doc comment) — one `Callee` map per unit, merged across that unit's + // own files inside the loop below. + let mut unit_callees: HashMap> = + HashMap::new(); // v0.119 (testing track slice 7, ADR 0155): a project-wide fold over every // parsed file, producing the identical `HashSet` regardless of which unit @@ -1843,6 +1894,7 @@ fn run_checks( &mut compiled, &schema_effective_versions, tys, + unit_callees.entry(name.clone()).or_default(), ); } @@ -1954,6 +2006,7 @@ fn run_checks( unit_consumes, unit_consumes_aliases, unit_tables, + unit_callees, unit_flattened, adapter_bindings, npm_deps, @@ -1978,6 +2031,7 @@ fn build_output( unit_consumes: HashMap>, unit_consumes_aliases: HashMap>, unit_tables: HashMap, + unit_callees: HashMap>, // v0.177 (#643): needed to build each context's *own* combined type table, // so its contract hashes are computed from the same namespace a caller sees. unit_uses: HashMap>, @@ -2048,6 +2102,7 @@ fn build_output( &unit_consumes, &unit_consumes_aliases, &unit_tables, + &unit_callees, &adapter_bindings, &unit_flattened, // D1: thread `env` through composeApp only when a native @@ -2128,6 +2183,9 @@ fn build_output( } _ => None, }; + // #1187's slice 6 plumbing: computed once, reused by every + // Workers-target emitter below that needs it. + let ctx_uses_emit = unit_table_uses_emit(table, unit_callees.get(ctx_name)); let (compose_ts, needs_locale_request) = emitter::emit_worker_compose( ctx_name, table, @@ -2142,12 +2200,14 @@ fn build_output( needs_kv, locale_bundle_info, import_ext, + ctx_uses_emit, ); let entry_ts = emitter::emit_worker_entry( ctx_name, table, &own_contracts, needs_locale_request, + ctx_uses_emit, ); // Adapters are not Workers, so they get no Service Binding in // the consumer's wrangler config — drop them from the list. @@ -2206,6 +2266,7 @@ fn build_output( needs_kv, &crons, &queues, + ctx_uses_emit, ); compiled.push(CompiledFile { source_path: PathBuf::from(format!("workers/{dashes}/")), @@ -2219,7 +2280,7 @@ fn build_output( // `emit_worker_compose`'s own `unit_table_uses_emit` gate on // `deps.__eventsDispatch`, so the two never disagree about // whether `env.EVENTS_FANOUT` is real). - if unit_table_uses_emit(table) { + if ctx_uses_emit { let fanout_ts = emitter::emit_events_fanout_do(ctx_name, &own_event_routes); compiled.push(CompiledFile { source_path: PathBuf::from(format!("workers/{dashes}/")), @@ -2275,7 +2336,7 @@ fn build_output( .get(ctx_name) .map(Vec::as_slice) .unwrap_or(&[]), - &aliases, + unit_callees.get(ctx_name), ); let mut expects: std::collections::BTreeMap< String, @@ -2722,17 +2783,81 @@ pub(crate) fn instantiate_provider_expr( /// Events track, slice 0 (spine #936): does any handler in this unit emit — /// the `UnitTable`-level analogue of `emitter::commons_uses_emit`, needed /// here because compose works from the project-wide `UnitTable` map, not a -/// single unit's `TypedCommons`. -pub(crate) fn unit_table_uses_emit(table: &UnitTable) -> bool { - table.services.values().any(|s| { - s.handlers - .iter() - .any(|h| crate::emitter::block_uses_emit(&h.body)) - }) || table.agents.values().any(|a| { - a.handlers - .iter() - .any(|h| crate::emitter::block_uses_emit(&h.body)) - }) +/// single unit's `TypedCommons`. #1187's slice 6 plumbing: reads the +/// checker's own already-resolved `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. +/// `callees` is `None` only defensively (a unit whose own check never ran) — +/// every call site this function actually reaches has already certified +/// (review of #1202: traced live, confirmed unreachable on the build path +/// today). A silent `false` here disables four emission gates at once (no +/// fan-out DO, no `dispatchToEventsFanout` import, no `EVENTS_FANOUT` +/// binding, no `__eventsDispatch` field) with no diagnostic — `debug_assert` +/// makes that invariant enforced, not just documented, so a future caller +/// that violates it fails loudly in tests rather than shipping a publishing +/// context that silently drops every emitted event. +/// +/// **Known follow-on, found while adding a regression fixture for this PR, +/// deliberately not attempted here:** `emitter::block_uses_emit` — the +/// still-AST-driven, per-*handler* twin this function used to share its own +/// name-matching logic with — decides `emit_service`/`emit_agent`'s own +/// `deps.__eventsDispatch` *parameter* threading (`emit.rs`'s +/// `needs_events_dispatch`/`body_emits_directly`), a genuinely different +/// call site from this project-wide compose-gating one. Before this PR both +/// checks used the same syntactic bare-`Ident("Events")` match, so a +/// locally-declared type also named `Events` with its own static `emit` +/// (legal, resolves to `Callee::Static`) fooled both identically — +/// needlessly wiring up real event-fanout machinery for a call that has +/// nothing to do with the capability, but *consistently*, so the emitted +/// TypeScript still type-checked. Now that this function reads the +/// checker's own resolved `Callee` and `block_uses_emit` still does not, +/// the two can disagree on exactly that shadowed-name case: this function +/// correctly says "no real emit here" (skips compose/fan-out generation), +/// while a handler's own body still gets a `deps.__eventsDispatch` call +/// site with nothing left to supply it — a `tsc` type error, confirmed by +/// hand (a fixture hitting this shape was written for review of #1202, +/// found to fail `tsc --strict`, and removed rather than landed broken). +/// Closing this needs `block_uses_emit`'s own several call sites +/// (`emit.rs`, `emitter.rs`, `ir/lower.rs`) converted too — a real, larger, +/// separately-scoped slice, not attempted here. +pub(crate) fn unit_table_uses_emit( + table: &UnitTable, + callees: Option<&HashMap>, +) -> bool { + let Some(callees) = callees else { + debug_assert!( + false, + "unit_table_uses_emit: no Callee map for a checked unit" + ); + return false; + }; + fn body_uses_emit( + body: &Block, + callees: &HashMap, + ) -> bool { + let mut found = false; + crate::emitter::walk_block_exprs(body, &mut |e| { + if !found + && matches!( + callees.get(&e.id), + Some(bynk_check::checker::Callee::Capability { cap, op }) + if cap == "Events" && op == "emit" + ) + { + found = true; + } + }); + found + } + table + .services + .values() + .any(|s| s.handlers.iter().any(|h| body_uses_emit(&h.body, callees))) + || table + .agents + .values() + .any(|a| a.handlers.iter().any(|h| body_uses_emit(&h.body, callees))) } /// Events track, slice 0 (spine #936): project-wide "who subscribes to @@ -2798,6 +2923,7 @@ fn emit_composition_root( unit_consumes: &HashMap>, unit_consumes_aliases: &HashMap>, unit_tables: &HashMap, + unit_callees: &HashMap>, adapter_bindings: &HashMap, unit_flattened: &HashMap>, // v0.19 (decision 0025, D1): when the program's closure reaches a @@ -2858,7 +2984,7 @@ fn emit_composition_root( // (there is no `EventsProvider`), so without this check a // publish-only context would never get a compose entry and // its service would simply never be called. - || unit_table_uses_emit(table) + || unit_table_uses_emit(table, unit_callees.get(name)) { needs_compose = true; break; @@ -2996,7 +3122,7 @@ fn emit_composition_root( // has run, so declaration order here doesn't matter). A publisher // with no subscribers still gets the field — its type is required — // just with an empty switch. - if unit_table_uses_emit(table) { + if unit_table_uses_emit(table, unit_callees.get(ctx_name)) { let mut cases = String::new(); for name in table.events.keys() { let Some(subs) = event_subscribers.get(&(ctx_name.clone(), name.clone())) else { @@ -3307,30 +3433,38 @@ fn _ensure_components_used(_p: &Path) { fn called_cross_context_services( table: &UnitTable, consumed: &[String], - aliases: &HashMap, + // #1187's slice 6 plumbing: reads the checker's own already-resolved + // `Callee::Cross { unit, service }` (`RunChecks::Checked::unit_callees`'s + // own doc comment has the full grounding) instead of re-deriving + // cross-context-ness by 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. `consumed` stays, purely as the cheap + // early-out below: an empty `consumes` list means no `Callee::Cross` + // could exist in this unit's own bodies regardless, so skip the walk. + callees: Option<&HashMap>, ) -> std::collections::BTreeMap> { let mut out: std::collections::BTreeMap> = std::collections::BTreeMap::new(); if consumed.is_empty() { return out; } - // Resolve a receiver chain to a consumed context: an alias, or the dotted - // name itself. Mirrors `CrossContextInfo::resolve_prefix`, over the maps - // `build_output` already holds. - let resolve = |chain: &str| -> Option { - if let Some(target) = aliases.get(chain) { - return Some(target.clone()); - } - consumed.iter().find(|c| *c == chain).cloned() + // See `unit_table_uses_emit`'s own matching `debug_assert` (review of + // #1202) — `consumed` non-empty means this unit certified with a real + // `consumes`, so `callees` missing here is the same "invariant broke a + // thousand lines away" case, just silently thinning the contracts + // manifest's `expects` instead of silently disabling emission. + let Some(callees) = callees else { + debug_assert!( + false, + "called_cross_context_services: no Callee map for a checked unit with a non-empty \ + consumes list" + ); + return out; }; let mut visit = |e: &bynk_syntax::ast::Expr| { - if let bynk_syntax::ast::ExprKind::MethodCall { - receiver, method, .. - } = &e.kind - && let Some(chain) = emitter::flatten_emit_ident_chain(receiver) - && 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) { + out.entry(unit.clone()).or_default().insert(service.clone()); } }; for service in table.services.values() { diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/bynk-contracts.json b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/bynk-contracts.json new file mode 100644 index 000000000..7e97cc847 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/bynk-contracts.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "provides": { + "log": "d35a05f56e7920e1" + }, + "expects": {} +} diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/compose.ts b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/compose.ts new file mode 100644 index 000000000..5e9161dc3 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/compose.ts @@ -0,0 +1,18 @@ +// Generated by bynkc — do not edit by hand. +// composition root for `shop.audit` Worker. + +import { type ServiceBinding } from "../../runtime.js"; +import * as handlers from "./handlers.js"; + +export interface Env { + SHOP_LEDGER: ServiceBinding; +} + +export function compose(env: Env) { + const deps = { env }; + return { + async log(amount: number) { + return handlers.log.call(amount, deps); + }, + }; +} diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/handlers.ts b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/handlers.ts new file mode 100644 index 000000000..f45418332 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/handlers.ts @@ -0,0 +1,54 @@ +// Generated by bynkc — do not edit by hand. +// context shop.audit + +import { Ok, Err, Some, None, type Result, type Option, type ValidationError, type JsonValue, type BoundaryError, type ServiceBinding, callService, boundaryError } from "../../runtime.js"; + +import type * as shop_ledger from "../shop-ledger/handlers.js"; + +export interface LocalLedger { + readonly balance: number; +} + +export const LocalLedger = { + authorise(self: LocalLedger, amount: number): Promise> { + return Promise.resolve(Ok(amount)); + }, +}; + +export const log = { + async call(amount: number, deps: { env: { SHOP_LEDGER: ServiceBinding } }): Promise> { + const Ledger = { balance: 0 }; + const r = await callService(deps.env.SHOP_LEDGER, "authorise", amount as JsonValue, deserialise_Result_Int_String, "shop.audit", "3042f1b7519e621d"); + return r; + }, +}; + +export function serialise_Result_Int_String(value: Result): JsonValue { + if (value.tag === "Ok") return { kind: "Ok", value: value.value as JsonValue }; + return { kind: "Err", error: value.error as JsonValue }; +} + +export function deserialise_Result_Int_String(json: JsonValue, path: string = "$"): Result, BoundaryError> { + if (typeof json !== "object" || json === null || Array.isArray(json)) { + return Err({ kind: "StructuralMismatch", path, expected: "object", actual: typeof json }); + } + const obj = json as { [k: string]: JsonValue }; + if (obj["kind"] === "Ok") { + if (typeof obj["value"] !== "number") { + return Err({ kind: "StructuralMismatch", path: `${path}.value`, expected: "number", actual: typeof obj["value"] }); + } + if (!Number.isInteger(obj["value"])) { + return Err({ kind: "StructuralMismatch", path: `${path}.value`, expected: "integer", actual: String(obj["value"]) }); + } + const __v = obj["value"]; + return Ok(Ok(__v) as Result); + } else if (obj["kind"] === "Err") { + if (typeof obj["error"] !== "string") { + return Err({ kind: "StructuralMismatch", path: `${path}.error`, expected: "string", actual: typeof obj["error"] }); + } + const __e = obj["error"]; + return Ok(Err(__e) as Result); + } + return Err({ kind: "StructuralMismatch", path, expected: "Ok | Err", actual: String(obj["kind"]) }); +} + diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/index.ts b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/index.ts new file mode 100644 index 000000000..a09f5df39 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/index.ts @@ -0,0 +1,39 @@ +// Generated by bynkc — do not edit by hand. +// Worker entry point for context `shop.audit`. + +import { Ok, Err, type Result, type JsonValue, type BoundaryError, boundaryError } from "../../runtime.js"; +import { compose, type Env } from "./compose.js"; +import * as handlers from "./handlers.js"; + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const path = url.pathname; + const method = request.method; + const surface = compose(env); + try { + if (path.startsWith("/_bynk/call/")) { + const servicePath = path.slice("/_bynk/call/".length); + switch (servicePath) { + case "log": { + const __contract = request.headers.get("X-Bynk-Contract"); + if (__contract !== "d35a05f56e7920e1") return new Response(JSON.stringify({ kind: "ContractMismatch", service: "log", expected: "d35a05f56e7920e1", actual: __contract }), { status: 409, headers: { "content-type": "application/json" } }); + const args = await request.json() as JsonValue; + const __r_amount = ((__v) => typeof __v !== "number" ? Err({ kind: "StructuralMismatch", path: "$", expected: "integer", actual: typeof __v } as BoundaryError) : Number.isInteger(__v) ? Ok(__v) : Err({ kind: "StructuralMismatch", path: "$", expected: "integer", actual: String(__v) } as BoundaryError))(args); + if (__r_amount.tag === "Err") return new Response(JSON.stringify(__r_amount.error), { status: 400, headers: { "content-type": "application/json" } }); + const amount = __r_amount.value; + const result = await surface.log(amount); + const body = handlers.serialise_Result_Int_String(result); + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + } + default: + return new Response("Not found", { status: 404 }); + } + } + + return new Response("Not Found", { status: 404 }); + } catch { + return new Response("Internal Server Error", { status: 500 }); + } + }, +}; diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/wrangler.toml b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/wrangler.toml new file mode 100644 index 000000000..76499e235 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-audit/wrangler.toml @@ -0,0 +1,9 @@ +# Generated by bynkc — do not edit by hand. +name = "shop-audit" +main = "index.ts" +compatibility_date = "2024-11-01" + +[[services]] +binding = "SHOP_LEDGER" +service = "shop-ledger" + diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/bynk-contracts.json b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/bynk-contracts.json new file mode 100644 index 000000000..09f62a6e5 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/bynk-contracts.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "provides": { + "authorise": "3042f1b7519e621d" + }, + "expects": {} +} diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/compose.ts b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/compose.ts new file mode 100644 index 000000000..5032d71d4 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/compose.ts @@ -0,0 +1,17 @@ +// Generated by bynkc — do not edit by hand. +// composition root for `shop.ledger` Worker. + +import { type ServiceBinding } from "../../runtime.js"; +import * as handlers from "./handlers.js"; + +export interface Env { +} + +export function compose(env: Env) { + const deps = { }; + return { + async authorise(amount: number) { + return handlers.authorise.call(amount, deps); + }, + }; +} diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/handlers.ts b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/handlers.ts new file mode 100644 index 000000000..2112dbd41 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/handlers.ts @@ -0,0 +1,40 @@ +// Generated by bynkc — do not edit by hand. +// context shop.ledger + +import { Ok, Err, Some, None, type Result, type Option, type ValidationError, type JsonValue, type BoundaryError, type ServiceBinding, callService, boundaryError } from "../../runtime.js"; + +export const authorise = { + async call(amount: number, deps: {}): Promise> { + return Ok(amount); + }, +}; + +export function serialise_Result_Int_String(value: Result): JsonValue { + if (value.tag === "Ok") return { kind: "Ok", value: value.value as JsonValue }; + return { kind: "Err", error: value.error as JsonValue }; +} + +export function deserialise_Result_Int_String(json: JsonValue, path: string = "$"): Result, BoundaryError> { + if (typeof json !== "object" || json === null || Array.isArray(json)) { + return Err({ kind: "StructuralMismatch", path, expected: "object", actual: typeof json }); + } + const obj = json as { [k: string]: JsonValue }; + if (obj["kind"] === "Ok") { + if (typeof obj["value"] !== "number") { + return Err({ kind: "StructuralMismatch", path: `${path}.value`, expected: "number", actual: typeof obj["value"] }); + } + if (!Number.isInteger(obj["value"])) { + return Err({ kind: "StructuralMismatch", path: `${path}.value`, expected: "integer", actual: String(obj["value"]) }); + } + const __v = obj["value"]; + return Ok(Ok(__v) as Result); + } else if (obj["kind"] === "Err") { + if (typeof obj["error"] !== "string") { + return Err({ kind: "StructuralMismatch", path: `${path}.error`, expected: "string", actual: typeof obj["error"] }); + } + const __e = obj["error"]; + return Ok(Err(__e) as Result); + } + return Err({ kind: "StructuralMismatch", path, expected: "Ok | Err", actual: String(obj["kind"]) }); +} + diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/index.ts b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/index.ts new file mode 100644 index 000000000..d981c1e25 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/index.ts @@ -0,0 +1,39 @@ +// Generated by bynkc — do not edit by hand. +// Worker entry point for context `shop.ledger`. + +import { Ok, Err, type Result, type JsonValue, type BoundaryError, boundaryError } from "../../runtime.js"; +import { compose, type Env } from "./compose.js"; +import * as handlers from "./handlers.js"; + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const path = url.pathname; + const method = request.method; + const surface = compose(env); + try { + if (path.startsWith("/_bynk/call/")) { + const servicePath = path.slice("/_bynk/call/".length); + switch (servicePath) { + case "authorise": { + const __contract = request.headers.get("X-Bynk-Contract"); + if (__contract !== "3042f1b7519e621d") return new Response(JSON.stringify({ kind: "ContractMismatch", service: "authorise", expected: "3042f1b7519e621d", actual: __contract }), { status: 409, headers: { "content-type": "application/json" } }); + const args = await request.json() as JsonValue; + const __r_amount = ((__v) => typeof __v !== "number" ? Err({ kind: "StructuralMismatch", path: "$", expected: "integer", actual: typeof __v } as BoundaryError) : Number.isInteger(__v) ? Ok(__v) : Err({ kind: "StructuralMismatch", path: "$", expected: "integer", actual: String(__v) } as BoundaryError))(args); + if (__r_amount.tag === "Err") return new Response(JSON.stringify(__r_amount.error), { status: 400, headers: { "content-type": "application/json" } }); + const amount = __r_amount.value; + const result = await surface.authorise(amount); + const body = handlers.serialise_Result_Int_String(result); + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + } + default: + return new Response("Not found", { status: 404 }); + } + } + + return new Response("Not Found", { status: 404 }); + } catch { + return new Response("Internal Server Error", { status: 500 }); + } + }, +}; diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/wrangler.toml b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/wrangler.toml new file mode 100644 index 000000000..5848272c9 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/expected/workers/shop-ledger/wrangler.toml @@ -0,0 +1,5 @@ +# Generated by bynkc — do not edit by hand. +name = "shop-ledger" +main = "index.ts" +compatibility_date = "2024-11-01" + diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/src/shop/audit.bynk b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/src/shop/audit.bynk new file mode 100644 index 000000000..2849fec69 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/src/shop/audit.bynk @@ -0,0 +1,25 @@ +context shop.audit + +consumes shop.ledger as Ledger + +-- Regression (review of #1202): a local binding named the same as a +-- consumed-context alias, whose own type has a same-named method, must +-- resolve to the local (UFCS), never the cross-context call — the checker's +-- own `check_cross_context_call` is gated on `ctx.lookup(head).is_none()`, +-- so `called_cross_context_services` (reading the resolved `Callee` instead +-- of re-deriving cross-context-ness from a flattened ident chain, #1202) +-- must not record `shop.ledger` in this context's own `expects`: the old +-- string-matching walk had no shadowing check and would have. +type LocalLedger = { balance: Int } + +fn LocalLedger.authorise(self, amount: Int) -> Effect[Result[Int, String]] { + Effect.pure(Ok(amount)) +} + +service log { + on call(amount: Int) -> Effect[Result[Int, String]] { + let Ledger = LocalLedger { balance: 0 } + let r <- Ledger.authorise(amount) + r + } +} diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/src/shop/ledger.bynk b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/src/shop/ledger.bynk new file mode 100644 index 000000000..0025b6e14 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/src/shop/ledger.bynk @@ -0,0 +1,7 @@ +context shop.ledger + +service authorise { + on call(amount: Int) -> Effect[Result[Int, String]] { + Ok(amount) + } +} diff --git a/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/target.txt b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/target.txt new file mode 100644 index 000000000..4cd0901d4 --- /dev/null +++ b/bynkc/tests/fixtures/positive/1203_cross_context_call_shadowed_by_local/target.txt @@ -0,0 +1 @@ +workers diff --git a/design/pending/p6-project-callee-plumbing.md b/design/pending/p6-project-callee-plumbing.md new file mode 100644 index 000000000..a3304ad3b --- /dev/null +++ b/design/pending/p6-project-callee-plumbing.md @@ -0,0 +1,4 @@ +--- +level: patch +changelog: "project.rs's unit_table_uses_emit and called_cross_context_services now read the checker's own already-resolved Callee classification (threaded forward from per-unit checking as a new RunChecks::Checked::unit_callees field) instead of re-deriving Events.emit/cross-context-call detection from raw AST method-call syntax (internal only, byte-identical output)" +---