Class ValueTrackingAnalyzer

java.lang.Object
org.ek9lang.compiler.phase5.flow.ValueTrackingAnalyzer

public final class ValueTrackingAnalyzer extends Object
Tracks variable values through control flow for tautological condition detection.

Maintains a constraint state per program point and manages branch splitting/joining as control flow constructs (if/else, switch, while, for, try/catch) are entered and exited. This is a single-pass AST walk analyzer that exploits EK9's perfectly nested SESE control flow (no break/continue/return/goto) for O(N) analysis.

Phase 1 tracks: Integer, Boolean, and String constants. Variables assigned from function calls or non-pure mutations become TOP (unknown).

The analyzer is scoped per function/method/operator. It is reset when entering a new callable scope and produces results at expression evaluation points within that scope. A callable walked INSIDE another - a dynamic class's methods - gets its own fresh state, and the enclosing callable's state is restored when it ends (enterCallableScope()); before 2026-11-10 the enclosing callable lost everything (who shares, what escaped) and the rest of it was walked inactive (D99 review).

  • Constructor Details

    • ValueTrackingAnalyzer

      public ValueTrackingAnalyzer()
      Create a new analyzer (inactive until a callable scope is entered).
  • Method Details

    • enterCallableScope

      public void enterCallableScope()
      Enter a callable scope (function, method, operator). Resets the analyzer state for a fresh analysis; when this callable is nested in one being walked (a dynamic class's method), that one's state is kept, to be restored by exitCallableScope().
    • exitCallableScope

      public void exitCallableScope()
      Exit a callable scope: the enclosing callable's state comes back, or, when there is none, tracking stops.
    • isActive

      public boolean isActive()
      Check if the analyzer is currently tracking within a callable scope.
    • registerModuleConstant

      public void registerModuleConstant(ISymbol symbol, AbstractValue abstractValue)
      Register a module-level constant (from defines constant blocks). These persist across callable scope resets and are available for value lookups.
      Parameters:
      symbol - the constant symbol
      abstractValue - the constant's value
    • recordAssignment

      public void recordAssignment(ISymbol symbol, AbstractValue abstractValue)
      Record a variable assignment with a known constant value.
      Parameters:
      symbol - the variable being assigned
      abstractValue - the abstract value being assigned
    • recordLiteralAssignment

      public void recordLiteralAssignment(ISymbol symbol, AbstractValue abstractValue)
      As recordAssignment(ISymbol, AbstractValue), for a value that is exact on EVERY pass of an open loop - a literal, which no earlier pass can have changed (D48). Inside a loop the variable then counts as fresh. A value copied from another variable is not such a value, so it is recorded with recordAssignment(ISymbol, AbstractValue).
    • recordMayValues

      public void recordMayValues(ISymbol symbol, Set<Object> values)
      Record that symbol's value is not known exactly, but is one of values.

      This is the shape an expression with ARMS produces — a ternary, or a switch/try/loop expression whose returning parameter is assigned differently per arm. The MUST side stays TOP because the arms disagree; the MAY side keeps all of them, which is exactly the information a "could this Panic?" question needs.

    • recordUnknownAssignment

      public void recordUnknownAssignment(ISymbol symbol)
    • recordSharing

      public void recordSharing(ISymbol first, ISymbol second)
      Record that first and second may refer to the same object from here on - alias <- base, or the copy a capture list makes. A later in-place change through either changes what both hold.
    • recordEscape

      public void recordEscape(ISymbol symbol)
      Record that the object symbol refers to is now kept somewhere the analysis cannot name (D61): it may be changed through that holder at any later point, so it is changed now (recordInPlaceChange(ISymbol)) and nothing learned about it later - a condition narrowing it included - survives a call that may run the program's own code (recordCallThatMayChange(boolean, Collection)) or an in-place change to anything.
    • recordEscapeOfWhatItHolds

      public void recordEscapeOfWhatItHolds(ISymbol symbol)
      As recordEscape(ISymbol), for an escape of only what symbol's object holds - show(name.get()) for an Optional: everything known about it is forgotten as for any escape, but the object itself is not kept anywhere new, so the questions about it (mayBeChangedByCall(ISymbol, boolean, Collection), mayBeChangedThrough(ISymbol, ISymbol)) are not widened.
    • recordCallThatMayChange

      public void recordCallThatMayChange(boolean reachesOutside, Collection<ISymbol> reachedNames)
      A call that may run the program's own code (not pure, not a concrete built-in) - it may change every object it can reach (D66, Steve 2026-10-21): the receiver, every argument, everything reachable from them and everything that may be the same object. An object that has escaped is reachable from anywhere (kept where nothing here can name it), so it is always forgotten. When the call reaches an object from OUTSIDE (this, a parameter, a property, a result another call gave back - reachesOutside, or a named object that is one), every other object from outside may be that same object or hold it, so what is known about a parameter, a property or a name that may be one is forgotten too. A call that is pure never gets here.
      Parameters:
      reachesOutside - the call is given this, or an object whose origin the analysis cannot name
      reachedNames - the variables the call is given (receiver and arguments, and what they hold)
    • mayBeChangedByCall

      public boolean mayBeChangedByCall(ISymbol symbol, boolean reachesOutside, Collection<ISymbol> reachedNames)
      THE reading of recordCallThatMayChange(boolean, Collection) as a question about ONE object: may a call that may run the program's own code, given what it reaches, change the object symbol refers to? Yes when that object itself has escaped, or when the call reaches an object from outside and symbol's may be one. The objects the call is given by name are changed by their own occurrences (mayBeChangedThrough(ISymbol, ISymbol)). Used by the safe-access proofs (E08030), which must give up what such a call may change; never a second reading.
    • mayBeChangedThrough

      public boolean mayBeChangedThrough(ISymbol changed, ISymbol symbol)
      THE reading of recordInPlaceChange(ISymbol) as a question about ONE object: may an in-place change made through changed (a mutating operator, a call given it) change the object symbol refers to? Yes when the two names may refer to the same object, or when symbol's object has escaped - the object changed may be the holder it is kept in.
    • recordMayAliasOutside

      public void recordMayAliasOutside(ISymbol symbol)
      Record that the object symbol refers to may be one that exists outside this callable (x <- view(), x <- this): a later call that reaches such an object may change it.
    • recordInPlaceChange

      public void recordInPlaceChange(ISymbol symbol)
      Record that the object symbol refers to was changed IN PLACE (x++, cat items > x, a call given it) - so nothing is known any more about it, nor about any name that may refer to the same object.
    • recordKeptUnset

      public void recordKeptUnset(ISymbol symbol)
      The variable symbol is UNSET and an operation that keeps an unset value unset (KEEPS_UNSET, e.g. total += 5 on an unset Integer) was applied to it: it stays UNSET. Not a write - the value is what it was - so a loop that changes the variable only this way leaves it UNSET after the loop too. Every OTHER name that may refer to the object is forgotten exactly as for any in-place change (aliasing is not modelled further).
    • reportWhenSure

      public void reportWhenSure(ISymbol symbol, Runnable report)
      Run report - a finding that symbol is UNSET at this point on every pass - once it is SURE. Outside a loop it is sure now. Inside one the body has been walked once, from the state before the loop: what holds here on the first pass holds on every pass only if nothing in the loop writes the variable (D41), so the report waits for each enclosing loop to end and is dropped if any of them wrote it. A variable fresh in the innermost loop was given its value on every path of THIS pass, so the finding holds on every pass now.
    • recordInPlaceChangeThroughOthers

      public void recordInPlaceChangeThroughOthers(ISymbol symbol)
    • enterStatement

      public void enterStatement(boolean throwFree)
      A block statement is about to be walked: throwFree when it cannot throw (THE test is phase 5's ThrowFreeStatement). Any other statement may throw, so from here on an exception may leave each open try - and what those tries store later may not have happened when it does.
    • exitStatement

      public void exitStatement()
      The block statement being walked has ended. A throw-free statement holds no other statement, so there is nothing to restore.
    • getMayValues

      public Set<Object> getMayValues(ISymbol symbol)
      The constant values that reach this point on SOME path — the MAY side of the analysis.

      getValue(ISymbol) answers "what is this definitely?", which joins to TOP when branches disagree. This answers "what could this be?", which unions instead. Use it when a SINGLE bad path is already a defect — a constrained construction Panics on the path that reaches it, whether or not the other branch would have been fine.

      Empty means nothing is provable, NOT that the variable is unconstrained.

    • getValue

      public AbstractValue getValue(ISymbol symbol)
    • evaluateCondition

      public ConditionResult evaluateCondition(AbstractValue left, String operator, AbstractValue right)
      Evaluate a condition between two abstract values.
      Parameters:
      left - the left operand value
      operator - the comparison operator
      right - the right operand value
      Returns:
      TRUE if always satisfied, FALSE if never, UNKNOWN if indeterminate
    • evaluateDecidedCondition

      public ConditionResult evaluateDecidedCondition(AbstractValue left, String operator, AbstractValue right)
      THE verdict a condition is REPORTED on (E08086/E08087): as evaluateCondition(AbstractValue, String, AbstractValue), but "always true" only when every operand is known SET (D66, Steve 2026-10-21, tri-state). A comparison with an unset operand is not true - it is unset, and an if takes the else path - so a value that is only "so, when set" (the else of an earlier comparison, a Boolean's else, the code after a throw on a comparison) never makes a condition always true. "Always false" stays: false and unset both fail the condition.
    • evaluateIsSet

      public ConditionResult evaluateIsSet(ISymbol symbol)
      Evaluate the isSet state of a variable based on its tracked value.

      A variable is provably SET if it holds a ConstantValue, RangeValue, or SetStateValue(SET). A variable is provably UNSET only if it holds SetStateValue(UNSET). Otherwise, the result is UNKNOWN.

      Parameters:
      symbol - the variable to check
      Returns:
      TRUE if provably SET, FALSE if provably UNSET, UNKNOWN otherwise
    • evaluateIsEmpty

      public ConditionResult evaluateIsEmpty(ISymbol symbol)
      Evaluate the empty state of a variable based on its tracked value.

      A variable is provably EMPTY if it holds a CollectionStateValue with isEmpty=true. A variable is provably NON-EMPTY if it holds a CollectionStateValue with isEmpty=false. Otherwise, the result is UNKNOWN.

      Parameters:
      symbol - the variable to check
      Returns:
      TRUE if provably EMPTY, FALSE if provably NON-EMPTY, UNKNOWN otherwise
    • narrowForIsSet

      public void narrowForIsSet(ISymbol symbol, boolean isTrueBranch)
      Narrow a variable's value based on an isSet check.

      On the true branch (isSet is true), records the variable as SET. On the false branch (isSet is false), records the variable as UNSET. If the variable already has a more precise value (ConstantValue, RangeValue), preserves the more precise value on the true branch.

      Parameters:
      symbol - the variable being checked
      isTrueBranch - true for the true/if branch, false for the else branch
    • narrowForCondition

      public void narrowForCondition(ISymbol symbol, String operator, long operandValue, boolean isTrueBranch)
      🔑 Narrowing must reduce the MAY side too, or it goes stale. These paths write the MUST side DIRECTLY rather than through recordAssignment(ISymbol, AbstractValue), so without this the may-set keeps a value the narrowing has just ruled out — and a value that provably does NOT arrive would be reported as if it did. Measured: size <- 20; if size == 15 reported 20 as reaching a construction inside the branch, where the value is provably 15.

      The rule is: a narrowing that pins an EXACT constant sets the may-set to exactly that; every other narrowing (range, excludes, set-state) INVALIDATES it, because a range cannot be enumerated as constants and a stale entry is worse than none.

    • enterBranch

      public void enterBranch()
      Enter a branching construct (if/switch). Pushes the current state as a snapshot for the false/else branch and creates a new scope for tracking branch exit states.
    • alternativesStartHere

      public void alternativesStartHere()
      THE refresh of where a branching construct's alternatives start: the state now replaces the snapshot taken when the construct was entered, so every later alternative (an else if, the else, the implicit else, a later case, the default) starts from it. Called once the state at the start of the next alternative is known - after an if block's or a switch's condition has been evaluated (pre-flow included), and after the first alternative of an if has applied the first condition's false narrowing. Before this, those alternatives saw the values variables held BEFORE a pre-flow or a condition changed them (D40).
    • recordSkipPath

      public void recordSkipPath()
      Record the state here as the path that skips the guarded construct being entered (its guard left its variable unset); joinSkipPath() joins it back in once the construct has been left.
    • joinSkipPath

      public void joinSkipPath()
      Join the path recorded by recordSkipPath() for the construct just left.
    • narrowedWithGuardSkipPath

      public void narrowedWithGuardSkipPath(ISymbol guardTarget, Runnable narrowing)
      Run narrowing - the false narrowing of an if block's condition, at the start of its next alternative - when the block's guard (v <- f(), v :=? f()) can leave guardTarget unset: then the condition after then was never evaluated, and the alternative is reached by that skip path as well as by the condition being false (D46). The two paths are joined; the skip path knows only that the target is unset.
      Parameters:
      guardTarget - the guard's variable, or null when the block has no skippable guard
      narrowing - applies the condition's false narrowing to the current state
    • switchToAlternateBranch

      public void switchToAlternateBranch()
      Record the end of the current branch (e.g., end of if-body) and switch to the alternative branch state (e.g., else-body or else-if).

      Saves the current state as a branch exit state and restores the pre-branch snapshot for the alternative path. The pre-branch state remains on the stack for subsequent alternate branches (else-if chains).

    • exitBranch

      public void exitBranch()
      Exit a branching construct. Joins the current branch state with all accumulated branch exit states from this nesting level.

      Call this after the final branch (else/default) has been processed.

    • exitBranchNoAlternate

      public void exitBranchNoAlternate()
      Exit a branching construct where only the true branch existed (no else).

      When there is no else clause, the false path retains the pre-branch state. Join the true-branch exit with the pre-branch snapshot.

    • enterTry

      public void enterTry()
      Enter a try block. Snapshots the pre-try state for catch block entry and creates a scope for tracking try/catch exit states.
    • enterCatch

      public void enterCatch()
      Enter a catch block: the pre-try state, with nothing known about any variable the try wrote.

      The catch handler can be entered from any point in the try where an exception is thrown - before, between or after its writes - so a variable the try (or its pre-flow) assigned may hold its old value or any value given to it on the way. Starting from the pre-try state alone claimed the old value: level := second then a call that throws, and the catch's level == first was a false E08086 (D40).

    • exitTryCatch

      public void exitTryCatch()
      Exit a try/catch construct. Joins the try-exit and catch-exit states; after a finally, the code that follows sees what the try or catch completed with, except for what the finally wrote (see enterFinally()).
    • enterFinally

      public void enterFinally()
      Enter a finally block. Joins the try and catch exit states FIRST, so the finally body runs on the joined state and its writes dominate every path out of the construct.

      This ordering is the whole point. finally is the last child of the try construct in the grammar, so without this its writes would land on the catch-exit state alone and the subsequent join would resurrect the very values it had just overwritten.

      A finally also runs while an exception is on its way out - from any point of the try, or of a catch that throws - so it starts from that join AND the pre-try state with what the try and catch wrote forgotten (D40: risky() then level := second, and the finally's level == second was a false E08086). The normal completion is kept aside, and exitTryCatch() gives the code after the construct that completion plus what the finally wrote - an exception path never reaches it.

    • isInsideLoop

      public boolean isInsideLoop()
      Check if currently inside a loop body. Used to suppress tautology detection inside loops because widening has not yet been applied — variables still hold their pre-loop constant values during the first pass through the body.
    • isFreshInLoop

      public boolean isFreshInLoop(ISymbol symbol)
      True if symbol was written, on every path to here, since the innermost loop body was entered: inside the loop its value is then what that write gave it on every pass, so a decision about it holds (D48) even though the body is walked only once. A variable the body has not written (on every path) holds, on this first walk, only its pre-loop value, which a later pass may change (D41).
    • enterLoop

      public void enterLoop()
      Enter a loop body. Snapshots the pre-loop state.
    • exitLoop

      public void exitLoop()
      Exit a loop body. Applies widening: any variable the loop WROTE is widened to TOP.

      The body is walked once, from the state before the loop, so what it computed is only what the FIRST pass computes; a later pass starts from values the first pass left. A variable the loop never writes holds the same value on every pass; one it writes may hold anything a later pass gives it, so nothing is known about it after the loop. Joining the pre-loop state with the first pass's end was not enough: latest := current then current := second leaves latest equal to its pre-loop value after one pass, and to second after two (a false E08086 after the loop, D41).

    • exitWhileLoop

      public void exitWhileLoop(EK9Parser.WhileStatementExpressionContext ctx)
      THE exit of a while / do ... while statement: the one place that decides whether the body is guaranteed to run. A do body always runs at least once (so it kills what it overwrites), unless its guard (do v <- f()) can leave the variable unset and skip the body; a while body may run zero times. Every listener that drives this analyzer through a while statement calls this, never a private choice between exitLoop() and exitLoopBodyAlwaysRuns().
      Parameters:
      ctx - the while / do-while statement being exited.
    • exitLoopBodyAlwaysRuns

      public void exitLoopBodyAlwaysRuns()
      Exit a loop body that is GUARANTEED to run at least once - the do ... while form.

      Same MUST-side widening as exitLoop(), but the MAY side is taken from the body alone: the body definitely ran, so a definition it overwrote cannot reach the loop exit. A while body may run zero times and so must keep unioning - that asymmetry is the entire difference between the two forms.

    • markUnreachable

      public void markUnreachable()
      Mark the current state as unreachable (after a throw statement). Subsequent code in the same block is dead.
    • isUnreachable

      public boolean isUnreachable()
      Check if the current program point is unreachable.