← board

The managed-string index COW rule is spelled seven times, one of them ungreppable

The measurement — and the denominator is the point

Seven backends exist, therefore there are seven sites. That is the count to check against, and it comes from ls compiler/ir_codegen*.inc, which is a directory listing — a source independent of any content search. Stated this way on purpose: the useful number is the denominator, not the list below it.

ir_codegen.inc:6053           (elemSize = 1)                baseAddr, not left
ir_codegen_aarch64.inc:3420   (Integer(IRIVal[node]) = 1)   the ungreppable one
ir_codegen_arm32.inc:3295     (elemSize = 1)
ir_codegen386.inc:3902        (elemSize = 1)
ir_codegen_riscv32.inc:1673   (elemSize = 1)
ir_codegen_wasm32.inc:1133    (elemSize = 1)
ir_codegen_xtensa.inc:1677    (elemSize = 1)

All seven answer the same question — is this base a managed string being indexed, so that an lvalue write must clone-if-shared? — with the same two conjuncts: IRTk[base] = Ord(tyAnsiString), plus a stride test separating indexing INTO a string from indexing an array whose ELEMENTS are strings (stride 8, where IRTk is tyAnsiString for the element's sake, not the base's).

grep 'elemSize = 1' compiler/ir_codegen*.inc returns SIX. aarch64 never hoists Integer(IRIVal[node]) into an elemSize local, so its otherwise identical line does not match. The census instrument shares the defect it is censusing: the rule is spelled inconsistently, and the inconsistency is what hides a copy of it.

Why it is worth consolidating

What to do

Hoist the predicate to one function beside the other shared backend helpers — something of the shape IRIndexBaseIsManagedStr(node, base): Boolean — and have all seven call it. Note it is NOT quite a pure textual duplicate:

So the consolidation must read the sites, not sed them. Budget accordingly, and per normalise-dont-special-case the win is measured in cases deleted.

Gate

make compiler/pascal26 + the copy-on-write corpus from step 4: aliasing (b := a; a[1] := 'J' must leave b intact) through a plain variable, a record field and an array element, plus the read path — output identical to pinned before and after.

Resolution — 2026-09-01, frankB (Track A)

One predicate, IRIndexNeedsStrCOW(base, elemSize), body in ir_codegen.inc, forward-declared in compiler.pas. All seven backends now ask it. That follows the pattern this file already documents five times over (IRNodeOwnsManagedStr, IRNodeOwnsManagedObj, IRTopLevelStmt, CmpFusible, IRIsSelfStrAppend): the cross backends are included BEFORE ir_codegen.inc, so the body goes there and the forward goes in compiler.pas. Sixth of the same shape, not a new one.

The copies had drifted on SUBSTANCE, not just spelling

The ticket had the spelling half: six spell the stride test (elemSize = 1), aarch64 spells it (Integer(IRIVal[node]) = 1), so a grep for the common spelling returns six files and silently omits the seventh. Confirmed exactly — git grep -l "elemSize = 1" returns those six and no aarch64.

The half that was not in the ticket: only the x86-64 copy guarded symIdx >= 0 before reading Syms[symIdx]. The other six index Syms[IRA[left]] unguarded. The unified predicate keeps the guard, so this can only ever remove a read of Syms[-1] and never add one — which is what makes merging the seven a safe operation rather than a vote between them.

Two independent drifts in one rule, and neither is visible to a grep aimed at the other.

Verified on all seven backends, with the control run rather than assumed

test/test_string_index_cow.pas (new) exercises the three base arms — LEA (a plain local), FIELD (a string in a record), INDEX (a string that is an array element) — plus the read path and the deliberately-excluded stride-8 array of AnsiString. Every line writes through one alias and reads back the OTHER, so a predicate that stops firing yields a wrong string, not a crash.

x86-64 i386 arm32 aarch64 riscv32 xtensa   MATCH   (qemu / native vs x86-64 oracle)
wasm32                                     MATCH   (wasmtime)

Positive control, because six identical MATCHes prove nothing if the probe cannot fail: with the predicate forced False, x86-64 output becomes garbage and arm32/riscv32 print lea s= t=hello against the correct lea s=Hello t=hello. The poisoned compiler could not even self-host — it died on the OOM message added that same morning in 4419e1aa7.

Wired into all five per-arch make targets (test-i386#153, test-aarch64#144, test-arm32#147, test-riscv32#128, test-xtensa#125), each run individually and passing. Note for whoever adds jobs next: repeated --job flags do NOT accumulate in testmgr — the last one wins, and the run reports a perfectly honest jobs=1 ... 1/1 pass GREEN that reads exactly like five passing. Found here by checking the jobs= count against what was asked for.

This closes the "guarded nowhere" complaint for THIS predicate only. The sibling [[refactor-a-the-owned-string-release-predicate-is-hand-copied-across-five-backends]] (~25 hand-written IRNodeOwnsManagedStr call sites) and [[bug-a-managed-string-arg-temp-predicate-is-duplicated-seven-times-and-guarded-nowhere]] are the same family and are untouched.

Log