Skip to content

plawk: compile END-only programs (no main rule) - #4243

Open
s243a wants to merge 5 commits into
claude/plawk-llvm-wam-hybrid-p9ujutfrom
claude/plawk-llvm-wam-hybrid-p9ujut-end-only
Open

s243a wants to merge 5 commits into
claude/plawk-llvm-wam-hybrid-p9ujutfrom
claude/plawk-llvm-wam-hybrid-p9ujut-end-only

Conversation

@s243a

@s243a s243a commented Sep 11, 2026

Copy link
Copy Markdown
Owner

What was wrong

END { print "done" } — and every other END-only form — declined at exit 3. These are among the simplest awk one-liners, so it was a real surprise for new users (the motivation for closing it: reduce surprise). The instant a main rule existed ({ n++ } END { ... }), the same END worked.

The key insight: an END-only program still reads all input

An END block sees NR (total record count), $0/$N (the last record), NF and length — so the record loop must still run, consuming all of stdin, counting NR and retaining the last record, with only the per-record rule chain empty. On empty input NR is 0, $0 empty, NF 0.

So an END-only program is the existing rules+END driver with an empty rule chain, not the BEGIN-only driver (which reads no input). Both pieces it depends on — the NR counter and the retained-last-record projection — already existed in the tree.

Three coordinated changes, each gated on empty rules

All three fire only when the rules are empty, so no program with rules is touched — 20/20 golden-corpus programs byte-identical:

  1. plawk_scalar_state_plan/3 admits an empty plan when Rules == [] (its guard otherwise requires an action / print-field / getline, none of which a rule-less program has).
  2. a dedicated plawk_scalar_rule_chain_ir([], ...) clause emits br label %continue_loop — the terminator the empty lowered_match: block needs. Without it the general clause emits an unterminated block: invalid LLVM, an exit-4 clang failure. The general clause keeps its RuleCount > 0.
  3. the END-loop driver clause is guarded Rules0 \== [].

Total: 5 lines of logic plus comments.

Change 3 is the sharp one — and why a full exit-4 sweep was mandatory

Relaxing the state-plan guard (change 1) made END { while ... } — which the END-loop clause handles, cutting as soon as it sees a loop — newly pass state_plan, commit past its cut, and emit a malformed loop driver: exit 4, a miscompile, on a program that used to decline cleanly. Change 3 keeps END-loop-with-no-rule declining (exit 3) as the follow-on it is.

The transferable lesson (recorded in the handoff): relaxing a shared gate can convert a clause that declined-by-relying-on-that-gate-failing into one that commits and miscompiles. A broad exit-4 sweep of the whole surface the relaxation touches is not optional — here it found exactly one such site, and the campaign's worst outcome (invalid LLVM on a supported-looking program) was one un-run probe away.

Scope

Compiles and matches gawk: END-only constant/NR/field/NF/length/arithmetic print, printf, multi-statement END lists, END-if (with else, empty input), and the empty program '' (reads input, prints nothing — correct gawk behaviour, pinned as intended).

Declines cleanly (exit 3, never exit 4), pinned as follow-ons: END-only loop, END-only scalar assignment, for-in, getline, record builtins (substr($0,…), toupper($1)).

Parse-level (exit 2): BEGIN { x=5 } END { print x }, END assoc assignment.

Verification

  • 29 tests in tests/test_plawk_end_only.pl — the target matrix (incl. empty input and the retained-record composition), the empty program, every boundary decline, an explicit "END-loop declines exit 3 not exit 4" pin, and an all-forms no-miscompile sweep.
  • Golden IR: 20/20 existing programs byte-identical vs the parent (this change is additive, gated on empty rules).
  • Broad exit-4 safety sweep of ~16 END-only shapes: zero miscompiles.
  • Two stale pins (test_plawk_end_if_nr.pl, test_plawk_end_field_reads.pl) that asserted END-only declines are re-attributed, not deleted, to assert the status flip; output parity is covered by the new suite. Both suites re-run green (14/14, 86/86).
  • Full sequential sweep on clang 14 running; totals to follow as a comment.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QrQistdoYMNpVtecwims6g

s243a and others added 3 commits September 11, 2026 17:31
`END { print "done" }` and every other END-only form declined (exit 3) -- the
simplest awk one-liners, a real surprise for new users. Now they compile and
match gawk: constant/NR/field/NF/length/arithmetic print, printf, multi-statement
END lists, END-`if`, and the empty program `''` (which reads input and prints
nothing, as gawk does).

An END-only program is NOT "skip the loop": an END block sees NR (record count),
$0/$N (LAST record), NF and length, so the record loop still runs -- consuming
all stdin, counting NR, retaining the last record -- with only the per-record
RULE CHAIN empty. On empty input NR is 0, $0 empty, NF 0. So it is the existing
rules+END driver with an empty rule chain, not the BEGIN-only driver (which reads
no input).

Three coordinated changes, each gated on empty rules so no program with rules is
touched (20/20 golden-corpus programs byte-identical):

  1. plawk_scalar_state_plan/3 admits an empty plan when Rules == [] -- its guard
     otherwise requires some action / print-field / getline, none of which a
     rule-less program has.
  2. a dedicated plawk_scalar_rule_chain_ir([], ...) clause emits
     `br label %continue_loop`, the terminator the empty `lowered_match:` block
     needs. Without it the general clause emits an unterminated block -- invalid
     LLVM, an exit-4 clang failure. The general clause keeps its `RuleCount > 0`.
  3. the END-loop driver clause is guarded `Rules0 \== []`.

Change 3 is the sharp one and the reason for a full exit-4 sweep. Relaxing the
state-plan guard (change 1) made `END { while ... }` -- which the END-loop clause
handles, cutting as soon as it sees a loop -- newly pass state_plan, commit past
its cut, and emit a malformed loop driver: exit 4, a MISCOMPILE, on a program that
used to decline cleanly. The guard keeps END-loop-with-no-rule declining (exit 3)
as the follow-on it is. Lesson recorded in the handoff: relaxing a shared gate can
convert a clause that declined-by-relying-on-that-gate-failing into one that
commits and miscompiles; enumerate every such clause and sweep the whole surface
for exit 4.

Scope: END-only print / printf / if / empty-program compile and match gawk.
Still declining cleanly (pinned, exit 3, never exit 4): END-only loop, END-only
scalar assignment, for-in, getline, record builtins (substr($0,..)/toupper($1)).
Parse-level (exit 2): BEGIN+assignment+END, END assoc assignment.

Tests: tests/test_plawk_end_only.pl (29) -- the target matrix incl. empty input,
the retained-record composition, the empty program, every boundary decline, an
explicit "END-loop declines exit 3 not exit 4" pin, and an all-forms no-miscompile
sweep. Two stale pins in test_plawk_end_if_nr.pl and test_plawk_end_field_reads.pl
that asserted END-only declines are re-attributed (not deleted) to assert the
status flip, output parity covered by the new suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrQistdoYMNpVtecwims6g
Strengthens the END-only suite with an explicit enumeration test: one rule-less
program routed at each END-driver clause type -- scalar print/list/if, loop, the
MIXED and ASSOC ends whose RuleCount>0 gates were deliberately NOT relaxed,
BEGIN+end, getline and assignment -- each asserted to build to a real exit code
(0/2/3) and NEVER 4.

The load-bearing cases are the mixed and assoc rule-less shapes (`END { print
c["x"] }`, `END { for (k in c) print k }`, `END { c["x"]++ }`): they confirm the
two unrelaxed gates keep those clauses DECLINING on empty rules rather than
committing past a cut and miscompiling -- the exact "did you enumerate every
clause that relied on the shared gate failing" question the state-plan relaxation
raises. All twelve build without exit 4. Complements the shape-level
no-miscompile sweep already in the suite with a clause-level one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrQistdoYMNpVtecwims6g
…exit-4)

An ultra review found a blocking miscompile the first cut missed: END-only
programs that read/write a scalar VARIABLE -- `END { print x }`,
`END { printf "%d\n", x }`, `END { if (x == 0) ... }`, `END { if (NR>0) print x }`,
`BEGIN{...} END { print x }` incl. BINFMT -- exited 4 (clang: use of undefined
value '%rule_-1_in_slot_0').

Cause: admitting an empty rule chain let plawk_scalar_state_plan collect the
scalar as a slot, so the state plan was NOT empty. The next-slot phi emitter then
computed LastRuleIndex = RuleCount - 1 = -1 and referenced %rule_-1_match /
%rule_-1_in_slot_N -- undefined SSA. My own exit-4 sweep missed it because every
print case it tried used a literal / field / NR / NF / length, never a scalar
variable; the defect lives in the interaction between an empty rule chain and a
NON-empty state plan.

Fix: guard the dedicated empty rule-chain clause to fire only when the state plan
has NO slots (plawk_state_plan_slots(StatePlan, [])). A scalar-var END-only
program therefore declines cleanly (exit 3) instead of miscompiling. Declining
(rather than a pass-through phi) is the right scope: an unset scalar's value in END
is context-dependent -- empty in string context, 0 in numeric, the
uninitialised-scalar representation problem -- so a correct compile needs that
settled first. Scalar-var END-only is now a pinned follow-on.

Every non-scalar END-only form is unaffected and still compiles + matches gawk
(literal/NR/field/NF/length print, printf, multi-statement, END-if, empty program).
20/20 golden-corpus programs remain byte-identical (the guard is inert for
rule-bearing programs). The reviewer's entire blocking list now exits 3, zero
exit-4 across a broad rule-less re-sweep.

Tests: the enumeration test gains the scalar-var forms (the exact blind spot), and
a new pin `end_only_scalar_var_read_declines_not_miscompiles` asserts exit 3 (not 4)
for five scalar-var shapes, so the miscompile cannot silently return. Suite now 31
tests. Handoff records the lesson: an exit-4 sweep must vary the DATA a relaxation
admits (does the END read a literal/field/special/scalar-VAR?), not just program
shapes, because the defect can hide in a data-dependent downstream a clause-level
enumeration never reaches -- and external review earns its keep precisely when the
change already looks polished.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrQistdoYMNpVtecwims6g
@s243a

s243a commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Blocking miscompile from the ultra review — fixed (commit e4e4fc28a)

The review (terra ultra) found a real exit-4 that my own verification missed, and it was right to BLOCK. Crediting it fully: END { print x } — a scalar variable read — miscompiled (clang: use of undefined value '%rule_-1_in_slot_0'), along with printf/if/BEGIN+/BINFMT variants of the same.

Root cause: admitting an empty rule chain let plawk_scalar_state_plan collect the scalar as a slot, so the plan was not empty. The next-slot phi emitter then computed LastRuleIndex = RuleCount - 1 = -1 and referenced %rule_-1_* — undefined SSA. My exit-4 sweep missed it because every print case I tried used a literal / field / NR / NF / length, never a scalar variable. The defect lives in the interaction between an empty rule chain and a non-empty state plan — a data dimension my clause-and-shape enumeration never varied.

Fix: the dedicated empty rule-chain clause is now guarded to fire only when the state plan has no slots (plawk_state_plan_slots(StatePlan, [])). A scalar-var END-only program declines cleanly at exit 3 instead of miscompiling. Declining (not a pass-through phi) is the right scope: an unset scalar in END is context-dependent (empty in string context, 0 in numeric — the uninitialised-scalar representation problem the review also touches on), so a correct compile needs that settled first. Scalar-var END-only is a pinned follow-on.

Verification of the fix:

  • The reviewer's entire blocking list now exits 3; a broad rule-less re-sweep shows zero exit-4.
  • Non-scalar END-only forms unchanged and still match gawk (literal/NR/field/NF/length/printf/multi-statement/END-if/empty program).
  • 20/20 golden-corpus programs still byte-identical — the guard is inert for rule-bearing programs.
  • Suite → 31 tests: the scalar-var forms are added to the no-miscompile enumeration (the exact blind spot), plus a dedicated end_only_scalar_var_read_declines_not_miscompiles pin (exit 3, not 4) so the miscompile cannot silently return. end_if_nr (14) and end_field_reads (86) re-run green.

The review's other two findings — confirmed, dispositions below

  1. EOF-by-text bug — real, but PRE-EXISTING, not introduced here. END { print NR, $0 } on input containing a literal end_of_file line prints 1 a vs gawk's 3 b. Confirmed — and the rule-bearing form { n++ } END { print NR, $0 } prints the same wrong 1 a, so it's a runtime stream-template issue (EOF detected by string comparison instead of atom identity) affecting all programs. Out of scope for this PR; filing as a separate runtime follow-on. This PR's END-only support makes it reachable END-only, but does not cause it.

  2. Parser-boundary corrections — confirmed and adopted. END { x = 5 } → exit 3; END { c["x"] = 5 } → exit 2; BEGIN { x = 5 } END { print x } → exit 2. My suite's pins don't contradict these (it only pins END { x = 5; print x } at exit 3, which holds); the looser wording in the original PR description is corrected by this note.

A full clang-14 sweep on the fixed branch is resuming; totals to follow.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QrQistdoYMNpVtecwims6g

s243a and others added 2 commits September 13, 2026 15:27
test_plawk_begin_only.pl pinned `BEGIN { print "b" } END { print "e" }` and
`END { print "e" }` as clean declines (exit 3) while END-only was a gap. The
END-only driver landed, so both now compile and match gawk. Re-attributed (not
deleted) to assert the new behaviour: they build (exit 0) and print their literal
output deterministically on empty stdin. begin_only now 31/31.

Surfaced by the full sweep -- which also exposed a harness bug (fixed in the local
sweep tooling, not this repo): under `set -e` a suite whose run_tests failed
aborted the whole sweep at that suite instead of recording the failure and
continuing, so these two stale pins had masqueraded as a sweep that "died at
11/195" rather than being reported as failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrQistdoYMNpVtecwims6g
…GES)

The review confirmed the scalar-slot fix is correct (no residual exit-4, guard
covers all scalar dispatch paths, no bypass) and asked only for stronger
regression coverage. Applied:

  - The scalar-var decline pin gains the four contexts the review named as
    missing: a concatenation (`print "n=" x`), an arithmetic operand
    (`print x + 1`), an ELSE branch (`if (NR==0) print "empty"; else print x`),
    and a union-BINFMT program. All decline cleanly at exit 3 (verified), never
    miscompile.
  - The no-miscompile enumeration gains an explicit redirection example
    (`print NR > "/dev/stdout"`, a parse error at 2) -- the comment claimed
    redirection coverage without a case.
  - build_status_not_4/1 now asserts the status is a KNOWN-GOOD value
    (memberchk in [0,2,3]) rather than merely `=\= 4`, which would have passed an
    unexpected exit 1 (a crash). Renamed intent in the comment.

Suite stays at 31 tests, all passing.

Also documents, in the handoff follow-ons, the EOF-sentinel defect both reviews
surfaced: a literal `end_of_file` input line is mistaken for EOF by a text
comparison in the runtime stream driver
(src/unifyweaver/targets/wam_llvm_target.pl ~23909). It is PRE-EXISTING (the
rule-bearing form is equally wrong) and needs an identity-based EOF check -- a
runtime fix affecting all programs, deliberately out of scope here; END-only
merely made it reachable in an END-only shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrQistdoYMNpVtecwims6g
@s243a

s243a commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

Second review (Astra, gpt-5.6, clang 14): SHIP-WITH-CHANGES — changes applied (62d9dfce7)

A fresh-model review verified the scalar-slot fix and asked only for stronger regression coverage. Its core findings:

  • The fix is correct. Independent enumeration of the scalar END-driver clauses (single print 788, statement list 851, END-if 980) confirmed each either has zero slots → compiles, or non-empty slots → declines at 3 — none can reach the negative-rule-index phi with slots. No alternate bypass (END-loop rejects empty rules pre-cut; mixed/assoc keep their gates; BINFMT flat/union hit the same guard). No residual exit-4 across 54 additional build probes.
  • Pins faithful, no supported form wrongly declined, 13 supported forms match gawk on normal/empty/unterminated/trailing-blank input.

Changes made per the review

  1. Four scalar-var contexts added to the decline pin — concatenation (print "n=" x), arithmetic (print x + 1), an ELSE branch, and a union-BINFMT program. All verified exit 3.
  2. Redirection example added to the no-miscompile enumeration (print NR > "/dev/stdout", exit 2) — the comment claimed redirection coverage without a case.
  3. build_status_not_4/1 tightened to assert memberchk(Status, [0,2,3]) rather than just =\= 4 — the old check would have passed an unexpected exit 1 (a crash), not only a miscompile.

Suite stays at 31 tests, all passing.

The one finding left open, by design

EOF-sentinel bug — confirmed by both reviews (terra + astra), PRE-EXISTING, out of scope here. A literal end_of_file input line is mistaken for EOF: END { print NR, $0 } on a\nend_of_file\nb\n prints 1 a vs gawk's 3 b. The runtime stream driver (src/unifyweaver/targets/wam_llvm_target.pl ~23909) compares record text against "end_of_file" instead of atom identity. It predates this PR and affects all programs — the rule-bearing { n++ } END { print NR, $0 } is equally wrong — so END-only merely made it reachable END-only. Now tracked as a runtime follow-on in the handoff; not fixed here to keep the END-only change scoped.

Net: both a from-scratch ultra review (which caught the original miscompile) and this fix-verification review now concur the scalar-slot handling is correct; the remaining item is a separate, pre-existing runtime defect.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QrQistdoYMNpVtecwims6g

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant