Skip to content

feat(ast,resolve): make if/else branch bodies real namespaces - #28

Merged
HuiJun merged 1 commit into
mainfrom
devin/1786056809-if-branch-namespaces
Aug 6, 2026
Merged

feat(ast,resolve): make if/else branch bodies real namespaces#28
HuiJun merged 1 commit into
mainfrom
devin/1786056809-if-branch-namespaces

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 6, 2026

Copy link
Copy Markdown

Summary

Declarations inside an if/else branch body were registered nowhere: the AST had no per-branch node, so nothing could own a scope for them (docs/SPEC_COMPLIANCE.md ❌ row). This adds that node and makes each branch a real, body-local namespace.

The node

type IfBranchKind int // IfBranchThen | IfBranchElse

// One branch body of an if action — an element in its own right, so it can own a scope.
type IfBranchNode struct {
    NodeBase
    Kind IfBranchKind
    Body []Node
}

type IfActionNode struct {
    NodeBase
    Condition Node
-   ThenBody  []Node
-   ElseBody  []Node
+   Then      *IfBranchNode // never nil once `{` was seen
+   Else      *IfBranchNode // nil when there is no else clause
}

func (n *IfActionNode) Branches() []*IfBranchNode // source order, skips an absent else

Both branch bodies were parsed by two copies of the same loop; they are now one parseIfBranch(kind, start, closeMsg). The then branch's span starts at its {, the else branch's at the else keyword, so an editor position inside a branch maps to the branch.

The node carries syntax only — no semantic data — and stays immutable. Membership it owns: everything its body declares. The scope is created by symbols/builder.go:

case *ast.IfActionNode:   // the condition is evaluated outside both branches
    for _, b := range d.Branches() { buildDecl(scope, b, vis, trivia) }
case *ast.IfBranchNode:
    child := NewScope(scope, d); child.markBodyLocal()
    scope.AddChild(child); buildMembers(child, d.Body)

i.e. exactly the shape WhileLoopActionNode already uses — an anonymous child scope keyed by the node, found again via the existing childScope(scope, node) helpers in resolve/document.go, symbols/bodyscopes.go and lsp/walk.go. So if c { action a; } else { action a; } declares two distinct as, and neither is a member of the enclosing behavior. The condition keeps resolving in the enclosing scope, since it is evaluated before either branch is entered.

Body-local: deliberate exclusion from recursive search

The branch scopes are marked markBodyLocal(), so import P::** (resolve/unqualified.go lookupInSubtree) and the REPL %eval scope-tree search (repl/meta.go lookupInScopeTree) both skip them — a branch-local name is not a member of the namespace being imported. Chosen for parity with loop bodies and body-expression parameters, and pinned by extending the two existing tests (TestImportRecursiveSkipsBodyLocalNames, TestLookupInScopeTreeSkipsBodyLocalNames) with thenLocal/elseLocal.

Lowering / control flow

Unaffected. internal/core/lower does not consume IfActionNode today (there is no if lowering; the guarded-succession shorthand if x then t; parses to a ControlFlowEdge, a path this PR does not touch). Branch bodies were never walked by ToActionGraph, so wrapping them in a node removes nothing; when if lowering lands, Branches() is the entry point and the branch node is the natural owner of the branch's subgraph. Succession lowering inside a branch body is likewise unchanged — the members are the same nodes, just reachable through the branch.

LSP (same PR, per the resolver-change rule)

lsp/walk.go's reference walker now descends into each branch through the branch's scope. No symbol is synthesized for a branch (the scope is anonymous, like a loop's), so there is no NameSpan/DocName to stamp; the branch's members are ordinary symbols and already carry both. internal/lsp/if_branch_test.go covers hover, go-to-definition and rename from both a declaration and a use, on a model that declares brake in the then branch, in the else branch, and in the enclosing package — definition from each use lands on that branch's own declaration, and rename touches only that branch.

ast.Dump learned both nodes so the golden fixture parse/action_if_branch_body.golden locks the parse structure instead of printing (*ast.IfActionNode).

Verification

CI was not used for this PR (CI is down for this repo); every gate below was run locally on this branch.

$ gofmt -l .
                                     # (no output)
$ go build ./... && go vet ./...
                                     # clean

$ go test ./...
ok  	github.com/Open-MBEE/Systemica/examples
ok  	github.com/Open-MBEE/Systemica/internal/core/ast
ok  	github.com/Open-MBEE/Systemica/internal/core/deps
ok  	github.com/Open-MBEE/Systemica/internal/core/format
ok  	github.com/Open-MBEE/Systemica/internal/core/lexer
ok  	github.com/Open-MBEE/Systemica/internal/core/libs
ok  	github.com/Open-MBEE/Systemica/internal/core/lower
ok  	github.com/Open-MBEE/Systemica/internal/core/model      37.520s
ok  	github.com/Open-MBEE/Systemica/internal/core/parser
ok  	github.com/Open-MBEE/Systemica/internal/core/passes
ok  	github.com/Open-MBEE/Systemica/internal/core/resolve
ok  	github.com/Open-MBEE/Systemica/internal/core/runtime
ok  	github.com/Open-MBEE/Systemica/internal/core/semantics
ok  	github.com/Open-MBEE/Systemica/internal/core/source
ok  	github.com/Open-MBEE/Systemica/internal/core/symbols
ok  	github.com/Open-MBEE/Systemica/internal/grpc
ok  	github.com/Open-MBEE/Systemica/internal/lsp
ok  	github.com/Open-MBEE/Systemica/internal/repl

$ go test -race ./...        # same 18 packages, all ok
$ make lint
✓ Lint passed                        # staticcheck + gosec, zero findings

Targeted suites for the tiers this PR touches:

$ go test -run TestGolden ./internal/core/parser              ok
$ go test -run TestNegative ./internal/core/parser            ok
$ go test -run TestStdlibConformance ./internal/core/libs     ok
$ go test -run TestExecutionConformance ./internal/core/runtime  ok
$ go test ./internal/lsp/...                                  ok
$ go test ./internal/core/model -run TestBodyLocal -v
--- PASS: TestBodyLocalDeclarationsAreVisible
    .../if_branch_body_reads_its_own_declaration
    .../else_branch_reuses_the_then_branch's_name
--- PASS: TestBodyLocalNamesDoNotEscape
    .../if_branch_member_from_outside
    .../else_branch_member_from_the_then_branch
$ go test ./internal/lsp -run Branch -v
--- PASS: TestHoverBranchLocalDeclaration
--- PASS: TestDefinitionBranchLocalDeclaration
--- PASS: TestRenameBranchLocalDeclaration
--- PASS: TestRenameBranchLocalFromUse

OMG training corpus gate (not run by CI; corpus fetched with ./scripts/download-training-examples.sh):

$ go test ./internal/core/model/ -run TestTrainingExamples -v
    training_examples_test.go:97: 81/100 training files clean
--- PASS: TestTrainingExamplesSemanticErrors (7.29s)

Unchanged from the main@97b5edd baseline — no drift, training_examples_expected.txt untouched.

docs/SPEC_COMPLIANCE.md's ❌ row is now ✅ with the implementation/test mapping and the recursive-import decision recorded.

Link to Devin session: https://nasa-jpl-demo.devinenterprise.com/sessions/2dc484815bec4dae9051904854c9ad3c
Requested by: @HuiJun

Add ast.IfBranchNode so each branch of an if action is an element that can own a scope. Declarations in a branch body are members of the branch: they resolve inside it, do not escape to the enclosing behavior or the sibling branch, and are body-local (excluded from recursive imports and the REPL scope-tree search), matching loop bodies.

Co-Authored-By: jason.han <jason.han@jpl.nasa.gov>
@HuiJun HuiJun self-assigned this Aug 6, 2026
@devin-ai-integration

Copy link
Copy Markdown
Author
Original prompt from Devin Bot

# Task: make if/else branch bodies real namespaces

docs/SPEC_COMPLIANCE.md ~line 309: declarations inside an if/else branch body are registered
nowhere, because the AST has no per-branch node to own a scope.

This needs an AST change, so design the node first and state the design in the PR body: what the
new branch node is, what membership it owns, how lowering consumes it, and how it interacts with
existing succession/control-flow lowering. Respect the architecture invariants — the AST is
immutable and semantic data lives in side tables.

Two hard-won lessons from earlier PRs that apply directly:

  • A resolver scoping change must be applied to internal/lsp in the same PR: the reference walker,
    plus NameSpan/DocName of any synthesized symbol. Verify hover, go-to-definition and rename work
    from both the declaration and a use.
  • Any new anonymous scope must be deliberately included in or excluded from the recursive-import
    search (import P::**) and the REPL scope-tree search. A previous PR leaked body-local names into
    recursive imports; do not repeat it. State your choice and cover it with a test.

Success: names declared in a branch body resolve inside that branch and do not leak outside it,
LSP behavior is correct and tested, the corpus gate stays at 81/100 or better, the ❌ row in
docs/SPEC_COMPLIANCE.md is updated, and the full verification gate passes.

#``# Repo and ground rules

Repo: JPL-Devin/Systemica (Go module github.com/Open-MBEE/Systemica). Clone it with
git clone https://github.com/JPL-Devin/Systemica.git into /home/ubuntu/repos/Systemica
if it is not already there.

Go may not be installed on the VM. If go version fails:

cd /tmp &amp;&amp; curl -sSLO https://go.dev/dl/go1.25.0.linux-amd64.tar.gz \
  &amp;&amp; sudo tar -C /usr/local -xzf go1.25.0.linux-amd64.tar.gz
export PATH=/usr/local/go/bin:$PATH

Read AGENTS.md first — it is binding: correctness over expedience (no hacks, no stubs),
immutable AST, semantic... (2614 chars truncated...)

@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

@HuiJun
HuiJun merged commit e5c7ffd into main Aug 6, 2026
3 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