Skip to content

feat!: standardize struct literals and adopt Range descriptor semantics - #65

Merged
thiremani merged 60 commits into
masterfrom
codex/explicit-struct-layout
Jul 29, 2026
Merged

feat!: standardize struct literals and adopt Range descriptor semantics#65
thiremani merged 60 commits into
masterfrom
codex/explicit-struct-layout

Conversation

@thiremani

@thiremani thiremani commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

  • require the explicit, uniformly indented : fields struct-literal layout
  • make a bare Range a first-class descriptor value: assignment copies it and print formats it, while operations iterate it
  • keep ArrayRange internal to immediate call specialization, with collision-free structural mangling, shared-driver safety, rank-N callee iteration, and loop-carried output aliasing
  • stage indirect outputs with per-output write flags, and give every direct scalar return a destination seed
  • classify destination writes per value expression in dead-store analysis, including non-yielding values and skippable callees
  • update ownership, C ABI, range, and formatting documentation; expand solver, IR, E2E, and leak coverage

The Range model

Range<T> is an immutable descriptor value; using it in an operation creates a ranged computation:

i = 0:5
j = i          # copies the descriptor
i              # prints 0:5
"i is -i"      # i is 0:5 — a single string
last = i + 0   # iterates; keeps the final yield: 4
values = [i]   # collects: [0 1 2 3 4]
Square(i)      # executes once per yield

Each named binding is a distinct driver: distinct names form a cartesian domain when consumed together; repeated use of one name shares a loop; a bare name bound as a driver by a sibling computation yields its per-iteration scalar. An empty ranged computation preserves an existing destination and leaves a fresh one at zero. Width/precision specifier operands consume a Range as a driver; an explicit numeric conversion on a Range main marker is a compile error.

Breaking changes

  • struct field headers require a space after :; value rows use uniform nested indentation; printed structs follow the same layout
  • j = i copies the Range descriptor instead of finalizing to the last yield; assigning a Range to an existing scalar is rejected (migrate with + 0)
  • printing a bare Range or bare main marker emits the descriptor once instead of one line per yield; two bare ranges print on one line rather than a cartesian expansion
  • indirect return carriers include output pointers plus per-output write flags; every direct I64/F64 return receives a hidden destination seed (native C callers must pass the current destination value, or zero for a fresh one)
  • dead-store analysis classifies writes per value expression: dead stores previously hidden behind a failable sibling or a ranged collector are now reported

Fixes found in review

Nine correctness defects fixed, each with a regression test verified to fail when its fix is reverted; plus refactors and coverage hardening. Highlights: 5b1e85f (collector over named-range selection ICE'd), 7a544b3/fb5e436/542fbd2 (dead-store analysis rejected valid programs, then classified per expression), c79a918 (comparison-LHS heap-string leak escalating to a double free), 61693ce (type-mismatched alias selects returned silently wrong results — fixes #66), 7d0fb6c (indirect alias refinement guard), cbb8614 (print descriptor literal beside a driving sibling ICE'd), 852572c (collector writes are unconditional).

Known follow-ups, pre-existing and tracked: #67, #68, #69, #70, #71, plus per-lane write/yield effects and .pt-body range liveness deferred to PIR.

Validation

  • go test -race ./lexer ./parser ./compiler · go vet ./... · gofmt clean
  • python3 test.py --leak-check — 68 passed, 0 failed

thiremani and others added 15 commits July 23, 2026 15:37
Require a spaced field marker and an aligned nested value row for struct declarations, and emit the same table-style layout when formatting values.

Update parser/compiler coverage, clarify formatted-string ownership, and correct the release package maintainer metadata.
Treat bare ranges and range-indexed selections as streams whose value roots retain the final yield, while explicit array literals collect all yields.

Keep ArrayRange internal to immediate call specialization, use collision-free structural mangling, preserve shared-driver identity, and support callee-side Range/ArrayRange iteration across direct and indirect returns. Stage indirect outputs with write flags and preserve compatible input/output aliases across iterations.

Update ownership and C ABI documentation and expand solver, IR, E2E, and leak coverage.

BREAKING CHANGE: Bare Range and array-range values no longer print or bind descriptor views. Root use iterates and retains the final yielded value.
Seed every direct I64/F64 return from the caller so skipped conditional writes preserve existing destinations, including through nested calls. Keep the seed in the type-derived ABI so write-effect-only edits cannot change a mangled symbol’s native signature.

Document the ABI contract, clarify named Range descriptor lowering, and add I64/F64 and conditional accumulator coverage.

BREAKING CHANGE: Native C callers of functions with a single I64 or F64 output must pass a final seed argument containing the current destination value, or zero for a fresh destination.
Remove the redundant HasSeedParam state and use ABIReturnDirect as the single predicate for LLVM signatures and call arguments. Cover zero-argument numeric and string return classifications.
Represent destination aliases once per prepared call and derive both indirect pointer substitution and direct ABI selector arguments from that plan. Add mixed direct-input and indirect-return accumulator coverage.
…ections

HandleCallRanges only registered the scalar callee variant on the path where
an argument was syntactically rewritten. Promoting a range-indexed array
selection to an internal ArrayRange changes the call's mangled name without
rewriting the argument list, so a collector over such a call asked codegen for
a variant that was never created.

The scalar variant is required whenever a surrounding collector invokes the
call once per yield, which is independent of any rewrite, so hoist the
registration above the unchanged-arguments early return. Require merged ranges
as well: LoopInside is true for any ordinary call, so it alone would register a
redundant variant for every user call rather than only for collectors.

Before this, "g = [Double(arr[i])]" with a named driver "i" aborted with an
internal compiler error, and any other call to the same function anywhere in
the script instantiated the variant and masked it. The new test calls each
helper exactly once so no sibling call can hide a regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Since direct scalar returns gained a destination seed, a callee that skips its
output write leaves the caller's previous value in place. Dead-store analysis
still classified "dest = Call(...)" as an unconditional write, so it rejected
programs whose earlier assignment is observably live:

    v = 7
    v = CondScalar(-1)   # callee skips, v stays 7
    "V: -v"

This affects direct scalar returns and indirect array/table outputs alike.
Only a call at a value root can preserve the destination; a call feeding an
operator always contributes to a new value, so that stays unconditional.

The rule is deliberately conservative. Proving a specific callee always writes
would need its per-specialization range types, which the CFG does not have, so
this gives up dead-store detection through call roots rather than reject valid
code. Both directions are covered by tests so the lint cannot be silently lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
compileExpression records a CompileError and returns no symbols when call
resolution fails, but storeArrayCellSlotWhenInBounds indexed vals[0]
unconditionally, so such a cell aborted with an internal compiler error and
swallowed the diagnostic that was already recorded.

Leaving the cell seed in place matches how conditional skips and out-of-bounds
paths already behave, and lets the real error reach the user. An empty result
with no recorded error is an internal fault rather than user error, so that
case still panics instead of silently dropping the cell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two halves of one ownership bug, fixed together because fixing either alone
leaves the other reachable.

extractComparisonSlots marked the comparison's left operand Borrowed so a later
assignment would copy rather than transfer that binding's payload. For a
non-pointer binding, compareScalars hands back the scope's own *Symbol, so the
mark landed on the binding itself and left the variable borrowed for the rest
of its life; scope cleanup then never freed its heap payload. Mark a copy.

That in turn exposed the print path. appendPrintSymbol decided whether printf
owns a heap string from the AST node kind rather than from ownership, and a
root-position comparison yields its left operand's payload under an
InfixExpression node. Printing "a > \"abc\"" therefore freed the binding's
payload: previously a silent use-after-free that left the name dangling, and a
double-free abort once the binding is correctly no longer borrowed. Ownership
now decides, behind a named predicate.

The node-kind check remains alongside it because identifier symbols are not
marked Borrowed today; dropping it double-frees in five existing tests.
Marking bindings correctly and removing the proxy needs its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
updateUnresolvedType lost its last caller when array-range call types became
trusted; addCallTypeError and createLoopState were already unreferenced. No
behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hidden alias selector names an output by position, and the callee then
reads that storage as the parameter's own type. directParamValue folded every
output into the select chain regardless of type, so a range-bearing callee with
a direct scalar parameter and a differently-typed sibling output emitted
"select i1, double, i64" followed by an arithmetic op on the wrong type. The
program compiled and returned a wrong answer: an I64 accumulator paired with an
F64 output yielded 0 instead of the accumulated sum.

Skip incompatible outputs rather than filter them, so index i keeps meaning the
i-th output for the remaining slots; filtering would renumber every selector
after the gap. The same rule now guards caller alias planning, which previously
applied it only to indirect parameters, and pointer promotion, where a selected
pointer would otherwise be loaded as the wrong type.

Function-level LLVM verification covers the emitted IR for float, string and
array sibling outputs. Verification is per function because module scope also
trips on format-string globals built in the global LLVM context instead of the
module's own.

Fixes #66

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thiremani and others added 4 commits July 25, 2026 12:14
Every existing case put the compatible accumulator first, so the accumulator
was always selector 1 and nothing proved that a preceding incompatible output
stays a numbering gap. Add reversed-output variants where the mismatched output
comes first and the accumulator is reached through selector 2, plus an F64
accumulator behind a string output.

These catch a distinct failure from the existing coverage. Filtering the
incompatible outputs instead of skipping them still emits valid IR, so function
verification passes, but every later selector shifts by one and the accumulator
silently stops aliasing: the reversed end-to-end expectation is what fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Writing a parameter through %n promotes it to memory, so the aliased slot is
picked by pointer rather than by value. Nothing exercised that path with a
mismatched leading output.

An end-to-end reversed accumulator is not enough on its own: opaque pointers
make a mistyped pointer select valid IR, and the selector never matches the
skipped index at runtime, so the program behaves identically either way. Assert
on the emitted slot selects instead, which is what actually distinguishes it,
and add the reversed %n accumulator so the runtime result is pinned too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The slot assertions proved a select existed at the right position but not what
it compared against or chose. Assert the ABI selector value is 2 and that it
selects the caller's destination, so compacting the compatible outputs is
caught by its own message rather than only by the absent-slot check.

Also record why the caller-side compatibility check has no test: a name that is
both argument and destination must hold one type, so the solver rejects the
mismatch before alias planning runs. The check is unreachable for valid source
and kept only so all three alias sites share one rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The destination a call argument aliases was kept in a slice parallel to the
argument list, so two loops cross-indexed it against sig.ABI.Params and the
"same length" invariant had to be maintained by hand. Move it onto callArg,
where it travels with the argument it describes.

Store the hidden ABI selector itself rather than an output index: the value is
what the call actually transmits, and 0 already means "no aliased destination",
so the zero value is correct for the three callArg literals that never set it.
An output index would have made 0 mean "aliases the first output".

Behavior is unchanged: alias emission is byte-identical across every test
module. (Whole-file IR comparison is not usable here because scope cleanup
iterates a map, so free ordering varies between runs of the same binary.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment claimed an incompatible caller alias was unreachable for valid
source. That holds only for direct scalars, where the solver rejects a name
that would need two numeric types. Indirect parameters can legitimately differ
in ownership flavor from a same-named output: a StrH binding may receive a StrG
output, and that program compiles.

Without the guard the caller redirects the StrH input to the StrG output's
adapter, so a sibling output reading the input sees the adapter's value:
"Sibling: static!" instead of "Sibling: hello!". The guard predates this branch
for exactly that reason; only its rationale had been lost.

Add the ranged StrH-input/StrG-output regression under the leak-checked suite,
and reword AliasSelector: indirect parameters do not transmit it, they consume
it caller-side to substitute the staged output pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thiremani and others added 5 commits July 25, 2026 20:47
The staged slot for a conditional over a ranged call takes the destination's
element flavor, not the call's own output type, but nothing covered that. The
suite passed with the flavor recovery removed, which made a load-bearing
fallback look like dead defensive code.

Add the case that distinguishes them: an established rank-2 destination reset
through a ranged call. With an empty driver the call yields nothing and the
destination must keep its matrix; taking the call's empty-array flavor instead
empties it and leaks the payload.

Fold the two lookups into destSlotType so the resolution reads in one place and
stops duplicating bindingSlotType inline. Behavior is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Treat complete bare Range assignments as descriptor construction or copy while preserving ranged execution for operations, active statement drivers, calls, interpolation, and array indexing.

Simplify identifier and ArrayRange lowering, document independent named-driver identity and migration behavior, and update regression coverage for conditional and empty domains.

BREAKING CHANGE: assigning a bare Range to a fresh name now copies the descriptor instead of retaining its final yield. Use an operation such as + 0 when a final scalar iterator value is intended.
Snapshot the compiler error count at the expression-lowering boundary so only a newly recorded diagnostic can explain an empty cell result. Keep the storage sink focused on storing a guaranteed value and cover recovery when an unrelated error already exists.
Dead-store analysis recognized three ways a destination could keep its previous
value: an explicit statement condition, a driver that iterates zero times, and a
root call whose callee may skip its write. A right-hand side that simply yields
nothing was still classified as an unconditional write, so valid programs were
rejected:

    y = 10
    y = Square(x < 5) + 5   # x >= 5 leaves y at 10

Three shapes were affected. A condition below the value root, reached through
any operator; an out-of-bounds read, which fails its lanes the same way; and
either of those inside a .pt function body, where no typed classification exists
because templates are validated before specialization.

Give the solver's traversal a caller-supplied node predicate so both dataflow
passes walk the tree once, from one implementation, and the resolver boundaries
cannot drift: an array literal settles a failed cell locally, and a || fails
only when its final fallback does. The solver keeps a condition-only predicate
because it also decides which programs are valid — ||, && and statement
conditions all require an operand that can fail — so folding array indexing in
there would legalize "arr[9] || -1". Only the CFG predicate, which feeds
diagnostics alone, counts an out-of-bounds read. A .pt body has nothing cached,
so it falls back to the syntactic shape and errs toward "may fail".

Known imprecision, both deliberate. Treating every array read as failable means
a statically safe "arr[0]" also suppresses a real dead-store warning.
Classification stays per statement rather than per target, so
"a, b = x < 5, 30" marks both destinations conditional and misses a genuinely
dead b; fixing that would newly reject code that compiles today.

Ranges inside a .pt body remain unhandled: an empty local driver still reports a
false dead store, because deciding that syntactically would require treating
every identifier as a possible Range. That and exact per-lane yield and write
effects belong in PIR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A failable value suspended every destination of its statement, so an
unconditional sibling write was also treated as conditional and a genuinely
dead store behind it went unreported:

    a = 10
    b = 20            # dead, but previously invisible
    a, b = x < 5, 30  # only a's write is optional

Runtime behavior says the effects are per expression, not per statement: with
an empty domain, "a, b = i + 0, 30" still writes b while preserving a. So a
statement condition keeps suspending the whole simultaneous assignment, and
every other source of a skipped write — an empty driver, a callee that keeps
its output, a value that never yields — now marks only the destinations its
own expression feeds, placed by the solver's ExprLen. A .pt body has no typing
yet, so spans are placeable there only when values pair one to one with
destinations; anything else stays statement-wide.

BREAKING CHANGE: dead stores previously hidden behind a failable sibling are
now reported. The suite carried three, all fresh-destination initializations
overwritten by an unconditional sibling read; they are removed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thiremani and others added 27 commits July 27, 2026 10:30
Record statement range-driver classification during type solving and consume that result during lowering, preventing nested array selections from producing invalid IR.

Remove obsolete driver fallbacks and duplicate CFG scans, reuse existing ArrayRange operand lowering, make Symbol copies future-proof, and align the ABI plan with the emitted SSA behavior.
Separate range discovery from per-iteration guard selection so scalar conjuncts remain active when another condition supplies the loop domain.

Fixes #72
Commit ranged collector outputs only after the shared statement gate admits an iteration, and release unused accumulator storage on blocked paths. Treat every statement-gated destination as conditional and cover scalar, string, rank-N, empty-domain, and function-output cases.
Inline the single-use value and condition classifier wrappers into their natural callers. Keep the shared tree traversal and CFG node predicate separate so collector, logical fallback, and out-of-bounds semantics remain explicit and unchanged.
A Range parameter is bound by the caller and drives the whole specialization,
so its possibly-empty domain is one shared effect at the function boundary; a
locally created range suspends only the statements it drives. Template-time
CFG has neither distinction, which is the known false dead-store on local
empty-range bodies, and typed per-specialization effects resolve it.

Also record the caching requirement that follows: effects, binding types, and
validation results must be cached atomically per mangled variant, since the
existing FuncCache/BindingTypes lifetime split is what produced issue #71.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A call site must consume exactly len(callee.Outputs) destinations and that
arity is fixed by the template declaration, so output spans are derivable
before any typing exists. Template analysis can therefore classify the literal
slot of a mixed statement as a definite write while the call's slots stay
conditional, instead of blanketing every destination; only shapes whose slot
count genuinely needs types keep the all-conditional fallback.

Also refine the Range-argument wording in the domain-ownership paragraph: the
argument establishes a function-level domain whose yielded values drive the
body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Route typed scripts through exact solver-backed write classification and untyped function templates through their syntax-only approximation. Share event processing while removing nullable ScriptCompiler checks and retain focused coverage for typed array-mask writes.
Collect Let and Print reads through one mode-neutral path, with typed and template passes supplying only their respective Let write kinds. This removes the template pass dependency on typed statement extraction.
Initialize conditional statement destinations directly as ConditionalWrite, and fill the default Write classification only for statements that continue into per-value analysis.
Create fully initialized write-kind slices through one shared helper. This avoids exposing EventType's Read zero value while removing duplicated fill loops in typed and template classification.
Trust solver-proven call arity and ArrayRange shape during argument lowering. Remove redundant alias-selector filtering and write-flag temporaries without changing generated behavior.
Keep the regression intent in focused comments while making the related test identifiers easier to scan.
Rename the lowering test and document that caller-side scalar iteration is the current correctness fallback until callee specializations encode shared driver identity.
Trust the specifier parser's forward cursor contract and derive marker validity solely from resolved main markers.
Make range symbol extraction total for its lowering callers and report invalid driver types in one place. This removes duplicate success checks and caller-side invariant panics.
Route every non-descriptor print argument through the ordinary expression collector while preserving bare Range descriptors until sibling drivers are known. This keeps array selections and other computations on the same path as call arguments without changing print semantics.
Make callScopedArrayRangeType the single authority for bare array-selection eligibility and reuse its result during range-driver classification. Preserve the diagnostic path where an invalid array source leaves its index untyped.
Rely on identifier typing and range handling to populate the cached range metadata before assignment resolution. When a statement condition binds the descriptor as an iterator, update only the returned and cached output types while keeping their slices independent.
Rely on statement-condition typing to populate the root and array-source expression cache entries before range-driver classification. Keep only the range and source-dependency checks that affect classification.
Rely on completed typing for statement, logical operand, lowering, and call-argument cache entries. Retain and document the condition propagation guard needed while diagnostics walk partially typed invalid expressions.
Remove repeated descriptor, final-value, collector, and statement-gate explanations while keeping each rule in one canonical section. Link dynamic formatting behavior to its dedicated specification and correct the final ABI table alignment; no language semantics change.
Seed the rank-N destination with a distinct matrix so any mistakenly admitted iteration changes the observable result.
Fold overlapping branch-added fixtures into the existing array, condition, and math suites, and trim redundant unit assertions. Keep focused IR checks plus both written and unwritten gated rank-reset paths while reducing the E2E executable count from 70 to 63.
@thiremani
thiremani marked this pull request as ready for review July 29, 2026 14:05
@thiremani
thiremani merged commit 57c2073 into master Jul 29, 2026
2 checks passed
@thiremani
thiremani deleted the codex/explicit-struct-layout branch July 29, 2026 14:06
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.

Ranged callee emits type-mismatched alias select, producing a silently wrong result

1 participant