Skip to content

Read structure off the compiler's walk, not a reflective scan - #97

Merged
robertvansteen merged 2 commits into
0.xfrom
unboundsymbols
Aug 17, 2026
Merged

Read structure off the compiler's walk, not a reflective scan#97
robertvansteen merged 2 commits into
0.xfrom
unboundsymbols

Conversation

@robertvansteen

Copy link
Copy Markdown
Contributor

UnboundSymbols walked the source tree by reflecting over public properties to answer structural questions — which symbols are free, which definitions reference which — while the compiler answered the same questions a second time by actually compiling. Two descriptions of one structure, and the reflective one was the guessed one: a child in a private property or a composite type missing from its hardcoded allowlist (MatchPattern, MatchArm) silently disappeared from parameter discovery and cycle detection. This PR deletes the shadow copy. Every structural fact now comes from the compiler's own descent, which each source compiler already states by compiling its children — the machinery #90 (recorded reads), #91 (the walk survives faults), and #92 (reads as the runtime contract) built.

Three things fall out of UnboundSymbols, one per call site:

Cycle detection moves into the descent. TypeEnvironment already carried an in-progress chain as a recursion backstop; it is now the sole detector, and DefinitionGraph's upfront graph pass (with ErrorRecovery's poisoning machinery) is deleted. The same broken expression, both channels:

$expression = new Expression(new SymbolSource('a'), definitions: new Definitions([
    'a' => new SymbolSource('b'),
    'b' => new SymbolSource('a'),
]));

Before, compile() refused with the unlocated wrapper The definition graph is not well-founded; evaluation would recurse without terminating. (path: null — the one refusal that named no node). Now it refuses with Cyclic symbol definition: a → b → a. at $.children[0].node.children[0].node — the reference that closed the cycle, two definition hops below the root. The chain is trimmed to start where the cycle does, so a definition merely passed through on the way in (dependant → a → b → a) reports a → b → a, not itself.

Expression::parameters() derives from the compiler's recorded reads (via diagnose(), which records references even through parts that refuse), filtered of defined names. One semantic improvement, in the same direction #92 set: a declared input read only through a definition now counts. With ['gross' => turnover * rate] defined, gross + 1 reported [] before (the reflective scan never descended into definitions) and reports ['turnover', 'rate'] now — exactly the bindings the call will demand.

The persisted-tree guard in compileOwnedSymbol is deleted. Its whole job was catching disagreement between the compiler and the reflective scan; with one walk there is nothing to disagree. A symbol a host compiler constructs without persisting is no longer refused — resolution itself records it, so it shows up in parameters() and Program::$references and cannot hide. The SourceCompilationTest "cannot hide a dependency" test now asserts that stronger property.

Behavioral changes to be aware of:

  • A cyclic definition nothing references now compiles clean. Compilation is path-insensitive — every branch and match arm is descended — so the only cycles that escape are among definitions the expression never names anywhere in its tree. Refusing those was overreach: a shared definitions bag with one broken entry no longer blocks unrelated expressions. A host that wants corpus-wide health checking compiles each definition as its own expression.
  • Overlapping cycles report one diagnostic per reference that closes a cycle (consistent with how two references to one unbound name already reported), rather than one whole-graph verdict: a → b + c, b → a, c → b read from c reports b → a → b and c → b → a → c.
  • Deleting UnboundSymbols is breaking — it shipped in 0.6.x. DefinitionGraph was unreleased, so its changelog entries are edited to describe the final state.

Evidence: PHPStan clean, 1,172 tests at 100% line coverage, infection MSI 100%. Net −796 lines.

🤖 Generated with Claude Code

robertvansteen and others added 2 commits August 17, 2026 15:13
Delete UnboundSymbols and DefinitionGraph: cycle detection moves into
TypeEnvironment's own descent, Expression::parameters() derives from
diagnose()'s recorded references, and the persisted-tree guard goes
with the scan it kept honest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CoversClass scopes each test's recorded coverage, so the Diagnosis-level
assertions were never credited to TypeEnvironment's cycle branch and
infection ran only the direct TypeEnvironmentTest tests against its
mutants. Add close-range kills: the cycle-branch read observed through a
recovery-enabled compiler, chain trimming and pop precision, parameters()
dropping a definition name, and parameters() being dialect-relative. The
nullsafe-strictness mutant on the cycle branch is equivalent ($reads is
provably non-null there) and is ignored in infection.json5.

Also document the other observable parameters() shifts in the changelog:
dialect relativity, first-read order, and the diagnose() cost —
Program::$references is the cheap answer for compiled expressions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@robertvansteen
robertvansteen merged commit 4833f25 into 0.x Aug 17, 2026
6 checks passed
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