Skip to content

fix: multi-line fn body with record-constructor tail no longer trips ILO-P020#361

Merged
danieljohnmorris merged 5 commits into
mainfrom
fix/p020-record-tail
May 17, 2026
Merged

fix: multi-line fn body with record-constructor tail no longer trips ILO-P020#361
danieljohnmorris merged 5 commits into
mainfrom
fix/p020-record-tail

Conversation

@danieljohnmorris
Copy link
Copy Markdown
Collaborator

Summary

A bare record constructor (cr field:val field:val) at the start of a continuation line in a multi-line function body was getting mis-classified as a new top-level fn declaration. The body terminated, the parser tried to read cr country:name revenue:rv as a fn header, and ILO-P020 fired because that line has no > at all. The persona-side workaround was paren-wrapping the constructor (cr country:name revenue:rv) — a four-character cost on every multi-line function returning a record, which is a very common JSON / API shape.

Reported by db-analyst rerun8 friction #3 (severity red). Manifesto framing: record-as-tail multiplied across every agent reaching for a record-shaped return is a measurable token-cost paper-cut that this fix removes for the whole population.

Repro

type cr{country:t;revenue:n}
build-country-rec p:L _>cr
  name=p.0
  rv=p.1
  cr country:name revenue:rv

main>n
  p=[ "uk", 100 ]
  rec=build-country-rec p
  rec.revenue

Before:

ILO-P020 incomplete function header for `cr`: header runs off the end of the line
ILO-T008 return type mismatch: expected cr, got _

After: 100 (clean run on tree, VM, Cranelift).

Root cause

Parser::is_fn_decl_start_strict in src/parser/mod.rs is the guard that prevents parse_body_with from terminating a body when the next ;-separated statement looks like Ident param:type ... but is actually a record-constructor expression. It scans forward looking for > (the return-type separator that confirms a real header) and stops only at ;, }, or EOF. Newlines have already been filtered out of the token stream (their positions are recorded in decl_boundary for ILO-P020 anchoring), so the scan would walk straight across a top-level declaration boundary, find the > of the NEXT function's header, and incorrectly return true.

What's in the diff

  • src/parser/mod.rs: per-token decl_boundary check inside the scan loop. A real fn header always has its > on the same logical line as the name, so finding a boundary before > means this is not a header.
  • tests/regression_p020_record_tail.rs: cross-engine coverage (tree + VM + Cranelift) for (a) 1/2/3-field record tails followed by another fn — the original repro; (b) record tail with no trailing fn (EOF branch); (c) record tail on single-line body after explicit ;; (d) regression guard for real fn decls still being recognised correctly (the case the strict check was originally added for).
  • examples/record-tail.ilo: agent-facing example, picked up by the examples_engines.rs harness so it runs on every engine on every commit.
  • SPEC.md: record-constructor row added to the "safe non-last function body endings" table. ai.txt and skills/ilo/SKILL.md regenerate from SPEC.md via build.rs and pick up the same row.

Test plan

  • minimal repro now runs clean on tree, VM, and Cranelift
  • new cross-engine regression test covers the four shapes
  • examples/record-tail.ilo runs on every engine via the harness
  • doc regeneration (ai.txt, SKILL.md) clean after cargo build
  • full cargo test --release --features cranelift green
  • CI all checks green

Follow-ups

None. The fix is narrow to one helper with one new early-return; no adjacent bugs surfaced.

is_fn_decl_start_strict was scanning forward for the > return-type
separator to confirm a candidate ident param:type ... shape was a real
fn header rather than a record-constructor expression. The scan only
stopped at ; / } / EOF, so when a multi-line function body ended with
a bare record-constructor tail like cr country:name revenue:rv, the
scan walked past the end of the line into the NEXT function's
name>type header, found a >, and incorrectly classified the record
as a fn-decl start. The current body terminated, parse_program then
tried to parse cr country:name revenue:rv as a fn header, and
ILO-P020 fired because that line has no > at all.

Add a per-token decl_boundary check inside the scan loop. A real fn
header always has its > on the same logical line as the name, so a
boundary before > unambiguously means the candidate is not a header.

Reported by db-analyst rerun8 friction #3 (record-constructor-as-tail
is a common JSON / API shape pattern, currently forcing a paren wrap
on every multi-line function returning a record).
Exercises the four shapes the strict-fn-decl-start scan needs to
classify correctly:
  (a) cr field:val in a multi-line body, followed by another fn
      (1, 2, 3 fields) — the original db-analyst rerun8 repro
  (b) cr field:val with no trailing fn (EOF branch of the scan)
  (c) cr field:val on a single-line body after an explicit ;
  (d) real fn declaration with > on the header line still parses
      as a fn decl (regression guard for the original purpose of
      is_fn_decl_start_strict)

All cases run on tree, VM, and Cranelift via the ENGINES_ALL fan-out
already used by sibling regression tests.
Drives the multi-line record-constructor tail shape through every
engine via tests/examples_engines.rs and gives an in-context example
that agents will encounter when grepping examples/ for record-shape
patterns. Reads named fields rather than asserting on the full
record's Display output because record-field iteration order on the
tree-walker is HashMap-backed and therefore non-deterministic across
runs.
Now that the parser accepts cr field:val as a tail expression
without paren-wrapping, add it to the SPEC.md table of safe endings
for non-last functions in a file. ai.txt and skills/ilo/SKILL.md
regenerate from SPEC.md via build.rs and pick up the same row.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 17, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

CI run failed because rec=build was binding rec to the function
reference F _, not to the call result, so rec.x then errored with
ILO-T018 field access on non-record type F _. Add the explicit
zero-arg call form rec=build(). Also reshape the negative-control
test (d) to use a single-line file (no newlines), which is the
shape that actually exercises the strict check's > scan and would
catch a regression where someone over-tightens the new boundary
guard.
@danieljohnmorris danieljohnmorris merged commit 3d47845 into main May 17, 2026
5 checks passed
@danieljohnmorris danieljohnmorris deleted the fix/p020-record-tail branch May 17, 2026 20:24
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