Repository navigation
Description
The invariant
A type/kind proof attached to a local binding is usable only at sites that every write to that binding dominates. A proof derived from a declared TypeScript type is not a proof at all without a runtime check.
Codegen repeatedly attaches a kind/representation proof to a binding — from its declaration or its initializer — and then uses that proof to select a specialized lowering or constant-fold a predicate. The proof is sound where it was derived and unsound afterwards, because nothing invalidates it when the binding is written again, and nothing validates a declared type against the value actually present at runtime (CLAUDE.md, Known Limitations: "declared TS types aren't enforced at runtime").
Four independent bugs found within one day are all this one mistake. Filing an umbrella because patching one spelling at a time is precisely what let the same root ship four times.
The four instances
| issue | state | binding proof | what it selected | symptom |
|---|---|---|---|---|
| #7700 | closed | "local is Uint8Array/Buffer", and the key node is not a string literal |
fold every non-STRING key to Uint8ArrayGet |
typeof u8[Symbol.iterator] is number; a string key held in a local reads a byte |
| #7796 | closed | the value's function-ness, lost once stored in a local | inlined truthiness fast path | f ? … falsy while Boolean(f) is true |
| #7837 | open | declared string on a local that actually holds a number |
concat lowering for + |
s + 7 → "427"; t + "x" → "x" — an operand silently dropped |
| #7844 | open | the initializer's kind, never invalidated by assignment | constant-folded Array.isArray |
answers from the initializer in both directions; a guard admits a number |
The shared tell is one sentence: correct inline or at the definition site, wrong once the value lives in a local binding. #7700's recorded tell was already literally "wrong only when STORED in a local", and #7796's title says "only when stored in a local". Two of these were closed with the tell written down and the root not named.
Severity note: #7844's false-positive direction and #7837's dropped operand are the two that fail silently in the dangerous direction — a validation guard admitting data it exists to reject, and data vanishing from a string. Both exit 0 with no diagnostic.
Why this keeps happening
This is the structural twin of the GC rooting-dominance invariant in docs/src/internals/gc-rooting-invariant.md: a proof must dominate every subsequent site that can invalidate it. There the invalidating event is a collection; here it is an assignment. That invariant needed a static checker (scripts/gc_root_dominance_check.py) plus a CI gate plus an allowlist that shrinks, because review alone did not catch it — the same is likely true here.
It is also the predicted failure mode of the representation-selection direction rather than bad luck. #6377 already recorded that "more type visibility" PRs un-gate latent fast paths, and the memory of the PERRY_PTR_SHAPE_LOCALS=0 episode records a declared-type fallback firing whenever the Ptr<Shape> proof is missing. Every increase in type visibility widens the set of sites where an unsound binding proof gets used.
Proposed acceptance criterion
Not "fix these four". Rather:
- Enumerate the binding proofs. Every place codegen/HIR consults a local's declared type or inferred initializer kind to pick a lowering or fold a predicate.
- For each, establish dominance or drop it. A proof survives only if every write to the binding is dominated by it; a reassignment must kill it. Where that cannot be shown, fall back to the runtime path — note that in Symbol-keyed function value is falsy in
if/!but true under Boolean() — only when stored in a local #7796 the runtime path (Boolean(f)) was correct and only the inlined one was wrong, so the fallback is known-good. - A declared TS type is never a representation proof on its own. A lying
string-declared local silently drops an operand:t + "x"returns "x", ands + 7concatenates instead of adding #7837 is the pure case; it needs a runtime check or a guarded fast path with a deopt, not trust. - A detector, not four patches. The realistic instrument is a static pass over HIR (or
--trace llvm) that flags a specialized lowering keyed on a binding proof where a write to that binding is not dominated — same shape asgc_root_dominance_check.py, including a--self-testthat proves it can still fail and an allowlist whose entries must match something.
Without (4), the honest expectation is a fifth instance.
Reproducers
All four issues carry standalone reproducers; #7837 and #7844 reproduce on current main (b1edd2340, perry 0.5.1464) against Node 26.5.1, with and without PERRY_NO_AUTO_OPTIMIZE=1.
Activity
perf(codegen): hoist the STRING_TAG test of js_string_addref_if_heap_…
fix(hir,codegen): `x ?? null` with an unknown left no longer costs th…
fix(hir,codegen): `x ?? null` with an unknown left no longer costs th…