Skip to content

hir: class-expression capture snapshot stale for vars assigned after the class — semver Comparator TypeError kills pi-native at init #6604

Description

@proggeramlug

Next wall of the pi coding-agent bring-up (tracker #6564), directly behind #6593 (PR #6603 makes the bundle compile+link; this bug stops the binary at init).

Symptom

Every pi-native invocation dies during module init:

TypeError: value is not a function
    at <anonymous>

Diagnosis (from a --debug-symbols build, breakpoint on js_throw_type_error_not_a_function)

js_throw_type_error_not_a_function
throw_not_callable
js_closure_unbox_callee_checked
pi_bundle_mjs___Comparator_constructor + 1112     ← bundle line 4836: options = parseOptions(options)
abi_trampoline / call_vtable_method
js_new_function_construct                          ← dynamic `new Comparator(...)`
semver/ranges/subset.js module init
semver/index.js → main

The failing shape is semver's standard CJS layout inside its esbuild __commonJS wrapper:

var Comparator = class _Comparator {
  constructor(comp, options) {
    options = parseOptions(options);   // ← throws: parseOptions is undefined
    ...
  }
};
module.exports = Comparator;
var parseOptions = require_parse_options();   // assigned AFTER the class definition

Correct JS: the ctor closes over the live parseOptions binding; by the time anything constructs a Comparator (the wrapper has completed), it holds the function.

Perry: the class captures the wrapper-local via the synthesized __perry_cap_<id> machinery. For dynamic construction (js_new_function_construct — subset.js constructs the require_comparator() VALUE), the ctor's capture params are filled from a capture snapshot taken at the class's source position — which predates the var parseOptions = ... assignment, so the snapshot holds undefined forever.

The #6037/#6052 refresh machinery (re-register snapshot after each captured var's assignment + before returns, insert_class_capture_refresh_after_assignments in crates/perry-hir/src/lower_decl/block.rs ~1250) only scans ast::Stmt::Decl(ast::Decl::Class(..)) — class declarations. var C = class _C {...} (a class expression in a var initializer — semver uses this in every class file, and the per-evaluation __perry_ctor_caps value-snapshot path applies) is not covered, so no refresh ever happens for this shape.

Suggested direction

Extend the refresh machinery to the var/let/const <name> = class ... statement shape (both the block-body and lower_fn_expr twins), or refresh the class value's __perry_ctor_caps on assignment to any captured var. Note the naming subtlety: the class registers under its expression ident (_Comparator), not the binding (Comparator), and scope renames must be resolved the same way the decl-path fix does (2026-07-02 audit P0 in block.rs).

Repro: compile secret-tests/pi-target/dist/pi-bundle.mjs with PR #6603's compiler, run HOME=$(mktemp -d) ./pi-native --help.

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