Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions changelog.d/7891-declared-array-claim-string-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
### fix(codegen): keep a string or symbol key off #7890's declared-array claim, and cover the shape #7890 added

Two follow-ups to #7890, both found by writing the coverage #7890 was missing.

#### A. A string/symbol key must not ride the claim (#7891)

#7890 lets a property read whose receiver's *declared* property type is an array
(`e.vals[i]`, `p.toks[p.pos]`) reach `expr/index_get.rs`'s array arm. That is a
CLAIM, not a proof, and it was admitted on the grounds that the array arm
re-checks `GC_TYPE_ARRAY` on the receiver and falls back.

That is true of the arm **as a whole** and false of one route inside it. The two
key routes have different receiver-validation strength:

* **numeric** — `js_array_get_f64`, which classifies the receiver through
`clean_arr_ptr` / `array_object_receiver` and answers correctly for a string,
an array-like object, a typed array or a number.
* **static string / symbol** — `js_array_get_index_or_string` →
`array_get_property_by_key` → `js_object_get_field_by_name`, which has **no
string-receiver index arm** and answers `undefined` for `s["0"]` where JS
answers the character.

So only the numeric route is claim-safe. The claim now requires a non-string,
non-symbol key; a string or symbol key keeps exactly the generic path it had
before #7890. `interp`'s and `iso_miss`'s reads are all numeric, so the measured
result is unchanged — every one of the 19 corpus binaries is byte-identical to
the ones timed for #7890.

The `undefined` answer itself is **pre-existing on `main`** and reachable without
any of this, through a plain non-union declared receiver:

```ts
type Bag = { items: string[] };
function mk(v: any): Bag { return { items: v }; }
function viaDeclared(b: Bag): string { return "" + b.items["0"] + "/" + b.items[0]; }
const s: any = "ss";
console.log(viaDeclared(mk(s))); // node: s/s perry: undefined/s
const direct: any = "ss";
console.log("" + direct["0"] + "/" + direct[0]); // node: s/s perry: s/s
```

The same read on a bare `any` is correct, which is the tell: the wrong answer is
selected by the ANNOTATION, not by the value. Tracked as **#7891**; not checked in
as a gap test, because it would be red by construction and
`test-parity/gap_snapshot.json` is generated on Linux and must not be hand-edited.

#### B. Coverage for the shape #7890 actually added

`test-files/test_gap_7890_declared_array_receiver_element_read.ts`. #7854's own
test always routes through an intermediate local (`const items = e.items`), so
nothing covered a `PropertyGet` used **directly** as the receiver — which is
exactly what #7890 added. The new file reads `e.items[i]` / `e.items.length`
through a `type` alias, an `interface`, a class, a nullable reassigned cursor and
a nested chain, handed an array, a string, a number, an array-like object with
numeric and with non-numeric `length`, a typed array, a function, `null` and
`undefined`, plus negative / fractional / out-of-range indexes, a store through
the same shape, and static string keys (which A leaves on the generic path).
Byte-identical to node on every row.

Live rather than decorative: on that file the guarded-read `arr.fast` blocks go
**11 → 15** and the `js_dyn_index_get` calls go **5 → 1**.

Writing it is what found #7891.
39 changes: 26 additions & 13 deletions crates/perry-codegen/src/expr/index_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
recv_ty,
None | Some(perry_hir::types::Type::Any) | Some(perry_hir::types::Type::Unknown)
);
// #5525: route every non-static-string/symbol read on an unknown
// receiver through `js_dyn_index_get` (numeric, runtime-string, and
// runtime-symbol are all triaged in the runtime). The earlier
// `is_numeric_expr(index)` gate missed `lr[off]`/`lr[off + 1]`
// (bcryptjs `_encipher`'s `off` is an `any` param, so `off + 1` is
// not provably numeric); statically-known string-literal / symbol
// keys keep their dedicated interned-handle / symbol routes below.
let index_is_static_string_or_symbol = matches!(
index.as_ref(),
Expr::String(_) | Expr::WtfString(_) | Expr::SymbolFor(_)
) || is_string_expr(ctx, index);
Comment on lines +1136 to +1139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Expect: every symbol expression form is excluded before the declared-array
# claim or reaches the runtime symbol-property path.
rg -n -C 8 \
  'Expr::SymbolFor|js_is_symbol|js_object_get_symbol_property|js_array_get_index_or_string|declared_array_property_claim|is_numeric_expr' \
  --glob '*.rs' .

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

target="crates/perry-codegen/src/expr/index_get.rs"

printf '%s\n' '--- target implementation ---'
sed -n '1110,1215p' "$target"
sed -n '1410,1485p' "$target"

printf '%s\n' '--- symbol-related HIR forms and constructors ---'
rg -n -C 3 \
  'enum Expr|SymbolFor|SymbolKeyFor|SymbolDescription|SymbolToString|well_known_symbol|js_symbol' \
  crates/perry-hir crates/perry-codegen/src --glob '*.rs' \
  | head -n 500

printf '%s\n' '--- declared-array claim references ---'
rg -n -C 6 \
  'declared_array_property_claim|index_is_static_string_or_symbol|claimed_array' \
  "$target"

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

target="crates/perry-codegen/src/expr/index_get.rs"

printf '%s\n' '--- complete claimed-array lowering ---'
sed -n '1190,1265p' "$target"

printf '%s\n' '--- runtime fallback dispatch ---'
sed -n '1440,1535p' "$target"

printf '%s\n' '--- predicate definitions and numeric analysis ---'
rg -n -C 12 \
  'fn is_string_expr|pub.*is_string_expr|is_numeric_expr|fn declared_array_property_claim|declared_array_property_claim' \
  crates/perry-codegen/src --glob '*.rs' \
  | head -n 700

printf '%s\n' '--- symbol-producing expressions in member/index lowering ---'
rg -n -C 8 \
  'Expr::SymbolNew|Expr::SymbolFor|Type::Symbol|local_type.*Symbol|Symbol\\.iterator|Symbol\\[|computed.*Symbol' \
  crates/perry-hir/src/lower crates/perry-hir/src/analysis crates/perry-codegen/src/expr \
  --glob '*.rs' \
  | head -n 1000

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- exact predicate implementations ---'
rg -n -C 25 \
  'fn is_string_expr|fn is_numeric_expr|fn declared_array_property_claim' \
  crates/perry-codegen/src/type_analysis --glob '*.rs'

printf '%s\n' '--- relevant type cases ---'
rg -n -C 18 \
  'Type::Symbol|Expr::SymbolNew|Expr::SymbolFor|Expr::LocalGet' \
  crates/perry-codegen/src/type_analysis/numeric \
  crates/perry-codegen/src/type_analysis/strings \
  crates/perry-codegen/src/type_analysis/refine \
  --glob '*.rs' | head -n 900

printf '%s\n' '--- declared-array claim callers and tests ---'
rg -n -C 15 \
  'declared_array_property_claim|`#7854`|claimed_array' \
  crates/perry-codegen --glob '*.rs' | head -n 900

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

target = Path("crates/perry-codegen/src/expr/index_get.rs").read_text()
numeric = Path("crates/perry-codegen/src/type_analysis/numeric.rs").read_text()
strings = Path("crates/perry-codegen/src/type_analysis/strings.rs").read_text()
hir_types = Path("crates/perry-hir/src/analysis/value_types.rs").read_text()

claim = "let claimed_array = recv_unknown"
claim_pos = target.index(claim)
claim_block = target[claim_pos:target.index("// Three cases:", claim_pos)]

checks = {
    "claim excludes only the static SymbolFor form": (
        "Expr::String(_) | Expr::WtfString(_) | Expr::SymbolFor(_)" in claim_block
        and "Expr::SymbolNew" not in claim_block
    ),
    "claimed array branch handles only SymbolFor specially": (
        "if matches!(index.as_ref(), Expr::SymbolFor(_))" in claim_block
        and '"js_object_get_symbol_property"' in claim_block
    ),
    "non-numeric claimed-array keys use array index-or-string helper": (
        "if !is_numeric_expr(ctx, index)" in claim_block
        and '"js_array_get_index_or_string"' in claim_block
    ),
    "numeric predicate does not classify SymbolNew as numeric": (
        "Expr::Integer(_)" in numeric
        and "Expr::LocalGet(id)" in numeric
        and "Some(HirType::Number) | Some(HirType::Int32)" in numeric
    ),
    "string predicate does not classify Symbol locals as strings": (
        "Some(HirType::String | HirType::StringLiteral(_))" in strings
        and "Expr::SymbolNew" not in strings[strings.index("pub(crate) fn is_string_expr"):strings.index("pub(crate) fn is_string_expr") + 5000]
    ),
    "later fallback performs runtime symbol dispatch": (
        '"js_is_symbol"' in target
        and '"js_object_get_symbol_property"' in target
    ),
}

for name, result in checks.items():
    print(f"{'PASS' if result else 'FAIL'}: {name}")

if not all(checks.values()):
    raise SystemExit(1)

print("Conclusion: a SymbolNew or Symbol-typed LocalGet can satisfy the claimed-array path, skip its SymbolFor arm, and reach js_array_get_index_or_string.")
PY

Repository: PerryTS/perry

Length of output: 516


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

target = Path("crates/perry-codegen/src/expr/index_get.rs").read_text()
numeric = Path("crates/perry-codegen/src/type_analysis/numeric.rs").read_text()
strings = Path("crates/perry-codegen/src/type_analysis/strings.rs").read_text()
value_types = Path("crates/perry-hir/src/analysis/value_types.rs").read_text()

claim_start = target.index("let claimed_array = recv_unknown")
array_start = target.index("if is_array_expr(ctx, object) || claimed_array", claim_start)
fallback_start = target.index("// Last-resort fallback with runtime tag checks", array_start)
claim_region = target[claim_start:fallback_start]

checks = {
    "claim predicate has no SymbolNew or general symbol-type check": (
        "Expr::SymbolFor(_)" in claim_region
        and "Expr::SymbolNew" not in claim_region
        and "Type::Symbol" not in claim_region
    ),
    "array branch special-cases only SymbolFor": (
        "if matches!(index.as_ref(), Expr::SymbolFor(_))" in claim_region
        and '"js_object_get_symbol_property"' in claim_region
    ),
    "other non-numeric claimed-array keys use array index-or-string helper": (
        "if !is_numeric_expr(ctx, index)" in claim_region
        and '"js_array_get_index_or_string"' in claim_region
    ),
    "SymbolNew is a symbol-valued HIR expression": (
        "Expr::SymbolNew(_) | Expr::SymbolFor(_) => Type::Symbol" in value_types
    ),
    "Symbol locals are not numeric or string locals": (
        "Some(HirType::Number) | Some(HirType::Int32)" in numeric
        and "Some(HirType::String | HirType::StringLiteral(_))" in strings
    ),
    "later fallback performs runtime symbol dispatch": (
        '"js_is_symbol"' in target
        and target.index('"js_object_get_symbol_property"') < target.rindex('"js_object_get_symbol_property"')
    ),
}

for name, result in checks.items():
    print(f"{'PASS' if result else 'FAIL'}: {name}")

if not all(checks.values()):
    raise SystemExit(1)

print("Conclusion: a SymbolNew or Symbol-typed LocalGet can satisfy claimed_array, skip the SymbolFor arm, and reach js_array_get_index_or_string.")
PY

Repository: PerryTS/perry

Length of output: 640


Exclude all symbol-valued keys from claimed_array.

index_is_static_string_or_symbol excludes only Expr::SymbolFor(_). Expr::SymbolNew(_) and a LocalGet with Type::Symbol can therefore reach js_array_get_index_or_string instead of js_object_get_symbol_property. Use a complete symbol predicate before declared_array_property_claim, or restrict the claim to proven numeric keys. Add a regression for const sym = Symbol(); arr[sym].

🤖 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/expr/index_get.rs` around lines 1136 - 1139, Update
the key classification around index_is_static_string_or_symbol so every
symbol-valued key, including Expr::SymbolNew(_) and Type::Symbol LocalGet
expressions, is excluded from claimed_array before
declared_array_property_claim. Ensure such keys use
js_object_get_symbol_property rather than js_array_get_index_or_string, and add
a regression covering const sym = Symbol(); arr[sym].

Source: Learnings

// #7854 recovered a receiver's declared array type for a LOCAL
// (`const names = e.names`), never for the read used directly as a
// receiver (`e.vals[i]`, `p.toks[p.pos]`) — the HIR types a
Expand All @@ -1141,20 +1152,22 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// fallback. A violated claim costs a branch, not an answer. (#6132
// records that the same guard is what makes a typed-array-valued
// member receiver safe on this path.)
let claimed_array =
recv_unknown && crate::type_analysis::declared_array_property_claim(ctx, object);
//
// Restricted to a NON-string, NON-symbol key, and that restriction is
// load-bearing rather than tidy. The string-key arm of the array
// branch is `js_array_get_index_or_string`, whose string half calls
// `js_object_get_field_by_name` on the receiver — and that answers
// `undefined` for `s["0"]` on a heap STRING receiver, where JS
// answers the character. That is a pre-existing wrong answer on
// `main` — reachable today through a plain non-union declared
// receiver, filed as #7891 with a minimal repro — and a claim must
// not widen the set of shapes that reach it. With
// the restriction, a string or symbol key takes exactly the generic
// path it takes today; only the numeric read moves.
let claimed_array = recv_unknown
&& !index_is_static_string_or_symbol
&& crate::type_analysis::declared_array_property_claim(ctx, object);
let recv_unknown = recv_unknown && !claimed_array;
// #5525: route every non-static-string/symbol read on an unknown
// receiver through `js_dyn_index_get` (numeric, runtime-string, and
// runtime-symbol are all triaged in the runtime). The earlier
// `is_numeric_expr(index)` gate missed `lr[off]`/`lr[off + 1]`
// (bcryptjs `_encipher`'s `off` is an `any` param, so `off + 1` is
// not provably numeric); statically-known string-literal / symbol
// keys keep their dedicated interned-handle / symbol routes below.
let index_is_static_string_or_symbol = matches!(
index.as_ref(),
Expr::String(_) | Expr::WtfString(_) | Expr::SymbolFor(_)
) || is_string_expr(ctx, index);
if recv_unknown && !index_is_static_string_or_symbol {
// #7640 section B: receiver live across an unconstrained index.
return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| {
Expand Down
12 changes: 12 additions & 0 deletions test-files/test_gap_7890_declared_array_receiver_element_read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,15 @@ function scan(b: Bag, needle: string): string {
console.log(scan(mkAlias(["a", "b"]), "a"));
console.log(scan(mkAlias([1, 2, 3] as any), "a"));
console.log(scan(mkAlias({ length: 3 }), "a"));

// A STRING-literal key on the same receiver shape is deliberately NOT admitted
// to the array arm by #7890 — see `index_get.rs`. It stays on the generic path,
// which is what these rows pin.
function stringKey(b: Bag): string {
return (
"" + b.items["length"] + "/" + b.items["nope"] + "/" + typeof b.items["constructor"]
);
}
console.log(stringKey(mkAlias(["k"])));
console.log(stringKey(mkAlias({ length: 2, 0: "obj0", nope: "here" })));
console.log(stringKey(mkAlias(9)));
Loading