Summary
The aggregate-scalar transform can delete an exported array of object literals because cross-module consumers are invisible to its escape analysis. Perry then emits an export getter that returns undefined.
This caused an application to fail in for (const item of ALLEYS) with TypeError: is not iterable; the reported source location pointed at an unrelated earlier call.
Reproduced on e81548ed17ff9488c3f8c19256048dc8d8497867. The transform is unchanged on main at bbf1bcf00921b1d15fec77866bd2f5f9fbfdff95.
Minimal two-module reproduction
values.js:
export const VALUES = [
{ x: 1, label: "one" },
{ x: 2, label: "two" },
];
main.ts:
import { VALUES } from "./values.js";
console.log(VALUES.length);
for (const value of VALUES) {
console.log(value.x, value.label);
}
Compile and run normally:
perry compile main.ts -o main
./main
Actual behavior
VALUES.length is undefined, followed by:
TypeError: is not iterable
In the larger reproducer, disassembly of the generated zero-argument export getter showed that it returns TAG_UNDEFINED unconditionally.
Post-transform HIR contains synthetic locals such as:
__perry_scalar_aggregate_<array-id>_<element>_<property>
but no longer contains the top-level Let for the exported carrier array.
Expected behavior
The exported array's identity, length, iteration behavior, and elements are externally observable and must be materialized.
Root cause
crates/perry-transform/src/aggregate_scalar.rs counts references across HIR regions in the current module. An imported use lives in another module and therefore contributes no LocalGet reference to the producer's analysis.
For an exported array unused inside its declaring module, scalarize_candidate concludes that the carrier does not escape, replaces its object fields with scalar locals, and removes the carrier declaration. Export metadata is not currently treated as an escape.
Proposed fix
Treat exported bindings as externally observable scalarization barriers:
- Derive the local binding IDs exported by
Module.exports / exported_objects before transforming module init.
- Pass those IDs into aggregate-scalar candidate analysis.
- Reject any candidate whose binding is exported, even if there are no same-module references.
Reconstructing the full value lazily in the getter would be substantially more complex and would still have to preserve identity, so retaining the carrier is the safe fix.
Regression coverage
- Transform unit test asserting an exported aggregate carrier
Let survives.
- Two-module compile/run test using the reproduction above.
- Assert
.length, iteration, element property reads, and repeated import reads preserve the same array identity.
- Keep an equivalent non-exported/local aggregate test to ensure scalar replacement still fires where the carrier is genuinely non-escaping.
Summary
The aggregate-scalar transform can delete an exported array of object literals because cross-module consumers are invisible to its escape analysis. Perry then emits an export getter that returns
undefined.This caused an application to fail in
for (const item of ALLEYS)withTypeError: is not iterable; the reported source location pointed at an unrelated earlier call.Reproduced on
e81548ed17ff9488c3f8c19256048dc8d8497867. The transform is unchanged onmainatbbf1bcf00921b1d15fec77866bd2f5f9fbfdff95.Minimal two-module reproduction
values.js:main.ts:Compile and run normally:
Actual behavior
VALUES.lengthisundefined, followed by:In the larger reproducer, disassembly of the generated zero-argument export getter showed that it returns
TAG_UNDEFINEDunconditionally.Post-transform HIR contains synthetic locals such as:
but no longer contains the top-level
Letfor the exported carrier array.Expected behavior
The exported array's identity, length, iteration behavior, and elements are externally observable and must be materialized.
Root cause
crates/perry-transform/src/aggregate_scalar.rscounts references across HIR regions in the current module. An imported use lives in another module and therefore contributes noLocalGetreference to the producer's analysis.For an exported array unused inside its declaring module,
scalarize_candidateconcludes that the carrier does not escape, replaces its object fields with scalar locals, and removes the carrier declaration. Export metadata is not currently treated as an escape.Proposed fix
Treat exported bindings as externally observable scalarization barriers:
Module.exports/exported_objectsbefore transforming module init.Reconstructing the full value lazily in the getter would be substantially more complex and would still have to preserve identity, so retaining the carrier is the safe fix.
Regression coverage
Letsurvives..length, iteration, element property reads, and repeated import reads preserve the same array identity.