Skip to content

fix(init): make --hooks idempotently merge into existing repos#1

Merged
tompassarelli merged 1 commit into
mainfrom
fix/beagle-init-hooks-merge
Jun 15, 2026
Merged

fix(init): make --hooks idempotently merge into existing repos#1
tompassarelli merged 1 commit into
mainfrom
fix/beagle-init-hooks-merge

Conversation

@tompassarelli

Copy link
Copy Markdown
Owner

Problem

beagle init --hooks — the intended one-command "sync this repo to the repair-compiler hook" — silently no-ops on already-initialized projects:

  • It wrote .claude/settings.json only when absent, so any repo that already had a settings.json never got the PostToolUse repair hook wired in.
  • It dropped stray starter files (src/main.*, CLAUDE.md, flake.nix, .envrc) into populated repos.

Net effect: on an existing repo you'd run the command, get a stray src/main.bclj, and still not have the hook — forcing manual JSON editing, which is exactly what the toolchain is supposed to avoid.

Fix

  • Detect an existing project (CLAUDE.md/.claude/settings.json/.git/package.json/flake.nix/existing *.b* sources). In that case, only wire the hook — skip all starter-file scaffolding.
  • Replace create-if-absent with an idempotent JSON merge: append the PostToolUse entry into any existing settings.json (preserving other keys), and no-op cleanly if already wired.
  • Fresh/empty dirs still get the full scaffold + hooks (unchanged).
  • Widened matcher to Edit|Write|MultiEdit.

Verified

  • Fresh dir + --hooks → full scaffold + hook.
  • Existing repo w/ settings.json (other keys) + --hooks → hook merged in, permissions preserved, no stray files.
  • Re-run → idempotent (already wired (no change)), entry count stays 1.
  • Wired into gjoa and confirmed the hook fires end-to-end and auto-balances an imbalanced .bjs.

beagle init --hooks was a no-op for already-initialized projects: it wrote
.claude/settings.json only when absent (so an existing settings.json never got
the PostToolUse repair hook) and dropped stray starter files (src/main, CLAUDE.md,
flake.nix, .envrc) into populated repos.

Now it detects an existing project and, in that case, ONLY wires the hook —
merging the PostToolUse entry into any existing settings.json (preserving other
keys, idempotent on re-run) and skipping all starter-file scaffolding. A fresh/
empty dir still gets the full scaffold + hooks. Makes 'beagle init --hooks' the
one-command sync it was meant to be.

Matcher widened to Edit|Write|MultiEdit.
@tompassarelli tompassarelli merged commit c2319a9 into main Jun 15, 2026
1 check passed
@tompassarelli tompassarelli deleted the fix/beagle-init-hooks-merge branch June 15, 2026 13:11
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