Skip to content

Iterate antecedent pass so self-assignment in a loop correctly narrows - #64295

Open
milkcask (milkcask) wants to merge 2 commits into
microsoft:mainfrom
milkcask:fix/59715-loop-fixpoint
Open

milkcask (milkcask) wants to merge 2 commits into
microsoft:mainfrom
milkcask:fix/59715-loop-fixpoint

Conversation

@milkcask

Copy link
Copy Markdown

Fixes #59715, see full discussion there.

A union-typed variable reassigned in a loop from an expression that reads it, such as cur = cur.next, now narrows to every constituent the loop can reach. Previously the loop body saw one step less than it should: for a chain of three classes cur was typed as the first two, so a property present only on the third was rejected and a property missing from it was accepted. Longer chains lost exactly the same step.

The antecedent pass over a loop label is one step of an approximation toward the correct type, and, since 4.3.5, it was only ever run once. To type the assignment it asks the expression checker for the assigned type, which starts a nested flow analysis of the same variable. That analysis reaches the loop label still on the flow loop stack, takes the partial union gathered so far, and returns it with its incomplete marker dropped at the analysis boundary. The label unions that in and caches the result as final, so the number of narrowing steps was the depth of that recursion rather than the number needed to converge.

Add a reentered field to FlowLoopInfo, the flow loop stack entry. It is set when the checker computes the flow type of a variable while that variable's own assignment is being evaluated, as in currentParent = currentParent.parent. The antecedent pass is then wrapped in an outer loop: push the loop label flow node onto the flow loop stack, run the pass, and if the reentered flag was set and the resulting union grew, run the pass again, as a fixed-point iteration, until nothing is added. At that point the approximation is exact. Only union declared types iterate, since only they narrow by assigned type; re-seeding an evolving array would degrade it to any[]. Passes are bounded by the union's size, falling back to the declared type.

Add a test with chains of 3, 4 and 50 levels, a discriminant narrowing that used to become never, a property access that must error, and an evolving array that must keep its element type.

A reference fix for the old js checker is at milkcask@ad43e66. Both checkers give identical numbers when run over the strada compiler sources: 10,782 loop labels, 445 with a second pass, none with a third, no result changed, and check time within noise of main.

Assisted by Copilot and Claude Code. They really struggled to produce a human readable write-up, so I have to write it, and stand ready to defend it.

A union-typed variable reassigned in a loop from an expression that
reads it, such as `cur = cur.next`, now narrows to every constituent the
loop can reach. Previously the loop body saw one step less than it
should: for a chain of three classes `cur` was typed as the first two,
so a property present only on the third was rejected and a property
missing from it was accepted. Longer chains lost exactly the same step.

The antecedent pass over a loop label is one step of an approximation
toward the correct type, and, since 4.3.5, it was only ever run once. To
type the assignment it asks the expression checker for the assigned
type, which starts a nested flow analysis of the same variable. That
analysis reaches the loop label still on the flow loop stack, takes the
partial union gathered so far, and returns it with its incomplete marker
dropped at the analysis boundary. The label unions that in and caches
the result as final, so the number of narrowing steps was the depth of
that recursion rather than the number needed to converge.

Add a `reentered` field to `FlowLoopInfo`, the flow loop stack entry. It
is set when the checker computes the flow type of a variable while that
variable's own assignment is being evaluated, as in `currentParent =
currentParent.parent`. The antecedent pass is then wrapped in an outer
loop: push the loop label flow node onto the flow loop stack, run the
pass, and if the `reentered` flag was set and the resulting union grew,
run the pass again, as a fixed-point iteration, until nothing is added.
At that point the approximation is exact. Only union declared types
iterate, since only they narrow by assigned type; re-seeding an evolving
array would degrade it to `any[]`. Passes are bounded by the union's
size, falling back to the declared type.

Add a test with chains of 3, 4 and 50 levels, a discriminant narrowing
that used to become `never`, a property access that must error, and an
evolving array that must keep its element type.

Fixes microsoft#59715
Copilot AI balanced review requested due to automatic review settings September 16, 2026 04:29
@github-project-automation github-project-automation Bot moved this to Not started in PR Backlog Sep 16, 2026
@typescript-automation typescript-automation Bot added the For Backlog Bug PRs that fix a backlog bug label Sep 16, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Nested loop cache entries can remain stale across fixed-point retries and preserve incorrect narrowing.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Fixes loop self-assignment narrowing by iterating flow analysis toward a fixed point.

Changes:

  • Tracks re-entry into partially analyzed loop flow.
  • Adds convergence logic and regression coverage for varied chain depths and evolving arrays.
File summaries
File Description
tsc/internal/checker/checker.go Adds loop re-entry state.
tsc/internal/checker/flow.go Implements iterative antecedent analysis.
tsc/testdata/tests/cases/conformance/controlFlow/controlFlowLoopSelfReferentialAssignment.ts Adds regression scenarios.
tsc/testdata/baselines/reference/conformance/controlFlowLoopSelfReferentialAssignment.types Records inferred types.
tsc/testdata/baselines/reference/conformance/controlFlowLoopSelfReferentialAssignment.symbols Records resolved symbols.
tsc/testdata/baselines/reference/conformance/controlFlowLoopSelfReferentialAssignment.errors.txt Records the expected soundness error.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread tsc/internal/checker/flow.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Cache entries from partial passes remain stale on the restart bailout and convergence fallback paths.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

tsc/internal/checker/flow.go:1431

  • The convergence fallback replaces this junction's result with the declared type but leaves inner-loop cache entries and shared-flow results from the final partial pass intact. Those entries were computed from the pre-fallback approximation and can later return narrower stale types. Apply the same per-pass invalidation before breaking on the bound.
		if pass >= maxPasses {
			result = f.declaredType
			break
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread tsc/internal/checker/flow.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The change modifies recursive compiler flow analysis and cache behavior, warranting final human validation despite comprehensive tests.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

For Backlog Bug PRs that fix a backlog bug

Projects

Status: Not started

Development

Successfully merging this pull request may close these issues.

Type is not referred correctly in the while loop

2 participants