OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / derive-gdscript.js

765 lines · javascript ref:
/**
 * The expression floor for GDScript.
 *
 * Same model as lib/derive.js produces for JS — operations, wires, named wires,
 * zones, exits — because the renderer must not care which language a file is.
 * Specified in docs/EXPRESSION-FLOOR.md.
 *
 * Three things about this grammar (vendor/lezer-gdscript) that are NOT guessable
 * and were established by dumping a real parse tree:
 *
 * 1. `break` and `continue` have NO node in the grammar. Both arrive as
 *    `IdentifierExpressionNode > Identifier` and are identified BY TEXT. Miss
 *    this and every loop exit silently becomes a free variable read.
 * 2. `elif` / `else` are already SIBLING clauses of `IfNode` (IfClause,
 *    ElifClause, ElseClause), so GDScript models bands the way the spec wants.
 *    There is no ladder to absorb, unlike JS where `else if` nests.
 * 3. A declaration and a write are the SAME node type. `var x = 1` and `x = 1`
 *    are both `AssignmentExpressionNode`; only the first child differs
 *    (`VariableNode`/`ConstantNode` vs a bare target).
 *
 * Also: `var x := 0` predates this grammar, which emits a zero-width error node
 * inside `TypeCast`. That is counted and reported, never fatal.
 *
 * Offsets are source offsets (UTF-16 code units), as they are on the JS side.
 * The field names say `byte` for model compatibility.
 */

import { parser } from '../vendor/lezer-gdscript/lezer-gdscript.mjs'

// R1 — native name -> the normalized kind the model speaks.
const OPS = new Map([
  ['CallExpressionNode', 'CallExpression'],
  ['PreloadExpressionNode', 'CallExpression'],
  ['AssertNode', 'CallExpression'],
  ['BinaryOperatorExpressionNode', 'BinaryExpression'],
  ['TypeTestExpressionNode', 'BinaryExpression'],
  ['UnaryOperatorNode', 'UnaryExpression'],
  ['TernaryOperatorExpressionNode', 'ConditionalExpression'],
  ['ArrayExpressionNode', 'ArrayExpression'],
  ['DictionaryExpressionNode', 'ObjectExpression'],
  ['ReturnNode', 'ReturnStatement'],
  ['AssignmentExpressionNode', 'AssignmentExpression']
])

// R2 — endpoints. A path, a reference, a literal: never a node.
const WIRES = new Set([
  'IdentifierExpressionNode', 'VariableName', 'LiteralExpressionNode',
  'SelfExpressionNode', 'GetNodeExpressionNode', 'SubscriptExpressionNode',
  'BuiltinConstants', 'String', 'True', 'False', 'Null', 'Identifier'
])

// R5 — transparent wrappers: unwrap to what they wrap, keep the marker.
const FOLDED = new Set([
  'AwaitExpressionNode', 'CastExpressionNode', 'GroupedExpressionNode'
])

const ZONES = new Set(['IfNode', 'ForNode', 'WhileNode', 'MatchNode'])
const FUNCS = new Set(['FunctionNode', 'LambdaExpressionNode'])
const OPERATORS = new Set(['ArithOp', 'BitOp', 'CompareOp', 'AndOp', 'OrOp', 'LogicOp', 'NotOp', 'in', 'is'])

function isExpression (name) {
  return OPS.has(name) || WIRES.has(name) || FOLDED.has(name) || FUNCS.has(name)
}

function children (node) {
  const out = []
  let c = node.firstChild
  while (c) { out.push(c); c = c.nextSibling }
  return out
}

function childByName (node, name) {
  for (const c of children(node)) if (c.name === name) return c
  return null
}

export function deriveGDScript (source, filePath) {
  const model = {
    filePath,
    language: 'gdscript',
    symbols: [],
    functions: [],
    nodes: [],
    names: [],
    zones: [],
    exits: [],
    errors: 0,
    partial: false,
    partialReason: null
  }

  let tree
  try {
    tree = parser.parse(source)
  } catch (err) {
    return { ...model, error: err.message }
  }

  // --- positions. Lezer hands back offsets; the model wants lines too.
  const lineStarts = [0]
  for (let i = 0; i < source.length; i++) {
    if (source[i] === '\n') lineStarts.push(i + 1)
  }
  function posAt (offset) {
    let lo = 0
    let hi = lineStarts.length - 1
    while (lo < hi) {
      const mid = (lo + hi + 1) >> 1
      if (lineStarts[mid] <= offset) lo = mid; else hi = mid - 1
    }
    return { line: lo + 1, col: offset - lineStarts[lo] }
  }
  function rangeOf (node) {
    const s = posAt(node.from)
    const e = posAt(node.to)
    return {
      startByte: node.from,
      endByte: node.to,
      startLine: s.line,
      startCol: s.col,
      endLine: e.line,
      endCol: e.col
    }
  }
  const idOf = (node) => filePath + ':' + node.from + '-' + node.to
  const textOf = (node) => source.slice(node.from, node.to)

  // --- scopes (E5)
  const scopes = [new Map()]
  const declare = (name, kind) => { if (name) scopes[scopes.length - 1].set(name, kind) }
  function resolve (name) {
    for (let i = scopes.length - 1; i >= 0; i--) {
      const kind = scopes[i].get(name)
      if (kind) return kind
    }
    return 'free'
  }

  // R5 — a property path is one label. `a.b[c]` folds to `a.b[..]`.
  function pathOf (node) {
    if (!node) return null
    const name = node.name
    if (name === 'Identifier' || name === 'VariableName') return textOf(node).trim()
    if (name === 'SelfExpressionNode') return 'self'
    if (name === 'IdentifierExpressionNode') {
      const id = childByName(node, 'Identifier')
      return id ? textOf(id) : textOf(node).trim()
    }
    if (name === 'GetNodeExpressionNode') return textOf(node).trim()
    if (name === 'LiteralExpressionNode') return textOf(node).trim()
    if (name === 'SubscriptExpressionNode') {
      let out = ''
      let bracket = false
      for (const c of children(node)) {
        if (c.name === '[') { bracket = true; out += '[..]'; continue }
        if (c.name === ']') { bracket = false; continue }
        if (bracket) continue
        if (c.name === '.') { out += '.'; continue }
        const part = pathOf(c)
        if (part === null) return null
        out += part
      }
      return out
    }
    return null
  }

  // The identifier a path is rooted in — what gets resolved.
  function rootOf (node) {
    let cur = node
    while (cur) {
      if (cur.name === 'SubscriptExpressionNode') { cur = cur.firstChild; continue }
      if (cur.name === 'IdentifierExpressionNode' || cur.name === 'VariableName') {
        const id = childByName(cur, 'Identifier')
        return id || cur
      }
      return cur
    }
    return null
  }

  function unwrap (node) {
    const flags = {}
    let cur = node
    while (cur && FOLDED.has(cur.name)) {
      if (cur.name === 'AwaitExpressionNode') flags.awaited = true
      const next = children(cur).find(c => isExpression(c.name))
      if (!next) break
      cur = next
    }
    return { node: cur, flags }
  }

  function operatorLabel (node) {
    for (const c of children(node)) if (OPERATORS.has(c.name)) return textOf(c)
    return node.name
  }

  function labelFor (node, kind) {
    switch (node.name) {
      case 'CallExpressionNode': {
        const callee = children(node).find(c => isExpression(c.name))
        const p = callee ? pathOf(callee) : null
        return p === null ? '(expr)' : p
      }
      case 'PreloadExpressionNode': return 'preload'
      case 'AssertNode': return 'assert'
      case 'BinaryOperatorExpressionNode': return operatorLabel(node)
      case 'TypeTestExpressionNode': return 'is'
      case 'UnaryOperatorNode': return operatorLabel(node)
      case 'TernaryOperatorExpressionNode': return '?:'
      case 'ArrayExpressionNode': return '[]'
      case 'DictionaryExpressionNode': return '{}'
      case 'ReturnNode': return 'return'
      case 'AssignmentExpressionNode': return '='
      default: return kind
    }
  }

  // `var x = 1` and `x = 1` are the same node type; the first child decides.
  function declaratorOf (node) {
    if (node.name !== 'AssignmentExpressionNode') return null
    const first = node.firstChild
    if (!first) return null
    if (first.name === 'VariableNode' || first.name === 'ConstantNode') return first
    return null
  }

  function declaredName (declNode) {
    const id = childByName(declNode, 'Identifier')
    return id ? textOf(id) : null
  }

  function inputFor (raw, slot) {
    const { node, flags } = unwrap(raw)
    if (!node) return null
    const base = { slot }
    if (flags.awaited) base.awaited = true

    if (node.name === 'LiteralExpressionNode' || node.name === 'String' ||
        node.name === 'BuiltinConstants' || node.name === 'True' ||
        node.name === 'False' || node.name === 'Null') {
      return { ...base, name: textOf(node).trim(), literal: true }
    }
    if (WIRES.has(node.name)) {
      const path = pathOf(node)
      const root = rootOf(node)
      if (path !== null) {
        const rootName = root ? textOf(root).trim() : null
        return {
          ...base,
          name: path,
          source: root && root.name === 'SelfExpressionNode' ? 'this' : resolve(rootName)
        }
      }
    }
    if (FUNCS.has(node.name)) return { ...base, name: '(fn)', source: 'closure', node: idOf(node) }
    if (OPS.has(node.name)) {
      return { ...base, name: labelFor(node, OPS.get(node.name)), source: 'node', node: idOf(node) }
    }
    return { ...base, name: node.name, source: 'expr' }
  }

  function operandsOf (node) {
    const kids = children(node)
    switch (node.name) {
      case 'CallExpressionNode': {
        const out = []
        const callee = kids.find(c => isExpression(c.name))
        if (callee) out.push([callee, 'callee'])
        const params = childByName(node, 'CallParamsExpressionNode')
        if (params) {
          let i = 0
          for (const c of children(params)) {
            if (!isExpression(c.name)) continue
            out.push([c, 'arg' + i])
            i++
          }
        }
        return out
      }
      case 'PreloadExpressionNode':
      case 'AssertNode': {
        const out = []
        let i = 0
        for (const c of kids) {
          if (c.name === 'CallParamsExpressionNode') {
            for (const p of children(c)) {
              if (!isExpression(p.name)) continue
              out.push([p, 'arg' + i]); i++
            }
            continue
          }
          if (!isExpression(c.name)) continue
          out.push([c, 'arg' + i]); i++
        }
        return out
      }
      case 'BinaryOperatorExpressionNode': {
        const ops = kids.filter(c => isExpression(c.name))
        return ops.length >= 2 ? [[ops[0], 'left'], [ops[1], 'right']] : ops.map(o => [o, 'value'])
      }
      case 'TypeTestExpressionNode': {
        const ops = kids.filter(c => isExpression(c.name))
        return ops.length > 0 ? [[ops[0], 'value']] : []
      }
      case 'UnaryOperatorNode': {
        const ops = kids.filter(c => isExpression(c.name))
        return ops.length > 0 ? [[ops[0], 'value']] : []
      }
      case 'TernaryOperatorExpressionNode': {
        const ops = kids.filter(c => isExpression(c.name))
        const slots = ['then', 'test', 'else']
        return ops.map((o, i) => [o, slots[i] || ('item' + i)])
      }
      case 'ArrayExpressionNode':
        return kids.filter(c => isExpression(c.name)).map((c, i) => [c, 'item' + i])
      case 'DictionaryExpressionNode': {
        const out = []
        let i = 0
        for (const entry of kids) {
          if (entry.name !== 'DictionaryEntry') continue
          const parts = children(entry).filter(c => isExpression(c.name))
          const value = parts.length > 1 ? parts[parts.length - 1] : parts[0]
          if (value) { out.push([value, 'item' + i]); i++ }
        }
        return out
      }
      case 'ReturnNode': {
        const ops = kids.filter(c => isExpression(c.name))
        return ops.length > 0 ? [[ops[0], 'value']] : []
      }
      case 'AssignmentExpressionNode': {
        // Only the VALUE flows in; the target is a write.
        const ops = kids.filter(c => isExpression(c.name))
        const value = ops.length > 1 ? ops[ops.length - 1] : (declaratorOf(node) ? ops[0] : null)
        return value ? [[value, 'value']] : []
      }
      default:
        return []
    }
  }

  // Z1 — shape follows control flow. A body holding exactly one jump is a guard.
  function bodyJumpOnly (clause) {
    const body = childByName(clause, 'Body')
    if (!body) return false
    const jumps = []
    let count = 0
    const visit = (n) => {
      for (const c of children(n)) {
        if (c.name === 'SimpleStatement' || c.name === 'CompoundStatement') count++
        if (c.name === 'ReturnNode') jumps.push('return')
        if (c.name === 'IdentifierExpressionNode') {
          const text = textOf(c).trim()
          if (text === 'break' || text === 'continue') jumps.push(text)
        }
        if (c.name === 'SuiteNodeBody' || c.name === 'SuiteNode' ||
            c.name === 'SimpleStatement' || c.name === 'SmallStatement') visit(c)
      }
    }
    visit(body)
    return count === 1 && jumps.length === 1
  }

  function shapeOfZone (node) {
    if (node.name === 'ForNode') return 'foreach'
    if (node.name === 'WhileNode') return 'repeat'
    if (node.name === 'MatchNode') return 'switch'
    if (node.name === 'IfNode') {
      const kids = children(node)
      const hasElse = kids.some(c => c.name === 'ElifClause' || c.name === 'ElseClause')
      if (hasElse) return 'two-exit'
      const ifClause = childByName(node, 'IfClause')
      if (ifClause && bodyJumpOnly(ifClause)) return 'guard'
      return 'single-branch'
    }
    return node.name
  }

  // The zone's driving input. For a branch that is the test; for a foreach it is
  // the ITERABLE, which means skipping past the item binding — `for row in rows`
  // is driven by `rows`, not by `row`.
  function conditionOf (node) {
    const holder = node.name === 'IfNode' ? childByName(node, 'IfClause') : node
    if (!holder) return null
    if (node.name === 'ForNode') {
      let seenIn = false
      for (const c of children(holder)) {
        if (c.name === 'in') { seenIn = true; continue }
        if (!seenIn) continue
        if (isExpression(c.name)) return { text: textOf(c).replace(/\s+/g, ' '), range: rangeOf(c) }
      }
      return null
    }
    for (const c of children(holder)) {
      if (isExpression(c.name)) return { text: textOf(c).replace(/\s+/g, ' '), range: rangeOf(c) }
    }
    return null
  }

  // ForNode: `for` Identifier `in` <iterable> Body — the item is the Identifier.
  function itemOf (node) {
    if (node.name !== 'ForNode') return null
    const id = childByName(node, 'Identifier')
    return id ? textOf(id) : null
  }

  function bandsOf (node) {
    if (node.name === 'IfNode') {
      const out = []
      for (const c of children(node)) {
        if (c.name === 'IfClause') out.push({ kind: 'then', test: conditionOf(c), range: rangeOf(c) })
        if (c.name === 'ElifClause') out.push({ kind: 'else-if', test: conditionOf(c), range: rangeOf(c) })
        if (c.name === 'ElseClause') out.push({ kind: 'else', test: null, range: rangeOf(c) })
      }
      return out.length > 1 ? out : null
    }
    if (node.name === 'MatchNode') {
      const out = []
      for (const c of children(node)) {
        if (c.name !== 'MatchBranchNode') continue
        const pattern = childByName(c, 'PatternNode')
        out.push({
          kind: 'case',
          test: pattern ? { text: textOf(pattern).trim(), range: rangeOf(pattern) } : null,
          range: rangeOf(c)
        })
      }
      return out.length > 0 ? out : null
    }
    return null
  }

  // --- the walk
  const writes = []
  const reads = []
  const declaredAt = new Map()
  const zoneStack = []
  const fnStack = []
  const classStack = []

  // Top-level code needs a home or it is unreachable from the file view (L2).
  const moduleBodyId = filePath + ':module-body'
  let moduleBodyUsed = false

  function container () {
    if (fnStack.length > 0) return fnStack[fnStack.length - 1].id
    moduleBodyUsed = true
    return moduleBodyId
  }

  // A lambda bound to a name takes that name. The declaration is visited before
  // its value, so the hint is waiting by the time the lambda is created.
  const hintByOffset = new Map()

  function nearestLoop () {
    for (let i = zoneStack.length - 1; i >= 0; i--) {
      if (zoneStack[i].kind === 'loop') return zoneStack[i]
    }
    return null
  }
  function nearestBreakTarget () {
    for (let i = zoneStack.length - 1; i >= 0; i--) {
      const z = zoneStack[i]
      if (z.kind === 'loop' || z.shape === 'switch') return z
    }
    return null
  }

  // Hoist one scope's declarations before walking it, so a method that reads a
  // class var declared further down still resolves (E5).
  function hoist (node) {
    for (const c of children(node)) {
      if (c.name === 'SimpleStatement' || c.name === 'SmallStatement' ||
          c.name === 'CompoundStatement' || c.name === 'StatementGroup') {
        hoist(c)
        continue
      }
      if (c.name === 'AssignmentExpressionNode') {
        const decl = declaratorOf(c)
        if (decl) declare(declaredName(decl), 'local')
        continue
      }
      if (c.name === 'VariableNode' || c.name === 'ConstantNode') {
        declare(declaredName(c), 'local')
        continue
      }
      if (c.name === 'FunctionNode') {
        const id = childByName(c, 'Identifier')
        if (id) declare(textOf(id), 'function')
        continue
      }
      if (c.name === 'SignalStatement') {
        const id = childByName(c, 'Identifier')
        if (id) declare(textOf(id), 'signal')
        continue
      }
      if (c.name === 'ClassNameStatement' || c.name === 'ExtendsStatement') {
        const id = childByName(c, 'Identifier')
        if (id) declare(textOf(id), 'type')
      }
    }
  }

  function walk (node) {
    if (node.name === '⚠') model.errors++

    let pushedScope = false
    let pushedZone = null
    let pushedFn = null

    if (FUNCS.has(node.name)) {
      const id = childByName(node, 'Identifier')
      const enclosing = fnStack.length > 0 ? fnStack[fnStack.length - 1] : null
      const fn = {
        id: idOf(node),
        name: id ? textOf(id) : (hintByOffset.get(node.from) || '(lambda)'),
        kind: node.name,
        range: rangeOf(node),
        parent: enclosing ? enclosing.id : null,
        params: [],
        async: false,
        generator: false
      }
      if (classStack.length > 0 && !enclosing) {
        classStack[classStack.length - 1].members.push(fn.id)
      } else if (!enclosing && classStack.length === 0) {
        model.symbols.push({
          id: fn.id, kind: 'function', name: fn.name, range: fn.range, fn: fn.id, members: []
        })
      }
      scopes.push(new Map())
      pushedScope = true
      const params = childByName(node, 'CallParamsParameterNode')
      if (params) {
        for (const p of children(params)) {
          if (p.name !== 'ParameterNode') continue
          const pid = childByName(p, 'Identifier')
          const name = pid ? textOf(pid) : '(pattern)'
          declare(name, 'param')
          fn.params.push(name)
        }
      }
      const body = childByName(node, 'Body')
      if (body) hoist(body)
      model.functions.push(fn)
      fnStack.push(fn)
      pushedFn = fn
    }

    // An inner `class Inner:` is a file-level symbol with method members.
    let pushedClass = null
    if (node.name === 'ClassNode') {
      const cid = childByName(node, 'Identifier')
      const sym = {
        id: idOf(node),
        kind: 'class',
        name: cid ? textOf(cid) : '(anonymous class)',
        range: rangeOf(node),
        fn: null,
        members: []
      }
      if (fnStack.length === 0 && classStack.length === 0) model.symbols.push(sym)
      classStack.push(sym)
      pushedClass = sym
    }

    if (ZONES.has(node.name)) {
      const parent = zoneStack.length > 0 ? zoneStack[zoneStack.length - 1] : null
      const zone = {
        id: idOf(node),
        type: node.name,
        kind: node.name === 'ForNode' || node.name === 'WhileNode' ? 'loop' : 'branch',
        shape: shapeOfZone(node),
        range: rangeOf(node),
        depth: parent ? parent.depth + 1 : 1,
        parent: parent ? parent.id : null,
        container: container(),
        condition: conditionOf(node),
        item: itemOf(node),
        bands: bandsOf(node),
        carried: [],
        exits: []
      }
      model.zones.push(zone)
      zoneStack.push(zone)
      pushedZone = zone
      if (zone.item) {
        scopes.push(new Map())
        declare(zone.item, 'local')
        zone.__scoped = true
      }
    }

    // A declaration names a wire (R3) and is NOT an operation.
    const decl = declaratorOf(node)
    if (decl) {
      const name = declaredName(decl)
      const value = children(node).filter(c => isExpression(c.name)).pop()
      const entry = { name, range: rangeOf(node), container: container(), from: 'none', node: null }
      if (value && FUNCS.has(unwrap(value).node.name)) hintByOffset.set(unwrap(value).node.from, name)
      // A class-level declaration is a file-level symbol; one inside a function
      // is just a named wire.
      if (fnStack.length === 0 && classStack.length === 0 && name) {
        const inner = value ? unwrap(value).node : null
        if (!inner || !FUNCS.has(inner.name)) {
          model.symbols.push({
            id: idOf(node), kind: 'const', name, range: rangeOf(node), fn: null, members: []
          })
        }
      }
      if (value) {
        const inner = unwrap(value).node
        if (inner.name === 'LiteralExpressionNode' || inner.name === 'String' ||
            inner.name === 'BuiltinConstants' || inner.name === 'True' ||
            inner.name === 'False' || inner.name === 'Null') entry.from = 'literal'
        else if (OPS.has(inner.name)) { entry.from = 'node'; entry.node = idOf(inner) }
        else if (FUNCS.has(inner.name)) { entry.from = 'closure'; entry.node = idOf(inner) }
        else entry.from = 'wire'
      }
      if (name) {
        model.names.push(entry)
        declaredAt.set(name, node.from)
      }
    } else if (node.name === 'VariableNode' || node.name === 'ConstantNode') {
      // `var x` with no value, standing alone — still a class-level field.
      // `var x` with no value, standing alone.
      const parentIsAssign = node.parent && node.parent.name === 'AssignmentExpressionNode'
      const name = declaredName(node)
      if (!parentIsAssign && name) {
        model.names.push({ name, range: rangeOf(node), container: container(), from: 'none', node: null })
        declaredAt.set(name, node.from)
        if (fnStack.length === 0 && classStack.length === 0) {
          model.symbols.push({
            id: idOf(node), kind: 'const', name, range: rangeOf(node), fn: null, members: []
          })
        }
      }
    } else if (OPS.has(node.name)) {
      const kind = OPS.get(node.name)
      const op = {
        id: idOf(node),
        kind,
        label: labelFor(node, kind),
        range: rangeOf(node),
        zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null,
        container: container(),
        inputs: []
      }
      for (const [operand, slot] of operandsOf(node)) {
        const input = inputFor(operand, slot)
        if (input) op.inputs.push(input)
      }
      if (node.name === 'AssignmentExpressionNode') {
        const target = children(node).find(c => isExpression(c.name))
        if (target) {
          const path = pathOf(target)
          const root = rootOf(target)
          if (path) op.writes = path
          if (root) {
            const rootName = textOf(root).trim()
            op.writesRoot = rootName
            writes.push({ name: rootName, at: rangeOf(node) })
          }
        }
      }
      model.nodes.push(op)
    }

    // --- exits (Z4). `return` has a node; break/continue do NOT (see header).
    let exitKind = null
    if (node.name === 'ReturnNode') exitKind = 'return'
    else if (node.name === 'IdentifierExpressionNode') {
      const text = textOf(node).trim()
      if (text === 'break') exitKind = 'break'
      else if (text === 'continue') exitKind = 'continue'
    }

    if (exitKind) {
      const fn = fnStack[fnStack.length - 1]
      let lands = { on: 'unresolved', id: null }
      if (exitKind === 'return') {
        lands = fn ? { on: 'function-output', id: fn.id } : { on: 'module-output', id: filePath }
      } else if (exitKind === 'continue') {
        const loop = nearestLoop()
        if (loop) lands = { on: 'zone-entry', id: loop.id }
      } else if (exitKind === 'break') {
        const target = nearestBreakTarget()
        if (target) lands = { on: 'zone-exit', id: target.id }
      }
      const exitId = idOf(node) + ':exit'
      model.exits.push({
        id: exitId,
        kind: exitKind,
        node: node.name === 'ReturnNode' ? idOf(node) : null,
        range: rangeOf(node),
        zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null,
        container: container(),
        lands
      })
      if (zoneStack.length > 0) zoneStack[zoneStack.length - 1].exits.push(exitId)
    }

    if (node.name === 'Identifier') {
      const text = textOf(node)
      if (text !== 'break' && text !== 'continue') reads.push({ name: text, at: node.from })
    }

    for (const c of children(node)) walk(c)

    if (pushedZone) {
      if (pushedZone.__scoped) { scopes.pop(); delete pushedZone.__scoped }
      zoneStack.pop()
    }
    if (pushedFn) fnStack.pop()
    if (pushedClass) classStack.pop()
    if (pushedScope) scopes.pop()
  }

  const root = tree.topNode
  hoist(root)
  walk(root)

  if (moduleBodyUsed) {
    model.symbols.push({
      id: moduleBodyId,
      kind: 'module-body',
      name: '(module body)',
      range: rangeOf(root),
      fn: moduleBodyId,
      members: []
    })
  }
  model.symbols.sort((a, b) => a.range.startByte - b.range.startByte)

  // A mis-parse is not a parse failure: lezer recovers and hands back a tree with
  // error nodes in it, so the model looks complete while structure has silently
  // gone missing — a `for` whose Body was lost stops being a zone, and the
  // `continue` inside it then lands nowhere. Mark the model PARTIAL so nothing
  // downstream treats it as authoritative. See the capability table in
  // docs/EXPRESSION-FLOOR.md: 11 of 33 GDScript 4 constructs trip this grammar.
  const orphanExits = model.exits.filter(e => e.lands.on === 'unresolved').length
  if (model.errors > 0 || orphanExits > 0) {
    model.partial = true
    model.partialReason = (model.errors > 0 ? model.errors + ' grammar error node(s)' : '') +
      (model.errors > 0 && orphanExits > 0 ? '; ' : '') +
      (orphanExits > 0 ? orphanExits + ' exit(s) landing nowhere' : '')
  }

  // Z3 — a zone's outputs are its phis. Identical rule to the JS side.
  for (const zone of model.zones) {
    const carried = new Set()
    for (const w of writes) {
      if (w.at.startByte < zone.range.startByte || w.at.endByte > zone.range.endByte) continue
      const at = declaredAt.get(w.name)
      if (at !== undefined && at >= zone.range.startByte) continue
      if (reads.some(r => r.name === w.name && r.at > zone.range.endByte)) carried.add(w.name)
    }
    zone.carried = [...carried].sort()
  }

  return model
}