← board

The fork

PRINT s + t, with s and t BASIC string variables and no USES in the file:

pascal26:3: error: compiler error: call to a runtime stub that was never
emitted (code offset 0 is the ELF entry point). A frontend driver is missing
its stub-emission call for the current flags/target.

Same for IF s = t on aarch64/arm32 (compiler error: PXXStrEq not found; x86-64 and i386 have an inline path). Present on pinned. Tracked as [[bug-a-basic-string-concat-in-a-unit-free-program-is-a-compiler-error]] — this ticket is the decision that unblocks it.

What the measurement says, and why it does not settle it

The emitted AnsiString "runtime" is a set of SHIMS: AnsiStrConcatAddr is nine pushes around EmitCallProc(FindProc('PXXStrConcat')). Every shim's body is a builtinheap procedure, and BASIC pulls builtinheap through exactly one door — USES <unit> during the parse.

There is no frozen-string path that avoids this. Measured: a Pascal program doing ShortString + ShortString also pulls builtinheap, and comes out at 63,760 bytes. That is not an accident of that program — DetectPascalRuntimeNeeds sets needsAnsiRuntime := PasDefineExists('PXX_MANAGED_STRING'), and PasApplyDefaults defines that symbol unconditionally, so every Pascal program pulls builtinheap, always.

So BASIC's small unit-free binary is the anomaly, not the norm:

program size
10 PRINT "hello" (.bas, no USES) 559 B
WriteLn('hello') (.pas) 63,760 B
test_basic_comprehensive.bas (has USES) 103,935 B

The three options

A — pull builtinheap unconditionally in the BASIC driver. One line (ParseUsesUnitAmbient('builtinheap')), exactly what Pascal, C and NilPy do. s + t and s = t work everywhere. Every .bas binary becomes ~100 KB, including 10 PRINT "hello", which is 559 B today. Simple, consistent, and throws away a property that was only just gained ([[bug-a-a-unit-free-basic-program-calls-a-helper-it-never-emits]], 2026-08-24).

B — keep the small path; make the refusal a real diagnostic. BASIC is a skeleton frontend. Say "BASIC: string concatenation requires a USES of any unit" instead of compiler error: ... driver is missing its stub-emission call, which reads as an internal fault and points the user at nothing. Cheap, honest, and leaves a language gap open.

C — pull it on demand, after the parse. BASIC's parse builds an AST and emits no user code; PatchProgramEntryJump already happens after ParseBBlock. So the driver could: parse → walk the AST for a managed-string operation → ParseUsesUnitAmbient('builtinheap') only if one is present → then emit the shims (they are reached only by call, so their position is free, they need only precede lowering) → lower. Exact rather than approximate: small programs stay small, string programs work. It is a real restructure of the driver and the only option that needs design.

Recommendation

C, and B as the fallback if C is judged too much machinery for a skeleton frontend. C is the only one that does not trade one of the two properties away, the "emit the shims after the parse" move is sound (nothing falls through into them), and the AST walk is small. A is one line and consistent with every other frontend, which is a real argument — but paying 100 KB on every BASIC binary to fix an operator most .bas files never use is the wrong trade for a frontend whose whole point is being small.

What NOT to do

Do not emit the shims unconditionally "to be safe". That is precisely how [[bug-a-a-unit-free-basic-program-calls-a-helper-it-never-emits]] happened: the shim was emitted, its builtinheap body was not, and the only thing hiding it was that nothing resolved the driver's forward calls — so the call kept its placeholder for the whole compile and silently did not happen.

Whatever is chosen

It must cover +, = and <>, and the gate must include the cross targets: x86-64 and i386 have an inline compare path, so a native-only run would call two of the three fixed.


DECIDED 2026-08-25 — option A: the BASIC driver pulls builtinheap unconditionally

Decided by an agent under the no-human-available rule (devdocs/progress/decided/README-agent-decisions.md). Judgement call on the pragmatic tiebreak, with one principle pointing the same way — labelled as such so it is cheap to revisit, because it does trade a real property away.

One line, ParseUsesUnitAmbient('builtinheap'), exactly what Pascal, C and NilPy already do. s + t, s = t and s <> t work on every target. Every .bas binary becomes ~100 KB, 10 PRINT "hello" included.

The principle that points here

normalise-dont-special-case.md: "When the frontend can reach a construct through two shapes ... Normalise the special shape into the general one ... it is a way of having one thing to get right instead of two that must stay in step."

Four frontends pull the runtime unconditionally; one does not. BASIC's unit-free path is the special case, and it is already the one that is broken — the ticket exists because a helper was called that was never emitted. Option C would keep the special case and add a second mechanism (a conditional, post-parse runtime pull) that no other frontend has, in Track A's shared driver ground.

Why the pragmatic tiebreak decides the rest

The owner's standing framing is a pragmatic C + Pascal + Python compiler — a tool that compiles and correctly runs real programs. Measured: BASIC has no track letter, is named nowhere in CLAUDE.md, has five tests, and is described in this ticket as "a skeleton frontend." Option C is "a real restructure of the driver and the only option that needs design" — design effort in shared core files, spent on a frontend outside the stated goal, to preserve a binary size that no program depends on and that was gained one day before this ticket was filed.

Against that, A costs one line and turns a compiler-internal error into working string support. Tickets-closed-per-change, per root-cause-over-microfix.md, strongly favours A.

What is genuinely lost, stated plainly

The 559-byte unit-free binary. That is a real and attractive property and this decision throws it away. Two things make it acceptable:

It is not BASIC's problem. A Pascal WriteLn('hello') is 63,760 bytes for the same reason — PasApplyDefaults defines PXX_MANAGED_STRING unconditionally, so every Pascal program pulls builtinheap. The size question is global, and its correct answer is reachability-gated emission, not a per-frontend hand-rolled pull. [[feature-emission-size-dce]] is that answer and is marked done while a hello-world still weighs 63 KB, which is itself worth a look.

Fixing it generally recovers BASIC's small binary for free, and fixing it in BASIC only recovers it for the one frontend nobody ships from.

Option B was rejected, but half of it is kept

B's diagnostic reasoning is right about the message and wrong about the outcome: shipping a good error for a feature we can enable in one line is choosing to keep a language gap. But the underlying complaint stands — the current text ("compiler error: ... driver is missing its stub-emission call") reads as an internal fault and points the user at nothing. Any BASIC construct that still cannot be lowered after this should say so in BASIC's terms.

Heeding the ticket's own warning

Do not emit the shims unconditionally without their bodies. That is the exact mechanism of [[bug-a-a-unit-free-basic-program-calls-a-helper-it-never-emits]] — the shim emitted, the builtinheap body not, and nothing resolving the driver's forward calls, so the call kept its placeholder for the whole compile and silently did not happen. Pulling the unit is what makes the bodies exist; that is the point of A over "emit the shims to be safe."

Re-filed as work

Log