Skip to main content

bynk_ts/
program.rs

1//! [`TsProgram`]/[`TsStmt`] — the tree. P7.5 built it wide enough only for
2//! the `Verbatim` escape hatch (Q2, `design/tracks/the-typescript-tree.md`
3//! §3.2). P7.8 (#1313) adds the real node algebra ([`TsExpr`]/[`TsType`]/
4//! [`TsDecl`], plus real [`TsStmt`] variants) — not the full §7.1 reference
5//! sketch as literally written (a variant-name list with almost no
6//! field-level design), but the subset `bynk-emit/src/emitter/
7//! events_fanout.rs` (Arc C's real next file — P7.8's own proposal
8//! corrected the track doc's stale schedule, see `design/tracks/
9//! the-typescript-tree.md` §6/§9) concretely needs, grounded against that
10//! file's own real shape rather than guessed. Building the rest of the
11//! sketch's unvalidated variants now would repeat the exact "guessing, not
12//! designing" risk `bynk-ts`'s own module doc (`lib.rs`) already named for
13//! this layer — Arc C's later slices add more variants file by file, the
14//! same precedent [`VerbatimOrigin`] already set.
15
16use crate::source_map::SourceMapBuilder;
17use bynk_syntax::span::Span;
18
19/// A whole generated TypeScript module, as an ordered sequence of top-level
20/// statements. `Vec<TsStmt>`, plain — no richer container yet (P7.6's own
21/// `Artefacts { docs: BTreeMap<PathBuf, Document> }` is where a *project's*
22/// documents get keyed; this is one document's own tree).
23#[derive(Debug, Default)]
24pub struct TsProgram {
25    pub stmts: Vec<TsStmt>,
26}
27
28impl TsProgram {
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    pub fn push(&mut self, stmt: TsStmt) {
34        self.stmts.push(stmt);
35    }
36}
37
38/// One statement — a `Verbatim`-tagged escape hatch (still constructible
39/// only via [`TsStmt::verbatim`], per #1307's Decision D — the
40/// `verbatim_sites` probe needs exactly one string to line-scan for), or,
41/// from this slice, a real structured kind. The real kinds have no such
42/// sealing: they're normal typed constructors, not a "wrap opaque text"
43/// escape hatch, so the `verbatim_sites` concern that motivates `verbatim`'s
44/// own single-constructor discipline doesn't apply to them.
45#[derive(Debug, Clone)]
46pub struct TsStmt {
47    pub(crate) kind: TsStmtKind,
48    /// Where this statement's content originated in the `.bynk` source, if
49    /// known. Only a *top-level* statement's own span is currently recorded
50    /// as a source-map checkpoint ([`crate::printer::print`], unchanged
51    /// from P7.5/R7.4's own scope) — a nested statement (inside a `Block`,
52    /// `If`, `ForOf`, `TryCatch`) still carries this field structurally, for
53    /// whichever future slice gives sub-statement source maps real value,
54    /// but the printer does not yet record a checkpoint from it. Named here
55    /// explicitly (P7.8's own accepted proposal: "an implementation-time
56    /// call within this same shape") rather than left ambiguous.
57    pub span: Option<Span>,
58    /// #1477's own real gap: a body-bearing statement — in practice always
59    /// a `TsStmtKind::Raw`/`TsStmtKind::Verbatim` opaque blob standing in
60    /// for a lowered function/method body (ADR 0391's own permanent
61    /// exclusion) — carries its *own* per-statement source-map checkpoints,
62    /// collected by the caller's own body-local `SourceMapBuilder` before
63    /// this node existed. Before this field, every real `bynk-emit` caller
64    /// that needed to merge those checkpoints into its own module map had to
65    /// reverse-engineer this node's own print-time byte offset from the
66    /// *outside* — `bynk-emit`'s own `emit_class_method_and_merge_source_map`
67    /// (`emitter/emit.rs`) recovers it by subtracting known lengths and
68    /// string-matching the printed text's own tail, degrading to a silent
69    /// skip if that search fails; still live for `emit_service`/`emit_agent`'s
70    /// own not-yet-converted call sites (#1481/#1482). `emit_free_fn` used to
71    /// recover it by separate, independent exact arithmetic, guarded by a
72    /// `debug_assert!` — #1480 converted it to set this field directly
73    /// instead, the first real `bynk-emit` caller to do so. Both existed
74    /// only because nothing reported *this* node's own real print-time
75    /// offset directly. Setting this field lets the printer itself do the
76    /// merge, at the exact offset it is about to write this node's text to
77    /// — no reverse-engineering, no silent-skip fallback (see this crate's
78    /// own private `printer::render_block_stmts` for the handling). `None`
79    /// for every real site that predates this field and every node whose own
80    /// text carries no nested checkpoints of its own — the overwhelmingly
81    /// common case, and the reason this is an `Option`, not a required
82    /// field.
83    pub nested_map: Option<SourceMapBuilder>,
84}
85
86#[derive(Debug, Clone)]
87pub(crate) enum TsStmtKind {
88    Verbatim {
89        #[allow(dead_code)]
90        // read by the lint's own violation attribution once Arc C gives it real content to report on; not yet, per Decision F
91        origin: VerbatimOrigin,
92        text: String,
93    },
94    /// A top-level declaration ([`TsDecl`]) printed as a statement — the
95    /// bridge between `TsProgram`'s flat `Vec<TsStmt>` and the reference
96    /// sketch's separate `TsDecl` enum (an `import`/`interface`/top-level
97    /// `const`/`class` *is* one kind of top-level statement in this tree,
98    /// not a different container).
99    Decl(TsDecl),
100    /// A local `const` binding, e.g. `const { events } = ...;` or
101    /// `const subs = ...;` — distinct from [`TsDecl::ConstDecl`], which is
102    /// the top-level form. Carries a real destructuring [`TsBindingName`]
103    /// because `events_fanout.rs`'s own `const { events } = ...` needs one
104    /// (a gap beyond the accepted proposal's own variant list — a bare
105    /// `String` name cannot represent it; named explicitly as a deviation,
106    /// not invented silently).
107    Const {
108        name: TsBindingName,
109        ty: Option<TsType>,
110        init: TsExpr,
111    },
112    /// `let`'s sibling to `Const` — unused by `events_fanout.rs` itself, but
113    /// the `const`/`let` distinction is real TypeScript semantics the
114    /// printer must preserve once one of the pair exists (the accepted
115    /// proposal's own reasoning for keeping it).
116    Let {
117        name: TsBindingName,
118        ty: Option<TsType>,
119        init: Option<TsExpr>,
120    },
121    /// An expression used as a whole statement (a bare call, e.g.).
122    ExprStmt(TsExpr),
123    Return(Option<TsExpr>),
124    /// `throw <expr>;` — #1353's own real gap: `emit_contract_guarded_body`'s
125    /// own precondition/postcondition guards (`bynk-emit/src/emitter/
126    /// emit.rs`) each throw a constructed `Error` on violation — no prior
127    /// slice needed a bare `throw` (every other error-signalling site in
128    /// this tree so far has been a `Return`). Mirrors `Return`'s own shape
129    /// exactly, minus the `Option` (a `throw` always carries a value; there
130    /// is no bare `throw;` in JS/TS).
131    Throw(TsExpr),
132    /// `if (cond) <then_branch>` (optionally `else <else_branch>`).
133    /// `then_branch`/`else_branch` may each be a [`TsStmtKind::Block`]
134    /// (printed with braces) or any other single statement (printed inline
135    /// on the same line, matching `if (!Array.isArray(subs)) continue;`'s
136    /// own real, brace-free shape). `else_branch` is #1323's own real gap:
137    /// `workers_entry.rs`'s queue-consumer ack/retry dispatch (`if
138    /// (result.tag === "Ack") msg.ack(); else { ...; msg.retry(); }`) —
139    /// `events_fanout.rs`'s own grounding never used one, so P7.8 named
140    /// omitting it a deliberate choice, not an oversight; this slice's own
141    /// real content needs it for real.
142    ///
143    /// `same_line_else` is #1325's own real gap: `workers_entry.rs`'s own
144    /// real `else` sits on its own fresh line (`}\nelse {`), but
145    /// `emit_test_main`'s own real content wants the conventional `} else
146    /// {` — two already-real files disagreeing on the same construct, the
147    /// same class of tension the `Await`-under-`As` correction (#1323/
148    /// #1324) found for parenthesisation. `false` (fresh line) is the
149    /// existing, already-tested default via [`TsStmt::if_else_stmt`]; `true`
150    /// (same line) is reached only through
151    /// [`TsStmt::if_else_same_line_stmt`].
152    If {
153        cond: TsExpr,
154        then_branch: Box<TsStmt>,
155        else_branch: Option<Box<TsStmt>>,
156        same_line_else: bool,
157    },
158    /// `for (const <binding> of <iter>) <body>`.
159    ForOf {
160        binding: String,
161        iter: TsExpr,
162        body: Box<TsStmt>,
163    },
164    /// `for (let <name> = <init>; <test>; <name>++) <body>` — a C-style
165    /// indexed loop, `ForOf`'s counterpart for index-based iteration (as
166    /// opposed to `for...of`'s element-based iteration over an existing
167    /// collection). Arc E slice 7 (#1447)'s own real, narrow gap:
168    /// `bynk-emit`'s `serialisation.rs` has two real sites — `ListInst`'s
169    /// and `MapInst`'s own deserialise-side element loops, both `for (let i
170    /// = 0; i < json.length; i++) { ... }` — that walk a JSON array by
171    /// index, a shape `ForOf` cannot represent (its own `binding` names a
172    /// per-element destructuring target bound fresh each iteration, not a
173    /// counter carrying its own init/test/update clauses across
174    /// iterations). Deliberately grounded in only what those two real call
175    /// sites need, not the general C-style-for grammar — the same "extend
176    /// narrowly" posture this file's own `TsBinaryOp::In`/`LessThan`
177    /// additions already took:
178    ///   - `name`/`init` are always a plain `let <name> = <init>;`
179    ///     declaration — no destructuring, no multi-declarator list, no
180    ///     explicit type annotation (neither real site needs one).
181    ///   - The update clause always renders as the postfix increment
182    ///     `<name>++`, over `name` itself rather than a separate field —
183    ///     review of #1448 found an independent `update: TsExpr` field
184    ///     structurally redundant with `name` (both real construction sites
185    ///     could only ever pass `ident(name)`) with a genuinely dangerous
186    ///     failure mode if the two ever disagreed: unlike a wrong `test`
187    ///     (which `tsc --strict` or a non-running loop tends to surface), a
188    ///     mismatched update clause (`for (let i = 0; i < json.length;
189    ///     j++)`) compiles cleanly whenever `j` is merely in scope and hangs
190    ///     the generated worker at runtime, with nothing in the type, the
191    ///     printer, or a test to catch it. Removing the field removes the
192    ///     failure mode entirely rather than merely asserting against it —
193    ///     the same "postfix increment has no expression-position
194    ///     representation, only a dedicated statement/clause shape"
195    ///     restriction `Increment`'s own doc above already states for a
196    ///     bare `<expr>++;` statement (#1325), applied here too.
197    ///   - `body` is the loop body; both real sites pass a `Block`, though
198    ///     the same brace-vs-inline rendering `If`/`ForOf` already share
199    ///     applies to any other shape too.
200    For {
201        name: String,
202        init: TsExpr,
203        test: TsExpr,
204        body: Box<TsStmt>,
205    },
206    /// `try <try_block> catch (<catch_param>) <catch_block>` — a real gap
207    /// beyond the reference sketch (`design/bynk-greenfield-compiler.md`'s
208    /// §7.1 has no `TryCatch` at all), found and named by P7.8's own
209    /// accepted proposal: `events_fanout.rs`'s subscriber-failure-isolation
210    /// `try`/`catch` (ADR 0284) is load-bearing control flow, not
211    /// decorative. `catch_param: None` prints the bare `catch { ... }`
212    /// form (ES2019's optional catch binding) — #1321's own real gap:
213    /// `workers.rs`'s `emit_http_sum_wrapper` reads the raw request body in
214    /// a `try`/`catch` that never uses the caught error (`} catch {`, no
215    /// `(e)` at all).
216    TryCatch {
217        try_block: Box<TsStmt>,
218        catch_param: Option<String>,
219        catch_block: Box<TsStmt>,
220    },
221    /// `{ <stmts> }` — the body container for `If`/`ForOf`/`TryCatch`/
222    /// constructor and method bodies.
223    Block(Vec<TsStmt>),
224    /// A bare `continue;` — a second real gap beyond the accepted
225    /// proposal's own variant list: `events_fanout.rs` uses it twice
226    /// (`if (!Array.isArray(subs)) continue;` / `if (!binding) continue;`),
227    /// load-bearing loop control the accepted proposal's `TsStmt` list
228    /// doesn't name. No label — nothing in the grounding file needs one.
229    Continue,
230    /// `target = value;` — a third real gap, found in review of the
231    /// implementing PR (#1313): `events_fanout.rs`'s own constructor body is
232    /// exactly one statement, `this.env = (env ?? {}) as
233    /// Record<string, ServiceBinding>;` (a field assignment, not a `const`/
234    /// `let` binding), which the accepted proposal's own grounding
235    /// catalogue missed — it catalogued the `fetch` method's body in detail
236    /// but not the constructor's. `target` is deliberately a full `TsExpr`
237    /// (not a narrower "assignable" type) so it can hold `this.env` (a
238    /// `Member` expression) without a second binding-target type; nothing
239    /// about `Assign` validates that `target` is actually assignable
240    /// (`bynk-check` already does that on the `.bynk` side before emission
241    /// ever runs).
242    Assign {
243        target: TsExpr,
244        value: TsExpr,
245    },
246    /// `// <text>` — a bare line comment. Added for Arc C's own first real
247    /// conversion slice (#1317): every `bynk-emit`-generated `.ts` file
248    /// opens with the same two-line header banner
249    /// (`// Generated by bynkc — do not edit by hand.` / a file-specific
250    /// second line), and nothing before this slice could represent one.
251    /// Not speculative — a universal need every later Arc C slice hits on
252    /// its own first line, closed once here rather than left as a residual
253    /// `Verbatim` wrap that would make `verbatim_sites` show zero
254    /// improvement for real, substantial conversion work.
255    ///
256    /// Carries no semantic content the printer or checker ever reads back —
257    /// pure, inert text. Printed one `// `-prefixed line per `\n` in `text`
258    /// (`bynk-ts/src/printer.rs`'s own `render_stmt`), so a multi-line
259    /// comment is representable as one statement, though
260    /// `events_fanout.rs`'s own two-line header is built as two separate
261    /// `Comment` statements instead — matching how its two adjacent
262    /// `import` lines are two separate `TsDecl::Import` statements, not one
263    /// with an embedded line break, and exercising the printer's own new
264    /// "no blank line between adjacent `Comment`s" rule (the same
265    /// exception already established for adjacent `import`s).
266    Comment(String),
267    /// `/** ... */` — a JSDoc block comment, distinct from
268    /// [`TsStmtKind::Comment`]'s own `//`-per-line form. #1333's own real
269    /// need: `emit_doc_block` (`bynk-emit/src/emitter/emit.rs`) renders a
270    /// Bynk `///` doc comment as a real JSDoc block — printed via
271    /// [`crate::printer::print_stmt`], not [`crate::printer::print`], since
272    /// every real call site today is a shared helper spliced into a
273    /// still-unconverted caller's own buffer, the same P7.9 pattern
274    /// `ts_type_ref`/`ts_ty` already used (keep the caller's own signature,
275    /// build a real node internally, print just that fragment). Escaping
276    /// (a literal `*/` inside the text becomes `*\/`, so it can't
277    /// prematurely close the comment and let trailing text land as
278    /// executable top-level TypeScript — issue #720) and the blank-line
279    /// convention (a blank source line prints as a bare ` *`, no trailing
280    /// space) live in the printer (`render_stmt`'s own `DocComment` arm,
281    /// `crate::printer`'s own private renderer), matching
282    /// where every other statement kind's rendering rules live.
283    DocComment(String),
284    /// A bare blank line — no statement content. #1323's own real, narrow
285    /// need: `workers_entry.rs`'s `fetch` method body has an unconditional
286    /// blank line after its internal Service-Binding dispatch block, and a
287    /// conditional one after the WebSocket-upgrade dispatch (when present)
288    /// and the Events dispatch (when present) — three specific points the
289    /// pre-conversion `writeln!(out).unwrap()` wrote directly, nested
290    /// *inside* the `try` block's own statement list. Distinct from
291    /// [`crate::printer::print`]'s own top-level blank-line policy (this
292    /// module's own printer doc), which only separates `TsProgram`'s own
293    /// top-level statements — nothing before this slice needed a blank line
294    /// anywhere inside a nested block, so that policy was never generalized
295    /// to every nesting depth. A single, narrow statement kind represents
296    /// exactly this, rather than widening the top-level-only policy to a
297    /// depth it has no other real content to justify.
298    Blank,
299    /// `switch (<discriminant>) { <cases> }` — #1323's own largest gap: the
300    /// first genuinely new statement-*grouping* construct in this tree
301    /// (every prior addition was a single expression/type variant or a
302    /// straightforward one-block wrapper). `workers_entry.rs` has four real
303    /// `switch` statements (the internal `/_bynk/call/` and `/_bynk/event/`
304    /// dispatches, the `scheduled` handler's `event.cron` dispatch, the
305    /// `queue` handler's `batch.queue` dispatch); every real `case` there is
306    /// a `{ ... }`-blocked body ending in a terminal statement (`return`/
307    /// `continue`), so no fallthrough/shared-body grouping is represented —
308    /// extend narrowly, the same posture every other addition here takes.
309    /// Arc E slice 6 (#1445) widens this one further, narrowly: a
310    /// non-`default` case's own bracing is now per-case
311    /// ([`TsSwitchCase::case_braced`]) rather than unconditional —
312    /// `emit_sum_codec`'s own payload-free-variant case is real, unbraced
313    /// content, not a hypothetical.
314    Switch {
315        discriminant: TsExpr,
316        cases: Vec<TsSwitchCase>,
317    },
318    /// `{ stmt; stmt; ... }` — a braced, multi-statement block printed on
319    /// ONE generated line, distinct from [`TsStmtKind::Block`] (always
320    /// multi-line). #1323's own real gap: `workers_entry.rs`'s queue
321    /// consumer has two real sites (`if (__r.tag === "Err") { console.
322    /// error(...); msg.retry(); continue; }`; the `else` branch of the
323    /// ack/retry dispatch) where the pre-conversion `writeln!` code
324    /// deliberately packed several short statements onto one physical
325    /// line — a real, distinct formatting choice from every other
326    /// multi-statement body in this tree, which always prints one
327    /// statement per line. Only reachable through [`TsStmt::if_stmt`]/
328    /// [`TsStmt::if_else_stmt`]'s own branches in real content today.
329    InlineBlock(Vec<TsStmt>),
330    /// `<expr>++;` — a postfix increment used as a whole statement. #1325's
331    /// own real, narrow gap: `emit_test_main`'s own `passed++;`/`failed++;`
332    /// counters. No prefix form, no decrement, no expression-position use
333    /// (every real site increments a bare counter as its own statement) —
334    /// [`TsExpr::Unary`] stays prefix-only (`!`/`typeof`), since a postfix
335    /// operator used as a *statement* is a different grammatical position
336    /// from a prefix operator used as an *operand*, not a shape `Unary`
337    /// could represent by adding a variant.
338    Increment(TsExpr),
339    /// A pre-rendered, unconditional-passthrough statement blob — printed
340    /// exactly as given, with no leading indent, no added semicolon, no
341    /// added braces (matching [`TsStmtKind::Verbatim`]'s own `out.push_str(
342    /// text)` rendering, not the ordinary per-kind `indent(depth)`-prefixed
343    /// shape every other variant gets). #1337's own real need:
344    /// `emit_method`'s own body is delegated wholesale to
345    /// `emit_block_as_function_body_with_return` (`bynk-emit`'s
346    /// `emitter/lower.rs:201`) — the one splice boundary ADR
347    /// `arc-c-lower-rs-permanent-exclusion` names as a *permanent*,
348    /// deliberate exclusion from this tree (`lower.rs` is the compiler's
349    /// own second code-generation pass, comprehensive language-surface
350    /// work Arc C was never scoped to cover), not a not-yet-converted
351    /// residue [`TsStmtKind::Verbatim`] would misrepresent it as.
352    ///
353    /// Deliberately a SEPARATE variant from `Verbatim`, not a reuse of it,
354    /// even though both render identically (`out.push_str(text)`): the
355    /// `verbatim_sites`/`verbatim_origins` probes exist specifically to
356    /// track Arc C's own *temporary* conversion residue trending toward
357    /// zero — a `Raw` site is never counted there (it has no
358    /// `VerbatimOrigin` at all), because using `Verbatim` here would make
359    /// this permanent exclusion look like unfinished Arc C work a future
360    /// slice is expected to close, which it structurally cannot.
361    ///
362    /// #1339's own second real use, broadening (not narrowing) the
363    /// above: `emit_refined_type`'s own `of()` guard block splices
364    /// `emit_refined_checks`'s own output this same way — that function
365    /// keeps its exact `out: &mut String` signature (the P7.9/step-1
366    /// pattern applied one level down, not a `lower.rs`-style permanent
367    /// exclusion), but its real content is ALREADY built and printed from
368    /// real `bynk_ts::TsStmt`/`print_stmt` calls internally — genuinely
369    /// statement-shaped pre-rendered text, the same mechanical fit `Raw`
370    /// already provides, just for a different underlying reason than
371    /// `lower.rs`'s own permanent exclusion.
372    ///
373    /// #1445's own third real use (Arc E slice 6, `serialisation.rs`'s
374    /// `emit_sum_codec`, via that file's own `raw_stmts_at_depth_one`
375    /// helper): a real, currently-shipped BYTE-LEVEL quirk, not a scope
376    /// boundary. `emit_field_deserialise_wire`'s own field guards, spliced
377    /// into a sum's deserialise-side payload case, have always printed at
378    /// depth 1 (`splice_stmts`'s own fixed indent) even though they sit
379    /// structurally inside a `TsStmtKind::Switch` case's own body (which
380    /// this printer would otherwise render two levels deeper, at depth 3) —
381    /// confirmed byte-for-byte against `212_json_codec`/
382    /// `407_workers_generic_sum_boundary`'s own real fixtures. Letting the
383    /// switch case render these at their structurally-correct depth instead
384    /// would be a real, deliberate formatting change with no fixture-corpus
385    /// backing either way, which that slice's own zero-diff mandate ruled
386    /// out choosing unprompted.
387    ///
388    /// All three real uses share the one property that actually matters for
389    /// this variant's own existence: *real, already-correctly-indented (or,
390    /// for the third, real-but-deliberately-NOT-restructured) statement text
391    /// this call site cannot turn into a properly-nested `Vec<TsStmt>`
392    /// without changing scope or bytes it isn't the one authorised to
393    /// change* — not whether the reason is permanent, temporary, or a
394    /// preserved historical quirk.
395    Raw(String),
396}
397
398/// One `case`/`default` arm of a `TsStmtKind::Switch`. `test: None` is the
399/// `default:` case — every real `default` in `workers_entry.rs` prints its
400/// body directly under `default:` with no `{ }` block, while every real
401/// non-`default` `case` (regardless of `test`) always prints a `{ }`-blocked
402/// body.
403///
404/// `default_braced` (Arc C slice 33, `tests_emit.rs` slice C, #1401) —
405/// `emit_stub_class`'s own `ReturnsEach` dispatch is the first real site
406/// with a BRACED `default: { ... }`, a genuinely different convention from
407/// `workers_entry.rs`'s own unbraced one; only meaningful when `test` is
408/// `None` (a non-`default` case was unconditionally braced before
409/// `case_braced` existed, below). Kept a per-case flag rather than changing
410/// the existing unbraced-default rendering, since that would risk
411/// `workers_entry.rs`'s own real, already-zero-diff content for no benefit —
412/// the same "don't touch working, unrelated content" judgment this track
413/// makes repeatedly.
414///
415/// `case_braced` (Arc E slice 6, #1445) — the mirror-image gap `default_braced`
416/// left open: `serialisation.rs`'s `emit_sum_codec` (this tree's first real
417/// non-`default` case that is NOT `{ }`-blocked) needs `case "Pending":
418/// return { kind: "Pending" };` — unbraced — right beside a sibling
419/// payload-carrying `case "Shipped": { ... }` — braced — in the *same*
420/// switch (`212_json_codec`'s own mixed `Status` fixture, confirmed by
421/// direct grep before this field was added: a payload-free variant's case
422/// is always unbraced, a payload-carrying variant's is always braced, and a
423/// real fixture exercises both side by side). Every prior real non-`default`
424/// case (`workers_entry.rs`'s dispatches, `tests_emit.rs`'s own sequential-
425/// outcome cases) already wants braces, so those call sites all set this
426/// `true` — the same "narrow, argued, backward-compatible extension"
427/// shape `default_braced` itself set as precedent. Only meaningful when
428/// `test` is `Some(..)` (a `default` case's bracing is `default_braced`'s
429/// business, not this field's — the inverse of that field's own scoping).
430#[derive(Debug, Clone)]
431pub struct TsSwitchCase {
432    pub test: Option<TsExpr>,
433    pub body: Vec<TsStmt>,
434    pub default_braced: bool,
435    pub case_braced: bool,
436}
437
438impl TsStmt {
439    /// The one constructor for a `Verbatim`-kinded statement.
440    pub fn verbatim(origin: VerbatimOrigin, text: impl Into<String>, span: Option<Span>) -> Self {
441        Self {
442            kind: TsStmtKind::Verbatim {
443                origin,
444                text: text.into(),
445            },
446            span,
447            nested_map: None,
448        }
449    }
450
451    pub fn decl(decl: TsDecl, span: Option<Span>) -> Self {
452        Self {
453            kind: TsStmtKind::Decl(decl),
454            span,
455            nested_map: None,
456        }
457    }
458
459    pub fn const_stmt(
460        name: TsBindingName,
461        ty: Option<TsType>,
462        init: TsExpr,
463        span: Option<Span>,
464    ) -> Self {
465        Self {
466            kind: TsStmtKind::Const { name, ty, init },
467            span,
468            nested_map: None,
469        }
470    }
471
472    pub fn let_stmt(
473        name: TsBindingName,
474        ty: Option<TsType>,
475        init: Option<TsExpr>,
476        span: Option<Span>,
477    ) -> Self {
478        Self {
479            kind: TsStmtKind::Let { name, ty, init },
480            span,
481            nested_map: None,
482        }
483    }
484
485    pub fn expr_stmt(expr: TsExpr, span: Option<Span>) -> Self {
486        Self {
487            kind: TsStmtKind::ExprStmt(expr),
488            span,
489            nested_map: None,
490        }
491    }
492
493    pub fn return_stmt(expr: Option<TsExpr>, span: Option<Span>) -> Self {
494        Self {
495            kind: TsStmtKind::Return(expr),
496            span,
497            nested_map: None,
498        }
499    }
500
501    pub fn throw_stmt(expr: TsExpr, span: Option<Span>) -> Self {
502        Self {
503            kind: TsStmtKind::Throw(expr),
504            span,
505            nested_map: None,
506        }
507    }
508
509    pub fn if_stmt(cond: TsExpr, then_branch: TsStmt, span: Option<Span>) -> Self {
510        Self {
511            kind: TsStmtKind::If {
512                cond,
513                then_branch: Box::new(then_branch),
514                else_branch: None,
515                same_line_else: false,
516            },
517            span,
518            nested_map: None,
519        }
520    }
521
522    pub fn if_else_stmt(
523        cond: TsExpr,
524        then_branch: TsStmt,
525        else_branch: TsStmt,
526        span: Option<Span>,
527    ) -> Self {
528        Self {
529            kind: TsStmtKind::If {
530                cond,
531                then_branch: Box::new(then_branch),
532                else_branch: Some(Box::new(else_branch)),
533                same_line_else: false,
534            },
535            span,
536            nested_map: None,
537        }
538    }
539
540    /// [`TsStmt::if_else_stmt`]'s own sibling with `} else {` on one line —
541    /// #1325's own real gap, `emit_test_main`'s own real `else` spacing. See
542    /// `TsStmtKind::If`'s own doc for why this needs to be a distinct
543    /// constructor rather than a change to the existing default.
544    pub fn if_else_same_line_stmt(
545        cond: TsExpr,
546        then_branch: TsStmt,
547        else_branch: TsStmt,
548        span: Option<Span>,
549    ) -> Self {
550        Self {
551            kind: TsStmtKind::If {
552                cond,
553                then_branch: Box::new(then_branch),
554                else_branch: Some(Box::new(else_branch)),
555                same_line_else: true,
556            },
557            span,
558            nested_map: None,
559        }
560    }
561
562    pub fn for_of(
563        binding: impl Into<String>,
564        iter: TsExpr,
565        body: TsStmt,
566        span: Option<Span>,
567    ) -> Self {
568        Self {
569            kind: TsStmtKind::ForOf {
570                binding: binding.into(),
571                iter,
572                body: Box::new(body),
573            },
574            span,
575            nested_map: None,
576        }
577    }
578
579    /// `for (let <name> = <init>; <test>; <update>++) <body>` — see
580    /// `TsStmtKind::For`'s own doc for exactly what this construct does
581    /// and does not represent.
582    pub fn for_stmt(
583        name: impl Into<String>,
584        init: TsExpr,
585        test: TsExpr,
586        body: TsStmt,
587        span: Option<Span>,
588    ) -> Self {
589        Self {
590            kind: TsStmtKind::For {
591                name: name.into(),
592                init,
593                test,
594                body: Box::new(body),
595            },
596            span,
597            nested_map: None,
598        }
599    }
600
601    pub fn try_catch(
602        try_block: TsStmt,
603        catch_param: Option<impl Into<String>>,
604        catch_block: TsStmt,
605        span: Option<Span>,
606    ) -> Self {
607        Self {
608            kind: TsStmtKind::TryCatch {
609                try_block: Box::new(try_block),
610                catch_param: catch_param.map(Into::into),
611                catch_block: Box::new(catch_block),
612            },
613            span,
614            nested_map: None,
615        }
616    }
617
618    pub fn block(stmts: Vec<TsStmt>, span: Option<Span>) -> Self {
619        Self {
620            kind: TsStmtKind::Block(stmts),
621            span,
622            nested_map: None,
623        }
624    }
625
626    pub fn continue_stmt(span: Option<Span>) -> Self {
627        Self {
628            kind: TsStmtKind::Continue,
629            span,
630            nested_map: None,
631        }
632    }
633
634    pub fn assign(target: TsExpr, value: TsExpr, span: Option<Span>) -> Self {
635        Self {
636            kind: TsStmtKind::Assign { target, value },
637            span,
638            nested_map: None,
639        }
640    }
641
642    pub fn comment(text: impl Into<String>, span: Option<Span>) -> Self {
643        Self {
644            kind: TsStmtKind::Comment(text.into()),
645            span,
646            nested_map: None,
647        }
648    }
649
650    pub fn doc_comment(text: impl Into<String>, span: Option<Span>) -> Self {
651        Self {
652            kind: TsStmtKind::DocComment(text.into()),
653            span,
654            nested_map: None,
655        }
656    }
657
658    pub fn blank(span: Option<Span>) -> Self {
659        Self {
660            kind: TsStmtKind::Blank,
661            span,
662            nested_map: None,
663        }
664    }
665
666    pub fn switch_stmt(discriminant: TsExpr, cases: Vec<TsSwitchCase>, span: Option<Span>) -> Self {
667        Self {
668            kind: TsStmtKind::Switch {
669                discriminant,
670                cases,
671            },
672            span,
673            nested_map: None,
674        }
675    }
676
677    pub fn inline_block(stmts: Vec<TsStmt>, span: Option<Span>) -> Self {
678        Self {
679            kind: TsStmtKind::InlineBlock(stmts),
680            span,
681            nested_map: None,
682        }
683    }
684
685    pub fn increment(expr: TsExpr, span: Option<Span>) -> Self {
686        Self {
687            kind: TsStmtKind::Increment(expr),
688            span,
689            nested_map: None,
690        }
691    }
692
693    /// The one constructor for a `Raw`-kinded statement — `text` is printed
694    /// verbatim, exactly as given (see `TsStmtKind::Raw`'s own doc for
695    /// why this is a distinct kind from `Verbatim`, not a reuse of it).
696    pub fn raw(text: impl Into<String>, span: Option<Span>) -> Self {
697        Self {
698            kind: TsStmtKind::Raw(text.into()),
699            span,
700            nested_map: None,
701        }
702    }
703}
704
705/// A binding's own name, in either of the two shapes `events_fanout.rs`
706/// itself uses: a plain identifier (`const subs = ...`), or an
707/// object-destructuring pattern (`const { events } = ...`) — naming only
708/// the destructured properties themselves (`{ a, b }`), not the renamed
709/// (`{ a: renamed }`) or nested (`{ a: { b } }`) forms, since nothing in
710/// the grounding file needs either.
711#[derive(Debug, Clone)]
712pub enum TsBindingName {
713    Ident(String),
714    ObjectPattern(Vec<String>),
715}
716
717/// An expression. Only the shapes `events_fanout.rs` concretely uses
718/// (Decision B) — not the reference sketch's full `TsExpr` list (`Arrow`,
719/// `Cond`, `TemplateLit`, `Spread` are all unused in the grounding file and
720/// deliberately not built here). Arc C slice 3 (#1321, `workers.rs`) adds
721/// `Arrow`, `OptionalMember`/`OptionalIndex` (Decision A, gaps 3/4) — this
722/// slice's own real, grounded needs.
723#[derive(Debug, Clone)]
724pub enum TsExpr {
725    Ident(String),
726    /// `object.property`.
727    Member {
728        object: Box<TsExpr>,
729        property: String,
730    },
731    /// `object?.property` — the optional-chaining form of [`TsExpr::Member`].
732    /// A distinct variant, not an `optional: bool` flag on `Member` itself
733    /// (#1321's own Decision A, gap 4, left the mechanism to the
734    /// implementation): a flag would touch every one of `Member`'s
735    /// already-many real call sites (`events_fanout.rs`, this crate's own
736    /// printer tests) that never need one, where a separate variant touches
737    /// none of them — narrower, matching this track's own repeated
738    /// "smallest correct scope" judgment. `workers.rs`'s own secret-probe
739    /// idiom (`emit_websocket_upgrade`/`emit_http_wrapper`/
740    /// `emit_secret_lookup`, three identical real sites) is the only
741    /// grounding.
742    OptionalMember {
743        object: Box<TsExpr>,
744        property: String,
745    },
746    /// `object[index]`.
747    Index {
748        object: Box<TsExpr>,
749        index: Box<TsExpr>,
750    },
751    /// `object?.[index]` — the optional-chaining form of [`TsExpr::Index`],
752    /// the same real grounding and the same "separate variant, not a flag"
753    /// reasoning as [`TsExpr::OptionalMember`].
754    OptionalIndex {
755        object: Box<TsExpr>,
756        index: Box<TsExpr>,
757    },
758    /// `(params) => body` (`is_async: false`) or `async (params) => body`
759    /// (`is_async: true`) — an arrow function, expression- or block-bodied
760    /// (see [`TsArrowBody`]). `is_async` added by #1327: `emit_composition_root`'s
761    /// own `__eventsDispatch` closure (`async (events: Array<...>) => {...}`)
762    /// is the first real `Arrow` site that's async — mirrors
763    /// `TsDecl::Function`'s own `is_async` field (#1325), added for the same
764    /// reason. `generics`/`return_type` added by #1339: `emit_sum_type`'s
765    /// own generic payload-constructor arrows (`<T>(name: T): Sum<T> =>
766    /// (...)`) need both — bare generic names only (empty for every
767    /// non-generic site, the overwhelming majority), matching
768    /// `TsObjectEntry::Method.generics`'s own (#1337) identical convention;
769    /// `return_type` mirrors `TsDecl::Function.return_type`'s own existing
770    /// `Option<TsType>` shape — a real gap the accepted proposal's own
771    /// grounding named `generics` for but missed: every one of this file's
772    /// own real generic-payload arrows carries an explicit return-type
773    /// annotation the arrow itself owns (`: {name}{params}`), not something
774    /// the body's own type alone determines.
775    ///
776    /// `body`'s type became [`TsArrowBody`] (#1435, Arc E slice 1): from
777    /// P7.8 through #1434 this field was a bare `Box<TsExpr>` — expression-
778    /// bodied only, "extend narrowly" (the same posture `TsBinaryOp` takes
779    /// for its own operator table), since every real site up to and
780    /// including #1339's generic payload-constructor arrows had an
781    /// expression body. `serialisation.rs`'s `serialise_field_expr_wire`
782    /// (`bynk-emit`) is the first real site that doesn't: its `Float`
783    /// non-finite guard is a genuine statement-bodied IIFE (`((v: number) =>
784    /// { if (!Number.isFinite(v)) throw new Error(...); return v as
785    /// JsonValue; })(value)`), not reducible to one expression. Widening the
786    /// existing field (`TsArrowBody::Expr(Box<TsExpr>)` |
787    /// `TsArrowBody::Block(Vec<TsStmt>)`) rather than adding a second
788    /// sibling `TsExpr` variant matches this file's own repeated
789    /// "extend the existing variant when every real site still needs the
790    /// same node kind, only the body shape differs" precedent
791    /// (`TsObjectEntry::Method.inline`, #1337) — every prior `Arrow`
792    /// construction site across the workspace wraps its already-correct
793    /// expression body in `TsArrowBody::Expr(..)`, a mechanical change with
794    /// no behavior difference.
795    Arrow {
796        params: Vec<TsParam>,
797        is_async: bool,
798        generics: Vec<String>,
799        return_type: Option<TsType>,
800        body: Box<TsArrowBody>,
801    },
802    Call {
803        callee: Box<TsExpr>,
804        args: Vec<TsExpr>,
805    },
806    New {
807        callee: Box<TsExpr>,
808        args: Vec<TsExpr>,
809    },
810    /// A value object literal, e.g. `{ status: 204 }` — comma-separated.
811    /// `multiline: false` (the ordinary case, via [`TsExpr::object`])
812    /// always prints on one line, matching [`TsType::Object`]'s own
813    /// (semicolon-separated) single-line convention for the *type*-position
814    /// shape. `multiline: true` (via [`TsExpr::multiline_object`]) is a
815    /// real, grounded gap found implementing Arc C's first slice (#1317):
816    /// `events_fanout.rs`'s own `__eventRoutes` table is a top-level
817    /// `const` initializer with one entry per line, each with its own
818    /// trailing comma, closing brace at the *statement's* own indent —
819    /// TypeScript's ordinary multi-line object-literal convention, which
820    /// nothing in this crate could represent before this addition. Only
821    /// statement/declaration-level renderers (which already carry `depth`)
822    /// can render this correctly — `printer.rs`'s own `render_stmt_level_
823    /// expr`, and (#1355) `render_multiline_object_entry`'s own `Prop` arm,
824    /// for a `multiline: true` object nested one level inside ANOTHER
825    /// multiline object as one of its own entries' values —
826    /// `emit_messages_bundle`'s own real doubly-nested `{ locale: { code:
827    /// expr, ... }, ... }` table. A `multiline: true` object nested any
828    /// OTHER way (an array element, a call argument, a `Prop`'s value
829    /// inside a non-multiline object, …) still renders via the ordinary
830    /// depth-unaware `render_expr` recursion, which cannot honour
831    /// `multiline` — not reachable from any real `bynk-emit` call site
832    /// today, but worth knowing before nesting one that way.
833    Object {
834        entries: Vec<TsObjectEntry>,
835        multiline: bool,
836    },
837    /// An array literal, e.g. `[{ binding: "x", service: "y" }]`.
838    /// `multiline: false` (the ordinary case, via [`TsExpr::array`]) always
839    /// prints on one line, comma-separated — every real site before this
840    /// slice. `multiline: true` (via [`TsExpr::multiline_array`]) is #1325's
841    /// own real gap: `emit_test_main`'s own `modules` array (one `{ name,
842    /// run }` entry per test, one per line, each with its own trailing
843    /// comma, closing `]` at the statement's own indent) — the exact same
844    /// shape [`TsExpr::Object`]'s own `multiline` field already represents
845    /// for object literals, just for an array. Same reachability boundary as
846    /// `Object`'s own `multiline` field (see its own doc, updated by
847    /// #1355): `render_stmt_level_expr` and `render_multiline_object_
848    /// entry`'s own `Prop` arm both honour it; nested any other way, a
849    /// `multiline: true` array falls back to single-line via the ordinary
850    /// depth-unaware `render_expr` recursion.
851    Array {
852        items: Vec<TsExpr>,
853        multiline: bool,
854    },
855    /// `` `text${expr}more text` `` — a template literal. `parts.len()` is
856    /// always `exprs.len() + 1` (`parts[0]` before the first substitution,
857    /// `parts[i+1]` after `exprs[i]`, …). #1325's own real, first grounding
858    /// for this shape (`bynk-ts`'s own module doc named `TemplateLit`
859    /// explicitly "unused in the grounding file" until now):
860    /// `emit_test_main`'s own `` `${m.name}:` ``/`` `${passed} passed,
861    /// ${failed} failed.` `` lines.
862    ///
863    /// **`parts` are printed verbatim — the printer applies no escaping of
864    /// its own.** The same "the field is already a raw-text slot" reasoning
865    /// [`TsDecl::Import`]'s own `names` field doc already uses, not a new
866    /// pattern: `emit_test_main`'s own two real ✓/✗ substitution lines embed
867    /// a literal `✓`/`✗` JS unicode escape as pre-formed ASCII
868    /// text (six literal characters, not the actual glyph) — an escaper
869    /// mirroring [`TsLit::Str`]'s own (which escapes every `\` it sees)
870    /// would double that literal backslash into `\\u2713`, corrupting the
871    /// exact byte-golden output this slice must match. Every real part in
872    /// `emit_test_main` is static, compiler-authored text (never Bynk user
873    /// data), so there is no real content this boundary loses safety on
874    /// today — a future caller passing untrusted/dynamic text into `parts`
875    /// is responsible for pre-escaping backtick/`` ${ ``/`\` itself before
876    /// constructing one.
877    TemplateLit {
878        parts: Vec<String>,
879        exprs: Vec<TsExpr>,
880    },
881    Await(Box<TsExpr>),
882    /// `expr as ty`.
883    As {
884        expr: Box<TsExpr>,
885        ty: TsType,
886    },
887    Unary {
888        op: TsUnaryOp,
889        expr: Box<TsExpr>,
890    },
891    Binary {
892        op: TsBinaryOp,
893        left: Box<TsExpr>,
894        right: Box<TsExpr>,
895    },
896    /// `test ? consequent : alternate` — #1323's own real gap: `method ===
897    /// "HEAD" ? headResponse(__response) : __response` (once per `GET`
898    /// route) and `method === "OPTIONS" ? 204 : 405` (the method-fallthrough
899    /// path). The lowest-precedence expression form after `Arrow` — needs
900    /// the same parenthesization-rule coverage `Arrow` got in review of
901    /// #1322 (`needs_parens_as_operand`/`render_binary_operand`/`As`'s own
902    /// local rule), added proactively in this same slice rather than left
903    /// for a review round to re-find.
904    Conditional {
905        test: Box<TsExpr>,
906        consequent: Box<TsExpr>,
907        alternate: Box<TsExpr>,
908    },
909    /// An explicit, printer-preserved parenthesization — distinct from every
910    /// other variant's own precedence-*derived* parens (`render_operand`/
911    /// `render_binary_operand`), which the printer adds or omits based on
912    /// what the wrapped expression *is*. `Paren` instead always prints
913    /// `(<inner>)` regardless of `inner`'s own shape or precedence. #1323's
914    /// own real, narrow need: `workers_entry.rs`'s CORS-preflight guard
915    /// wraps its own path-match condition in unconditional parens
916    /// (`&& ({cond})`) even when `cond` reduces to a single equality check
917    /// with no operator lower-precedence than the outer `&&` — a real case
918    /// the precedence-derived rules correctly do *not* parenthesize (they're
919    /// answering "is this needed for correctness", not "did the source
920    /// always write parens here"). Not a general escape hatch: every other
921    /// real site in this file's own content still goes through the ordinary
922    /// precedence machinery unchanged.
923    Paren(Box<TsExpr>),
924    Lit(TsLit),
925}
926
927/// [`TsExpr::Arrow`]'s own `body` shape (#1435, Arc E slice 1) — see that
928/// field's own doc for why this is a widened field rather than a second
929/// `TsExpr` variant. `Expr` is every real site before this slice (and the
930/// overwhelming majority after it): the arrow's body is one expression,
931/// printed with no surrounding braces. `Block` is `serialisation.rs`'s own
932/// `Float` non-finite guard, the first real statement-bodied arrow anywhere
933/// in this tree — printed as a real braced block, reusing this crate's
934/// own printer's existing compact-statement-list renderer
935/// (`render_compact_stmts`, the same one `TsStmtKind::InlineBlock` already
936/// shares with `render_branch`'s own same-line `if`/`else`) rather than a
937/// third copy of that "one physical line, semicolon-separated" logic. Every
938/// real `Block` site today is exactly this one-line IIFE shape (an arrow
939/// with no other real use of a genuinely multi-line block body has been
940/// found); a future multi-line block-bodied arrow is a real, separate gap
941/// this variant does not yet cover.
942#[derive(Debug, Clone)]
943pub enum TsArrowBody {
944    Expr(Box<TsExpr>),
945    Block(Vec<TsStmt>),
946}
947
948impl TsExpr {
949    /// The ordinary, single-line object literal, `Prop`-only — every entry
950    /// is `key: value`. `events_fanout.rs`'s own entries (and this crate's
951    /// own printer tests) are all this shape; kept taking a plain
952    /// `Vec<(String, TsExpr)>` rather than `Vec<TsObjectEntry>` so none of
953    /// those existing call sites needed to change when `TsObjectEntry` was
954    /// added (#1321) — see [`TsExpr::object_entries`] for the mixed-entry
955    /// form `workers.rs` itself needs (shorthand/spread/method entries).
956    pub fn object(entries: Vec<(String, TsExpr)>) -> Self {
957        TsExpr::Object {
958            entries: entries
959                .into_iter()
960                .map(|(k, v)| TsObjectEntry::Prop(k, v))
961                .collect(),
962            multiline: false,
963        }
964    }
965
966    /// One entry per line, each with its own trailing comma — see
967    /// [`TsExpr::Object`]'s own doc for the real shape and the
968    /// depth-awareness this needs at the print site. `Prop`-only, the same
969    /// convenience [`TsExpr::object`]'s own doc explains.
970    pub fn multiline_object(entries: Vec<(String, TsExpr)>) -> Self {
971        TsExpr::Object {
972            entries: entries
973                .into_iter()
974                .map(|(k, v)| TsObjectEntry::Prop(k, v))
975                .collect(),
976            multiline: true,
977        }
978    }
979
980    /// The single-line object literal, taking [`TsObjectEntry`] directly —
981    /// for a mixed entry list (shorthand/spread/method alongside `Prop`),
982    /// which [`TsExpr::object`]'s own `Vec<(String, TsExpr)>` convenience
983    /// can't represent. #1321's own real grounding: `workers.rs`'s local
984    /// capability-provider `deps` object mixes shorthand names
985    /// (`{ cap1, cap2 }`) with explicit `key: value` entries
986    /// (`__exec: exec`) in one literal.
987    pub fn object_entries(entries: Vec<TsObjectEntry>) -> Self {
988        TsExpr::Object {
989            entries,
990            multiline: false,
991        }
992    }
993
994    /// [`TsExpr::multiline_object`]'s own `TsObjectEntry` sibling —
995    /// #1321's own real grounding: `workers.rs`'s `compose`-returned
996    /// surface object is one shorthand-async-`Method` entry per wrapper,
997    /// one per line.
998    pub fn multiline_object_entries(entries: Vec<TsObjectEntry>) -> Self {
999        TsExpr::Object {
1000            entries,
1001            multiline: true,
1002        }
1003    }
1004
1005    /// The ordinary, single-line array literal — every real site before
1006    /// #1325.
1007    pub fn array(items: Vec<TsExpr>) -> Self {
1008        TsExpr::Array {
1009            items,
1010            multiline: false,
1011        }
1012    }
1013
1014    /// One entry per line, each with its own trailing comma — see
1015    /// [`TsExpr::Array`]'s own doc for the real shape and the
1016    /// depth-awareness this needs at the print site. #1325's own real
1017    /// grounding: `emit_test_main`'s own `modules` array.
1018    pub fn multiline_array(items: Vec<TsExpr>) -> Self {
1019        TsExpr::Array {
1020            items,
1021            multiline: true,
1022        }
1023    }
1024
1025    /// A template literal — see [`TsExpr::TemplateLit`]'s own doc for the
1026    /// real shape and its no-escaping boundary. The sole caller-facing
1027    /// invariant (`parts.len() == exprs.len() + 1`) is asserted here rather
1028    /// than left to `render_expr`'s own `parts`-driven loop, which would
1029    /// otherwise silently drop trailing `exprs` on a malformed tree instead
1030    /// of failing loudly (review of #1326, finding 1).
1031    pub fn template_lit(parts: Vec<String>, exprs: Vec<TsExpr>) -> Self {
1032        debug_assert_eq!(
1033            parts.len(),
1034            exprs.len() + 1,
1035            "TsExpr::template_lit: parts.len() must be exprs.len() + 1 \
1036             (parts[0] before the first substitution, parts[i+1] after \
1037             exprs[i], …) — got {} parts and {} exprs",
1038            parts.len(),
1039            exprs.len()
1040        );
1041        TsExpr::TemplateLit { parts, exprs }
1042    }
1043}
1044
1045/// One entry of a [`TsExpr::Object`] literal. Only `Prop` existed before
1046/// #1321 (`events_fanout.rs`'s own grounding never needed the other three) —
1047/// `workers.rs`'s own dominant shape (Decision A, gap 1) is a literal
1048/// object whose entries are shorthand async methods (every `on call`/`on
1049/// http`/… wrapper attaches this way), its local capability-`deps` object
1050/// mixes bare shorthand names with explicit `key: value` pairs, and three
1051/// real sites spread another object into one (gap 2, folded in here rather
1052/// than as its own top-level `TsExpr` variant — a spread only ever appears
1053/// as an object- or array-literal entry in this file, never as a
1054/// standalone expression, so scoping it to entry position is the narrower
1055/// correct change).
1056#[derive(Debug, Clone)]
1057pub enum TsObjectEntry {
1058    /// `key: value`.
1059    Prop(String, TsExpr),
1060    /// `key` alone — object-literal property shorthand, e.g. `{ cap1,
1061    /// cap2 }`. Distinct from `Prop(name, TsExpr::Ident(name))`, which
1062    /// prints `name: name`, not the shorthand form real TypeScript
1063    /// property-shorthand text actually is.
1064    Shorthand(String),
1065    /// A shorthand async method entry, e.g. `async foo(a: T) { ... },` —
1066    /// `workers.rs`'s own dominant shape (Decision A, gap 1): every
1067    /// wrapper helper (`emit_call_wrapper`, `emit_event_wrapper`, …)
1068    /// attaches to the returned `compose` surface this way — none of
1069    /// `workers.rs`'s own real sites annotates a return type, so
1070    /// `return_type` stayed unneeded until #1323's own real gap:
1071    /// `workers_entry.rs`'s `export default { fetch, scheduled?, queue? }`
1072    /// entries all carry one (`: Promise<Response>`/`: Promise<void>`), the
1073    /// same field [`TsClassMethod::return_type`] already has for the
1074    /// declaration-position sibling this mirrors.
1075    ///
1076    /// `generics` and `doc` (#1337's own real gaps, both found only by the
1077    /// zero-diff fixture check — not the accepted proposal's own citation,
1078    /// which searched the project-form fixture corpus only;
1079    /// `402_generic_instance_method` is single-file form, and while
1080    /// `65_money_uses_time`/`64_full_time_commons` ARE project form, the
1081    /// citation's own doc-comment search used the wrong marker, `///`
1082    /// rather than this language's real `---`-delimited doc block —
1083    /// both gaps outside what that first pass actually checked):
1084    ///
1085    /// - `generics`: a method on a generic type erases to
1086    ///   `{name}<{generics}>(self: {Type}<{generics}>, …)` — `Box.map<A,
1087    ///   U>`, the type's own `A` plus the method's own `U`. Bare names
1088    ///   only, no bounds/defaults — every real generic parameter list this
1089    ///   tree ever builds is (matching [`crate::printer::print_type`]'s
1090    ///   own `TsType::Named`-only-name convention for the identical
1091    ///   reason), so a full `Vec<TsParam>` (with its own unneeded
1092    ///   `ty`/`optional` fields) would be the wrong shape here.
1093    /// - `doc`: a JSDoc block immediately preceding the method entry, at
1094    ///   the same indent — `Timestamp.diff`/`Timestamp.add`
1095    ///   (`65_money_uses_time`) both carry one. Printed the identical way
1096    ///   `TsStmtKind::DocComment` is (same escaping, same blank-line
1097    ///   convention), reusing that one renderer rather than a second copy
1098    ///   — this field exists because an object-entry method has no
1099    ///   `TsStmt` slot of its own to hold a preceding statement in.
1100    Method {
1101        name: String,
1102        is_async: bool,
1103        generics: Vec<String>,
1104        params: Vec<TsParam>,
1105        return_type: Option<TsType>,
1106        doc: Option<String>,
1107        /// `false` (every method entry landed before #1337): `body` renders
1108        /// as an ordinary braced, multi-line block. `true`: `body` renders
1109        /// compactly on the SAME line as the signature — `{ <stmts>; }`,
1110        /// reusing this crate's own established compact-statement
1111        /// machinery (`TsStmtKind::InlineBlock`'s own sibling shape). A
1112        /// real gap #1337's own zero-diff check found:
1113        /// `emit_forwarded_methods`'s own pre-conversion `writeln!` always
1114        /// built the WHOLE entry — signature and one-statement body alike —
1115        /// on one physical line (`"  {method}({params}): {ret} {{ return
1116        /// …; }},"`), a genuinely different real shape from every other
1117        /// `Method` entry in this tree, all of which are multi-line.
1118        inline: bool,
1119        body: Vec<TsStmt>,
1120    },
1121    /// `...expr` — an object-spread entry (Decision A, gap 2), e.g. `{
1122    /// ...deps, identity: __caller }`.
1123    Spread(TsExpr),
1124}
1125
1126/// `!x` (`events_fanout.rs`'s `!Array.isArray(subs)`, `!binding`) and
1127/// `typeof x` (#1321, `workers.rs`'s own secret-probe idiom: `typeof
1128/// __secret !== "string"`) — the two unary operators real content uses, not
1129/// the full JS/TS table.
1130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1131pub enum TsUnaryOp {
1132    Not,
1133    Typeof,
1134}
1135
1136/// `??` (`events_fanout.rs`'s `env ?? {}`), plus three more real operators
1137/// #1321 (`workers.rs`) grounds: `||`/`&&` (the Bearer-header presence
1138/// checks, `__authz === null || !__authz.startsWith(...)` /
1139/// `__authz !== null && __authz.startsWith(...)`) and `===`/`!==`
1140/// (pervasive throughout the file's own tagged-result and header checks).
1141/// #1323 (`workers_entry.rs`) grounds one more: `>` (the request-body-
1142/// ceiling guard's own `Number(__contentLength) > <cap>` — the one real
1143/// site anywhere in `bynk-emit` that needs a relational, not equality,
1144/// comparison). Not the
1145/// full JS/TS operator table (Decision B's own "extend narrowly" posture) —
1146/// see the printer's own `binary_precedence` (`bynk-ts/src/printer.rs`,
1147/// private) for why a nested `Binary` operand's parenthesisation needed to
1148/// become precedence-aware once more than one operator existed. Arc C, step
1149/// (11) (#1388) grounds one more: `+` (string concatenation) — the
1150/// ICU-formatting cluster's own dominant structural pattern, every
1151/// literal/placeholder segment in a message template joins this way. Real
1152/// JS/TS precedence (binds tighter than every comparison/logical operator
1153/// this table already has) and real left-associativity (`"a" + "b" + "c"`
1154/// needs no parens, the same way a same-operator `||`/`&&` chain already
1155/// prints flat) both matter here, not just the operator symbol itself.
1156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1157pub enum TsBinaryOp {
1158    NullishCoalescing,
1159    Or,
1160    And,
1161    StrictEq,
1162    StrictNotEq,
1163    GreaterThan,
1164    /// `instanceof` — Arc C slice 34 (`tests_emit.rs` slice D, #1403)'s own
1165    /// real gap: `emit_test_case_function`'s own catch clause
1166    /// (`e instanceof ExpectationError`) is the first real `instanceof`
1167    /// anywhere in `bynk-emit`'s own content. Real JS/TS precedence puts it
1168    /// at the same tier as the relational comparisons (`<`/`>`) — sharing
1169    /// [`TsBinaryOp::LessThan`]'s own tier, not a new one — rendered as the
1170    /// keyword `" instanceof "`, textually the same shape as `&&`/`||`
1171    /// rather than symbol punctuation.
1172    InstanceOf,
1173    /// `<` — Arc C slice 33 (`tests_emit.rs` slice C, #1401)'s own real gap:
1174    /// `emit_stub_class`'s `ReturnsEach` sequence-cursor guard
1175    /// (`this.__seq_N < <bound>`) is the first real `<` comparison anywhere
1176    /// in `bynk-emit`'s own content. Same precedence tier as
1177    /// [`TsBinaryOp::GreaterThan`] (real JS/TS relational operators all
1178    /// share one level) — added alongside it rather than folding into a
1179    /// single "relational" variant, matching this enum's own existing
1180    /// one-variant-per-real-operator convention.
1181    LessThan,
1182    Add,
1183    /// `in` — Arc E slice 5 (`serialisation.rs`, #1443)'s own real gap:
1184    /// `emit_record_codec`'s per-field default-value prevalidation line
1185    /// (`"<field>" in obj ? obj["<field>"] : <default>`) needs a real "is
1186    /// this wire key present at all" test, distinct from `!== undefined`
1187    /// (Events slice 3a, #972's own Decision D: a wire key present with an
1188    /// explicit `null`/`{"kind":"None"}` value must NOT fall through to the
1189    /// default, only a genuinely *absent* key may). Real JS/TS grammar puts
1190    /// `in` at the exact same `RelationalExpression` precedence tier as
1191    /// `<`/`>`/`instanceof` (all five — plus `<=`/`>=`, unmodelled here —
1192    /// share one level in the spec), so it joins [`TsBinaryOp::LessThan`]'s
1193    /// tier rather than a new one, the same "share, don't multiply, tiers"
1194    /// convention `InstanceOf`/`LessThan` already set. Rendered as the
1195    /// keyword `" in "`, the same textual-keyword-operator shape
1196    /// `InstanceOf` already established (not symbol punctuation).
1197    In,
1198    /// `>=` — #1471's own real gap: `pred_condition_and_message`'s
1199    /// (`bynk-emit`) `NonNegative`/`InRange`/`InRangeF`/`MinLength` arms
1200    /// each build a real "at least" comparison (`{receiver} >= 0`,
1201    /// `{receiver} >= {a}`, `{receiver}.length >= {n}`) that this enum had
1202    /// no home for — [`TsBinaryOp::In`]'s own doc already named this exact
1203    /// gap ("plus `<=`/`>=`, unmodelled here"). Same `RelationalExpression`
1204    /// precedence tier as [`TsBinaryOp::GreaterThan`]/[`TsBinaryOp::
1205    /// LessThan`]/[`TsBinaryOp::InstanceOf`]/[`TsBinaryOp::In`] (real
1206    /// JS/TS puts all six at one level), rendered `" >= "` — symbol
1207    /// punctuation, matching `GreaterThan`/`LessThan`'s own convention,
1208    /// not a keyword like `InstanceOf`/`In`.
1209    GreaterThanEq,
1210    /// `<=` — the same gap's other half: `InRange`/`InRangeF`/`MaxLength`'s
1211    /// own "at most" comparison (`{receiver} <= {b}`, `{receiver}.length
1212    /// <= {n}`). Same tier and rendering convention as
1213    /// [`TsBinaryOp::GreaterThanEq`], symmetric with it the way
1214    /// `GreaterThan`/`LessThan` already are.
1215    LessThanEq,
1216}
1217
1218/// A literal — the three kinds `events_fanout.rs` uses (a string, a number,
1219/// `null`), plus `Bool` (#1323's own real gap: `workers_entry.rs`'s
1220/// `CorsPolicy.credentials`/`SecurityPolicy.nosniff` object-literal fields
1221/// are real booleans, e.g. `credentials: true`, `nosniff: false` — nothing
1222/// before this slice's own grounding ever built one).
1223#[derive(Debug, Clone)]
1224pub enum TsLit {
1225    Str(String),
1226    /// Rendered verbatim, as text — TypeScript's own numeric-literal
1227    /// grammar is not this crate's problem to re-derive; the caller passes
1228    /// the exact digits it wants printed.
1229    Num(String),
1230    Null,
1231    Bool(bool),
1232    /// The complete literal text, printed exactly as given — no quoting, no
1233    /// escaping, nothing added or removed. #1325's own real, narrow gap:
1234    /// `emit_test_main`'s own `const PREFIX = "integration · ";` embeds
1235    /// a literal JS unicode escape (six ASCII characters, `·`) as
1236    /// pre-formed text — [`TsLit::Str`]'s own escaper (which turns every `\`
1237    /// it sees into `\\`) would double that literal backslash, corrupting
1238    /// this specific byte-golden output. The same "already a raw-text slot"
1239    /// boundary [`TsExpr::TemplateLit`]'s own `parts` field documents, just
1240    /// for a whole literal (quotes included) rather than a template
1241    /// literal's own static segments. One real site — not a general
1242    /// escaping bypass for ordinary string content, which stays on
1243    /// `TsLit::Str`.
1244    Raw(String),
1245}
1246
1247/// A type-position node. `Named` (extended with type arguments — a real gap
1248/// the reference sketch left unaddressed: `Record<string,
1249/// Array<{...}>>`/`Promise<Response>` both need one, and a bare `Named`
1250/// with no type-argument slot cannot represent either), `Array` (extended
1251/// with a `readonly` modifier — P7.9's own real gap: every `List`/`Query`
1252/// element type `bynk-emit`'s `ts_type_ref*`/`ts_ty` families build is
1253/// `readonly T[]`, not plain `T[]`), `Object`, and `Fn` (P7.9's own second
1254/// real gap — the query-thunk wrapper `(() => readonly T[])` and a real
1255/// parametered function type `(a0: T0, …) => Ret` both need one) — not the
1256/// sketch's `Union`/`Intersection`/`Literal`/`TypeParam`/`Readonly` (still
1257/// unused; `readonly` here is a modifier on `Array`, not the sketch's own
1258/// separate `Readonly` wrapper variant).
1259#[derive(Debug, Clone)]
1260pub enum TsType {
1261    /// A named type, optionally generic — `string`/`unknown` (no type
1262    /// arguments) or `Record<string, T>`/`Promise<Response>` (some).
1263    Named {
1264        name: String,
1265        type_args: Vec<TsType>,
1266    },
1267    /// `T[]` (`readonly: false`) or `readonly T[]` (`readonly: true`) —
1268    /// TypeScript's own postfix array-type syntax (not the equivalent
1269    /// `Array<T>`/`ReadonlyArray<T>` generic spelling either family uses).
1270    ///
1271    /// **Hazard (review of #1315/#1316, not yet closed):** the printer does
1272    /// not parenthesise `element` when it is itself a [`TsType::Union`] or
1273    /// [`TsType::Fn`] — `Array { element: Union(..), .. }` prints as
1274    /// `A | B[]`, which TypeScript reads as `A | (B[])`, not the intended
1275    /// `(A | B)[]`; likewise a `Fn` element's own `[]` binds to its return
1276    /// type, not the whole function type. Not a P7.9 regression — every
1277    /// `bynk-emit` call site building this exact malformed shape
1278    /// (`Ty::List` of an `Ty::ActorSum`) produced the identical bytes
1279    /// before this slice too, so the zero-diff bar is genuinely met. Fixing
1280    /// it is a real output change, out of this slice's own behaviour-
1281    /// preserving scope — a caller building `Array` over `Union`/`Fn` today
1282    /// must not trust the printer to parenthesise correctly.
1283    Array {
1284        element: Box<TsType>,
1285        readonly: bool,
1286    },
1287    /// A type-position object shape, e.g. `{ type: string; payload: unknown }`
1288    /// — semicolon-separated, always printed on one line (see
1289    /// [`TsExpr::Object`]'s own doc for the value-position contrast). An
1290    /// `interface`'s own *members* are each printed on their own line by
1291    /// [`TsDecl::Interface`]'s printer, independent of this variant's own
1292    /// (always-inline) rendering — the two only look different because
1293    /// `TsDecl::Interface` is the thing choosing to put each member on its
1294    /// own line, not because `Object` has two rendering modes.
1295    ///
1296    /// #1323 replaced the plain `Vec<(String, TsType, bool)>` (`optional`
1297    /// only, #1321) with [`TsTypeMember`] — `workers_entry.rs`'s own real
1298    /// gap needed both a `readonly` modifier (every structural type this
1299    /// file builds is `readonly`-qualified, e.g. `{ readonly cron: string;
1300    /// readonly scheduledTime: number }`) and a method-signature member
1301    /// (`ack(): void`, `retry(): void`, `waitUntil(promise: Promise<
1302    /// unknown>): void`), neither representable by one more anonymous
1303    /// tuple `bool` — a fourth positional `bool` next to `optional` would
1304    /// be genuinely ambiguous at call sites (`readonly` vs. `optional`, ordered
1305    /// how?), the same reasoning [`TsObjectEntry`] already used over a raw
1306    /// tuple for the value-position sibling.
1307    Object(Vec<TsTypeMember>),
1308    /// `(a0: T0, a1: T1, …) => Ret` — a function type. Parameters carry no
1309    /// name of their own (just their type); the printer numbers them
1310    /// positionally (`a0`, `a1`, …), matching the exact convention
1311    /// `bynk-emit`'s own pre-P7.9 `ts_type_ref_with`/`ts_ty` already used
1312    /// (TypeScript requires *some* name in function-type syntax, and
1313    /// nothing about a `TypeRef::Fn`/`Ty::Fn` parameter carries a real one
1314    /// to use instead). A zero-`params` `Fn` is the query-thunk wrapper
1315    /// shape, `() => Ret`. See [`TsType::Array`]'s own doc for a real,
1316    /// unclosed parenthesisation hazard when a `Fn` sits inside one.
1317    Fn {
1318        params: Vec<TsType>,
1319        ret: Box<TsType>,
1320    },
1321    /// `A | B | C` — a type-position union. Added in review of #1315:
1322    /// `bynk-emit`'s `ts_ty` builds a real union type for a resolved
1323    /// multi-actor sum (`Ty::ActorSum`, discriminated-union members tagged
1324    /// by actor name), a shape none of `Named`/`Array`/`Object`/`Fn` can
1325    /// represent — a real, grounded gap the same way `readonly`/`Fn`
1326    /// themselves were (P7.9's own accepted proposal), not a speculative
1327    /// addition. Each member prints through the ordinary `render_type`
1328    /// recursion; a member that is itself a `Union` is legal to construct
1329    /// but nothing in `bynk-emit` builds one today. See [`TsType::Array`]'s
1330    /// own doc for a real, unclosed parenthesisation hazard when a `Union`
1331    /// sits inside one.
1332    ///
1333    /// `multiline` (#1339): `false` (via [`TsType::union`]) is this variant's
1334    /// original single-line `A | B | C` form, unchanged. `true` (via
1335    /// [`TsType::multiline_union`]) is `emit_sum_type`'s own real
1336    /// discriminated-union shape — one variant per line, a leading `|` on
1337    /// every line *except* the first (which gets equivalent spacing
1338    /// instead), the closing `;` appended directly to the last variant's own
1339    /// line. Mirrors [`TsExpr::Object`]'s own `multiline` field precedent
1340    /// (#1317), but — unlike that one — needs no depth-aware wrapper: the
1341    /// pre-conversion `writeln!` code this reproduces always used a fixed
1342    /// two-space indent regardless of nesting (this shape is only ever a
1343    /// top-level `export type` alias body, never nested inside another
1344    /// type), so `render_type` stays entirely depth-unaware here too. Only
1345    /// rendered correctly from [`TsDecl::TypeAlias`]'s own top-level
1346    /// render — not reachable, and not given a defined rendering, from any
1347    /// nested position (an array element, a type argument, …), the same
1348    /// named boundary [`TsExpr::Object`]'s own `multiline` doc already
1349    /// draws for its own nested case.
1350    Union {
1351        members: Vec<TsType>,
1352        multiline: bool,
1353    },
1354    /// `A & B` — a type-position intersection. #1339's own real gap:
1355    /// `emit_refined_type`'s own branded-type alias,
1356    /// `{base} & { readonly __brand: "..." }`, has no representation among
1357    /// `Named`/`Array`/`Object`/`Fn`/`Union` — mirrors `Union`'s own
1358    /// shape/precedent exactly (a flat `Vec`, each member printed through
1359    /// the ordinary `render_type` recursion, joined by ` & `), single-line
1360    /// only (nothing in `bynk-emit` builds a multi-line intersection).
1361    Intersection(Vec<TsType>),
1362}
1363
1364impl TsType {
1365    /// A plain named type with no type arguments — `string`, `unknown`,
1366    /// `Request`, …
1367    pub fn named(name: impl Into<String>) -> Self {
1368        TsType::Named {
1369            name: name.into(),
1370            type_args: Vec::new(),
1371        }
1372    }
1373
1374    /// A generic named type — `Record<K, V>`, `Promise<T>`, …
1375    pub fn named_with_args(name: impl Into<String>, type_args: Vec<TsType>) -> Self {
1376        TsType::Named {
1377            name: name.into(),
1378            type_args,
1379        }
1380    }
1381
1382    /// `T[]` — the non-`readonly` array shape.
1383    pub fn array(element: TsType) -> Self {
1384        TsType::Array {
1385            element: Box::new(element),
1386            readonly: false,
1387        }
1388    }
1389
1390    /// `readonly T[]`.
1391    pub fn readonly_array(element: TsType) -> Self {
1392        TsType::Array {
1393            element: Box::new(element),
1394            readonly: true,
1395        }
1396    }
1397
1398    /// `A | B | C` — the original single-line union form, unchanged.
1399    pub fn union(members: Vec<TsType>) -> Self {
1400        TsType::Union {
1401            members,
1402            multiline: false,
1403        }
1404    }
1405
1406    /// `emit_sum_type`'s own real multi-line discriminated-union shape — see
1407    /// [`TsType::Union`]'s own doc for the exact rendering rules and why
1408    /// this needs no depth parameter.
1409    pub fn multiline_union(members: Vec<TsType>) -> Self {
1410        TsType::Union {
1411            members,
1412            multiline: true,
1413        }
1414    }
1415
1416    /// `A & B` — an intersection type.
1417    pub fn intersection(members: Vec<TsType>) -> Self {
1418        TsType::Intersection(members)
1419    }
1420}
1421
1422/// One member of a [`TsType::Object`] structural type — a property (`Prop`)
1423/// or a method signature (`Method`, #1323's own real gap: `ack(): void`,
1424/// `retry(): void`, `waitUntil(promise: Promise<unknown>): void` — no body,
1425/// a type-position sibling to [`TsObjectEntry::Method`], which does carry
1426/// one). `Method`'s own parameters reuse [`TsParam`] directly (not a bare
1427/// `Vec<TsType>` the way [`TsType::Fn`]'s anonymous, positionally-numbered
1428/// parameters do) — `waitUntil`'s own real parameter has a real name
1429/// (`promise`) the printed text must show, unlike `Fn`'s callers, none of
1430/// which have one to show.
1431#[derive(Debug, Clone)]
1432pub enum TsTypeMember {
1433    Prop {
1434        name: String,
1435        ty: TsType,
1436        optional: bool,
1437        readonly: bool,
1438    },
1439    /// `generics`/`doc` added by #1357: `emit_capability`'s own interface
1440    /// methods are genuinely generic (`op<T>(...): ret;`, no
1441    /// monomorphisation) and doc-commented — bare generic names, matching
1442    /// every other real generics-list precedent in this crate; `doc`
1443    /// mirrors `TsObjectEntry::Method.doc`'s own identical field (#1337).
1444    /// Both default empty/`None` via [`TsTypeMember::method`]'s own
1445    /// existing constructor — every one of its 6 real pre-#1357 callers is
1446    /// unaffected.
1447    ///
1448    /// `doc` renders from exactly one of this variant's two reachable
1449    /// positions: `TsDecl::Interface`'s own render arm (a real, multi-line
1450    /// declaration body with `depth` available, so it calls
1451    /// `render_doc_comment` before a documented member's own line — the
1452    /// only place `doc` is honoured). A `Method` reached through
1453    /// `TsType::Object`'s own inline, single-line shape (a type-position
1454    /// object literal, e.g. `{ a: X; b(): Y }`) has no line budget for a
1455    /// JSDoc block at all — `doc: Some(_)` there is a real, `debug_assert`-
1456    /// guarded misuse (`render_type`'s own `TsType::Object` arm), the same
1457    /// "loud, not silently dropped" precedent review of #1338 already
1458    /// established for `render_object_entry_inline`'s identical
1459    /// `TsObjectEntry::Method.doc` case.
1460    Method {
1461        name: String,
1462        generics: Vec<String>,
1463        params: Vec<TsParam>,
1464        ret: TsType,
1465        doc: Option<String>,
1466    },
1467    /// `[key_name: key_ty]: value_ty` — a TypeScript index signature.
1468    /// #1323's own real gap: `workers_entry.rs`'s multi-param `on call`
1469    /// dispatch casts its raw JSON args object through `{ [k: string]:
1470    /// JsonValue }` before indexing it by field name — textually distinct
1471    /// from the semantically-equivalent `Record<string, JsonValue>` (a
1472    /// `Named` type with type arguments), which `bynk-emit`'s own
1473    /// pre-conversion `writeln!` never wrote here. The one real site.
1474    Index {
1475        key_name: String,
1476        key_ty: TsType,
1477        value_ty: TsType,
1478    },
1479}
1480
1481impl TsTypeMember {
1482    /// `name: ty` — the ordinary, non-`optional`, non-`readonly` case.
1483    pub fn prop(name: impl Into<String>, ty: TsType) -> Self {
1484        TsTypeMember::Prop {
1485            name: name.into(),
1486            ty,
1487            optional: false,
1488            readonly: false,
1489        }
1490    }
1491
1492    /// `name?: ty`.
1493    pub fn optional_prop(name: impl Into<String>, ty: TsType) -> Self {
1494        TsTypeMember::Prop {
1495            name: name.into(),
1496            ty,
1497            optional: true,
1498            readonly: false,
1499        }
1500    }
1501
1502    /// `readonly name: ty`.
1503    pub fn readonly_prop(name: impl Into<String>, ty: TsType) -> Self {
1504        TsTypeMember::Prop {
1505            name: name.into(),
1506            ty,
1507            optional: false,
1508            readonly: true,
1509        }
1510    }
1511
1512    /// `name(params): ret` — no body, no generics, no doc comment.
1513    pub fn method(name: impl Into<String>, params: Vec<TsParam>, ret: TsType) -> Self {
1514        TsTypeMember::Method {
1515            name: name.into(),
1516            generics: Vec::new(),
1517            params,
1518            ret,
1519            doc: None,
1520        }
1521    }
1522
1523    /// `[key_name: key_ty]: value_ty`.
1524    pub fn index(key_name: impl Into<String>, key_ty: TsType, value_ty: TsType) -> Self {
1525        TsTypeMember::Index {
1526            key_name: key_name.into(),
1527            key_ty,
1528            value_ty,
1529        }
1530    }
1531}
1532
1533/// One function/method/constructor parameter.
1534#[derive(Debug, Clone)]
1535pub struct TsParam {
1536    pub name: String,
1537    pub ty: Option<TsType>,
1538    /// `name?: ty` — `events_fanout.rs`'s own `env?: unknown` constructor
1539    /// parameter needs this; nothing in the grounding file needs a default
1540    /// value, so only optionality is represented, not defaults.
1541    pub optional: bool,
1542}
1543
1544/// One `class` field.
1545#[derive(Debug, Clone)]
1546pub struct TsClassField {
1547    pub name: String,
1548    pub ty: TsType,
1549    /// `private` — the one visibility modifier `events_fanout.rs` uses
1550    /// (`private env: ...`). Not a decorator or a constructor parameter
1551    /// property (R7.1 forbids both categorically — there is no variant
1552    /// shape here that could construct either).
1553    pub private: bool,
1554}
1555
1556/// A class's own constructor.
1557#[derive(Debug, Clone)]
1558pub struct TsClassCtor {
1559    pub params: Vec<TsParam>,
1560    pub body: Vec<TsStmt>,
1561}
1562
1563/// One class method.
1564#[derive(Debug, Clone)]
1565pub struct TsClassMethod {
1566    pub name: String,
1567    /// `private {name}(...)`, e.g. `loadState`/`commitState` — the
1568    /// grounding pass's own predicted gap (#1366), closed by Arc C's own
1569    /// `emit_agent` class-scaffold slice: every real `TsClassMethod` site
1570    /// before this one (`emit_provider` #1359's own op methods) was
1571    /// public-only. Rendered before `async`, matching the one real site's
1572    /// own modifier order (`private async loadState()`, not `async private
1573    /// loadState()`).
1574    pub private: bool,
1575    pub is_async: bool,
1576    pub params: Vec<TsParam>,
1577    pub return_type: Option<TsType>,
1578    /// A JSDoc block immediately preceding the method — the grounding
1579    /// pass's own second predicted gap (#1366), closed alongside `private`'s
1580    /// own sibling site: `emit_agent`'s own per-handler methods each carry
1581    /// a doc comment (`emit_doc_block`'s pre-conversion standalone call),
1582    /// the same need `TsObjectEntry::Method.doc` (#1337) and
1583    /// `TsTypeMember::Method.doc` (#1357) already solved for their own node
1584    /// kinds. Rendered the identical way those two already are — a
1585    /// `TsStmtKind::DocComment`-shaped block at the method's own indent,
1586    /// immediately before its header line.
1587    pub doc: Option<String>,
1588    pub body: Vec<TsStmt>,
1589}
1590
1591/// A top-level declaration. `Import`, `Export`, `Interface`, `ConstDecl`,
1592/// and `Class` were `events_fanout.rs`'s own grounding (P7.8's own note:
1593/// "not the sketch's `Function`/`TypeAlias` — unused in the grounding
1594/// file"). #1321 (`workers.rs`) needed both after all — a real gap the
1595/// accepted proposal's own Framing didn't name (its own "no other gap
1596/// surfaced" checked `TsStmt`/`TsExpr`/`TsType` shapes, not `TsDecl` ones):
1597/// `compose.ts`'s own `export function compose(env: Env, …) { … }` is a
1598/// top-level function declaration, and (when the Worker has agents or
1599/// publishes events) a `type DurableObjectNamespace = { … };` fallback
1600/// alias sits alongside it. Named explicitly as a deviation from the
1601/// accepted proposal's own catalogue, not silently added — both are
1602/// mechanical, direct `TsDecl` siblings of `ConstDecl`/`Class` already
1603/// here, the same "small, grounded, same class of gap" this track's own
1604/// history repeatedly found and closed (P7.8's `Assign`/`Continue`/
1605/// `TryCatch`, Arc C slice 1's `Comment`).
1606#[derive(Debug, Clone)]
1607pub enum TsDecl {
1608    /// `import { a, b } from "spec";` (`type_only: false`) or
1609    /// `import type { a, b } from "spec";` (`type_only: true`). Only the
1610    /// named-imports form — `events_fanout.rs` never uses a default
1611    /// import, so none is represented. `names` entries are pushed verbatim
1612    /// (`printer.rs`'s own `names.join(", ")`), so a renamed import
1613    /// (`messagesLocales as __locale_declaredLocales`, `workers.rs`'s own
1614    /// locale-negotiation import) is one opaque `String` entry, not a
1615    /// structured rename — the same "the field is already a raw-text slot"
1616    /// reasoning that also covers a `type`-prefixed single specifier
1617    /// (`"type KVNamespace"`) inside an otherwise non-`type_only` import.
1618    Import {
1619        type_only: bool,
1620        names: Vec<String>,
1621        from: String,
1622    },
1623    /// `import * as alias from "spec";` — a namespace import, structurally
1624    /// different from [`TsDecl::Import`]'s braced named-imports form (no
1625    /// `{ }`, one bound name for the whole module). #1321's own real gap:
1626    /// `workers.rs`'s `compose.ts` imports `handlers.js` and each
1627    /// referenced unit's binding module this way; `events_fanout.rs` never
1628    /// used one. `type_only` (Arc C, step (10), #1392) mirrors
1629    /// [`TsDecl::Import`]'s own identical field, a parallel gap by
1630    /// omission, not deliberate design — `emit_cross_context_namespace_
1631    /// imports`'s own real `import type * as ns from "...";` form (a
1632    /// Workers-mode consumed-context import reaching the callee's types
1633    /// only, #661) had no way to represent the `type` keyword until now.
1634    ImportNamespace {
1635        type_only: bool,
1636        alias: String,
1637        from: String,
1638    },
1639    /// `import name from "spec";` — a default import, structurally
1640    /// different from both [`TsDecl::Import`] (braced named-imports form)
1641    /// and [`TsDecl::ImportNamespace`] (`* as` form) — no braces, no `* as`,
1642    /// one bound local name for the module's own default export. No
1643    /// `type_only` form — nothing in the grounding file default-imports a
1644    /// type. Arc C, slice 37 (#1409, `tests_emit.rs`'s own slice G): a
1645    /// per-participant `import worker_{ns} from "../workers/{dir}/
1646    /// index.js";` (the participant's own Worker entry module's default
1647    /// export) is the first real default import anywhere in `bynk-emit`'s
1648    /// own converted content — every prior import site is either named
1649    /// ([`TsDecl::Import`]) or namespace ([`TsDecl::ImportNamespace`]).
1650    ImportDefault { alias: String, from: String },
1651    /// `export { a, b } from "spec";` — a re-export, structurally distinct
1652    /// from both [`TsDecl::Import`] (which binds locally, carries no
1653    /// `export` keyword) and [`TsDecl::Export`] (which wraps a whole
1654    /// declaration, not a braced name list re-pointed at another module).
1655    /// #1323's own real gap: `workers_entry.rs` re-exports each agent's
1656    /// Durable Object class from `./handlers.js`, and (when the context
1657    /// publishes events) the fan-out DO's class from `./events_fanout.js`.
1658    /// No `type_only` form — nothing in the grounding file re-exports a
1659    /// type-only name.
1660    ReExport { names: Vec<String>, from: String },
1661    /// `export * from "spec";` — a wildcard re-export, structurally distinct
1662    /// from [`TsDecl::ReExport`] (which always carries a braced name list —
1663    /// an empty `names` there would render the ill-formed `export {  }
1664    /// from "spec";`, not this). Mirrors [`TsDecl::ImportNamespace`]'s own
1665    /// "no braces, no name list, one bound target" shape, on the export
1666    /// side. #1329's own real gap: `emit_commons_barrel`
1667    /// (`bynk-emit/src/project/tests_emit.rs`) builds a multi-file
1668    /// `commons` unit's barrel module as one `export *` line per
1669    /// constituent source file.
1670    ReExportAll { from: String },
1671    /// Marks the wrapped declaration `export`ed — `export class Foo { .. }`
1672    /// is `Export(Box::new(Class { .. }))`. A wrapper, not a per-variant
1673    /// `exported: bool` field, matching the reference sketch's own naming
1674    /// (`TsDecl::Export` is a peer variant, not a modifier on each other
1675    /// one).
1676    Export(Box<TsDecl>),
1677    /// `type_params`/per-member `readonly` (#1339's own real gap):
1678    /// `emit_record_type`'s own `export interface {name}{params} {
1679    /// readonly {field}: {ty}; ... }` — bare generic names (matching
1680    /// `ts_type_params`'s/`TsObjectEntry::Method.generics`'s own
1681    /// convention) and a `readonly` modifier every real field here
1682    /// carries. `members` reuses [`TsTypeMember`] (the exact same shape
1683    /// [`TsType::Object`]'s own structural members already use) rather
1684    /// than a bespoke tuple, since `readonly` is already that type's own
1685    /// field — this interface's real content has never needed
1686    /// `Method`/`Index`, but nothing about reusing the shared type
1687    /// forecloses a future slice that does.
1688    Interface {
1689        name: String,
1690        type_params: Vec<String>,
1691        members: Vec<TsTypeMember>,
1692    },
1693    /// A top-level `const` — distinct from `TsStmtKind::Const` (private —
1694    /// reachable through [`TsStmt::const_stmt`]), the local
1695    /// form.
1696    ConstDecl {
1697        name: String,
1698        ty: Option<TsType>,
1699        init: TsExpr,
1700    },
1701    Class {
1702        name: String,
1703        fields: Vec<TsClassField>,
1704        constructor: Option<TsClassCtor>,
1705        methods: Vec<TsClassMethod>,
1706    },
1707    /// `function name(params): ret { body }` (`is_async: false`) or `async
1708    /// function name(params): ret { body }` (`is_async: true`) — a top-level
1709    /// function declaration. `is_async` added by #1325: `workers.rs`'s own
1710    /// one real site (`compose`) is never async, so the field didn't exist
1711    /// until `emit_test_main`'s own top-level `async function main() {...}`
1712    /// needed it — the exact "extend narrowly, add it when a future slice's
1713    /// own grounding needs it" deferral this variant's own history already
1714    /// named. `generics` added by #1351: `emit_free_fn`'s own v0.20a erased
1715    /// generics (`export function foo<T>(...)`) — bare names only, empty
1716    /// for every non-generic site (`compose`/`main`), matching every other
1717    /// real generics-list precedent in this crate
1718    /// (`TsObjectEntry::Method.generics` #1337, `TsExpr::Arrow.generics`
1719    /// #1339). `inline` added by #1369 (Arc C, slice 20, step (9)'s own
1720    /// second sub-slice): `emit_agent`'s own zero-factory function
1721    /// (`function __zeroOf{Name}State(): {Name}State { return {...}; }`) is
1722    /// a genuinely single-physical-line declaration — braces and body share
1723    /// the header's own line, not `render_block_stmts`'s always-multi-line
1724    /// shape. `false` (every site landed before #1369) renders the ordinary
1725    /// multi-line body; `true` reuses `render_inline_block`'s own compact
1726    /// `{ stmt; stmt; }` shape directly at the declaration's own header
1727    /// line, the same "one more bool, mirroring an existing precedent"
1728    /// scope `TsObjectEntry::Method.inline` (#1337) already set for the
1729    /// identical single-line-vs-multi-line tension at a different node kind.
1730    Function {
1731        name: String,
1732        generics: Vec<String>,
1733        params: Vec<TsParam>,
1734        return_type: Option<TsType>,
1735        body: Vec<TsStmt>,
1736        is_async: bool,
1737        inline: bool,
1738    },
1739    /// `type name = ty;` (non-generic) or `type name<T, U> = ty;` (via
1740    /// `type_params`, #1339's own real gap: `emit_sum_type`'s own generic
1741    /// sum types erase to `export type Foo<T> = ...`) — a top-level type
1742    /// alias. `workers.rs`'s own real site: the `DurableObjectNamespace`
1743    /// local fallback type (emitted only when the Worker has agents or
1744    /// publishes events), never generic — `type_params` is empty there.
1745    TypeAlias {
1746        name: String,
1747        type_params: Vec<String>,
1748        ty: TsType,
1749    },
1750    /// `export default <expr>;` — a default export of an *expression*, not
1751    /// a declaration. #1323's own real gap (`workers_entry.rs`'s own
1752    /// top-level shape, `export default { fetch, scheduled?, queue? }`):
1753    /// [`TsDecl::Export`] wraps a [`TsDecl`] (a `Class`/`ConstDecl`/etc.,
1754    /// something with its own name), which cannot represent exporting a
1755    /// bare, unnamed object-literal expression — so this is its own
1756    /// variant, not `Export(Box::new(ConstDecl { .. }))` with a synthetic
1757    /// name that would print wrong.
1758    ExportDefault(TsExpr),
1759    /// `declare const name: ty;` — an ambient binding with no initialiser,
1760    /// narrowing a global TypeScript otherwise doesn't know about. #1325's
1761    /// own real, narrow site: `emit_test_main`'s own `declare const process:
1762    /// { exit(code: number): never; env: { [k: string]: string | undefined
1763    /// } };`, narrowing Node's `process` global without a `@types/node`
1764    /// dependency. Distinct from [`TsDecl::ConstDecl`] (always has a real
1765    /// `init` expression) — a `declare const` has none, by definition; a
1766    /// `TsDecl::ConstDecl` with a synthetic placeholder `init` would print
1767    /// wrong (`= <something>;` where nothing should appear at all).
1768    DeclareConst { name: String, ty: TsType },
1769}
1770
1771/// Which family of residual, not-yet-converted emission a [`TsStmt::verbatim`]
1772/// statement came from. A closed enum, deliberately — "makes the ratchet a
1773/// compile-time construct, not a grep" (Q2's own settling text). Named
1774/// file-by-file as Arc C actually needs them (`ast_importers`'s own five-file
1775/// floor is the precedent for how this track names residue), not
1776/// pre-populated for the whole ~19-slice Arc C schedule up front.
1777///
1778/// Deliberately **not** `#[non_exhaustive]`: a `match` over every variant —
1779/// in this crate, or in `bynk-emit` once Arc C reads it — must fail to
1780/// compile the moment a new variant is added, forcing every consumer to
1781/// account for it explicitly. A non-exhaustive enum would let a wildcard arm
1782/// silently absorb a new residue family instead, exactly the "grep, not a
1783/// compile-time construct" Q2's own settling text rejected. P7.8's own
1784/// grounding work found that `Contracts`/`Secrets`/`RuntimeUse` were seeded
1785/// against files that turn out not to need `bynk-ts` conversion at all
1786/// (`design/tracks/the-typescript-tree.md` §9) — recorded there, not fixed
1787/// by removing the variants here, since that's separate follow-on work.
1788#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1789pub enum VerbatimOrigin {
1790    /// `bynk-emit/src/emitter/contracts.rs`.
1791    Contracts,
1792    /// `bynk-emit/src/emitter/secrets.rs`.
1793    Secrets,
1794    /// `bynk-emit/src/emitter/runtime_use.rs`.
1795    RuntimeUse,
1796    /// P7.6's own transitional wrap (#1309): the whole of a still-`String`-
1797    /// producing `bynk-emit` document (an entry point, `compose.ts`, the
1798    /// runtime module, an adapter binding, a test module, …), carried into
1799    /// `Document::Ts` so `Artefacts` never stores a bare `String` for TS
1800    /// output (R7.8) even before Arc C converts the function that built it.
1801    /// Deliberately **not** file-specific like the three variants above —
1802    /// this is Arc B's own infrastructure slice, not an Arc C conversion, so
1803    /// it covers everything Arc C hasn't reached yet, one call site per
1804    /// document `bynk-emit/src/project.rs`/`project/tests_emit.rs`
1805    /// constructs (not funnelled through a shared helper: a shared wrap
1806    /// point would collapse every one of those call sites to a single
1807    /// textual `TsStmt::verbatim(` occurrence, defeating `verbatim_sites`'
1808    /// own purpose of counting how much is genuinely still unconverted).
1809    /// Retires site by site as Arc C converts each underlying emitter
1810    /// function to return a real `TsProgram` directly — at which point that
1811    /// document's own construction site stops calling `TsStmt::verbatim` at
1812    /// all, not by this variant being deleted first.
1813    NotYetConverted,
1814}
1815
1816#[cfg(test)]
1817mod tests {
1818    use super::*;
1819
1820    #[test]
1821    fn verbatim_carries_its_own_span() {
1822        let span = Span::new(3, 8);
1823        let stmt = TsStmt::verbatim(VerbatimOrigin::RuntimeUse, "x", Some(span));
1824        assert_eq!(stmt.span, Some(span));
1825    }
1826
1827    #[test]
1828    fn a_program_prints_its_statements_in_push_order() {
1829        // Kept as a construction-order check (not a print check — printer.rs
1830        // owns that) since `TsStmtKind` is `pub(crate)` and no longer
1831        // exposes a uniform `text()` accessor once non-`Verbatim` kinds
1832        // exist; span order is what's left to check at this layer.
1833        let mut program = TsProgram::new();
1834        program.push(TsStmt::verbatim(VerbatimOrigin::Contracts, "a", None));
1835        program.push(TsStmt::verbatim(
1836            VerbatimOrigin::Secrets,
1837            "b",
1838            Some(Span::new(0, 1)),
1839        ));
1840        assert_eq!(program.stmts[0].span, None);
1841        assert_eq!(program.stmts[1].span, Some(Span::new(0, 1)));
1842    }
1843
1844    /// Review of #1326, finding 1: `TsExpr::template_lit`'s own
1845    /// `debug_assert_eq!` must actually fire on a malformed tree, not just
1846    /// exist as documentation — proves the guard guards, the same "prove
1847    /// it" discipline this crate applies to every other invariant.
1848    #[test]
1849    #[should_panic(expected = "parts.len() must be exprs.len() + 1")]
1850    fn template_lit_rejects_a_parts_exprs_count_mismatch() {
1851        let _ = TsExpr::template_lit(
1852            vec!["a".to_string()],
1853            vec![TsExpr::Ident("too_many".to_string())],
1854        );
1855    }
1856}