← board

A whole-array assignment destination is never type-checked

Measured 2026-09-06 at compiler aa8f3c3b4b68 against fpc 3.2.2.

type TSA = array[0..2] of AnsiString;
     PSA = ^TSA;
     TC = class SA: TSA; DA: array of AnsiString; end;
var sa: TSA; p: PSA; c: TC; s: AnsiString;
statement pxx fpc 3.2.2
sa := s accepted, SIGSEGV Incompatible types: got "AnsiString" expected "TSA"
c.SA := s accepted, SIGSEGV refused
p^ := s accepted, exit 0 refused
c.DA := s accepted, exit 0 refused

Four spellings, one behaviour: the check never fires. The two that crash are the ones where the store lands on managed-string slots.

Why the check cannot fire, which is the part that is not obvious

AssignSideKind types one side of an assignment. An array's TypeKind IS its element's kind, so an array-valued side cannot be described by a kind at all and the function returns False. The funnel is

if AssignSideKind(lhs, dstTk) and AssignSideKind(rhs, srcTk) and
   AssignKindsIncompatible(dstTk, srcTk) then Error(...)

so a False short-circuits the whole rule. AssignSideKind's own header states this is deliberate — "a shape it cannot type must fall back to the old accept — a false REJECT of working code is a worse defect than the false accept being fixed here" — and it is right about the trade in general. It is wrong for this shape specifically, because there is no legal assignment of a scalar to a whole array for the accept to protect.

So the bail that reads as a safety measure is the thing letting the store through, and adding an arm to AssignSideKind cannot fix it: there is no TTypeKind for "array of AnsiString" to return.

What the fix has to look like

A rule ABOVE the kind funnel, at the same AN_ASSIGN site, that asks ASTNodeIsWholeArray(lhs) and refuses a right-hand side that is not itself an array of a compatible shape — the same place and shape as the fixed→dynamic guard that already sits there for the pair the kind cannot express.

Note the fixed→dynamic guard is the precedent in every respect: same site, same reason ("both sides are tyInteger, because an array symbol's TypeKind is its ELEMENT's kind, so the check above sees a matching pair and waves it through"), and it was landed as a named refusal with a follow-on for the copy.

Why it was not landed with the census

It is a narrowing. The funnel would start refusing programs that compile today, and the population is unknown — lib/**, the corpora, four frontends. Two shapes must survive by name, both already documented one screen up in ir.inc: d := e (dyn to dyn) and t := o (an open-array PARAMETER to a dynamic array, which fpc rejects and we accept deliberately). A parameter's recorded length is untrustworthy in both directions because AllocParam stamps ArrLen := 1000, so the parameter row is the one to measure first.

Neighbour

[[refactor-p-is-this-node-a-whole-array-is-answered-in-four-places-with-four-lists]] is where this was found and it carries the census: three questions, not four answers to one, and a whole array has no kind to be typed as.

RESOLVED 2026-09-06 — and the census is the finding, not the fix

The fix is four lines above the kind funnel in ir.inc. Everything worth recording is in what the measurement said about the fix this ticket prescribed.

The instrument: PXXDBG=a.wholearr

One line per assignment whose destination is a whole array, tagged keep or REFUSE by the candidate rule, plus a TOTAL seen=/keep=/refuse= denominator so a run that prints no REFUSE lines can say which kind of silent it is.

The channel calls the rule; it does not describe it. DbgWholeArrRhsShaped is the function the refusal calls, so refuse=N is exactly the number of sites the narrowing breaks and there is no second implementation to drift away from the measured one.

Placed ABOVE the fixed->dynamic guard on purpose. That guard REWRITES the right-hand side into an element-list constructor, so a channel below it would report every d := s as an array constructor and count its neighbour's fix as this ticket's population.

What the census said, and it inverted the prescription

sites
attempted 2109 every Pascal fixture under the test tree plus examples/, --threadsafe
compiled to the end 1834 the 275 that did not report nothing, and that zero means nothing
whole-array destinations 1445 the denominator

Candidate 1 — this ticket's own prescription, "refuse a right-hand side that is not itself an array of a compatible shape": 49 false refusals in twelve files, zero instances of the defect.

RHS node kind sites what it is
AN_CALL 35 a function returning a STATIC array
AN_INDEX 6 a fixed ROW out of a dynamic array — the index has not selected an element
AN_CALL_IND / AN_INTF_CALL 4 the same through a procedural value or an interface
AN_DEFAULT 3 Default(T) on an aggregate
AN_INT_LIT 1 Default(TDyn), which is already lowered to a zero literal by the time the rule runs

The reason is structural and is the part worth keeping: there is no reader in the tree that can say "this expression's VALUE is an array" for a call result or a row, so a rule phrased that way refuses everything it cannot see. It is the same defect as the one being fixed, pointed the other way.

Candidate 2 — what landed: refuse only what can be POSITIVELY typed as a non-array. That is AssignSideKind's own documented policy applied to arrays instead of kinds, and an IDENT is the one spelling where "not an array" is a recorded flag on a symbol that exists rather than a False that might mean "not found". Cost over the same 1445: zero. It is exactly the four rows reported, whose right-hand side is an AnsiString identifier in all four.

The named blank, which is not a clear

A FIELD or DEREF right-hand side is still accepted. sa := c.Name therefore still compiles and still crashes. RecFieldIsArray returns False both for a field that is not an array and for a field it did not find, and no reader separates those — a refusal there fires on an unresolved record, which is a false reject of working code. Closing that blank needs a field-exists reader covering builtin and user records alike, which is [[refactor-p-is-this-node-a-whole-array-is-answered-in-four-places-with-four-lists]]'s territory.

Also unmeasured: lib/ units no fixture reaches, and the Rust/Zig/BASIC/Ada corpora, which DO go through this funnel (only C and NilPy are excluded).

The fixtures, and why one of them is the interesting one

test_a_whole_array_destination_refuses_a_scalar.pas must not compile; the Makefile asserts the diagnostic TEXT and asserts that all four spellings are reported, because the check recovers and a fatal one would have certified three of them untested.

test_a_whole_array_destination_takes_every_shape_the_census_found.pas is the positive control for the narrowing and every row is a right-hand-side node kind the census actually saw — not one invented. Rows 6 through 9 are the four families candidate 1 would have refused; if any of them stops compiling, the rule has been rephrased back into the shape the census rejected. Twelve of its thirteen rows are byte-identical to fpc 3.2.2 -Mobjfpc. The thirteenth is row 4, t := o, an open-array PARAMETER assigned to a dynamic array, which fpc refuses (Incompatible types: got "{Open} Array Of LongInt") and we accept deliberately — measured, not assumed, and nothing in the rule asks a length because AllocParam stamps ArrLen := 1000 on every array parameter.

Log

The frontend corpora, measured after the fix landed — and the number is THIN

Rust and Zig go through this funnel; only C and NilPy are excluded. The narrowing landed without them measured, so they were measured immediately after rather than left as a blank someone else would have to find.

691 non-Pascal sources, 596 compiled to the end, refuse=0. Read that number carefully, because it splits into one useful half and one vacuous one:

population whole-array destinations refused
Rust, Zig, BASIC, Erlang, Fortran, Algol, LOLCODE — the refusal applies 12 0
C and NilPy — CProgramMode / PyProgramMode exclude them from this check 2169 0

The second row is a zero by construction and is not evidence of anything: it would read 0 with the rule set to refuse everything. It is recorded only so nobody re-derives it and mistakes it for coverage.

The first row is the one that counts and twelve is a thin aperture. It is not a clear for those frontends; it is "no signal, from here". The Rust and Zig corpora simply do not assign whole arrays much. Ada has no corpus in this tree at all — zero sources, so its cell is unmeasured rather than clean.

lib/ units that no fixture reaches are still unmeasured, for the reason frankA's case-pair census records about the same population: a unit nothing imports contributes nothing and is indistinguishable here from a clean one.

Was the census population the tree it claims? Asked and answered from the reflog

Raised by frank-coordinator, and it is the right question to ask of any number a narrowing was landed on: CLAUDE.md's mid-sweep hazard is that a pull moves the population under a running harness, and a corrupted measurement that survives the question you happened to ask is indistinguishable from a clean one.

The checkout's reflog settles it, and it is the instrument that fails differently from a file mtime:

HEAD moved to a0fef585c (the instrument's sync) 19:00:34
census run 1 — the 49 false refusals 19:02:32 → 19:05:13
census run 2 — the 1445 / refuse=0 19:08:44 → 19:11:59
next HEAD move (f8c2bbf72, a LOCAL commit, no pull) 19:16:32
next PULL (rebase (start)) 19:24:58

No pull, no rebase, no HEAD move inside either window — the nearest one is eight minutes before the first and thirteen minutes after the last. Corroborated independently: every mtime under the test and example trees newer than 18:55 is 19:25:48, the rebase, so nothing in the population moved while it was being read.

What the census tree is NOT is the tree the fix landed on. Six corpus files changed between a0fef585c and the fix — four of them other agents' — so the number is honest about a0fef585c and silent about the delta. That is a real gap and it is cheap to close, so it was: re-run over exactly those six, seen=16 keep=16 refuse=0. The only REFUSE lines in it are the four from this ticket's own refusal fixture, which is the file that must not compile, and it is also why five of the six reach a TOTAL rather than all six.