perf(repsel): exempt CommonJS module scaffolding from the Ptr<Shape> rule-5 barrier - #7139
perf(repsel): exempt CommonJS module scaffolding from the Ptr<Shape> rule-5 barrier#7139proggeramlug wants to merge 7 commits into
Conversation
…rule-5 barrier
The rule-5 module-wide kill (`ModuleDispatchFacts::shape_barrier_sites`)
disables ALL `Ptr<Shape>` promotion in a module that contains any
`Object.defineProperty`-family site, regardless of target. Two
`defineProperty` calls that have nothing to do with user objects were
therefore denying every promotion in the entire CommonJS dependency graph:
1. Perry's own CJS preamble emits
`Object.defineProperty(require, 'name', {...})` into EVERY
cjs_wrap-compiled module (cjs_wrap/wrap.rs `cjs_preamble`), so the
barrier was armed in 100% of them independently of package source.
2. The transpiler interop marker
`Object.defineProperty(exports, "__esModule", { value: true })`.
Recognise exactly those two sites and nothing else. A site is exempt iff
the target is `LocalGet(id)` where the module binds `id` with a
`Stmt::Let` named `exports` (resp. `require`) that has no `Expr::New`
initializer anywhere, and the key is the string literal `"__esModule"`
(resp. `"name"`). The `New`-init check is the soundness hinge: it is the
exact negation of `find_new_candidates`' seed, so an exempted target can
never itself be promoted, and rule 2's containment already keeps every
promoted object out of any other binding.
Every other target, key, computed key, and barrier family (`delete`,
`setPrototypeOf`/`__proto__`, `Proxy`, mutating `Reflect.*`) keep the
module-wide kill untouched.
Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
📝 WalkthroughWalkthroughThe change adds HIR-based detection for two CommonJS scaffolding patterns. It excludes eligible sites from module-wide shape barriers, preserves other barriers, integrates the analysis into dispatch collection, and adds unit, promotion, and end-to-end validation. ChangesCommonJS shape-barrier handling
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant ModuleExpressions
participant CjsScaffolding
participant ScalarMethodDispatch
participant PtrShapePromotion
ModuleExpressions->>CjsScaffolding: Collect bindings and HIR expressions
CjsScaffolding-->>ScalarMethodDispatch: Provide scaffolding facts
ScalarMethodDispatch->>CjsScaffolding: Classify defineProperty expressions
CjsScaffolding-->>ScalarMethodDispatch: Return exemption results
ScalarMethodDispatch->>PtrShapePromotion: Record remaining barriers
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…lacklist `find_new_candidates`' seed set is a moving target: #7034 §4 already landed return-shape facts (`ModuleDispatchFacts::return_shape_class`) under which a call to a proven function IS a rule-1 provenance seed. A blacklist of `Expr::New` would silently widen this exemption the day such a seed is wired into the candidate search. Admit only initializers that are not fresh allocations at all — `PropertyGet` (`var exports = __cjs_module.exports`), `Closure` (`function require(…) {}`), `Undefined` / absent (the hoisted `var`) — so the check fails closed instead. Two tests cover it: an `exports` binding initialized by `new` OR by a call is not exempt, and a later disqualifying rebinding of the same `LocalId` (which `var` redeclaration reuses) removes the exemption from the earlier one. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
There was a problem hiding this comment.
🧹 Nitpick comments (2)
crates/perry-codegen/src/collectors/scalar_method_dispatch.rs (1)
193-204: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftDuplicated module-traversal structure with
cjs_scaffolding::collect.
collect_module_dispatch_factswalkshir.init, every function body, and every class member body — the same structural walk thatcjs_scaffolding::collect(crates/perry-codegen/src/collectors/cjs_scaffolding.rs, lines 121-163) independently re-implements to gatherexports/requirebindings. Two independent implementations of the same module walk must be kept in sync by hand wheneverModuleorClassgains new fields.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-codegen/src/collectors/scalar_method_dispatch.rs` around lines 193 - 204, Refactor collect_module_dispatch_facts and cjs_scaffolding::collect to share one module-body traversal helper covering hir.init, function bodies, and class member bodies. Update both collectors to consume that shared traversal so future Module or Class fields are handled consistently, without changing their collector-specific behavior.crates/perry-codegen/src/collectors/cjs_scaffolding.rs (1)
121-163: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftDuplicated module-traversal structure with
collect_module_dispatch_facts.
collectre-implements the same walk overmodule.init, every function body, every class constructor/method/getter/setter/computed-member body, and every field/computed-member key expression thatcollect_module_dispatch_factsinscalar_method_dispatch.rsalready performs. The doc comment above (lines 116-120) even states this function "Mirrors …collect_module_dispatch_facts's coverage", which means any future change toClassorModuleshape requires updating both traversals in lockstep, or the two collectors silently diverge.Extract a shared "walk every function/method/constructor body and field/computed-member expression in a module" helper that both
collect_module_dispatch_factsandcollectcan drive with different per-node callbacks. This removes the manual-mirroring requirement documented in the comment.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-codegen/src/collectors/cjs_scaffolding.rs` around lines 121 - 163, Extract a shared module traversal helper covering module initialization, function bodies, class constructors, methods/getters/setters/computed-member bodies, and field/computed-member key expressions. Refactor both collect and collect_module_dispatch_facts to use this helper with their respective per-node callbacks, removing the duplicated traversal logic while preserving each collector’s existing behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/perry-codegen/src/collectors/cjs_scaffolding.rs`:
- Around line 121-163: Extract a shared module traversal helper covering module
initialization, function bodies, class constructors,
methods/getters/setters/computed-member bodies, and field/computed-member key
expressions. Refactor both collect and collect_module_dispatch_facts to use this
helper with their respective per-node callbacks, removing the duplicated
traversal logic while preserving each collector’s existing behavior.
In `@crates/perry-codegen/src/collectors/scalar_method_dispatch.rs`:
- Around line 193-204: Refactor collect_module_dispatch_facts and
cjs_scaffolding::collect to share one module-body traversal helper covering
hir.init, function bodies, and class member bodies. Update both collectors to
consume that shared traversal so future Module or Class fields are handled
consistently, without changing their collector-specific behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 7fafd556-65cd-48e0-b4ee-56bb1af6a6ac
📒 Files selected for processing (5)
changelog.d/7139-cjs-scaffolding-barrier-exemption.mdcrates/perry-codegen/src/collectors/cjs_scaffolding.rscrates/perry-codegen/src/collectors/mod.rscrates/perry-codegen/src/collectors/ptr_shape.rscrates/perry-codegen/src/collectors/scalar_method_dispatch.rs
Picked up by `cargo fmt -p perry-codegen`; it is a pre-existing formatting deviation on `main`, not part of this change. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
…ding The `require` / `"name"` half of #7139's exemption is coupled to `cjs_wrap`'s preamble template by nothing but a matching binding name, initializer shape and property key. Rename the local, change the key, or bind it through anything but a function declaration and the exemption silently stops applying: nothing breaks, no test fails, and the rule-5 barrier quietly re-arms for 100% of CommonJS modules. Since that site is the one arming every module, silent drift costs the entire win with no symptom. Runs the real template through the real recogniser — wrap -> parse -> lower -> `module_has_ptr_shape_barrier` — and asserts the barrier is NOT armed. Two anti-vacuity guards: the wrapped source must still CONTAIN the `defineProperty(require, …)` site (so the test cannot pass trivially the day the template drops it, leaving the recogniser arm as unnoticed dead code), and a positive control proves the same chain still reports a genuine `delete`. `perry_codegen::module_has_ptr_shape_barrier` is public solely for this: the recogniser is in perry-codegen, the template is in perry, and only perry can see both. Same shape as `iter_native_method_signatures`, which exists for perry-api-manifest's consistency test. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
Narrows the
Ptr<Shape>rule-5 module-wide barrier so that thedefinePropertycalls everycjs_wrap-compiled CommonJS module carries stopdisabling shape promotion for the whole module. Closes the
#7034 §2barriernarrowing item.
What was actually blocking
#7034 recorded a source-level scan:
Object.defineProperty(exports, "__esModule", { value: true })appears in 52 % of sampled dependency JS.Reproducing that on
scriptc/node_modules(1841 JS files): 718 have at leastone barrier-family site, and 403 of those 718 (56 %) have no barrier other
than that one line.
But exempting only that line would have recovered nothing, because Perry
arms the barrier itself.
cjs_wrap's preamble emitsinto every wrapped module —
crates/perry/src/commands/compile/cjs_wrap/wrap.rs:841,inside the shared
cjs_preambleused by both the IIFE wrap and the flat emit.Confirmed in HIR: a trivial CJS dependency lowers to
Expr(ObjectDefineProperty(LocalGet(10), String("name"), …))before a singleline of package source runs. So
shape_barrier_siteswas true in 100 % ofthe CommonJS dependency graph regardless of what the package did.
Both sites are recognised here. Nothing else is.
The predicate
An
Expr::ObjectDefineProperty(target, key, desc)node is exempt ifftargetisExpr::LocalGet(id);idwith aStmt::Letnamedexports(resp.require);Stmt::Letbinding ofidin the module has an initializer thatis not a fresh allocation at all —
PropertyGet(
var exports = __cjs_module.exports),Closure(
function require(specifier) {…}),Undefined, or absent;keyis the string literal"__esModule"(resp."name").Any other target, any other key, a computed key, and every other barrier family
(
delete,setPrototypeOf/__proto__write,new Proxy, mutatingReflect.*) keep the module-wide kill untouched.Soundness
A
definePropertycan only invalidate aPtr<Shape>proof if the object itmutates is the object the promoted local holds. Two independent facts rule
that out:
Ptr<Shape>candidates are seeded byfind_new_candidatesfromStmt::Let { init: Some(Expr::New { .. }), .. }.The third clause above is deliberately a whitelist, not a blacklist of
Expr::New: rule 1's seed set is a moving target — repsel row 1: scoping Ptr<Shape> beyond locals — measured, promotion is 0 on the motivating workload; build --opt-report (#6952) first #7034 §4 already landedreturn-shape facts (
ModuleDispatchFacts::return_shape_class) under which acall to a proven function is a provenance seed — and a blacklist would
silently widen this exemption the day such a seed is wired into the
candidate search. A whitelist fails closed. It is checked on the HIR rather
than on an expectation about the wrap template, so if
cjs_wrapeverchanges shape the exemption degrades to no exemption, never to an unsound
one.
local only when every use is a declared-chain field read/write/update or a
vetted method call; reassignment, aliasing, capture, and passing it as a
call/constructor argument all disqualify. A promoted object therefore never
flows into another binding, so it can never be the value of
exportsorrequire.The descriptor argument is deliberately unconstrained: a descriptor is a plain
value, and if it contains an accessor closure the module walk descends into
that closure's body on its own, so a barrier inside a descriptor still arms
the flag.
shape_barrier_sitesis also read byptr_numarrayandproven_this. Theargument is about the identity of the mutated object, not about which analysis
reads the flag, so it carries over unchanged.
Measurement — eligibility and promotion, separately
85 self-contained
__esModuleCJS leaves fromscriptc/node_modules, eachcompiled as a
compilePackagesdependency by both arms(
--opt-report=json --no-link, 85/85 compiled in both):ptr-shapedenials: rule 5 (module barrier)ptr-shapedenials: rule 1 (provenance)ptr-shapedenials: rule 2 (containment)ptr-shapeselectionsptr-shapeconsumptionsEligibility recovered: 84 of 85 modules (98.8 %). The one holdout has a
genuine barrier of its own.
Promotion gained: small, and that is the finding. Of the ~103 candidates the
barrier stopped denying, 100 were immediately re-denied by rule 2
(containment). The 18 that got through, named:
@eslint-community/regexpp/index.js— 15 proven-thisreceivers(
BranchID.sibling,GroupSpecifiersAsES2018/2025.*,RegExpVisitor.visit*),16 consumption sites. This is the only module in the corpus that gained
consumed promotions.
typescript/vendor/vscode-jsonrpc/lib/common/linkedMap.js— 3 anon-shaperecord locals (
result, the{ value, done }iterator result in threeclosures), selected and consumed zero times (0 numeric fields proven).
So the honest one-liner is: eligibility recovered ~99 %, promotion gained 18
values in 2 of 85 modules, of which 16 consumptions all land on proven-
thisand zero on a
Ptr<Shape>local. Containment, not the barrier, is the wall inminified dependency code — this redirects effort to Track D rather than to more
barrier work.
IR evidence for the claimed consumption
Object hashes and counters are not enough, so the call sites were checked.
A minimal CJS dependency (
class Point,function compute(n) { const p = new Point(n, n+1); p.x = p.x + 1; return p.sum() + p.x + p.y; }),--trace llvm,function
perry_closure_…_17(thecomputebody):js_typed_feedback_class_field_get_guardjs_typed_feedback_class_field_set_guardjs_typed_feedback_record_fallback_calljs_object_get_field_by_name_f64js_method_direct_shape_guardjs_native_call_method_by_id…Point__sum…Point__sum__pshapeThe
__pshapeclone appears only in the fix arm — Phase 5a's proven-thisadmission consumes the same flag, as expected.
Sabotage
New tests, run against the unfixed rule (
&& !cjs.exempts_shape_barrier(expr)removed): exactly the 3 exemption-dependent tests go red
(
cjs_scaffolding_define_property_sites_do_not_arm_the_module_barrier,each_scaffolding_site_is_exempt_on_its_own,an_eligible_local_promotes_in_a_module_carrying_only_scaffolding_sites) andthe 8 negative/anti-vacuity tests stay green — i.e. they are not asserting
something the unfixed compiler already did. Restored, 11/11 pass.
the_same_local_is_denied_when_a_real_barrier_is_presentsabotages the otherdirection: one
deleteanywhere in the same module still denies the samelocal, so the promotion test is not vacuous.
Template-change canary
The
require/"name"arm is coupled tocjs_wrap's preamble template bynothing but a matching binding name, initializer shape and property key. Rename
the local, change the key, or bind it through anything but a function
declaration and the exemption silently stops applying: nothing breaks, no test
fails, and the rule-5 barrier re-arms for 100 % of CommonJS modules. Since
that site is the one arming every module, silent drift costs the entire win with
no symptom — the "gate that cannot fail" shape CLAUDE.md documents.
cjs_wrap::preamble_canary_testscloses it by running the real template throughthe real recogniser: wrap → parse → lower →
module_has_ptr_shape_barrier,asserting the barrier is not armed. Two anti-vacuity guards:
defineProperty(require, …)site,so the canary cannot pass trivially the day the template stops emitting it and
leaves that arm of the recogniser as dead code nobody notices;
delete, soan empty or failed lowering cannot make the canary pass for the wrong reason.
Sabotage-verified in four independent directions, positive control green in
all four:
'name'→'displayName'defineProperty(require, …)block deletedREQUIRE_KEYchangedEXPORTS_KEYchangedperry_codegen::module_has_ptr_shape_barrieris public solely for this —the recogniser is in
perry-codegen, the template is inperry, and onlyperrycan see both. Same shape asiter_native_method_signatures, whichexists for
perry-api-manifest's consistency test; nothing in the compilepipeline calls it.
Adjacent question answered: does the barrier walk run before or after DCE?
After — but Perry has no dead-code elimination in a default build, so the
walk sees everything.
collect_module_dispatch_factsis called atcrates/perry-codegen/src/codegen/mod.rs:1385, on the fully-transformed HIR.off (
tree_shake: false,crates/perry/src/commands/compile/types.rs:1146;set only by
PERRY_TREE_SHAKEorperry.experiments.treeShake,host_config.rs:44/:212):reachability::tree_shake(compile/reachability.rs:45, called fromrun_pipeline.rs:375) — prunes whole modules, never intra-module code;env_fold::fold_env_branches(compile/env_fold.rs:30, called fromcollect_modules.rs:779) — splices statically-falseprocess.envifbranches.
collect_module_dispatch_facts(scalar_method_dispatch.rs:181-220) walkshir.init, every entry ofhir.functionswhether called or not, andevery class member body.
Quantified before deciding: over the same corpus, of 1750 barrier sites in
718 files, 12 (0.7 %) sit inside a statically-dead
if(if (false)/if (0)/ deadprocess.env.NODE_ENVguards), and zero files go frombarrier-armed to barrier-free if all of them are removed. Making the walk
DCE-aware would need an intra-module call graph and would buy nothing measurable
here, so this PR does not attempt it; the numbers are recorded rather than an
issue filed for a non-problem.
Validation
cargo test -p perry-codegen --lib— 417 passed (11 new);cargo test -p perry --bin perry preamble_canary— 2 passed (the canary above).cargo test -p perry-hir -p perry-transform --lib— 266 + 54 passed.cargo clippy -p perry-codegen --all-targets— no warnings on the changed files.compiler_output_regression.py census --gate) — Census OK;batchptr-shape= 2 asserted programmatically against its floor of 2;benchmarks/repsel_census/baseline.jsonuntouched..node-version), byte-identical outputon both arms and node:
16/16/16;@eslint-community/regexpp(the module that gained all 16 consumptions),compiled as a
compilePackagesdependency and asked to parse/(?<y>a|b)+c[d-f]\\1/guand validate(?<z>x)\\k<z>—RegExpLiteral true true (?<y>a|b)+c[d-f]\\1+validatedfrom node,base perry and fix perry alike.
-p perry -p perry-runtime-static -p perry-stdlib-staticinseparate
CARGO_TARGET_DIRs; binary hashes verified distinct(
b1b2e6d5…vs51ebdde7…) before any A/B was believed.workloads (
benchmarks/app-patterns/kernels/batch.ts,test_gap_repsel_canonical_i32.ts,test_gap_repsel_gc_stress.ts) — codegenchanges only where CJS scaffolding exists.
issue_4872_barrel_default_reexports,issue_5257_require_adopt_no_default_namespace,issue_6585_cjs_class_forward_function— 4 passed.scripts/gc_repsel_matrix.sh --arms all --pressure 8— FAIL=0(PASS=426, UNVER=119, XFAIL=1), byte-exact vs node 26.5.1 on 545/546 cells,
with the
requires=movearms live (25/26 copy-minor). Run twice, before andafter the whitelist hardening; identical both times.
benchmarks/gc_ratchet/probes/emitbyte-identical LLVM IR under both arms (per-probe hashes, all 8 distinct from
each other — so the hashing is reading real content, the perf(codegen): module-init / program-entry bodies select canonical i32/u32/Str (#7109) #7121 anti-vacuity
precedent), i.e. 0/8 can move the collector measurement.
scripts/addr_class_inventory.py— exit 0.scripts/check_file_size.sh— the 16 failures are pre-existing onmain;none of the files in this PR are among them (1881 / 810 / 606 lines).
Not measured
__esModuleleaves of one corpus wereA/B'd — modules with
require()edges need their whole dependency treepresent and were out of scope for this pass.
noise floor of anything in
benchmarks/, and claiming a speedup from itwould be dishonest.
https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9