#6

feat(js): compile the spellings models reach for most

OpenEzraBuildwants to merge intofrom

What

The JS frontend now desugars the spellings models write most often into the let/call steps the author could have written by hand, instead of rejecting them and costing a repair round-trip. Nothing about the plan format, validation, limits, or reconciliation changes: every commit is frontend-only, and the plan that comes out is one the author could already have written.

One commit per feature, reviewable independently:

  1. Destructuring – const { items, total = 0 } = await repo.list({}) is the call plus one derivation per name (items = s1.items); const [head, ...tail] = items reads by index and slice. Renames, defaults, nesting, string keys, object rest, and nested patterns in a Promise.all tuple are covered. Inside try/catch the fields carry if: !($errors.<id>). Computed keys, forbidden keys, and destructuring a detached call keep a pointed message. patternNames recurses so minted ids never collide with deep pattern names.
  2. in operator – "labels" in issue evaluates as Object.hasOwn: plain data only, never a prototype chain, forbidden names still unreachable, non-object RHS is a type error as in JS.
  3. Pointed rejections – new Date names Date.parse/Date.now (it used to get the new Set dedupe hint), new RegExp names includes/startsWith/endsWith, a bare forEach names the Promise.all(list.map(...)) fan-out, console.log says to return the value, and an unknown tool suggests the nearest mounted name (capped edit distance, or the same tool under another namespace). No guess when nothing is close; unknownToolHint still rides along.
  4. for..of bodies – a loop body may carry pure locals (inlined into the call as renames), if (cond) continue, and a trailing if (cond) { ... } around the one awaited call. Guards become a .filter on the list, so the slice(0, N) bound still caps the fan-out. A second call, an else, or a return stays rejected with the fan-out spelling.
  5. Nested awaits hoist – return { issues: await repo.list({}) } and close({ id: (await find({})).id }) compile each nested awaited call into its own step in source order, and the expression reads the step id. Hoisted calls join the effect frontier like any awaited call. Not hoistable, with the message: an await inside an arrow, inside a fan-out's own arguments or a loop body, a nested Promise.all, or an await on a non-call.
  6. let x = a; if (cond) x = b – the conditional-value idiom compiles to the single derivation x = cond ? b : a (else branch, block bodies, dead initializer covered; inherits an enclosing guard). Else-if chains, other work in a branch, or an await anywhere in the pair keep the single-assignment message.
  7. Wider pure expression surface – arrays: at, findLast, findLastIndex, lastIndexOf, toSorted, toReversed; strings: at, substring, trimStart, trimEnd, charCodeAt, concat; callable globals parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent, encodeURI, decodeURI, usable bare and as callbacks (xs.map(Number), xs.filter(Boolean)); spread in call arguments (Math.max(...xs)); non-mutating Object.assign. Array mutators name the immutable spelling; Math.random says why it is out (a run must replay deterministically).
  8. Wrappers unwrap – (async () => { ... })() (awaited or not, arrow or function expression) and async function main() { ... } main() compile as the program: the body's return is the output, a leading comment the intent, inner names still reserve their ids. A .then/.catch chained on the wrapper is rejected with the reason.
  9. Fan-out arrows with a block body – list.map(async i => { ... }) shares the for..of body grammar: locals, if (cond) return; as the skip guard, a trailing if (cond) { ... }, one awaited call (bound, bare, or returned). One shared walker (fanOutBody) and step builder (fanOutStep) serve both.
  10. Docs (two commits) – README lists every new spelling and the pure natives expressions cover; the js.ts header points at the helper owning each desugar.

Why

On a probe of 35 scripts written the way models actually write them, 16 were rejected by the frontend, and most of the rejections were these shapes – destructuring alone hit on every retry. Each one had an exact spelling in the plan already; the frontend just didn't do the translation.

Tests

45 new cases across js.test.ts, expr.test.ts, and validate.test.ts: each form above, end-to-end runs through engine.run, and every rejection path. Suite: 318 passing, typecheck clean, biome clean. Probe of 35 model-written scripts: 28 accepted, up from 19 on main; the 7 left are deliberate and each names its fix.