feat!: standardize struct literals and adopt Range descriptor semantics - #65
Merged
Conversation
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>
This was referenced Jul 25, 2026
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>
This was referenced Jul 25, 2026
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>
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>
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
: fieldsstruct-literal layoutRangea first-class descriptor value: assignment copies it and print formats it, while operations iterate itArrayRangeinternal to immediate call specialization, with collision-free structural mangling, shared-driver safety, rank-N callee iteration, and loop-carried output aliasingThe Range model
Range<T>is an immutable descriptor value; using it in an operation creates a ranged computation: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
:; value rows use uniform nested indentation; printed structs follow the same layoutj = icopies the Range descriptor instead of finalizing to the last yield; assigning a Range to an existing scalar is rejected (migrate with+ 0)I64/F64return receives a hidden destination seed (native C callers must pass the current destination value, or zero for a fresh one)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 ./...·gofmtcleanpython3 test.py --leak-check— 68 passed, 0 failed