← board

--strict-visibility covers classes and not records, and says nothing about the difference

Not a compat question. Us accepting what fpc rejects is not a defect (CLAUDE.md) and the lax default is a stated dialect choice (defs.inc:3063: "PXX's dialect parses the markers but grants access from anywhere -- the deliberate lax ergonomics"). The defect is in the flag: --strict-visibility is documented as "enforce private/protected across units" (compiler.pas:880) and enforces it for two of the four shapes it accepts markers on.

A guard that cannot fail on records is not a guard on records, and nothing in the output distinguishes "checked and fine" from "not checked" -- the same clean-bill-written- by-nobody shape as an exemption list that cannot tell the two apart.

Measured

Two units, one program per row; vu1.pas declares TR = record private fx plus TC = class private fy strict private fz, and each program reads one of them cross-unit.

shape default --strict-visibility pin fpc 3.2.2
private CLASS field accepts rejects cannot access private member "fy" of TC from here (--strict-visibility) same identifier idents no member "fy"
strict private CLASS field accepts rejects cannot access strict private member "fz" of TC same identifier idents no member "fz"
private RECORD field accepts accepts same identifier idents no member "fx"
private RECORD method accepts accepts same (not probed)

Controls, both branched on: a PUBLIC class field still compiles under the flag (the flag is not rejecting everything), and the flag flips two of four rows (the flag is on). The pin column is not decoration -- the finding this came out of was correctly controlled against the pin, and the pin agrees on every row in both flag states, so no pin control could have surfaced this. The missing axis was CONFIGURATION, not time.

fpc rows need {$modeswitch advancedrecords} for the record to parse at all; that is the oracle's requirement, not ours.

Where to look

EnforceMemberVis (pasparser_class.inc:58) implements the full scoping rule and is correct on the rows that reach it. EnforceFieldVis (:86) and EnforceMethVis (:99) are the aimed wrappers, called from pasparser_lval.inc:3843 and :5767 (plus the two mirrored sites in pyparser.inc). Records DO get a UCls entry (UClsIsRecord[ci]) and the class-body loop stamps curVis for them (pasparser_decl.inc:7444-7459), so the vis is almost certainly RECORDED and the gap is at the access path -- but that is the reading, not the measurement. Whoever takes this should establish which of the two it is before fixing either; a stamp fix and a call-site fix are different changes and one of them is a no-op. --strict-visibility is also reachable as {$STRICT_VISIBILITY ON}.

Disposition of the row that surfaced it

None. terecs1.pp is skip-listed gap: accepts-invalid at 0fe46e061 and that stays correct: it is a RECORD row, the runner passes only --strict-case --strict-operator, and the default is lax by design. This ticket does not reopen it.

Log

2026-09-09 — fixed, and the ticket's own reading was backwards

The ticket named the fork correctly and picked the wrong side, which is why it told whoever took it to measure first: "a stamp fix and a call-site fix are different changes and one of them is a no-op." It is the stamp.

The access path was already wired. EnforceFieldVis is called for records at pasparser_lval.inc:3895 and :5891, both carrying the comment { --strict-visibility member access control }.

The stamp was not. ParseRecordFields recognises the section markers — but as tkIdent + CaseEqual(CurTok.SVal, 'private'), NOT as tkPrivate, which is why a token-kind grep finds nothing and reads as "records don't parse visibility at all". It consumed the marker, continued, and passed a hardcoded VIS_PUBLIC literal to ParseFieldDeclInto. ParseRecordMethodDecl never wrote UMthVis at all.

And VIS_PUBLIC = 0 (defs.inc:573), so the unstamped default was publicEnforceMemberVis exits on if vis = VIS_PUBLIC before it reads the context. Every record member was public to the enforcement no matter what the source said. The expected value collided with the do-nothing value, which is the CLAUDE.md rule about a probe whose right answer must differ from the default: had the array defaulted to anything else, this would have been loud.

What changed

ParseRecordFields now tracks curVis/curStrictVis across the body and stamps both fields and methods; ParseRecordMethodDecl takes the visibility and writes UMthVis.

Measured, at commit ae1ce9232 + this change, binary cd4563b0b667

row default --strict-visibility
private RECORD field, cross-unit accepts rejects
strict private RECORD field, cross-unit accepts rejects
private RECORD method, cross-unit accepts rejects
public RECORD field / method accepts accepts
private CLASS field (unchanged) accepts rejects
public CLASS field (unchanged) accepts accepts

The control that matters is not in that table. A record method reading its own private AND strict private fields, and calling its own private method, still compiles under the flag — so the fix did not turn enforcement into blanket rejection. test_record_visibility.pas is that control, and it is drawn from the RECORD population: the class control passed throughout and could never have caught this.

Diagnostics name the record: cannot access strict private member "fs" of TR from here (--strict-visibility).

gate.sh quick GREEN, read from the log rather than the wrapper's exit code; FPC seed canary PASS (not SKIP). Self-host fixedpoint converged after 1 round(s).

Not touched

The lax DEFAULT, which is a stated dialect choice (defs.inc:3063), and terecs1.pp's skip-list entry, which the ticket correctly said this does not reopen — the conformance runner passes --strict-case --strict-operator and never --strict-visibility.