Skip to content

Fix 4 emit-bug classes: async-await, let-swallow, %-modulo, camelCase-export mangle#2

Merged
tompassarelli merged 4 commits into
mainfrom
fix/emit-bug-classes
Jun 16, 2026
Merged

Fix 4 emit-bug classes: async-await, let-swallow, %-modulo, camelCase-export mangle#2
tompassarelli merged 4 commits into
mainfrom
fix/emit-bug-classes

Conversation

@tompassarelli

@tompassarelli tompassarelli commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Four classes of incorrect JS emit. Each passes beagle syntax but produces output that only fails at runtime. This PR fixes or guards each.

1. async-IIFE-await — emit correctness (678bbd1)

A try/loop/doseq containing (js/await …) compiles to an async IIFE (async () => {…})(). When bound in a let without an enclosing js/await, it was emitted without await, so the binding held a pending Promise that downstream code read synchronously.
Fix: emit-js awaits async IIFEs in value/statement position (await-async-iife, applied at binding-value and expr-stmt sites). Tail position is left un-awaited; the helper matches only the exact (async () => prefix, so it never double-awaits an explicit js/await or an inline f(await g()).

2. let-swallow → bare name; — guard (004d291)

A let binding whose value form is one paren short absorbs the following name value pair as body forms; the swallowed binding name emits as a bare name; statement, causing a ReferenceError at load. Net parens balance, so beagle syntax reports ok.
Fix: check.rkt raises swallowed-binding (E020) when a non-final body statement is a bare symbol that resolves to nothing, with a message pointing at the enclosing let. Tail-position bare symbols (legitimate returns) are not flagged.

3. % as call head → undefined _pct — guard (2f7a441)

(% a b) emits _pct(a, b) because % is the #() anonymous-arg shorthand. Outside a lambda this is an undefined call.
Fix: parse.rkt rejects % as a call head with an error naming rem/mod. % inside #(…) is unaffected.

4. camelCase export name mangle — lint (7b5b6d5)

kebab names mangle to snake_case; camelCase emits as-is. An exported camelCase name referenced cross-module in kebab mangles to a different identifier and is undefined in the bundle.
Fix: lint.rkt lint-export-naming warns on camelCase JS exports and names the kebab form. It runs in lint-program!, so it fires at build and plain check, and is counted by count-lint-warnings so the --agent path reports it.

Verification

  • active suite 1377/1377
  • gated tier: no new regressions vs baseline (emit-js 127/131, js-quote 67/71, jst 24/33, js-exec-oracle 4/5 — pre-existing)
  • downstream consumer compiles clean (79/79 files), integration 42/42

A control-flow form (try/do/let/loop/when/match/doseq-body) containing js/await
compiles to an async IIFE `(async () => {...})()` which RETURNS A PROMISE. In
value position (let/loop binding) and statement position (doseq body, non-final
body stmt) that promise was used WITHOUT await -> the binding held a pending
promise and code after it ran before it settled. This 'fire-and-forget' bug bit
6 unrelated files in one gjoa session (marionette client, two wait_for loops,
sidebar loop, branding.bjs copy loop -> 'copied 0 files').

Fix: await-async-iife helper prepends `await` when an emitted value/statement is
exactly the iife/loop output prefix `(async () => `. Applied at all six binding-
value sites + emit-expr-stmt. The exact-prefix match means it never over-awaits
inline-await calls (`f(await g())`) and never double-awaits explicit js/await
(`await (async...`). Active suite 1377/1377; gated js tests identical to baseline;
gjoa corpus (46 modules) recompiles clean.

Thread: @2026-06-15-230101
…non-final stmt

A let binding whose value form is paren-short silently absorbs the next binding;
the swallowed name emits as a bare `name;` JS statement -> runtime ReferenceError.
beagle syntax reports ok (net parens balance), so it was a silent miscompile (hit
gjoa drag.bjs setup_drag, vim.bjs setup_vim_keys).

A bare symbol in non-final statement position has no effect; when it also resolves
to nothing (not local/param/let/extern/builtin/top-level def), it is the swallow
signature. last-expr-type now raises E020 with a pointed message + source loc.
Tail-position symbols (the return value) and bound symbols are not flagged.
gjoa corpus (46 modules) + beagle active suite (1377/1377): no false-positives.

Thread: @2026-06-15-230102
…rror -> rem/mod

`(% a b)` written intending modulo silently emitted `_pct(a, b)` (undefined) —
`%` is the anonymous-fn argument shorthand, only valid inside #(...). The reader
rewrites `%`->`%1` inside #(), so a bare `%` at parse time can only be outside a
lambda = always an error. Hit gjoa vim.bjs cycle-space (broke gs/gS space chord).
Reject at parse with a pointed message naming rem/mod. Legit `%` inside #() (e.g.
#(= (.-id %) 5) -> (_pct1)=>...) is unaffected; gjoa corpus + beagle suite clean.

Thread: @2026-06-15-230103
kebab names mangle to snake_case; camelCase emits as-is. An exported
camelCase name that a consumer references in kebab mangles to a different
JS identifier and is undefined in the concatenated bundle. Warn (not
hard-error, since declare-extern is the intentional escape hatch) and
name the kebab fix. Fires at build and plain check; counted by
count-lint-warnings so the agent/repair-loop surfaces it too.
@tompassarelli tompassarelli merged commit 08e7655 into main Jun 16, 2026
1 check passed
@tompassarelli tompassarelli deleted the fix/emit-bug-classes branch June 16, 2026 05:42
tompassarelli added a commit that referenced this pull request Jun 16, 2026
emit-claims now emits a uniform 'child' containment edge for every minted node
(never for inlined literals), so structural containment is one traversable
predicate and node-refs are unambiguous vs literal integers — this is what makes
the call graph cleanly derivable in Datalog (chartroom turtle #2).

Adds bin/beagle-claims + claims-cli.rkt: a cross-cutting analysis command that
claims-emits any file (.bjs/.bclj/.bnix) regardless of its #lang, bypassing the
per-file target dispatch (claims are an analysis projection, not a compile target).
tompassarelli added a commit that referenced this pull request Jun 17, 2026
…ingle-colon)

Gates the 9 type-cluster fixes from adversarial sweep #2 (chartroom resolve.clj):
constructor heads rename with the type, defunion variants, cross-module t/Type
annotations cascade, single-colon ':' annotations cascade, and a rename matching
no binding is refused (no silent 0-edit success).
tompassarelli added a commit that referenced this pull request Jun 17, 2026
Gates the 5 sequential/destructuring fixes from sweep #2: :or default refs cascade,
for/let sequential-sibling capture is refused, and a legitimate sequential rename
still succeeds (no over-refusal).
tompassarelli added a commit that referenced this pull request Jun 17, 2026
Gates cluster C+D from sweep #2: quasiquote template refs to module defs rename
(template locals stay, hygiene), bare (require m :refer [x]) refs rename cross-module,
and renaming into a name a consumer already binds is refused (no duplicate import).
tompassarelli added a commit that referenced this pull request Jun 17, 2026
…oles

Quantifies the lesson: a graph engine is scope-correct only where adversarially
proven, not because it is a graph. Two sweeps surfaced 2 + 17 concealed
recompiled-but-wrong bugs in an engine whose pitch is "correct unlike sed."
tompassarelli added a commit that referenced this pull request Jun 20, 2026
* docs: codegen/check findings from gjoa dogfooding (8 items, minimal repros)

A companion ledger to text-as-source-latent-bugs.md, capturing codegen/emit/lint
findings surfaced by authoring gjoa with beagle. #2 (unary minus) is already fixed;
#1 (statement-position lowering) is flagged as an invasive, deliberate change — NOT
a dogfooding tangent. The rest are filed with minimal repros while fresh.

* docs(findings): add #9 — keyword map-key underscore doubled vs property read

A keyword map-literal key with '_' lowers to a doubled underscore ({:a_b 1}
-> {a__b: 1}) while the matching property read (.-a_b) stays single (m.a_b),
so writes and reads disagree and the round-trip silently returns undefined.
Surfaced building the gjoa audit ledger; worked around with camelCase keys.

* docs(findings): add #10 — emitted import specifier vs snake_cased filename mismatch

A hyphenated module (drag-overlay.bjs -> drag_overlay.js) is imported by a
sibling with the original hyphen (from './drag-overlay.js'), which doesn't
match the emitted filename — broken under plain ESM. Same root family as #9
(divergent name normalization across positions). Surfaced resolving the gjoa
audit-ledger dependency graph.
tompassarelli added a commit that referenced this pull request Jun 21, 2026
…scape-across-fn)

Closes soundness hole-class #3 (escape across the fn boundary) from beagle-2's
adversarial boundary map: a fn RETURNING a compound-keyed map (or a `(set var)`
over a compound-typed param) was read NATIVE by the caller because rep-selection
was intraprocedural + per-node-table only (bare var-refs and call results carry
no rep).

Two additions to classify-rep:
  - TYPE-DIRECTED dispatch: an expression whose node-type is a concrete
    compound-keyed Map / compound-elem Set classifies hmap/hset directly. This
    catches annotated fn returns (`build-index :- (Map (Vec Int) Keyword)`) and
    any typed expr — the caller's get/contains/count now route to HAMT ops.
  - BINDING-TYPE ENV (current-type-env): a var-ref resolves to its declared type
    (param `:-` annotation / let value type) via arg-type, so `(set paths)` with
    paths :- (Vec (Vec Int)) sees the compound element and routes to hamtSet.
    Seeded at defn/fn/arity params (with-param-envs) + composes with rep-env.
  - rep-env now also seeded from param types (a param :- (Map compound ..) reads
    as hmap inside the body).

Real-program evidence: bjs-bench/programs/structdiff.bjs (pure-value diff/merge
= Eddy skip-if-unchanged at lib scale) goes 5/7 -> 7/7 — distinct-paths (value
dedup over a compound param) and cross-fn compound-map lookup both flip GREEN.
No regression: active 1474/1474, gated JS (emit-js 147, fixtures, jst,
exec-oracle) + behavioral 78 all green. Still provably-compound predicate; the
Any/records/unions flip (#1,#2,#5,#6) is the next commit.
tompassarelli added a commit that referenced this pull request Jun 21, 2026
…phic reads

Closes soundness hole-classes #1 (records-as-keys), #2 (union keys), #5
(heterogeneous-key literals), #6 (Any-typed compound keys) from beagle-2's
adversarial boundary map. The classifier predicate flips from "provably-COMPOUND
→ HAMT" to "provably-SCALAR → native, else → HAMT":

  key-class(t): scalar-eq-safe -> 'native
              ; Map/Set/Vec/List ctor OR a record type -> 'hamt (object-emitting,
              ;   no scalar subtype: a value of this type is ALWAYS a HAMT)
              ; Any | type-var | union | Float | Nil | unknown -> 'poly

PRODUCTION: a map/set whose key/elem is not provably-scalar produces a HAMT (incl
records, unions, Any, heterogeneous K=Any). Map LITERALS classify by their actual
KEY DATA per-pair (robust to an uncaptured node-type — e.g. a map built inside a
`.map` arrow); scalar literal keys (incl quoted `':k`) resolve to their scalar
type via arg-type, so they stay native (no over-promotion).

READS — 3-way, because Any is a BIDIRECTIONAL wildcard (a native scalar map <:
(Map Any V)), so a native value can flow into an Any/union-typed read:
  - provably-compound/record coll -> hamtMapGet/Has/Count (monomorphic, O(log n))
  - scalar coll -> native
  - poly (Any/union) coll -> $$bc.get/contains/count/keys/vals (polymorphic over
    native + HAMT; beagle-2's @26df650). count/keys/vals/get gain a 'poly branch.

Adversarial-verified (vs the Clojure shape): record keys (distinct lookup +
absent-miss), Any-keyed assoc-chain (put2 -> "x" not "y"), heterogeneous-key
count (3 not 2) all flip RED->GREEN. rep-soundness gate +4 flip cases (records /
Any-key / heterogeneous / Any-typed poly read) = 18/18. Active 1478/1478, gated
JS (emit-js 147, exec-oracle 7, ...) + behavioral 78 all green. DEFERRED (rarer,
documented): poly-PRODUCTION ops (assoc/conj/dissoc on an Any-typed COLL) need a
$$bc COW producer; the Any->concrete-typed-param coercion boundary (gradual-typing
blame point).
tompassarelli added a commit that referenced this pull request Jun 21, 2026
…HAMT

Closes the last soundness hole-class (#4 runtime dedup) from beagle-2's boundary
map. The runtime collection builders no longer emit native ARRAYS for sets (a
pre-existing set-builder bug) and route to value-dedup HAMT when the element type
is compound:

  coll-kind helper: 'set | 'vec | 'map | 'unknown, seeing through conj/into/disj
  to the underlying collection (so a native Set/conj-result isn't mistaken for
  an array).
  conj  : value-set -> hamtSetAdd fold ; native set -> new Set([...c, x]) (a Set,
          not [...c,x]) ; vec -> [...c,x].
  into  : value-set -> xs.reduce(hamtSetAdd, target) ; native set -> new Set([...
          target, ...xs]) ; vec -> spread.
  count : native branch now dispatches on coll-kind -> set:.size / map:Object.keys
          ().length / else:.length, so (count (conj #{..} x)) is .size not .length
          (was undefined).
  frequencies : compound elements -> value-keyed hamtMap (was native object ->
          "[object Object]" collisions). group-by left native (its key type comes
          from f's return, not reliably inferable — documented residual).

Verified vs Clojure shape: conj/into on scalar+compound sets (3/1/4/2),
frequencies compound/scalar (2/2). rep-soundness +4 = 24/24; active 1484/1484,
gated JS (emit-js 147, exec-oracle 7, ...) + behavioral 78 green.

ALL 6 hole-classes now closed: #1 records, #2 unions, #3 escape-across-fn,
#4 runtime dedup, #5 heterogeneous, #6 cross-rep equality.
tompassarelli added a commit that referenced this pull request Jun 22, 2026
… build)

P0 follow-up (fram-2 #18). parse.rkt has its OWN beagle-readtable — it drives
read-beagle-syntax (check --agent, build, the repair loop + PostToolUse hook) —
SEPARATE from reader-impl.rkt's readtable (#lang module loading). bc53027 added
the `^` metadata reader macro only to reader-impl's, so read-beagle-syntax read
`^:dynamic` as a bare symbol → "malformed def" on the first dynamic var, and the
agent path STOPPED there (real type-errors after it skipped) — the repair loop +
hook went blind on every dynamic-var file (resolve.clj + the daemon are
dynamic-var-heavy). One missing macro caused both reported issues:
  #1 check --agent / build / repair-loop blind on dynamic-var files
  #2 "typed dynamic def rejected" — never a grammar gap; the read path just
     never produced #%meta, so (def ^:dynamic NAME :- TYPE VAL) misparsed too.

Fix: mirror the `^` macro into parse.rkt's readtable (meta-reader-local), with a
comment flagging the two-readtable duplication so future reader features stay in
sync. Regression test drives read-beagle-syntax directly with a typed ^:dynamic
def. Active 1500/1500 + gated 437/437, all targets.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant