Skip to content

fix(codegen,runtime): a declared string is not a proof, so it may not pick the + operator - #7842

Merged
proggeramlug merged 1 commit into
mainfrom
fix/7837-declared-string-add-operator
Aug 11, 2026
Merged

fix(codegen,runtime): a declared string is not a proof, so it may not pick the + operator#7842
proggeramlug merged 1 commit into
mainfrom
fix/7837-declared-string-add-operator

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Closes #7837.

The bug

is_definitely_string_expr answered true on the strength of an erased TypeScript annotation, and + chose string concatenation from it. Perry does not enforce declared types at runtime — CLAUDE.md says so under Known Limitations — so const s: string = (42 as any) really does put a number in the slot.

I enumerated the arms of that predicate rather than probing spellings, and found 13 shapes that were silently wrong on 82f0e9681, exit 0, no diagnostic. #7835 has since landed and fixes 4 of them. Splitting the credit honestly, measured on purpose-built compilers for each of the three commits:

shape Node 82f0e9681 main ab1bd464b (with #7835) this PR
s + 7 49 427 427 49
7 + s 49 742 742 49
s + true 43 42true 42true 43
a + b + "x" (N-way fold) 141x 4299x 4299x 141x
a + b + a 183 429942 429942 183
const u = s; u + 7 49 427 427 49
(c ? s : "q") + 7 49 427 427 49
arr.slice(0) + 7 1,27 `` (empty) `` (empty) 1,27
f(a: string, b: number) via a function value 49 427 427 49
t + "x" 99x x — operand vanished 99x 99x
"x" + t x99 x x99 x99
a + b (both declared) 141 `` (empty) 141 141
pf(a: string) returning a + "x" 99x x 99x 99x

The last four rows are #7835's — they route through js_string_concat_box, which it made total. The nine above them are #7837 defect 1 and are still wrong on main today: the wrong OPERATOR, chosen from an annotation.

Two of the nine deserve a second look, because they show the premise is wider than "an annotation".

The .toString() / .slice() / .replace() … arm matches on the METHOD NAME, with no look at the receiver. Array.prototype.slice returns an array — so arr.slice(0) + 7 claimed a string and the array was decoded as the empty string. A name is a guess about the receiver's type, which is evidence of exactly the same quality as an annotation.

A string PARAMETER is live too. #7837 records a (string, number) parameter pair as already-correct, and it is — when called directly, because the inliner substitutes the argument and the annotation evaporates. Reached through a function value it is not, and that is what the row above measures. Three negative probes did not establish absence; nor did four.

The policy

The same one #7831 is applying on the numeric side: a static type may select a lowering, never an answer. It is applied in the place each site can afford it, which is different for each.

Helpers that receive both operands NaN-boxed can be made total, and #7835 did thatjs_string_concat_box no longer decodes a non-string operand as the empty string. That hunk was in this branch before the rebase and has been dropped: main has it, and it is the right fix for those four rows. Nothing to merge there.

The one-sided l ^ r arm cannot be repaired that way. Codegen unboxes the string operand to a StringHeader* before the call, so by the time js_string_concat_value runs there is no tag left to test. When the operand's string-ness is declared-only it is now passed NaN-boxed to js_string_add_value / js_value_add_string, which test the tag and then either run the identical fused single-allocation concat or fall through to the spec's +.

The N-way chain fold formats every part as a string, so it reproduces the source tree only when the FIRST node really concatenates — once the accumulator is a string every later + concatenates whatever its part holds. The fold now requires a proven string in the head pair; a chain that fails that falls through to the pairwise lowering, which resolves each node from the runtime tags.

A new predicate, string_value_is_runtime_guaranteed, separates the two kinds of evidence is_definitely_string_expr had been mixing. A literal, String(x), JSON.stringify, path.join, os.arch() construct a string; a LocalGet and a receiver-blind method name only claim one. Its whitelist is deliberately closed — an arm nobody has classified answers "claim" and gets guarded, because that costs one predictable compare while the other default costs a wrong answer.

Cost: none measurable, and it is proved rather than sampled

I did not have to trust a stopwatch for this. Compiling all 19 corpus programs with the base compiler and the fixed compiler against the same runtime archives isolates the codegen delta exactly, and the answer is available statically.

All 19 linked executables are byte-identical. cmp on the binaries, base compiler vs fixed compiler, same runtime archives, same output basename: 19 identical, 0 differing. Identity is the conclusive direction — the Mach-O UUID is content-derived, so nondeterminism could manufacture a spurious difference but never a spurious identity. Identical bytes mean identical machine code, which means this change cannot have cost those programs a nanosecond.

One trap worth recording, because it cost me a wrong conclusion first time round. Perry's output is byte-deterministic, but the ad-hoc code signature embeds the output basename, and the UUID is derived over content that includes it. So building the two arms to b_churn and f_churn — the obvious thing for an A/B script to do — makes every program differ by exactly 49 bytes (16 UUID + 33 signature) on pure naming, and I briefly concluded from that that Perry's executables were not comparable at all. They are; build both arms to the same basename in different directories. churn.ts and fib40.ts both rebuild identically under that discipline.

The IR comparison (--trace llvm) agrees and says a little more about why:

All 19 programs' IR differs by exactly two lines — the declares for the two new helpers, which emit no code:

+declare double @js_string_add_value(double, double)
+declare double @js_value_add_string(double, double)

Zero call sites moved, zero blocks changed, zero folds lost — the two declares emit no code, which is why the linked binaries come out identical. 15 of the 19 emit no string-concat call at all; the other four (interp, iso_miss, pipeline, shapes) still call exactly the same helpers they did before, because their concats are led by string literals and a literal is a proof. cat_local additionally fails to link against the base runtime — undefined js_string_add_value — which is independent proof that the corpus never references the new symbols.

There is no codegen diamond anywhere in this change, which is the deliberate difference from #7831's shape. On the numeric side the fast arm is a single fadd, so a guard is expensive beside it and its phi blocks LLVM from proving the result is a canonical double — that is where #7831's +8.6% / +34.2% came from. Here the fast arm is a heap-allocating runtime call in every case, so the guard rides inside a call that was already happening and there is no phi to lose an optimisation to.

Timing on the quiet M1 mini (load 1.3–1.9, bench lock held under a unique token and released with the guarded check, best-of-5, exit code recorded per cell, arms alternated within each rep; both arms built from the same worktree with the identical -p perry -p perry-runtime-static -p perry-stdlib-static package set, each linking its own runtime archives, so this measures the whole change and not just codegen). Base is ab1bd464b — the branch has since been rebased twice more (onto 0321c6554, which now carries #7831, #7832 and #7839); the A/B is a controlled comparison in which base and fix were built from the same commit, so later main-side speedups do not affect the delta. Every ceiling in the acceptance list is met, most of them by a wide margin now that #7833/#7834 have landed:

bench base fix delta ceiling
churn 0.2890 0.2888 −0.1% 0.44
churn_alloc 0.2408 0.2412 +0.2% 0.39
churn_read 0.0228 0.0224 −1.8% 0.026
push_num 0.1430 0.1434 +0.3% 0.155
push_cls 0.2366 0.2369 +0.1% 0.38
cycles 0.1114 0.1115 +0.1% 0.20
deeplist 0.1229 0.1222 −0.6% 0.13
tree 1.1619 1.1628 +0.1% 1.68
tree_wide 1.6486 1.6486 +0.0% 2.15
retain 0.3501 0.3498 −0.1% 0.37
retain1 0.1360 0.1361 +0.1% 0.155
retain_wide 0.4590 0.4590 +0.0% 0.48
retain_wide1 0.1591 0.1590 −0.1% 0.17
fib40 0.3937 0.3937 +0.0% 0.41
asyncpipe 0.1333 0.1331 −0.2% 0.14
shapes 0.1839 0.1840 +0.1% 0.20
interp 1.2359 1.2365 +0.0% 1.53
iso_miss 1.6708 1.6712 +0.0% 1.96
pipeline 0.4844 0.4848 +0.1% 0.54

Every cell exited 0 on both arms. Nothing is outside run-to-run noise, which is what the IR comparison predicted. (The same table measured before the rebase, against 82f0e9681, agreed to within 0.1 points on every row.)

The cost, on a probe of the affected shape

Three probes in gc-handoff/m0810/p7837/, each verified to exercise its subject by inspecting the emitted IR rather than assuming. Best-of-21, same session, lock held:

probe base emits fix emits base fix delta per concat
cat_localconst s: string = parts[0]; s + i ×2M strrecv tag diamond + js_string_concat_value js_string_add_value 0.06794 0.07003 +3.08% +1.05 ns
cat_lit"id-" + i ×2M js_string_concat_value identical IR 0.06652 0.06654 +0.02%
cat_builds = s + "ab" ×2M js_string_append identical IR 0.03082 0.03089 +0.22%

+1.05 ns per guarded concatenation is the honest price, and it is paid only where the compiler could prove nothing. s is read from an array element specifically so constant propagation cannot fold it back to a literal and route the site around the guard; cat_local's loop does nothing but concatenate, so the guard's share of the work is as large as it can be — and it is still ~3%. Worth noting the fix actually removes IR here: the strrecv.heap/strrecv.cold unbox diamond disappears, replaced by one call.

For contrast with #7831, which is the same policy on the numeric side: its guard costs +8.6% on this.v + 1 and +34.2% on an escaped s += p.x + p.y. The asymmetry is structural, not a disagreement — see the note above about fadd versus an allocating call.

Validation

  • test-files/test_gap_declared_string_local_holds_number_7837.ts — 41 assertions, byte-identical to Node 26.5.1. 13 of the rows are wrong on 82f0e9681 and right here. The other 28 are the reason the fix cannot just route everything to the dynamic helper: honest string concatenation, honest arithmetic, typeof on both, the three shapes A lying string-declared local silently drops an operand: t + "x" returns "x", and s + 7 concatenates instead of adding #7837 lists as already-correct (declared field, object property, parameter pair), class fields, array elements, and six proven-string producers that must keep their fused concat.
  • gc-handoff/m0810/lyinglocal.ts prints 49 then 99x, byte-identical to node --experimental-strip-types, exit 0.
  • gc-handoff/apps/iso_miss.ts prints checksum 437840 misses 0.
  • All 19 corpus programs byte-identical to their expected output, exit 0.
  • 10 new cargo-test-visible codegen tests (crates/perry-codegen/src/codegen/declared_string_add_tests.rs) asserting on which helper is emitted — each paired with the neighbouring proven shape that must NOT be guarded.
  • 2 new runtime unit tests covering both helpers and js_string_concat_box's totality.
  • cargo test --release -p perry-codegen --no-fail-fast: 1301 passed, 1 failed — large_local_array_push_inbounds_store_emits_precise_slot_barrier, which is pre-existing (asserts on js_write_barrier_slot placement inside apush.inbounds. for a 2050-element array push; fix(codegen): a declared numeric type is not a proof that the value is a number #7831 verified BASE_EXIT=101 on a clean-main build).
  • cargo test --release -p perry-runtime --lib (RUST_TEST_THREADS=1): 2115 passed, 0 failed.
  • cargo fmt --all -- --check and scripts/check_file_size.sh clean.

What I could not run: the full gap suite. The dev host was at load 55–120 from other sessions and 20 GB free; a single gap test's compile was taking 3.5 minutes, which puts the 536-test suite at several hours, and the bench mini has 15 GB free — below the floor a parity run wants. I stopped the run at test 31/536 (2 failures, both already listed in test-parity/gap_snapshot.json: test_gap_2159_defineproperty_class_prototype, test_gap_2514_settracesigint). Saying so plainly rather than implying coverage I do not have.

Left for a follow-up, deliberately

s += x on a declared string local has the same defect from the same premise — let c: string = (42 as any); c += 1 gives "421", not 43 — and it survives this PR. It is a different lowering (lower_string_self_append, reached from LocalSet, not from binary::lower), it sits on the load-bearing O(n) string-builder path, and fixing it correctly means hoisting a tag test above a ToString that has observable side effects. That wants its own change and its own measurement rather than a rider on one that currently measures at zero. Filed as #7841, with the repro, the reason the fix is not a one-liner, and a suggested shape.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Declared string types no longer prove runtime string values during + lowering. The compiler now uses runtime-proof analysis, NaN-boxed dispatch helpers, guarded chain folding, and regression coverage for numeric and string runtime values.

Changes

Declared string addition

Layer / File(s) Summary
Runtime proof and chain safety
crates/perry-codegen/src/type_analysis/..., crates/perry-codegen/src/expr/binary.rs
String analysis distinguishes runtime-guaranteed values from declaration-only classifications. Concatenation-chain folding requires runtime-safe operands.
Dynamic addition lowering
crates/perry-codegen/src/lower_string_concat.rs, crates/perry-codegen/src/runtime_decls/strings.rs, crates/perry-runtime/src/string/...
Declared-only operands use NaN-boxed runtime helpers. The helpers select numeric addition or string concatenation from runtime tags.
Regression validation and changelog
crates/perry-codegen/src/codegen/..., test-files/test_gap_declared_string_local_holds_number_7837.ts, changelog.d/7842-declared-string-is-not-a-runtime-proof.md
Tests cover declared operands, proven strings, method calls, chains, numeric addition, heap strings, SSO strings, and mixed values. The changelog documents the fix and remaining self-append issue.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BinaryLowering
  participant StringAnalysis
  participant RuntimeAddHelpers
  BinaryLowering->>StringAnalysis: check runtime string proof
  BinaryLowering->>RuntimeAddHelpers: emit boxed addition helper call
  RuntimeAddHelpers->>RuntimeAddHelpers: inspect operand runtime tags
  RuntimeAddHelpers-->>BinaryLowering: return boxed string or numeric result
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7831 — Both PRs prevent erased declarations from selecting unsafe + lowering.
  • PerryTS/perry#7835 — Both modify declared-string detection and string-concatenation lowering.
  • PerryTS/perry#6888 — Both modify NaN-boxed and SSO string handling in runtime concatenation.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address both #7837 defects by using runtime-tag-aware addition and preserving correct string concatenation paths.
Out of Scope Changes check ✅ Passed The code, tests, runtime helpers, and changelog entry directly support the linked issue and stated fix.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly identifies the codegen and runtime fix for incorrect operator selection from declared string types.
Description check ✅ Passed The description thoroughly explains the bug, implementation, tests, performance results, limitations, and related issue.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7837-declared-string-add-operator

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ot pick the `+` operator

`is_definitely_string_expr` answered `true` on the strength of an erased
TypeScript annotation, and `+` chose string concatenation from it. Perry does
not enforce declared types at runtime, so `const s: string = (42 as any)` puts
a number in the slot and thirteen shapes came out silently wrong, exit 0:
`s + 7` printed "427" instead of 49, and `t + "x"` printed "x" — the operand
was decoded as the empty string and vanished.

The policy, matching #7831 on the numeric side: a static type may select a
lowering, never an answer. Applied where each site can afford it.

* `js_string_concat_box` becomes total: a non-string operand is delegated to
  `js_dynamic_string_or_number_add` instead of `unwrap_or((null, 0))`-ing to
  the empty string. (Same hunk as #7835; whichever lands second drops its copy.)
* The one-sided `l ^ r` arm cannot be repaired that way — codegen unboxes to a
  `StringHeader*` before the call, so the tag is gone. A declared-only operand
  is now passed NaN-boxed to `js_string_add_value` / `js_value_add_string`,
  which test the tag and then run either the identical fused concat or the
  spec's `+`.
* The N-way chain fold requires a proven string in its head pair, since it
  formats every part as a string and only reproduces the source tree when the
  first node really concatenates.

`string_value_is_runtime_guaranteed` separates the two kinds of evidence the
predicate had been mixing. Its whitelist is closed: an unclassified arm answers
"claim" and gets guarded, which costs a compare rather than an answer.

Compiling all 19 corpus programs with the base and fixed compilers against the
same runtime archives yields LLVM IR differing by exactly two lines — the
`declare`s for the new helpers. No call site moved.

Refs #7837.

Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
@proggeramlug
proggeramlug force-pushed the fix/7837-declared-string-add-operator branch from 24eb1e1 to 0b30df4 Compare August 11, 2026 11:51
@proggeramlug
proggeramlug marked this pull request as ready for review August 11, 2026 11:52

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
crates/perry-runtime/src/string/tests.rs (1)

590-628: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for a non-number, non-string other operand.

The matrix covers heap strings, SSO strings, and numbers. It does not cover a real string on the declared side with null, undefined, or an object on the other side. That input takes js_string_concat_value's slow path through js_jsvalue_to_string, which can run user toString and collect. That path is the highest GC risk in the new helpers and it is currently unexercised through these entry points.

Run this suite with RUST_TEST_THREADS=1, as required for perry-runtime tests that share process-global runtime state. As per coding guidelines: "Run perry-runtime tests single-threaded with RUST_TEST_THREADS=1 because they share process-global runtime state."

🧪 Proposed additional assertions
         assert_eq!(
             boxed_text(js_value_add_string(boxed_heap("x"), 42.0)),
             "x42"
         );
+        // The other operand is neither a number nor a string, so the fused
+        // helper takes its `js_jsvalue_to_string` slow path.
+        assert_eq!(
+            boxed_text(js_string_add_value(
+                boxed_heap("ab"),
+                crate::value::JSValue::undefined().to_f64()
+            )),
+            "abundefined"
+        );
🤖 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-runtime/src/string/tests.rs` around lines 590 - 628, Add
coverage to string_add_value_picks_the_operator_from_the_bits for a real
declared-side string combined with a non-number, non-string value such as null,
undefined, or an object, exercising the js_string_concat_value slow path through
js_jsvalue_to_string and validating the resulting string. Run the perry-runtime
test suite with RUST_TEST_THREADS=1.

Source: Coding guidelines

crates/perry-codegen/src/codegen/declared_string_add_tests.rs (1)

206-209: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the positive assertion to pin the intended lowering.

This test asserts only that js_string_add_value is absent. That assertion also passes if the expression stops using the fused concat path entirely, for example if it routes to js_dynamic_string_or_number_add. The sibling tests at lines 157-165 and 179-183 assert a positive and a negative together. Match that pattern so a regression that loses the fused concat fails here.

♻️ Proposed additional assertion
     assert!(
+        ir.contains("call i64 `@js_string_concat_value`("),
+        "a string method on a proven receiver must keep the fused \
+         single-allocation concat:\n{ir}"
+    );
+    assert!(
         !ir.contains("call double `@js_string_add_value`("),
         "a string method on a string literal returns a string:\n{ir}"
     );
🤖 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/codegen/declared_string_add_tests.rs` around lines
206 - 209, Update the test around the existing js_string_add_value negative
assertion to also positively assert the intended fused string-concatenation
lowering, matching the sibling tests’ positive-and-negative pattern. Use the
expected concat IR symbol so the test fails if lowering falls back to
js_dynamic_string_or_number_add or another path.
🤖 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.

Inline comments:
In `@changelog.d/7842-declared-string-is-not-a-runtime-proof.md`:
- Line 22: Correct the release-note text in the one-sided operator reference by
replacing the bitwise XOR symbol with the addition symbol; leave the surrounding
explanation unchanged.

---

Nitpick comments:
In `@crates/perry-codegen/src/codegen/declared_string_add_tests.rs`:
- Around line 206-209: Update the test around the existing js_string_add_value
negative assertion to also positively assert the intended fused
string-concatenation lowering, matching the sibling tests’ positive-and-negative
pattern. Use the expected concat IR symbol so the test fails if lowering falls
back to js_dynamic_string_or_number_add or another path.

In `@crates/perry-runtime/src/string/tests.rs`:
- Around line 590-628: Add coverage to
string_add_value_picks_the_operator_from_the_bits for a real declared-side
string combined with a non-number, non-string value such as null, undefined, or
an object, exercising the js_string_concat_value slow path through
js_jsvalue_to_string and validating the resulting string. Run the perry-runtime
test suite with RUST_TEST_THREADS=1.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5bfe58bd-6d7b-46af-9182-6f1f157df5d5

📥 Commits

Reviewing files that changed from the base of the PR and between 0321c65 and 0b30df4.

📒 Files selected for processing (12)
  • changelog.d/7842-declared-string-is-not-a-runtime-proof.md
  • crates/perry-codegen/src/codegen/declared_string_add_tests.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/expr/binary.rs
  • crates/perry-codegen/src/lower_string_concat.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-codegen/src/type_analysis.rs
  • crates/perry-codegen/src/type_analysis/strings.rs
  • crates/perry-runtime/src/string/concat.rs
  • crates/perry-runtime/src/string/mod.rs
  • crates/perry-runtime/src/string/tests.rs
  • test-files/test_gap_declared_string_local_holds_number_7837.ts

The policy, matching #7831 on the numeric side: **a static type may select a lowering, never an answer.** It is applied in the one place each site can afford it.

- **Helpers that receive both operands NaN-boxed can be made total, and #7835 did that**: `js_string_concat_box` forwards a non-string pair to `js_dynamic_string_or_number_add` rather than decoding it as the empty string.
- **The one-sided `l ^ r` arm could not be fixed that way**, because codegen unboxes the string operand to a `StringHeader*` before the call and the tag is gone by the time `js_string_concat_value` sees it. When the operand's string-ness is declared-only it is now passed NaN-boxed to `js_string_add_value` / `js_value_add_string`, which test the tag and then either run the identical fused single-allocation concat or fall through to the spec's `+`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the operator in the release-note text.

The text reads "the one-sided l ^ r arm". ^ is the bitwise XOR operator. This entry describes the + arm. The fragment is assembled verbatim into the release notes, so correct it here.

✏️ Proposed fix
-- **The one-sided `l ^ r` arm could not be fixed that way**, because codegen unboxes the string operand to a `StringHeader*` before the call and the tag is gone by the time `js_string_concat_value` sees it.
+- **The one-sided `l + r` arm could not be fixed that way**, because codegen unboxes the string operand to a `StringHeader*` before the call and the tag is gone by the time `js_string_concat_value` sees it.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- **The one-sided `l ^ r` arm could not be fixed that way**, because codegen unboxes the string operand to a `StringHeader*` before the call and the tag is gone by the time `js_string_concat_value` sees it. When the operand's string-ness is declared-only it is now passed NaN-boxed to `js_string_add_value` / `js_value_add_string`, which test the tag and then either run the identical fused single-allocation concat or fall through to the spec's `+`.
- **The one-sided `l + r` arm could not be fixed that way**, because codegen unboxes the string operand to a `StringHeader*` before the call and the tag is gone by the time `js_string_concat_value` sees it. When the operand's string-ness is declared-only it is now passed NaN-boxed to `js_string_add_value` / `js_value_add_string`, which test the tag and then either run the identical fused single-allocation concat or fall through to the spec's `+`.
🤖 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 `@changelog.d/7842-declared-string-is-not-a-runtime-proof.md` at line 22,
Correct the release-note text in the one-sided operator reference by replacing
the bitwise XOR symbol with the addition symbol; leave the surrounding
explanation unchanged.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Correction to my own cmp claim, and a trap worth recording in the measurement protocol.

I wrote earlier that Perry's executables are not byte-deterministic. That was wrong, and the way it was wrong is a trap any A/B script walks straight into.

Perry's output is byte-deterministic. But the ad-hoc code signature embeds the output basename, and the Mach-O UUID is content-derived over content that includes it. So:

build result
same compiler, -o d1/p_fib40 vs -o d2/p_fib40 IDENTICAL
same compiler, -o d1/b_fib40 vs -o d1/f_fib40 DIFFERS, 49 bytes

Those 49 bytes are 16 (LC_UUID) + 33 (signature blob). Nothing else in the binary moves. churn.ts behaves the same way. My first sweep named the arms b_<name> / f_<name> — the obvious thing to do — and got DIFFERS on all 19 programs from naming alone, which is what sent me to --trace llvm.

Re-run with both arms written to the same basename in different directories, base compiler vs fixed compiler, same runtime archives:

identical=19 differs=0

All 19 corpus executables are byte-identical. That is the conclusive direction — a content-derived UUID can manufacture a spurious difference but never a spurious identity — and it is a stronger statement than the IR argument, because it covers the linked machine code and not just the emitted IR. The IR comparison still stands and still explains why: the only delta is two declare lines, which emit no code.

Practical rule: hold the output basename constant across A/B arms. A differs result from a script that names its arms differently is telling you about the filename, not the code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A lying string-declared local silently drops an operand: t + "x" returns "x", and s + 7 concatenates instead of adding

1 participant