← board

fpjson (fcl-json) — rung 2 of the Pascal OOP corpus

Why fpjson

Abstract base + polymorphic descendants (TJSONData → number/string/bool/null/array/object), class of factory dispatch, owned-child lifetimes, and a byte-exact roundtrip oracle. Its own suite is 12k LOC of fpcunit — which now RUNS, which is what makes this rung testable.

Cleared so far (each landed green with its own regression)

Compiler:

RTL (new units and surface):

fpjson WORKS

{ "name" : "pxx", "version" : 2, "ok" : true, "ratio" : 1.5,
  "list" : [1, 2, "three"], "child" : { "nested" : "yes" } }

Every accessor correct: Get / Strings / Integers / Floats / Booleans, nested objects, arrays, IndexOfName, JSONType. fpjson.pp is used UNMODIFIED (888 procs).

The last three, and every one was SILENT

Plus the property index specifier (b293) and runtime set members in in (b294), both from the scanner.

What is left

Only the SCANNER's \uXXXX escape path, which builds a UTF-16 surrogate pair (WideChar(u1) + WideChar(u2)). That is a genuine string-model boundary, not a bug — see [[feature-unicodestring-model]], which spells out what a real UnicodeString would take and why faking it is exactly the failure mode this corpus keeps catching. fpjson's DOM does not need it; only parsing JSON text containing \u escapes does.

fpjson's OWN suite (testjsondata.pp, 4138 lines) — long tail, no blocker

Reachable because fpcunit runs. Walls cleared FOR the suite, each a real feature:

landed what it was
b295 for F in Data — a container EXPRESSION (a property) with GetEnumerator
b296 class-reference ops chained after a value (d.M.ClassName) — were silently DROPPED
b297 a PARENTHESISED expression keeps its class id ((b as T)[i], (b as T).ClassName)
b298 array of const literal to an OVERLOADED ctor — silently passed GARBAGE
b299 CLASS PROPERTIES through the class name (also resolves feature-pascal-class-property)
b300 FreeAndNil SILENTLY SKIPPED THE DESTRUCTOR

b300 is the one to remember: every object freed through FreeAndNil had its destructor skipped — no inherited, no child cleanup — because the RTL called FreeMem directly AND Free through an untyped reference cannot dispatch Destroy (the VMT slot is unknown without a class id). It is now an intrinsic that uses the call site's static type, which is what obj.Free already did correctly.

The current wall, narrowed (2026-07-13, instrumented)

AssertEquals('FormatJSON equals JSON', S.AsJSON, S.FormatJSOn) in TTestString.TestFormat dies with Expected: , at the . of S.AsJSON. Established by instrumentation, gated on CurProc (NOT on a line number — see below):

Next probe: find why the selector path is not reached for S.AsJSON here when the identical construct compiles standalone (fj3.pas: fpcunit + fpjson + the same AssertEquals call). The answer is something about what is or is not registered at that point in the unit — which is what "decl-order dependent" means.

State

The suite's remaining walls are a LONG TAIL, not an architectural blocker. Each one is CONTEXTUAL — it reproduces green in isolation and only fails inside the real file — so each needs instrumentation against testjsondata.pp itself, at roughly one compile (~2 min) per iteration. Current one: line 2062, by-reference argument must be a variable in TTestFloat.DoTest, where Str(F,S) / Delete(S,1,1) / TestJSONType(J,...) all compile fine standalone.

This deserves its own focused session rather than being chased at the tail of a long one. The method that works (proven repeatedly tonight): do NOT keep writing reproductions — blank the failing line in a COPY of the real file to walk the wall forward, then instrument the compiler at the exact dispatch. See [[project_dump_tokens_before_theorising]].

Rung 2's actual goal — fpjson compiles and produces correct JSON — is DONE. The suite is a bonus oracle on top of it.

What is left in fpjson proper

Only the SCANNER's \uXXXX escape path, which builds a UTF-16 surrogate pair. That is a genuine string-model boundary, not a bug — [[feature-unicodestring-model]]. fpjson's DOM does not need it.

Next

Rung 3 — reassess. Likely rtl-generics (generic classes x interfaces x class constraints) or fcl-xml DOM.

Gate

make test + self-host byte-identical + cross.

2026-07-13 — the suite's long tail turns out to be REAL frontend bugs, not context noise

The earlier note called the remaining walls "CONTEXTUAL — reproduces green in isolation, only fails inside the real file" and recommended a focused session. That session happened, and the diagnosis was wrong in an instructive way: every wall so far has been a genuine, minimal, FPC-divergent frontend bug. They looked contextual because each needed a second declaration elsewhere in the file to trigger — not because the compiler is order-sensitive in some vague way.

The method that worked, every time: instrument the dispatch, do not theorise. Tag the competing Expect(tkComma) sites to find WHICH one fires; print the symbol a name resolves to; dump the overload candidates. Each wall fell in one or two compiles. See [[project_dump_tokens_before_theorising]].

Wall at 1725 — AssertEquals('...', S.AsJSON, S.FormatJSOn) → b313

NOT a member-access bug, and nothing to do with classes. ParseFactor expands any identifier naming an untyped string CONSTANT into a literal, without first checking whether a VARIABLE of that name is in scope. testjsondata declares a method-local const S early and a method-local var S : TJSONString later, so the variable was silently replaced by the CONSTANT'S TEXT — the identifier was consumed, a string literal handed back, and .AsJSON left unconsumed. Hence "expected ," AT THE DOT, which is what made it read as a selector failure. Fixed: a variable in scope beats a same-named constant.

The defect underneath it → b314

The string-constant table was FLAT and searched oldest-first, so a routine's const leaked into every routine parsed after it and even beat that routine's OWN const of the same name: function A: const S='first' / function B: const S='second' gave B='first'. Silently. FindStrConst is now innermost-wins like FindSym (owner = CurProc, -1 = unit level). Filed and resolved as bug-pascal-string-const-not-scoped.

Wall at 2774 — J.Insert(0) on TJSONArray → b315

An overloaded method's BODY could clobber a DIFFERENT overload's method-table entry: when the impl header failed to match its declaration by proc identity, the binder fell back to a NAME match (the first entry of that name) and overwrote its proc. TJSONArray has Insert(Index) plus ten Insert(Index, ...), and each two-arg body landed on the one-arg entry. So no one-argument Insert existed any more, the arity search fell through to a two-arg overload, and the parser demanded a second argument.

Found by dumping FindUMethArity's candidates: entry 356 pointed at proc 723 (ParamCount 2) early in the compile and at proc 880 (ParamCount 3) later — the same slot, silently rebound.

The impl-to-decl signature match failing AT ALL is the deeper defect and is filed as [[bug-pascal-overload-impl-decl-signature-match]] (same-arity overloads can still be confused: entries 365/366 both bound to proc 724).

Also cleared on the way

Wall at 3042 — J.FormatJSON() → b316

EMPTY parens on a method whose parameters ALL have defaults. The argument loops had a case for trailing defaults AFTER at least one argument, but none for ZERO arguments; the ) went straight to ParseExpr → "expected expression". A parameterless method called with () always worked (the loop never ran), which is why it was never noticed. Six separate method-call argument loops needed the same guard — they are copies of one dispatch, and that duplication is exactly what lets a case go missing in some of them.

THE SUITE NOW PARSES END TO END (~5000 lines)

Walls: 1725 → 2774 → 3042 → frontend clear. The remaining wall is in CODEGEN, which is a different class of problem (and a different track — this is Track A ground, not P).

Wall in CODEGEN — invalid symbol in lea → b317

A ROUTINE-LOCAL const array of CLASS REFERENCES registered a PENDING GLOBAL initializer holding the routine-local symbol index. That index is rolled back with the routine's scope, so main lowered a dangling IR_LEA. testjsondata declares exactly that: Const MyJSONInstanceTypes : Array[TJSONInstanceType] of TJSONDataClass = (TJSONData, ...). Every other element kind already forked on isLocalConst; the class-ref branch did not.

RUNG 2 COMPLETE — testjsondata.pp COMPILES END TO END (~5000 lines, 1369 procs)

Walls: 1725 (b313) → 2774 (b315) → 3042 (b316) → codegen (b317) → compiles.

Every one was a genuine, minimal, FPC-divergent bug — not the "contextual, only fails in the real file" noise the earlier note assumed. They looked contextual because each needs a second declaration elsewhere in the file to trigger. Instrument the dispatch, do not theorise is what cracked every one of them, in one or two compiles each.

What is left on this ticket

  1. RUN the suite. It compiles; it has not been executed yet. Needs a driver that actually runs the registered tests (fpcunit's consoletestrunner, or call the registry directly). Expect a fresh crop of RUNTIME divergences — that is the point of the rung.
  2. \uXXXX in the scanner (UTF-16 surrogate pair) — [[feature-unicodestring-model]], a genuine string-model boundary, not a bug. fpjson's DOM does not need it.
  3. Open bugs found here, filed separately:
    • [[bug-pascal-metaclass-array-element-not-a-receiver]] — Map[0].Tag returns garbage
    • [[bug-pascal-overload-impl-decl-signature-match]] — same-arity overloads still confusable

2026-07-13 night — THE SUITE RUNS: 203/203 tests execute, 187 pass, 16 fail, 0 errors

Driver: /tmp/fpjson-stage/tjrun.pp (walks the registry, prints each test name before running it — a crash names its test). Eight compiler bugs fixed to get here, every one minimal and silent (b318-b325):

Remaining 16 failures (semantic long tail, no crashes)

RUNG 2 COMPLETE AND GREEN — 2026-07-13 night: run 203 / failures 0 / errors 0

Second half of the night (b326, b327 + RTL strictness, commit a1565ed5):

The suite is a real oracle now: driver = /tmp/fpjson-stage/tjrun.pp against the staged symlink dir (see the invocation note above; rebuild is one compile). Remaining items on this ticket's original scope:

Log