← board

A parameter and a local that differ only in case are two symbols

Measured 2026-09-06 at compiler 6a676f92b94d.

function WordPosition(const N: Integer; const S: AnsiString;
                      const WordDelims: TSysCharSet): Integer;
var i, n, count: Integer;
begin
  n := Length(S);
  while (i <= n) and (count <> N) do ...        { `n` and `N` are ONE identifier }

fpc 3.2.2 refuses this (Duplicate identifier). pxx registers two symbols and, until the resolution fix above, kept them apart by case — so the routine worked, and would have started returning wrong answers the moment the lookup was corrected. Three routines in lib/rtl/strutils.pas had this shape (WordPosition, ExtractWordPos, ExtractDelimited); the locals were renamed to sLen as part of that fix.

Why it is worth a diagnostic rather than a rename campaign

The collision is decidable at the declaration, from two lists we already have, and it is otherwise found as a wrong value in a routine that reads the two names as different variables. Everything about this defect is cheap on one side of the line and expensive on the other.

Why it was not landed together with the resolution fix

A refusal is a narrowing, and the population is unknown: no sweep has asked how many routines across lib/**, compiler/**, the corpus and the frontends declare a parameter and a local differing only in case. Two were found by accident, in one file, because a different change made them fail. The first step is the census, not the diagnostic — and if the count is large, the answer may be a warning rather than an error.

Boundary, so nobody re-derives it

Two LOCALS differing only in case, or two PARAMETERS, are the same question and should be caught by the same check. A local differing in case from a symbol in an OUTER scope is NOT this: that is ordinary shadowing and is now resolved correctly.

Neighbour

[[bug-p-an-exact-case-match-in-an-outer-scope-beats-a-case-insensitive-one-in-a-nearer-scope]] is the lookup half; this is the declaration half, and only the declaration half can name the file that is wrong.

The census instrument landed 2026-09-06 — PXXDBG=a.casedup

SymHashInsert reports every symbol registered under a name that differs only in case from one already visible: samescope=1 is this ticket's population, samescope=0 is ordinary shadowing of an outer name. It is a committed channel and not a local print, deliberately — frankS's condition for running it over the fpc-testsuite corpus, and the right one: a measurement taken with an uncommitted compiler is one nobody can reproduce.

test/test_a_parameter_and_a_local_that_differ_only_in_case_are_two_symbols.pas is its positive control, drawn from this population rather than from the shadowing one, and it is wired into test-core with a matching silent-when-off control. When the diagnostic lands, that file must be REFUSED and its assertion inverts — it asserts the channel, never the values.

First count: compiler/compiler.pas — 21 pairs, ZERO same-scope

So our own compiler has no instance of THIS defect. What it does have is the same collision across scopes under {$CASESENSITIVE ON}, which fpc ignores; that is a different bug and is filed as [[bug-a-the-compilers-own-source-means-two-different-things-to-fpc-and-to-pxx]].

The corpus count is still open, and it is the one that decides error-or-warning.

The corpus census — 2026-09-06 (frankA), and it answers error-vs-warning

Run at frankB's invitation, because this ticket blocks on its own sentence: "The corpus count is still open, and it is the one that decides error-or-warning."

Population: every fixture under test/, compiled with --threadsafe so the ones that need it reach their program body, plus every unit they transitively use. Twice — once at 10797249be20 and again at e5b9b3a096b1 after frankB's samescope fix landed. The corpus GREW by four files between the two runs (2053 then 2057); both numbers are printed rather than the later one alone.

Every count below is of DECLARATION SITES, not occurrences. A pair inside a shared unit is re-reported once per fixture that uses it: 2847 occurrences collapse to 2363 sites. Ranking by occurrence says pylib, rsa, ecdsa, ed25519 — which is a ranking of how many fixtures use a unit, not of how many declarations are wrong, and it is how the first read of this census got the shape of the problem exactly backwards.

Reach, so the zeroes are not vacuous: 2057 attempted, 1790 compiled to the end, 267 did not. Those 267 report zero pairs for their own bodies and that zero means nothing. This is the trap recorded in [[bug-a-the-compilers-own-source-means-two-different-things-to-fpc-and-to-pxx]], applied to this sweep.

The result: eight same-scope sites, one of which is this ticket

site pair what it is
bignum:495 result / Result the only real instance — a routine-local beside the implicit function result
test_a_parameter_and_a_local_..._two_symbols:47 n / N this ticket's own positive control
test_case_sensitive:4, test_case_sensitive_error:7 value / Value deliberate {$CASESENSITIVE ON} fixtures
test_class_inherits_from_tobject:93,101,102, test_threadsafe_exception_managed_fields:52 E / e a DIFFERENT defect — the exception-handler binder leaked its scope; fixed separately

Plus 342 in gtk3, which are correct and are the trap in this data. They are GDK keysyms arriving through uses gtk3_c, a C header import, where GDK_KEY_Armenian_AT and GDK_KEY_Armenian_at are legitimately two names. Probed with four values that all differ, so a merge would be visible:

Armenian_E = 0x1000537   Armenian_e = 0x1000567
AT         = 0x1000538   at         = 0x1000568

All four resolve correctly and distinctly from a case-INSENSITIVE program — lookup respects the declaring unit's case mode. a.casedup reports them because it fires whatever the case mode is, which its doc says is deliberate. The consequence is that a reader ranking by samescope=1 count goes straight to the one file that is fine, and the channel's doc should say so.

Recommendation: ERROR, and re-ranked to 45

The narrowing costs one site. That is the whole argument. A refusal was held back because "a refusal is a NARROWING over a population nobody has enumerated"; the population is now enumerated and it is bignum:495 — which fpc refuses outright (Error: Duplicate identifier "RESULT"), and which only survives because lib/rtl is built by $(PXX_STABLE) and never by the seed. A warning buys nothing when there is one thing to warn about.

result/Result needs the diagnostic to name the implicit side. The collision is with a name the LANGUAGE declares, not one the author wrote, so a message pointing only at the user's declaration points at nothing.

Prio 35 -> 45. Not higher: every instance known today is fixed, so nothing is red on it. Not left at 35: the class has now produced silent wrong values three times in lib/rtl/strutils.pas, five times across the cascade fixtures — including a signal handler handed a stack pointer computed from an array-as-integer — and each was found as a wrong value far from the declaration that caused it.

The stated boundary is too narrow, and four of five instances fall outside it

This ticket's Boundary section says two locals or two parameters are the same question, and that a collision with an OUTER scope is not. Four of the five cascade fixtures were neither: const SPARE beside var spare at PROGRAM scope — one scope, and the case fpc refuses. The population is two declarations in one scope differing only in case, at any scope level, and samescope could not report it at program scope until frankB's fix because it derived from Procs[CurProc].ScopeBase with CurProc = -1.

What this census does NOT cover, measured rather than guessed

The population is the transitive closure of the fixture corpus. lib/ holds 141 *.pas files and I did not establish which of them no fixture reaches — a unit nothing imports contributes no pairs and is indistinguishable here from a clean one. examples/** and the frontend corpora are not covered at all. compiler/compiler.pas was counted separately by frankB (21 pairs, all cross-scope).

RESOLVED 2026-09-06 — and the second census is the finding

The diagnostic is CaseDupRefuse, called from SymHashInsert. What is worth recording is that the census this ticket blocked on was not enough, and the re-count is what stopped a false refusal.

The rule, and the one field that makes it safe

Two declarations whose names differ only in case, visible in the same scope, and neither declared under a case-sensitive modeSymCaseSensitive on BOTH sides, not the mode in force at the check. That last part is the whole safety argument and it is not a heuristic:

population pairs why it is not a duplicate
GDK keysyms via uses gtk3_c 340 distinct a C header import; GDK_KEY_dead_A and GDK_KEY_dead_a are two names, and they resolve correctly and distinctly
{$CASESENSITIVE ON} fixtures 2 the directive says so
every NilPy user symbol DeclCaseSensitive := CaseSensitiveMode or NilPyUserCode

A reader ranking by raw samescope=1 count goes straight to gtk3, which is the one file that is fine. The bit is what separates them, per declaration, and it was already being recorded.

frankA's census answered the question this ticket blocked on — ERROR, one real site — and it was right about that. Re-measuring at HEAD before landing found the site already gone (bignum:495's local result is renamed to acc) and found something the first count could not have known it was reporting:

on E: Exception do WriteLn(E.Message);   { beside an outer `var e` }

fpc accepts this with no warning — measured, not assumed — and test_exception_handler_binder_is_scoped_to_its_handler.pas exists in this tree to assert exactly that the binder is scoped to its handler. a.casedup reported all eight such sites, across three fixtures, as samescope=1.

The cause: a handler is a scope, and nothing recorded that. A routine's scope is named by Procs[CurProc].ScopeBase; a handler's had no name, so at program scope the derivation fell through to "same unit" and the binder read as a program-scope declaration. CurHandlerScopeBaseP1 records it now, set before AllocVar because the binder is itself in the handler's scope, saved and restored so nested handlers nest.

This is the second time this derived flag has been wrong, in opposite directions — first it could never report 1 at program scope, which hid four of the five cascade fixtures; then it reported 1 for something legal. Both times a peer or a re-measurement caught it, never the flag itself. So every input it is derived from is now printed beside it (proc=, base=, hbase=, cs=, vscs=, unit=, vsunit=), and the census channel and the refusal call one CaseDupSameScope rather than two copies of the subtraction.

Measured cost: one file, and it is this ticket's fixture

Not reasoned from the channel — measured by compiling. The whole corpus was compiled before and after with identical arguments and the exit codes diffed:

compiled refused
before 1834 275
after 1833 276

Newly failing: exactly one, this ticket's own fixture. Newly passing: none. Exactly one file in the corpus mentions the new diagnostic at all. The 691 non-Pascal frontend sources compile identically (598 before, 598 after, zero refused by it) — expected, because C, Rust, Zig and NilPy all declare case-sensitively, but measured rather than argued.

The fixture inverted, as its own earlier header promised

test_a_parameter_and_a_local_that_differ_only_in_case_are_two_symbols.pas used to RUN and print CASEDUP FIXTURE OK, asserting only that the census channel saw the pair. It is now a program that must not compile, and the Makefile asserts both message arms by text plus that both are reported from one compile — the check recovers, and a fatal one would have certified the second arm untested.

The second arm is the one with no positive control otherwise: a local result colliding with the implicit function result. The collision is with a name the LANGUAGE declares, so a message pointing only at the user's declaration points at nothing, and ours names it:

duplicate identifier "result": Pascal is case-insensitive, so it is the
same identifier as the implicit function result "Result" of Doubled

fpc says Duplicate identifier "RESULT". Both refuse; ours says which side the author did not write.

The three negative controls are wired beside it and are drawn from the census's own data rather than invented: test_case_sensitive.pas, test_c_gtk_types.pas and test_exception_handler_binder_is_scoped_to_its_handler.pas.

Blank, named

lib/ units that no fixture reaches are not covered, for the reason frankA's census records about the same population: a unit nothing imports contributes no pairs and is indistinguishable here from a clean one. A duplicate in one of those would now be a hard error the first time something imports it.

Log