← board

The shape

compiler/ir.inc:9507 says it plainly:

{ Pascal spells `shr` as an IDENTIFIER — there is no tkShr token for it }
...
if item = Ord(tkIdent) then item := Ord(tkShr);     { :9518 at HEAD }

That substitution is correct and local. The problem is that it is local: the token that reaches IR_BINOP's operator field is still Ord(tkIdent), so the normalisation is a property of one reader rather than of the IR.

Why it is worth a ticket rather than a shrug

devdocs/dev/normalise-dont-special-case.md: when a construct is reachable through two shapes, normalise rather than growing a second path, because the second path is the one that stays broken. There is now a second path. The wasm32 backend (branch wasm, compiler/ir_codegen_wasm32.inc) repeats the substitution with a comment pointing here.

The failure mode for a consumer that does not know is worse than a missing feature: tkIdent is not an unlikely value that would obviously fall through to an "unsupported operator" arm. It is token 1. Any consumer that dispatches on a small operator ordinal, or that treats an unrecognised operator as a default, can quietly do the wrong thing for every shr in the program.

Fix

Substitute at the point the IR_BINOP node is appended, so the IR carries Ord(tkShr) and no consumer has to know the lexer's accident. Then delete the substitution at ir.inc:8878 and the one in ir_codegen_wasm32.inc, and grep for others — per the doc's own rule, fixing one arm of a double case means checking the sibling before closing.

Genuinely low prio: nothing is wrong today, both current consumers handle it. The value is that the next one cannot get it wrong, and the fix deletes code rather than adding it.

Found

By the wasm32 backend, 2026-08-28: shr showed up in the coverage report as binary operator 1, which is how the lexer accident became visible at all.


2026-08-31 — corrected, and repriced 20 → 60 (owner)

The premise sentence was half wrong, in the direction that understates it. This ticket said "there is no tkShr token for it". tkShr exists, and compiler/clexer.inc:871 produces it correctly:

begin Inc(SrcPos); CurTok.Kind := tkShrEq; end
else CurTok.Kind := tkShr;

So this is not "a token we never minted". It is one IR operator field meaning two different things depending on which frontend built the nodeOrd(tkShr) from C, Ord(tkIdent) from Pascal — read by 25 tkShr sites across the compiler. A consumer that handles Ord(tkShr) is correct for C code and silently wrong for Pascal code, in the same binary, at the same site. That is a strictly stronger statement of the bug than "each consumer has to know separately", and it is the reason the fix is normalisation rather than tidying.

Line numbers refreshed: the substitution is at ir.inc:9518 (was cited as :8878) and the comment at :9507 (was :8867). Still live at HEAD; the wasm32 second arm is still at ir_codegen_wasm32.inc:1276.

Why the reprice, and it is a ranking finding, not a judgement about this bug. shr has produced ten tickets. Eight are closed, individually, between 2026-06-26 and 2026-08-25, priced 30-60:

bug-cardinal-expr-promotion-shr-orphan            bug-a-strict-fpc-shr-by-zero-drops-the-sign
bug-const-expr-shl-shr-not-folded                 bug-a-unary-minus-binds-looser-than-and-shr
bug-shr-signed-integer-width                      bug-a-shr-on-a-32-bit-operand-does-not-promote-like-fpc
bug-a-promoint-shr-yields-nothing-...             bug-a-variant-shr-is-arithmetic-where-static-shr-is-logical

This ticket — upstream of that family — sat at 20, the lowest prio of all ten. That is not a mistake anyone made. Prio propagates only down declared blocked-by: edges, and seven of the eight symptoms were filed BEFORE this ticket existed, so they could never have declared one. The ranker reads one ticket at a time and has no way to see the shape of a pile. Tooling follow-up: feature-t-detect-ticket-clusters-that-share-a-construct.


2026-08-31 — the prescribed fix is a MISCOMPILE, and the first live symptom is fixed (frankC)

Do not perform the # Fix section as written. Substituting Ord(tkShr) for Ord(tkIdent) at IRAppend sends Pascal's shr to the arm that emits sar. Measured at HEAD: the backends distinguish logical from arithmetic right-shift by exactly these two ordinals, and the C frontend already relies on it —

{ cparser.inc, CMakeBinop }
if TypeSigned(IntToTypeKind(ASTTk[l])) then ivalOp := Ord(tkShr)   { arithmetic }
else                                       ivalOp := Ord(tkIdent); { logical    }
{ ir_codegen386.inc:1212 and its four siblings }
else if (op = Ord(tkShr)) and signedOp then ... sar

So i shr 1 for i: Integer = -8 would answer -4 instead of the logical value, on x86-64, i386, arm32, aarch64 and riscv32 at once. That is bug-c-signed-arith-shift-right run backwards — the same collision, from the other side.

The premise, corrected once more — and this direction OVERSTATES it

The 2026-08-31 note says the field "means two different things depending on which frontend built the node". It does not. It means one thing, and both frontends already agree on it:

ordinal means written by
Ord(tkIdent) = 1 logical shr Pascal shr; C >> on an unsigned operand
Ord(tkShr) = 119 arithmetic shr C >> on a signed operand only

C normalises at CMakeBinop, at the frontend boundary, which is the right place. The IR is already normalised. What is wrong is only the spelling: the logical-shr opcode is spelled with a name that says "identifier".

That distinction is the whole ticket. "Two meanings, one field" invites a merge, and the merge is the miscompile above. "One meaning, a lying name" invites a rename, which is safe and is what this needs.

The count that says it is a design flaw, not a wart

root-cause-over-microfix.md: two mechanisms for one concept is a smell, three is a design flaw. There are three, and the third is not in this ticket:

ordinal static backends variant runtime (VarBitwiseInt, builtinheap.pas:4702)
Ord(tkIdent) = 1 logical
Ord(tkShr) = 119 arithmetic arithmetic (NilPy >>)
1119 logical (Pascal, via PXXVarBinOpPas)

ir.inc:9518's if item = Ord(tkIdent) then item := Ord(tkShr) maps logical onto the ordinal that means arithmetic in the static vocabulary. It is correct only because PXXVarBinOpPas rewrites 119 back to 1119 for Pascal — two rewrites that cancel, in different files, one of them in the runtime, which builtinheap.pas:4679 notes "is not a token: it is the out-of-band opcode". A reader who finds either rewrite alone will draw the wrong conclusion, which is how the # Fix above got written.

The live symptom — this ticket is no longer "nothing is wrong today"

Measured 2026-08-31, and fixed in 5b12e6a5e:

writeln(IntToHex(-(256 shr 4), 8));   { pxx FFFFFFFFFFFFFFF0, fpc FFFFFFF0 }
writeln(IntToHex(-(256 shl 1), 8));   { FFFFFE00 — correct, both }

ASTConstIntValue (pasparser_stmt.inc) had arms for tkShl and tkShr and none for tkIdent, so it fell to else Result := False for every Pascal shr and the enclosing constant did not fold. Its two callers are the ones that type a folded constant the way FPC does — smallest signed type that holds it — so the unfolded operand was typed Int64 and bound the Int64 IntToHex overload.

That is a third consumer, and it was neither of the two this ticket says "handle it". It failed in precisely the way the ticket predicts — dispatching on a small ordinal and defaulting — which is the argument for the rename, made by the code rather than by the doc. test/test_shr_const_fold_typing.pas guards it, with the shl and plain-constant controls that made it legible.

What is actually left, and it is a rename

Add a distinct ordinal for logical shr — appended at the END of TTokenKind, so no existing ordinal moves and no emitted byte changes — and replace the ~25 Ord(tkIdent) shift sites with it. Reference list, from HEAD:

ir_codegen.inc:3079,6940,7176   ir_codegen386.inc:1171,1184,2745
ir_codegen_aarch64.inc:1649,2743  ir_codegen_arm32.inc:702,713,2198
ir_codegen_riscv32.inc:845,851,2395  ir_codegen_xtensa.inc:969,2370
ir_codegen_wasm32.inc:1289      ir.inc:1421,4854,9518   cir.inc:86
cparser.inc:959   pasparser_expr.inc:9115   pasparser_call.inc:134
pasparser_stmt.inc (the arm added by 5b12e6a5e)   pyparser.inc:43532

The substitution point is IRAppend, conditioned on kind = IR_BINOP — the c field is an operator only there, and a blanket rewrite would corrupt every other node whose c happens to be 1.

Acceptance is byte-identity, not a green suite. A pure rename must leave every emitted binary unchanged on every target; anything else means an ordinal moved. That is a positive control the change carries for free, and it is stronger than any test in the tree.

Not attempted in this session: the two live wrongs above were worth more than the rename, and a 25-site cross-backend rename deserves its own gate run rather than the tail of someone else's.

2026-08-31 — done as a RENAME, with byte-identity as the acceptance test (frankC)

tkShrLogical, appended at the tail of TTokenKind so no existing ordinal moves. ParseTerm renames on the way in — once, at the parser boundary, where the loop condition has already established that this tkIdent is shr and nothing else — and all 25 downstream sites read the new name. 314481dd7.

The merge was measured, not argued. Building op := tkShr instead and running test_shr_width.pas:

row 1  i shr 1     (Integer -8)   9223372036854775804  ->  -4
row 2  c shr 1     (Cardinal)              2147483644  ->  -4
row 3  q shr 1     (Int64 -8)     9223372036854775804  ->  -4

Row 2 is the one that settles it: an UNSIGNED operand, where no reading of any dialect makes -4 right. And the self-host fixedpoint converged cleanly in 2 rounds under that miscompile — compiler.pas shifts nothing negative — which is CLAUDE.md's "the fixedpoint cannot see a construct the compiler never writes" earning itself a fourth time.

Acceptance was byte-identity of emitted output, against a compiler built from the parent commit: shift, set, bitfield, C-shift and demo inputs, on x86-64 / i386 / arm32 / aarch64 / riscv32 / wasm32 / xtensa-bare, at -O0..-O4 and under --strict-fpc. The three renamed paths byte-identity cannot reach were checked by OUTPUT instead: operator shr overloading (the overload key and ASTIVal must move together, and test_operator_unary_and_keyword_forms.pas covers it), Variant shr, and NilPy >>.

Both predicted failures had already fired by the time the rename landed: ASTConstIntValue declined every shr for two months (5b12e6a5e), and IRValidate's binop range check admitted the operator only because Ord(tkIdent) is 1 — it now has to name tkShrLogical explicitly, which is that check doing its job on this operator for the first time.

What is deliberately NOT closed with it

The wasm32 fold survives, and it is no longer a second arm of anything: that backend has one right-shift instruction per width because it does not implement C's signed >> yet, so it folds tkShr onto shr_u by its own gap. The comment now says that, and names the line to delete (shr_s for tkShr) when the gap closes. That is Track A/wasm work, not this ticket.

The third vocabulary is untouched and stays that way: the variant runtime, where 119 is arithmetic and out-of-band 1119 is logical, reached through two cancelling rewrites in two files. Unifying it means changing an opcode the RUNTIME hardcodes because it cannot see defs.inc — a different job with a different blast radius, and no symptom asks for it today. ir.inc's rewrite now states the cancellation out loud so the next reader does not simplify one half.

Log