Skip to content

Layer 1 campaign: migrate perry-codegen's lowerings onto the rooting-by-construction API (88 modules, 262 hazard sites) #7615

Closed

Description

@proggeramlug

Layer 1 of the GC-correctness plan (docs/engine-plan.md, docs/src/internals/rfc-rooting-by-construction.md) is the only track that was never started. This issue is its campaign map: the ordered inventory of perry-codegen modules to migrate onto the crate::rooting combinators, one slice per PR.

Routing: linked from #7294. Design: #7459 (the RFC's own constructor was E0499, corrected). First lowering: #7461. Template slice: the PR that opens this issue.


What "migrated" means

Two properties, both checked by cargo test -p perry-codegen --lib rooting:::

  1. The module names no expr::temp_root symbol. That module is the raw, order-sensitive API — push / get / set / truncate, plus guards the caller must remember to release — and every bug in the gc(layer 3): from-space quarantine catches 55 stale dereferences across the gap suite — the instrument is in CI but aimed at one synthetic fixture #7341 family was an ordering mistake against it. It is the escape hatch the RFC's step 3 says to #[deny] per module. Rust has no attribute that denies calling a pub(crate) function from one module, so the deny is a test over the module's own source, inlined by include_str!.
  2. No raw heap pointer exists as a value the lowering can hold across a collection point. rooting::call_rooted returns a slot, never a register; rooting::call_with_roots re-reads each slot as part of emitting the consuming call; rooting::with_operands_rooted owns the release on every path out, including ?.

A slice finishes by adding its modules to MIGRATED_MODULES in crates/perry-codegen/src/rooting.rs. Removing a line is a regression, not a cleanup.

Acceptance per slice

  • Emitted IR byte-identical (normalised for register/label renumbering) on a program set that exercises the module's lowerings, or every difference itemised and justified — a difference is normally a real rooting bug the migration found, which is the point.
  • gc-root-dominance corpus green in both gated modes, including --seeded-violations 40.
  • cargo test -p perry-codegen --lib, -p perry-runtime --no-fail-fast.
  • The gap-test families that touch the module's lowerings, identical failure set on both arms.

Honest statement of what this buys

Measured on the template slice by sabotaging the migrated module four ways:

reintroduced shape compiles? caught by
#7192 in the borrow form (RootingEmitter, the RFC's design) NO — E0499 rustc, via compile_fail doctest
hold the call_with_roots result across a later lowering yes nothing
the verbatim pre-#7453 code, via a bare ctx.block().call yes nothing — see below
reach back into expr::temp_root yes the ledger test
hold the operand guard so it can be released on one arm (#7462) yes the ledger test

So: on the real emitter the API does not make the bug fail to compile. FnCtx has no interior mutability, so the borrow-carrying Raw<'e> cannot be built on it (#7459/#7461 established this). What the migration buys is that the correct form is the only one the API can express, that the release cannot be branch-conditional, and that the escape hatch is denied per module. Getting an actual compile error needs the RefCell'd emitter of the RFC's step 1 — a separate, wider change.

Ordering

Hazard density first, because that is where the bug lives. haz counts sites where a raw-pointer binding is used after an intervening emission — the #7453/#7192 shape, measured on the emitter's own source (heuristic, over-reports; it is a worklist, not a verdict). raw counts .call(I64, "js_*") sites, which by perry's convention return a raw heap pointer (NaN-boxed values come back as DOUBLE). root counts existing rooting references, i.e. how much of the module is already hand-rooted.

Whole campaign: 88 modules, 694 raw sites, 262 hazard sites, ~79k lines.

Slice 0 — DONE (the template)

History: #7453 #7461 #7462 #7463 #7464 #7465

1 modules · 669 lines · 11 raw sites · 7 hazard sites

module lines raw haz root issues named in the file
expr/url_main.rs 669 11 7 4 #7463 #7462 #7461 #7459 #7453 #7341

Slice 1a — array methods (largest hazard population)

1 modules · 1200 lines · 37 raw sites · 40 hazard sites

module lines raw haz root issues named in the file
lower_array_method.rs 1200 37 40 0 #6229 #4091 #3148 #2814 #2808 #2805

Slice 1b — array search/copy methods

2 modules · 1564 lines · 40 raw sites · 26 hazard sites

module lines raw haz root issues named in the file
expr/arrays_finds.rs 1204 28 23 0 #7590 #6996 #6229 #6088 #5067 #4091
expr/array_methods.rs 360 12 3 0 #2838 #2836 #2816 #2013 #1312 #1177

Slice 2 — instance methods + collections

3 modules · 3209 lines · 60 raw sites · 40 hazard sites

module lines raw haz root issues named in the file
expr/instance_misc1.rs 1776 36 17 13 #7210 #7154 #6364 #6301 #6003 #5834
expr/logical_collections.rs 1052 24 14 21 #7200 #7198 #7154 #6951 #5560 #5559
lower_call/property_get/map_set.rs 381 0 9 49 #6970 #2872 #2856 #2830

Slice 3 — literal accumulators (#7211 / #7280 home; NOT IR-identical)

History: #6951 #7211 #7280 #7602

4 modules · 1776 lines · 18 raw sites · 7 hazard sites

module lines raw haz root issues named in the file
expr/objects_arrays_lit.rs 116 5 1 15 #7280 #6971
expr/array_literal.rs 269 3 0 8 #6951 #5391 #1146 #1098
expr/object_literal.rs 437 2 0 21 #6951 #1098
expr/array_push.rs 954 8 6 0 #7590 #7574 #7511 #7469 #6904 #5459

Slice 4 — computed reads/writes (#7206 home)

History: #7206

9 modules · 6189 lines · 10 raw sites · 8 hazard sites

module lines raw haz root issues named in the file
expr/index_get.rs 1404 0 1 8 #7574 #7192 #7154 #6904 #6750 #6132
expr/index_get/guarded_array.rs — — — — (no raw-pointer site; listed for completeness)
expr/index_get/inline_dyn_typed_array.rs — — — — (no raw-pointer site; listed for completeness)
expr/index_set.rs 1755 7 0 32 #7590 #7341 #7207 #7201 #7154 #6011
expr/index_set_typed_array.rs — — — — (no raw-pointer site; listed for completeness)
expr/property_get.rs 1737 3 0 0 #7153 #7128 #6710 #6588 #6003 #5924
expr/property_get/globalget.rs 427 0 6 0 #6674 #4033 #3527 #2904 #1374 #1373
expr/property_get/helpers.rs 866 0 1 0 #7480 #7153 #5093
expr/property_set.rs — — — — (no raw-pointer site; listed for completeness)

Slice 5 — call / construct paths (#7192 / #7207 home)

History: #7192 #7207

14 modules · 11138 lines · 70 raw sites · 9 hazard sites

module lines raw haz root issues named in the file
lower_call/new.rs 1989 4 0 17 #7552 #7536 #7535 #7532 #7525 #7512
lower_call/new_ctor_args.rs 400 3 0 0 #6538 #6537 #5437 #740 #672
expr/call_spread.rs 547 27 5 0 #6475 #653
expr/this_super_call.rs 1272 3 1 0 #6710 #6326 #5437 #5137 #5127 #1787
expr/super_method.rs 442 6 1 0 #1126 #774
expr/new_dynamic.rs 758 4 1 0 #5437 #5253 #4699 #3680 #3663 #3142
lower_call/property_get/dynamic_dispatch.rs 1416 4 0 4 #7211 #7143 #7142 #6927 #5437 #5391
lower_call/property_get/static_dispatch.rs 387 5 1 2 #7211 #5703 #1788 #1787 #915 #912
lower_call/property_get/number_string.rs 238 6 0 0 #3146 #2864
lower_call/property_get/promise_chain.rs 172 3 0 0 #1030 #1008 #973
lower_call/func_ref.rs 922 1 0 5 #7240 #7211 #7154 #4785 #3576 #1816
lower_call/early_branches.rs 985 1 0 6 #7211 #6369 #6328 #5030 #3576 #1113
lower_call/scalar_method.rs 1161 2 0 0 #5094
lower_call/mod.rs 449 1 0 32 #7592 #7510 #7240 #7154 #6951 #6326

Slice 6 — strings / regex

2 modules · 2623 lines · 71 raw sites · 6 hazard sites

module lines raw haz root issues named in the file
lower_string_method.rs 1958 37 2 37 #7128 #6971 #6951 #5906 #4871 #3987
expr/string_regex_proc.rs 665 34 4 0 #6666 #4091 #2789 #2788 #2013 #618

Slice 7 — builtins, math, misc

16 modules · 16547 lines · 171 raw sites · 32 hazard sites

module lines raw haz root issues named in the file
lower_call/builtin.rs 1710 41 2 0 #5458 #4915 #4103 #3985 #3657 #2875
expr/misc_methods.rs 1033 25 10 0 #6709 #4091 #2826 #2804 #2770 #1401
expr/math_simple.rs 1255 16 1 14 #6970 #4091
expr/os_uri_dates.rs 512 23 5 0 #2851 #2796 #1187
expr/env_clones.rs 422 19 5 0 #4546 #3917 #2013 #1123 #630 #600
expr/bigint_set.rs 1372 15 5 0 #4091 #2796
expr/compare.rs 629 4 2 0 #3576
expr/literals_vars.rs 865 10 0 0 #7055 #6369 #6003 #1380 #1343 #915
expr/static_field_meta.rs 828 5 2 18 #7211 #7154 #6951 #6654 #6523 #6438
expr/static_method.rs 390 5 0 0 #5924 #5922 #4831 #4656 #2582 #894
expr/helpers.rs 636 3 0 0 #7469 #6921 #6011 #4511 #2905 #2875
expr/binary.rs 730 1 0 13 #6951 #6904 #6884 #5525
expr/pod_record.rs 549 1 0 0 —
expr/index.rs 643 1 0 0 #7396 #6904 #5094 #1098
expr/i32_fast_path.rs 1906 1 0 0 #7232 #6593 #5466 #1098
expr/mod.rs 3067 1 0 4 #7603 #7590 #7573 #7480 #7469 #7404

Slice 8 — leaves: crypto, native, ext, fetch

27 modules · 14252 lines · 174 raw sites · 82 hazard sites

module lines raw haz root issues named in the file
lower_call/options/fetch.rs 1287 50 1 0 #4915 #2638 #1688 #1649 #1211 #1099
lower_call/options/abort.rs 152 2 1 0 #1099 #1097
lower_call/options/mod.rs 100 2 0 0 #1099 #1097
lower_call/extern_func.rs 1799 15 0 0 #7210 #7154 #6087 #5812 #5626 #5621
lower_call/extern_timers.rs 276 14 0 15 #7210 #2013 #1671 #1213 #665
lower_call/namespace_call.rs 384 8 6 17 #7210 #5924 #680 #678 #636
lower_call/native_module_dispatch.rs 418 5 1 0 #6563 #1852 #927 #915 #605
lower_call/event_target.rs 80 1 3 0 —
lower_call/console_promise.rs 1583 9 0 45 #7214 #7211 #7206 #7192 #7184 #7154
lower_call/ui_styling.rs 565 0 1 0 —
lower_call/native/mod.rs 543 3 1 0 #6185 #1074 #1002
lower_call/native/native_tui_layout_branch.rs 534 8 9 0 #679 #634
lower_call/native/native_instance_branch.rs 454 2 3 0 —
lower_call/native/native_ui_widgets_branch.rs 454 4 2 0 #1867 #1865 #610
lower_call/native/native_ui_appshell_branch.rs 415 3 0 0 #1495 #1280 #658 #635
lower_call/native/jsonwebtoken.rs 291 7 0 0 #1074 #927
expr/calls/crypto_kdf.rs 304 5 23 0 #3146 #2013
expr/calls/crypto_misc.rs 619 7 14 0 #3146 #2550 #2013 #1364
expr/calls/crypto_hash.rs 352 6 3 0 #3146 #2013 #1419 #1354 #1352 #1076
expr/calls/crypto_keys.rs 232 3 1 0 —
expr/calls/fs.rs 398 1 0 0 #631
expr/child_proc.rs 445 4 7 38 #3079 #1938 #1937 #1933 #1780
expr/dyn_extern_i18n.rs 1187 9 2 8 #7280 #7211 #6951 #6660 #5389 #5230
expr/js_runtime.rs 288 1 1 0 —
expr/native_memory.rs 370 0 3 0 —
expr/closure.rs 433 4 0 0 #4831 #1845 #1021
expr/fs_await.rs 289 1 0 12 #7341 #5437 #788

Slice 9 — codegen/ and stmt/ tail

12 modules · 18858 lines · 29 raw sites · 5 hazard sites

module lines raw haz root issues named in the file
codegen/entry.rs 1653 1 4 4 #7128 #7109 #7106 #6991 #6952 #6903
codegen/closure.rs 1488 6 0 2 #7208 #7170 #7128 #7109 #7106 #7055
codegen/method.rs 1732 2 0 4 #7128 #7109 #7106 #6952 #6469 #6369
codegen/helpers.rs 1704 2 0 0 #7493 #7354 #7340 #7332 #7326 #7174
codegen/function.rs 1080 1 0 2 #7302 #7170 #7128 #7109 #7107 #7106
codegen/string_pool.rs 1333 1 0 0 #5957 #5592 #5391 #5042 #4880 #4850
codegen/arguments.rs 112 3 0 0 #7055
stmt/loops.rs 7120 9 0 1 #7480 #7287 #7154 #6812 #6809 #6750
stmt/mod.rs 755 1 0 0 #7590 #7521 #7302 #7154 #7105 #6750
stmt/masked_window_region.rs 1055 1 0 0 #6794 #6750
stmt/unused_expr.rs 194 1 1 0 #4091
native_value/materialize.rs 632 1 0 0 #7128 #854

Unassigned (add to a slice before starting it)

module lines raw haz root
expr/temp_root.rs 1133 3 0 84

Terminal condition

expr/temp_root.rs becomes pub(in crate::rooting) — the raw API unreachable from any lowering, not merely uncounted. That is the same end state Layer 3 states for its raw accessor (#7457/#7458), and it is what makes the ledger permanent rather than a convention.

Related findings from the template slice

Activity

  1. proggeramlug commented on Aug 8, 2026

    @proggeramlug
    ContributorAuthor

    Slice 1a — DONE, in #7618

    1 module · 1199 → 1346 lines · 37 raw sites · 40 hazard sites → 0.
    lower_array_method.rs is migrated end to end and listed in MIGRATED_MODULES. No boundary outstanding; the module landed whole, in one PR.

    slice module state
    0 expr/url_main.rs DONE (#7617)
    1a lower_array_method.rs DONE (#7618)
    1b expr/arrays_finds.rs, expr/array_methods.rs next

    Running total: 2 of 88 modules, 48 of 694 raw sites, 47 of 262 hazard sites.

    Three findings worth carrying into the remaining slices

    1. The hazard-density number was measuring the right thing, but the fix is not where "40 sites" suggests. All 40 came from one structural cause: lower_array_method lowered the receiver above the match, so every arm inherited the same window. One with_operands_rooted around the whole match closes all of them. When a module's hazard count is high, look for a shared prologue before assuming 40 independent edits — the map's haz heuristic counts uses, not causes.

    2. root_reload.rs (#7280) already covers part of this bug class, and the map does not account for it. Where an operand is a shadow-slotted local, #7280's post-pass re-reads it below the collection point, so the pre-migration code was not stale. Three shapes it structurally cannot cover, and those are the real content of a slice:

    • an operand reassigned inside the window — GC: #7154's residual is NOT fixed — the loop-polls config is red 0/30, and stock zod alone fails 5/40 #7280 deliberately bails there (re-loading would observe the assignment, operand_is_reloadable's documented miscompile), so it leaves a stale register. Only a temp root gives both the call-time value and a rewritten address. Confirmed in emitted IR for a.concat(reassign()).
    • operands with no slot at all — call results, property chains, literals.
    • operand-to-operand windows within one argument list.

    Concretely: 36 of the load double instructions this slice removed were #7280 reloads that a root read replaced. A future slice that claims "N hazard sites closed" without separating these is overclaiming. Check the baseline IR for an out-of-sequence register number (%r309 after %r250) — that is #7280 having already fired.

    3. The ledger's escape-hatch check is vacuous for a module that never used temp_root. lower_array_method.rs named it zero times before the migration, so migrated_modules_do_not_reach_past_the_rooting_api passed the instant it was listed. The sabotage arm is what makes the listing mean something, and it was run: a real compiling temp_root_push_double + temp_root_truncate pair injected into the reverse arm turns the test red, naming both lines. Every slice should run that arm and record the result, because for most of the remaining 86 modules the ledger will otherwise pass for free.

    Ordering note for slice 1b and later

    The structural property worth copying is not "call the combinators" — it is lower_expr must not appear in the migrated module. In this slice the arguments each arm consumes are declared once (lowered_arg_count) and the arms only emit, so no arm can reintroduce an unrooted operand. The asymmetry that makes a central table safe: under-counting is a codegen panic on the first program that reaches the arm; over-counting costs an evaluation JS requires anyway. That shape should transfer to arrays_finds.rs and array_methods.rs directly, since they have the same "receiver + N arguments + one consuming call" skeleton.

    Also: lint is red on main, unrelated to this campaign

    python3 scripts/check_test_registration.py fails on a pristine origin/main — test-files/test_gap_repsel_element_shape_loop_clone.ts landed in #7612 (96e034809) without being registered in test-parity/gc_repsel_corpus.txt, so gc_repsel_matrix.sh (gc-stress, gc-moving-witnesses) never runs it. A required-and-red gate means every merge is bypassing it — CLAUDE.md hazard 2. Not fixed in #7618: registering a test changes what those gates execute, which is its own change with its own measurement.

  2. proggeramlug commented on Aug 8, 2026

    @proggeramlug
    ContributorAuthor

    Slice 1b — DONE, in #7620

    2 modules · 1562 → 1763 lines · 40 raw sites · 26 hazard sites.
    expr/arrays_finds.rs and expr/array_methods.rs are migrated end to end and listed in MIGRATED_MODULES. No boundary outstanding; both landed whole, in one PR.

    slice module state
    0 expr/url_main.rs DONE (#7617)
    1a lower_array_method.rs DONE (#7618)
    1b expr/arrays_finds.rs, expr/array_methods.rs DONE (#7620)
    2 expr/instance_misc1.rs, expr/logical_collections.rs, lower_call/property_get/map_set.rs next

    Running total: 4 of 88 modules, 88 of 694 raw sites, 73 of 262 hazard sites.

    One combinator was added, and it is the only one the campaign should need for this shape

    rooting::with_operands_rooted_across(ctx, exprs, across_exprs, across, body) — root a group across a lowering step whose representation the caller picks. Its two callers are u8[i] and buf[i], where the index goes through lower_expr_as_i32 or fptosi and the receiver is live across that choice; handing the index to with_operands_rooted would force every such read back onto the NaN-boxed path, which is a codegen-quality regression rather than a rooting fix.

    Note what it is not: it never takes a bare register. The root_i64(ctx, reg) that rooting.rs's comment warns about would put ordering back in the author's hands (#7192); this one still owns the lowering, the re-read point and the release. with_operands_rooted is now its empty-across case, so operand_protection stays the single decision site — the drift that produced #7114 was two helper families answering that question separately, and the campaign should not recreate it by growing a family of combinators that each re-derive collects.

    Four findings for the remaining slices

    1. The haz heuristic under-counts one shape and it is the worst one. The map gives these two modules 26 hazard sites. It counts NaN-boxed operand registers used after an intervening emission. Expr::BufferSlice does something worse and is not in that count: it unboxes the receiver to a raw BufferHeader* and then lowers start and end. When you audit a module, grep for unbox_to_i64 / unbox_str_handle above a lower_expr, not only for reused _box registers.

    2. That raw shape is also the one #7280 provably cannot reach. root_reload re-reads a shadow slot into a double. If the stale value is an i64 derived above the window, there is nothing for it to rewrite. Slice 1a's finding was that #7280 already covered the shadow-slotted majority (still true — 16 of this slice's removed load double instructions are exactly those reloads); this is the complement, and it is what a slice should be claiming.

    3. Ledger vacuity is now written into the code, not just into a PR. escape_hatch_uses strips comments, and every module migrated so far except expr/url_main.rs named temp_root zero times beforehand — so listing it goes green instantly. MIGRATED_MODULES' doc comment now says so and states the obligation: run the sabotage arm, per module (the assert stops at the first offender, so one run cannot speak for two), and record it. Slice 1b did: arrays_finds.rs:279/:280 and array_methods.rs:59/:60, both red, both reverted.

    4. ★ Codegen is not run-to-run deterministic, which is a trap for exactly this campaign's evidence (#7622). Compiling one source twice with the same binary permutes @.str.N numbering, js_register_function_name order, and — in test_gap_class_expr_dynamic_parent_ctor — which perry_method_* symbol three call sites name. In slice 1b's 149-module IR comparison, 3 of the 9 differing functions were this and not the change. They are distinguishable only because their instruction multisets were identical AND a same-binary double compile reproduced the same permutation. A future slice that reports raw diff output will report phantom regressions; one that compares multisets without checking determinism will silently dismiss a real ordering change. Both halves are needed.

    Also filed from this slice, not fixed in it

    #7621 — path.resolve(base, computedShortString) throws ERR_INVALID_ARG_TYPE where node returns the path. Expr::PathResolveJoin unboxes with unbox_to_i64, so an SSO string's inline bytes are dereferenced as a StringHeader* (#214 class; bisected by string length). Same unaudited shape in PathExtname, PathNormalize, PathResolve, PathMatchesGlob. The correct helper allocates, so the fix opens the #7213 window in the same arm — it needs its own rooting decision, and does not belong inside an IR-identical refactor.

    lint is still red on main for an unrelated reason

    python3 benchmarks/ci_public_baseline_check.py fails on a pristine origin/main (public artifact benchmark harness changed; regenerate it with ./benchmarks/run_public_baseline.sh). #7618 reported the same step failing on every main run from 2026-07-29. Because lint is a sequence, that step's failure used to skip every later gate in the job; the if: ${{ !cancelled() }} added to the later steps means they now still speak, but the job itself stays red.

  3. proggeramlug commented on Aug 8, 2026

    @proggeramlug
    ContributorAuthor

    Slice 2 — DONE, in #7627

    3 modules · 3207 → 3739 lines · 60 raw sites · 40 hazard sites.
    expr/instance_misc1.rs, expr/logical_collections.rs and
    lower_call/property_get/map_set.rs are migrated end to end and listed in
    MIGRATED_MODULES. No boundary outstanding; all three landed whole, in one PR.

    slice module state
    0 expr/url_main.rs DONE (#7617)
    1a lower_array_method.rs DONE (#7618)
    1b expr/arrays_finds.rs, expr/array_methods.rs DONE (#7620)
    2 expr/instance_misc1.rs, expr/logical_collections.rs, lower_call/property_get/map_set.rs DONE (#7627)
    3 expr/objects_arrays_lit.rs, expr/array_literal.rs, expr/object_literal.rs, expr/array_push.rs next

    Running total: 7 of 88 modules, 148 of 694 raw sites, 113 of 262 hazard sites.

    Two combinators added, and slice 3 already needs one of them

    with_operands_rooted_across_call — the window's across step is an emitted
    runtime call
    , not a lowered expression, so there is no Expr for
    any_may_trigger_gc to read and the answer is stated (conservatively) rather
    than derived. Its callers are re.test / re.exec, whose unconditional
    js_jsvalue_to_string_coerce dispatches a user toString. Deriving the window
    from the string operand answers false for a plain local and drops the root at
    exactly the site #7154 faults at. The precedent for taking the answer is
    temp_root::guard_store_operand_across, which has done so since #7201;
    operand_protection still decides how each operand is protected, and all three
    with_operands_rooted* forms now share one implementation so the family cannot
    grow three orderings.

    with_rooted_accumulator — a GC-managed value the lowering keeps updating
    while it lowers more user code. Slice 3 is the "literal accumulators" slice, so
    it should reach for this rather than building a second one.
    The invariant it
    enforces is the one a raw handle cannot: the accumulator never exists as a
    register held across an emission. Consuming calls re-read it as part of being
    emitted, a helper returning a fresh address publishes it straight back
    (advance), and the one point a register escapes is finish, below the last
    collection point and above the release.

    RootedSlot now carries its own Repr. A temp-root slot is representation-
    agnostic, so the temp_root_get_i64 vs temp_root_get_double choice used to
    live at each call site, where a mismatch is a silent miscompile rather than a
    type error.

    Four findings for the remaining slices

    1. A high root count in the map means "already correct", not "already
    migrated" — and the slice must say which.
    map_set.rs carries root: 49 and
    was hand-rooted end to end by #6970; its migration is a translation, and its
    IR is byte-identical. ObjectSpread and Object.assign are the same. Claiming
    those as closed hazards would be overclaiming in the way slices 1a and 1b warned
    about. What the translation genuinely buys is that the release stops being a
    statement a later edit can move into a branch — the shape #7462 shipped in
    URLSearchParams.delete, which is the direct sibling of these arms, so the
    risk is demonstrated rather than hypothetical.

    2. The haz heuristic misses a third shape: an interned key. Slice 1b found
    it misses raw unbox_to_i64 receivers. Expr::WithSet shows the other end —
    the stale value was the property key, a load from a __perry_init_strings_*
    handle global materialised above the RHS. That global is a registered root that
    evacuation rewrites, so the register named from-space while the global did not:
    the write landed under a garbage key. When auditing a module, look for
    emit_with_key / ctx.strings.entry(..).handle_global loads above a
    lower_expr, not only for reused _box registers.

    3. Two accumulators in this slice were not rooted at all, and neither is in
    the map's root column: Math.min/Math.max with three or more arguments
    threads a raw ArrayHeader* through js_array_push_f64 across each remaining
    argument's lowering, and fetch's static-headers build does the same with
    js_object_alloc. Both are #7154's ObjectSpread bug in arms nobody had looked
    at, and both are beyond #7280 because the stale value is a raw i64. Grep the
    remaining slices for let mut current / a handle threaded through a loop that
    contains a lower_expr.

    4. #7622 is still live and the control is still mandatory. On the 149-module
    corpus, compiling twice with the same binary produced 5 ordering-only
    permutations; the A/B produced 16 diffs of which 5 were ordering-only, 4 in the
    control set and one (test_gap_class_ref_new_dynamic::main) not — it had to be
    pinned separately by compiling that one source six times with the baseline
    binary, differing every time. The set of contaminated functions varies between
    runs
    , so "it was in my control set" is not a sufficient test; the shape
    (opcode multiset equal, ordering only) plus a targeted repeat is.

    Also filed from this slice, not fixed in it

    lint on main

    python3 benchmarks/ci_public_baseline_check.py is still red on pristine
    origin/main, as #7618 and #7620 both reported (every main run since
    2026-07-29). Every other step of the lint job passes on this branch.

  4. proggeramlug commented on Aug 8, 2026

    @proggeramlug
    ContributorAuthor

    Correction to the comment above: the dependency-scale corpus was also run for slice 2 and is green in both gated modes (81 zod modules, 12899 functions, 12908 root stores → 0 violations; --seeded-violations 40 at 40/40; --unrooted-allocas 0 over 15242 gc-capable allocas). Root stores there went 12878 → 12908, and the informational --stale-registers --moving-only ratchet is unchanged on both arms (23 curated / 115 dependency). That is the population the accumulator combinator most needed to be measured on, since zod is precisely the "builds objects field by field out of data" shape #7280 named.

  5. proggeramlug commented on Aug 8, 2026

    @proggeramlug
    ContributorAuthor

    Slice 3 — DONE, in #7636

    4 modules · 1776 → 2264 lines · 18 raw sites · 7 hazard sites.
    expr/objects_arrays_lit.rs, expr/array_literal.rs, expr/object_literal.rs
    and expr/array_push.rs are migrated end to end and listed in
    MIGRATED_MODULES. No boundary outstanding; all four landed whole, in one PR.

    slice module state
    0 expr/url_main.rs DONE (#7617)
    1a lower_array_method.rs DONE (#7618)
    1b expr/arrays_finds.rs, expr/array_methods.rs DONE (#7620)
    2 expr/instance_misc1.rs, expr/logical_collections.rs, lower_call/property_get/map_set.rs DONE (#7627)
    3 expr/objects_arrays_lit.rs, expr/array_literal.rs, expr/object_literal.rs, expr/array_push.rs DONE (#7636)
    4 expr/index_get.rs, expr/index_set.rs, expr/property_get.rs + submodules next

    Running total: 11 of 88 modules, 166 of 694 raw sites, 120 of 262 hazard
    sites.

    No fourth combinator, and the one place that argued for one

    Slice 2 asked slice 3 to reuse with_rooted_accumulator rather than build a
    second one. It does — the spread literal's growing array (advance, because the
    address legitimately changes on every append), the object literal's half-built
    handle on both build paths — and with_operands_rooted covers the array
    literal's element group and arr.push(...src)'s operand pair.

    The shape that looked like it needed a fourth: a this-capturing method
    closure's deferred value
    . It is consumed by the this patch loop below every
    remaining initializer
    , so its root must span exactly that suffix of the literal,
    and the number of such values is data-dependent. All three
    with_operands_rooted* forms lower their operand list up front, which for an
    object literal would evaluate every property before storing any of them and
    reorder observable side effects — so none of them can express it. But
    with_rooted_accumulator already is "a GC value held while more user code is
    lowered", so the answer is one nested scope per such property, which falls out as
    a small recursion over the property list. Recorded here because the next slice
    will hit the same question: "the list is dynamic" is not on its own a reason
    for a new combinator; "the operands must be lowered interleaved with their
    consumers" is what rules the operand forms out.

    Four findings for the remaining slices

    1. Slice 3's honest count is one repair, three translations, and the map's
    root column predicted it.
    objects_arrays_lit.rs (root: 15),
    array_literal.rs (8) and object_literal.rs (21) were all correctly rooted
    already — by #7280 and #6951 — so their migrations are translations with
    byte-identical IR. array_push.rs (root: 0) named no rooting symbol at all
    and needed none, which is the case the ledger doc calls vacuous: only the
    sabotage arm makes its listing an assertion. The haz heuristic counted 6 sites
    there and all 6 are the same false positive — a raw i64 unboxed from a
    receiver that is lowered after the value and therefore has no window.

    2. A high haz on a module with root: 0 can mean "no window", not "no
    root".
    Slice 2's finding 3 said to grep for let mut current threaded through
    a loop containing a lower_expr; the complement is worth stating too. Before
    concluding a raw unbox_to_i64 is a hazard, check what is lowered after it.
    In both array_push.rs arms the answer is "an Expr::LocalGet", i.e. a slot
    read, and a slot read placed after arbitrary user code observes what evacuation
    wrote back — so the register is fresh by construction.

    3. That safety can rest on an evaluation order the spec does not permit, and
    then it is a bug in the other direction — #7634.
    arr.push(f()) /
    arr.push(...g()) evaluate the receiver after the argument, so an argument that
    reassigns the receiver's binding pushes onto the wrong array (node [9], perry
    [9,2]). Filed rather than fixed: restoring spec order is one call to the
    existing with_operands_rooted_across, but it puts the receiver in a window, so
    every pointer-valued push gains a temp root — a measured before/after, not a
    behaviour-preserving refactor. When a slice finds an arm that is rooting-free,
    ask whether it is rooting-free for a legitimate reason.

    4. A branch the corpora cannot reach needs a unit test, and the way to find out
    is to count, not to read.
    lower_object_literal's by-name path with a
    non-empty property list — where the this-patch machinery lives — is not
    reachable from TypeScript at all: since #809 every literal containing a
    Prop::Method lowers to a source-ordered IIFE over {}. Measured by grepping
    the emitted corpus rather than by reading the HIR: over all 129 sources / 149
    modules every js_object_alloc is (i32 0, i32 0). So the restructured
    branch got four HIR-built unit tests in perry-codegen --lib instead of relying
    on an A/B that structurally could not cover it. Slice 4's index_set.rs has
    fifteen arms and property_get/globalget.rs six hazard sites; count the call
    sites your probes actually emitted before believing the A/B covered them.
    In
    this slice the first object-literal probe read its literals' fields back and
    every one was scalar-replaced away — the A/B was green over a corpus containing
    none of the subject.

    Verification, and one thing that is no longer needed

    Per #7625 the emitted IR is run-to-run deterministic, so the #7622 double-compile
    control is retired. The A/B was measured directly: probes byte-identical in both
    environments, the dependency-scale corpus (81 zod modules, 64 MB) byte-identical,
    and the curated corpus 2450/2452 identical — the two differences being one
    instruction pair swapping in the iterator-result literal, where the accumulator's
    re-read is now fused to the emission that consumes it and therefore lands after
    the value's bitcast instead of before. Root-store counts unchanged on both
    corpora (9846 / 12908), which is what a byte-identical translation should read.
    Both gated modes green on both corpora with an empty allowlist, --seeded-violations 40
    at 40/40 on each. Full evidence in #7636.

    lint on main

    python3 benchmarks/ci_public_baseline_check.py is still red on pristine
    origin/main, as #7618, #7620 and #7627 all reported (every main run since
    2026-07-29). The other 26 steps of the lint job pass on this branch.

  6. proggeramlug commented on Aug 8, 2026

    @proggeramlug
    ContributorAuthor

    Slice 4 — DONE, in #7642

    9 modules · 6189 → 6600 lines · 10 raw sites · 8 hazard sites.
    expr/index_get.rs, expr/index_get/guarded_array.rs,
    expr/index_get/inline_dyn_typed_array.rs, expr/index_set.rs,
    expr/index_set_typed_array.rs, expr/property_get.rs,
    expr/property_get/globalget.rs, expr/property_get/helpers.rs and
    expr/property_set.rs are migrated end to end and listed in
    MIGRATED_MODULES. No boundary outstanding; all nine landed whole, in one PR.

    slice module state
    0 expr/url_main.rs DONE (#7617)
    1a lower_array_method.rs DONE (#7618)
    1b expr/arrays_finds.rs, expr/array_methods.rs DONE (#7620)
    2 expr/instance_misc1.rs, expr/logical_collections.rs, lower_call/property_get/map_set.rs DONE (#7627)
    3 expr/objects_arrays_lit.rs, expr/array_literal.rs, expr/object_literal.rs, expr/array_push.rs DONE (#7636)
    4 expr/index_get.rs + 2 submodules, expr/index_set.rs + 1, expr/property_get.rs + 2, expr/property_set.rs DONE (#7642)
    5 lower_call/new.rs + 13 others (call / construct paths) next

    Running total: 20 of 88 modules, 176 of 694 raw sites, 128 of 262 hazard
    sites.

    Still no fourth combinator

    Slice 4 is a single shape end to end — the StoreOperandGuard family, which is
    with_operands_rooted when the value is lowered by lower_expr and
    with_operands_rooted_across when it is lowered by
    lower_value_for_dynamic_{index,property}_set /
    lower_value_for_optional_barrier. Nothing here argued for a new form.

    Five findings for the remaining slices

    1. ★ A listed module is not an audited module, and this slice is where that
    stopped being pedantry.
    Two properties, and the ledger only checks the first:

    1. every rooting decision the module makes goes through crate::rooting;
    2. every window in the module HAS a rooting decision.

    index_set.rs and index_get.rs satisfy (1) and do not satisfy (2). The act of
    translating the six guarded arms of index_set.rs is what made it obvious that
    ~20 arms in the same files lower a receiver, lower more user code, then use
    the register, and root nothing — the bounded-index array store, the #5525
    typed-array stores, globalThis[k] = v, and ten read-side arms of
    index_get.rs. Filed as #7640. A ledger line does not mean "no rooting bugs
    here"; it means the module cannot make an ORDERING mistake against the raw API,
    because it no longer names it.
    That distinction is now written into
    rooting.rs next to the ledger, so slice 5 does not have to rediscover it.

    2. The haz column's false positives have a single signature, and it is worth
    knowing before you start.
    globalget.rs is credited with 6 hazard sites and
    all six are the same shape: js_get_global_this_builtin_value →
    ctx.strings.intern(property) → format! → unbox_to_i64 → load. intern
    and format! emit no IR. The heuristic measures source distance between a
    binding and its use; a window is measured in emissions. Before triaging a
    haz hit, ask what was EMITTED in between, not how many lines passed — slice
    3's finding 2 is the complement of this ("check what is lowered after it").

    3. #7201 was applied as prose and not as code, and the operand list is what
    makes that unrepeatable.
    guard_store_operand(ctx, operand, lowered, value)
    derives the window from ONE sibling. Its own doc says that is wrong — "leaves
    o[f()] = 1 unguarded" — and there is a three-argument
    guard_store_operand_across for the right answer. Two call sites in
    index_set.rs used the two-argument form anyway (#7639). With an operand group
    the window is across_collects || any_may_trigger_gc(exprs[i+1..]), i.e. a
    property of list order rather than an argument someone has to pass correctly.
    Slice 5's call/construct paths have exactly this shape — callee, this,
    then N arguments, each live across everything after it — so prefer one group
    over a per-operand flag.

    4. Count the arm's call sites, then check whether the ARM ITSELF is reachable
    from TypeScript.
    Slice 3's finding 4 said to count before believing an A/B.
    Slice 4 needed the next step. All three repaired arms were reached by the
    curated corpus or by a probe, yet the IR stayed byte-identical, because
    operand_protection returns Reuse when the window cannot collect — so
    "reached" is necessary and not sufficient; you need reached with a collecting
    window
    . And js_array_set_length_strict is called zero times across all
    129 curated sources. Six hand-written probe shapes failed to reach it too, for a
    structural reason worth writing down: an ordinary o.k = v / o[k] = v
    assignment statement lowers to Expr::PutValueSet and reaches the dynamic IC
    (js_put_value_set_dyn_ic), not the Expr::PropertySet / Expr::IndexSet arms
    in these modules.
    The answer was differential HIR-built unit tests: same store,
    allocating RHS vs inert RHS, assert the first reserves strictly more root slots.

    5. The ledger sabotage has a failure mode that reads as success. Planting
    super::temp_root::… into a module one directory down (expr/property_get/,
    expr/index_get/) does not resolve — it is crate::expr::temp_root:: from
    there. The plant then fails to COMPILE, the test run is red for the wrong
    reason, and a script that only greps for "FAILED" records it as a successful
    sabotage. Four of this slice's nine modules are nested. Check for error[E0…]
    before believing a red.

    lint on main

    python3 benchmarks/ci_public_baseline_check.py is still red on pristine
    origin/main, as #7618, #7620, #7627 and #7636 all reported (every main run
    since 2026-07-29). The other 23 enumerated steps of the lint job pass on this
    branch.

  7. 10 remaining items

  8. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Closed — the terminal condition is met, by #7670 (slice 8, v0.5.1384).

    The condition as this issue stated it ("expr/temp_root.rs going pub(in crate::rooting) — the raw accessor unreachable, not merely uncounted") turned out not to be expressible in Rust: pub(in path) requires path to be an ancestor of the item (E0742), and crate::rooting is not an ancestor of crate::expr::temp_root. So the file moved. It is now crate::rooting::temp_root, declared mod temp_root; (private) with pub(in crate::rooting) on all 25 accessors — both, because either alone is one keyword from being undone.

    Verified in the merged tree, two ways: a raw call planted in a migrated module at top level (expr/math_simple.rs) and in a nested submodule (expr/static_field_meta.rs) each fail with error[E0603]: module temp_root is private. That is the difference between a ledger line and a guarantee.

    Fourteen raw entry points were deleted rather than narrowed (the StoreOperandGuard and RootedHandle families, lower_exprs_rooted, lower_operand_pair_rooted, temp_root_scope_*, …), per CLAUDE.md's kill-policy: a narrowed-but-live API is a decision nobody has made.

    Eight slices, and every one found live bugs — which was the point, not a side effect. A partial list: arr.length = f() unguarded; two array element stores holding a heap-string key across the value; the polymorphic o[k]=v fallback guarding neither operand; a namespace call with rest parameters printing nothing at exit 0; console.dir(x, y, f()) printing before running f; console.time("t", g()) never evaluating g at all; console.table(a,b,c) silently becoming console.log; child_process validating each argument as it lowered, so execSync(123, sideEffect()) threw before evaluating sideEffect.

    What this does not claim, and the ledger says so in its own comment: a listed module cannot make an ordering mistake against the raw API, because it no longer names it. A window with no rooting decision at all is invisible to the check. #7640 (~20 such arms) and Layer 3 are the open half.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions