OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / derive.js

773 lines · javascript ref:
/**
 * The expression floor — one file becomes operations, wires, zones and exits.
 *
 * Specified in docs/EXPRESSION-FLOOR.md. The short version:
 *
 *   an operation        -> a node          (R1)
 *   a value             -> a wire endpoint (R2) — never a node
 *   a declaration       -> a wire's NAME   (R3) — never a node
 *   control flow        -> a zone (region) (R4) — never a node
 *   a property path     -> a socket label  (R5)
 *
 * So `x + 1` is ONE node with one wired input and one literal input, not three.
 * That single distinction is the difference between 23,164 nodes and ~4,900 on
 * this repo, which is the difference between a readable graph and a wall.
 *
 * Everything carries a byte range, because a node has to be able to show the code
 * it came from when you expand it, and because ids are derived from that range
 * and never stored (R7).
 *
 * JS only for now. GDScript, C++ and GLSL all keep a full tree and can reach the
 * floor the same way; they visit only top-level declarations today.
 */

import { parse as acornParse } from '../vendor/acorn/dist/acorn.mjs'
import { deriveGDScript } from './derive-gdscript.js'

const PARSE_OPTS = {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
  ranges: true,
  allowHashBang: true
}

// R1 — these compute or act, so they are nodes.
//
// Note what is NOT here: `await` and `...spread` are MARKERS on the thing they
// wrap (R5), not nodes of their own, so `await f()` is one call node flagged
// awaited rather than two nodes in a chain.
const OPS = new Set([
  'CallExpression', 'NewExpression', 'BinaryExpression', 'LogicalExpression',
  'UnaryExpression', 'UpdateExpression', 'AssignmentExpression',
  'ConditionalExpression', 'ObjectExpression', 'ArrayExpression',
  'TemplateLiteral', 'TaggedTemplateExpression', 'ReturnStatement',
  'ThrowStatement', 'YieldExpression'
])

const LOOPS = new Set([
  'ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement',
  'DoWhileStatement'
])

const ZONES = new Set([...LOOPS, 'IfStatement', 'SwitchStatement', 'TryStatement'])

const JUMPS = new Set([
  'ReturnStatement', 'BreakStatement', 'ContinueStatement', 'ThrowStatement'
])

const FUNCS = new Set([
  'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'
])

// Children that are never part of the model: positions, and the raw token soup
// of a template literal.
const SKIP_KEYS = new Set(['loc', 'range', 'start', 'end', 'quasis'])

function rangeOf (node) {
  return {
    startByte: node.range[0],
    endByte: node.range[1],
    startLine: node.loc.start.line,
    startCol: node.loc.start.column,
    endLine: node.loc.end.line,
    endCol: node.loc.end.column
  }
}

function idOf (filePath, node) {
  return filePath + ':' + node.range[0] + '-' + node.range[1]
}

// R5 — a property path is one label, not a chain of member nodes. Computed
// access becomes `[..]` because the key is a value, not a name.
function pathOf (node) {
  if (!node) return null
  if (node.type === 'Identifier') return node.name
  if (node.type === 'ThisExpression') return 'this'
  if (node.type === 'Literal') return String(node.value)
  if (node.type === 'MemberExpression') {
    const base = pathOf(node.object)
    if (base === null) return null
    if (node.computed) return base + '[..]'
    return base + '.' + pathOf(node.property)
  }
  return null
}

// The identifier a path is rooted in — what actually gets resolved in scope.
function rootOf (node) {
  let cur = node
  while (cur && cur.type === 'MemberExpression') cur = cur.object
  return cur
}

function labelFor (node) {
  switch (node.type) {
    case 'CallExpression':
    case 'NewExpression': {
      const p = pathOf(node.callee)
      const prefix = node.type === 'NewExpression' ? 'new ' : ''
      return prefix + (p === null ? '(expr)' : p)
    }
    case 'BinaryExpression':
    case 'LogicalExpression':
    case 'AssignmentExpression':
      return node.operator
    case 'UnaryExpression':
    case 'UpdateExpression':
      return node.operator
    case 'ConditionalExpression': return '?:'
    case 'ObjectExpression': return '{}'
    case 'ArrayExpression': return '[]'
    case 'TemplateLiteral':
    case 'TaggedTemplateExpression': return 'template'
    case 'ReturnStatement': return 'return'
    case 'ThrowStatement': return 'throw'
    case 'YieldExpression': return 'yield'
    default: return node.type
  }
}

/**
 * The name a function takes from the thing it is bound to.
 *
 * `const bump = (n) => ...`, `{ read () {} }`, `class S { read () {} }` all give
 * a function with no `id` of its own. Naming them '(anonymous)' made every class
 * method in the model anonymous, which makes a class's interior unreadable — so
 * the binding supplies the name, and the hint travels down one level only.
 */
function hintFor (node, key) {
  if (node.type === 'VariableDeclarator' && key === 'init' && node.id.type === 'Identifier') {
    return node.id.name
  }
  if ((node.type === 'MethodDefinition' || node.type === 'PropertyDefinition') && key === 'value') {
    if (node.kind === 'constructor') return 'constructor'
    if (node.key && node.key.type === 'Identifier') return node.key.name
    if (node.key && node.key.type === 'Literal') return String(node.key.value)
  }
  if (node.type === 'Property' && key === 'value') {
    if (node.key && node.key.type === 'Identifier') return node.key.name
    if (node.key && node.key.type === 'Literal') return String(node.key.value)
  }
  if (node.type === 'AssignmentExpression' && key === 'right') {
    const p = pathOf(node.left)
    if (p) return p
  }
  return null
}

// `await x` / `...x` wrap the thing that matters. Unwrap to the node underneath
// and report the markers, so the operand is described once, with its flags.
function unwrap (node) {
  const flags = {}
  let cur = node
  while (cur) {
    if (cur.type === 'AwaitExpression') { flags.awaited = true; cur = cur.argument; continue }
    if (cur.type === 'SpreadElement') { flags.spread = true; cur = cur.argument; continue }
    if (cur.type === 'ParenthesizedExpression') { cur = cur.expression; continue }
    if (cur.type === 'TSAsExpression' || cur.type === 'TSNonNullExpression') { cur = cur.expression; continue }
    break
  }
  return { node: cur, flags }
}

/**
 * One operand slot -> one input descriptor.
 *
 * A literal names itself and sits IN the socket (R2). A reference or a path
 * names the wire and records where it resolved (E5). An operand that is itself
 * an operation points at that node's id — the operand still becomes its own
 * node when the walk reaches it.
 */
function inputFor (raw, slot, ctx) {
  const { node, flags } = unwrap(raw)
  if (!node) return null

  const base = { slot }
  if (flags.awaited) base.awaited = true
  if (flags.spread) base.spread = true

  if (node.type === 'Literal') {
    return { ...base, name: node.raw === undefined ? String(node.value) : node.raw, literal: true }
  }
  if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
    return { ...base, name: 'template', literal: true }
  }
  if (node.type === 'Identifier' || node.type === 'ThisExpression' || node.type === 'MemberExpression') {
    const path = pathOf(node)
    const root = rootOf(node)
    if (path !== null && root) {
      return {
        ...base,
        name: path,
        source: root.type === 'ThisExpression' ? 'this' : ctx.resolve(root.name)
      }
    }
  }
  if (FUNCS.has(node.type)) {
    return { ...base, name: '(fn)', source: 'closure', node: idOf(ctx.filePath, node) }
  }
  if (OPS.has(node.type)) {
    return { ...base, name: labelFor(node), source: 'node', node: idOf(ctx.filePath, node) }
  }
  // Anything else (a class expression, a sequence, a regex-as-node): still an
  // endpoint, still positioned, just not one we have a better name for.
  return { ...base, name: node.type, source: 'expr' }
}

// Operand slots per operation, in the order they read.
function operandsOf (node) {
  switch (node.type) {
    case 'CallExpression':
    case 'NewExpression': {
      // The callee keeps a socket even though its PATH became the label, so the
      // binding it resolves to (an import, a global) is never lost (E5).
      const out = [[node.callee, 'callee']]
      for (let i = 0; i < node.arguments.length; i++) out.push([node.arguments[i], 'arg' + i])
      return out
    }
    case 'BinaryExpression':
    case 'LogicalExpression':
      return [[node.left, 'left'], [node.right, 'right']]
    case 'AssignmentExpression':
      // `left` is the WRITE target, recorded separately — only the value flows in.
      return [[node.right, 'value']]
    case 'UnaryExpression':
      return [[node.argument, 'value']]
    case 'UpdateExpression':
      return [[node.argument, 'value']]
    case 'ConditionalExpression':
      return [[node.test, 'test'], [node.consequent, 'then'], [node.alternate, 'else']]
    case 'ObjectExpression':
      return node.properties.map((p, i) => [p.type === 'Property' ? p.value : p, keyName(p, i)])
    case 'ArrayExpression':
      return node.elements.map((e, i) => [e, 'item' + i])
    case 'TemplateLiteral':
      return node.expressions.map((e, i) => [e, 'slot' + i])
    case 'TaggedTemplateExpression':
      return [[node.tag, 'tag']]
    case 'ReturnStatement':
    case 'ThrowStatement':
    case 'YieldExpression':
      return node.argument ? [[node.argument, 'value']] : []
    default:
      return []
  }
}

function keyName (prop, i) {
  if (prop.type !== 'Property') return 'item' + i
  if (prop.key && prop.key.type === 'Identifier') return prop.key.name
  if (prop.key && prop.key.type === 'Literal') return String(prop.key.value)
  return 'item' + i
}

// Z1 — a conditional's shape follows its control flow, not its keyword.
function shapeOfIf (node) {
  const body = node.consequent.type === 'BlockStatement' ? node.consequent.body : [node.consequent]
  if (!node.alternate && body.length === 1 && JUMPS.has(body[0].type)) return 'guard'
  if (node.alternate) return 'two-exit'
  return 'single-branch'
}

function shapeOfZone (node) {
  if (node.type === 'IfStatement') return shapeOfIf(node)
  if (node.type === 'ForOfStatement' || node.type === 'ForInStatement') return 'foreach'
  if (LOOPS.has(node.type)) return 'repeat'
  if (node.type === 'SwitchStatement') return 'switch'
  if (node.type === 'TryStatement') return 'try'
  return node.type
}

// The zone's driving input: a branch's test, a switch's discriminant, or — for
// for-of/for-in, which have neither — the ITERABLE being walked. Without that
// last case a foreach zone reported no driver at all, and the GDScript side
// reported the item binding instead, so the two languages disagreed on the same
// program.
function conditionOf (node, source) {
  const test = node.test || node.discriminant ||
    ((node.type === 'ForOfStatement' || node.type === 'ForInStatement') ? node.right : null)
  if (!test) return null
  return { text: source.slice(test.range[0], test.range[1]), range: rangeOf(test) }
}

// The binding a foreach zone hands its body, e.g. `row` in `for (const row of rows)`.
function itemOf (node) {
  if (node.type !== 'ForOfStatement' && node.type !== 'ForInStatement') return null
  const left = node.left
  if (left.type === 'VariableDeclaration' && left.declarations.length > 0) {
    const id = left.declarations[0].id
    return id.type === 'Identifier' ? id.name : null
  }
  if (left.type === 'Identifier') return left.name
  return null
}

export function deriveJS (source, filePath) {
  const empty = {
    filePath,
    language: 'javascript',
    symbols: [],
    functions: [],
    nodes: [],
    names: [],
    zones: [],
    exits: []
  }

  let ast
  try {
    ast = acornParse(source, PARSE_OPTS)
  } catch (err) {
    try {
      ast = acornParse(source, { ...PARSE_OPTS, sourceType: 'script' })
    } catch {
      return { ...empty, error: err.message }
    }
  }

  const model = { ...empty }

  // --- scope chain. A reference resolves to a kind, or is marked free (E5).
  const scopes = [new Map()]

  function declare (name, kind) {
    if (!name) return
    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'
  }

  const ctx = { filePath, resolve }

  // Hoist the declarations of one scope BEFORE walking it, so a reference that
  // reads before its declaration still resolves instead of being called free.
  function hoist (body) {
    for (const stmt of body) {
      if (!stmt || typeof stmt.type !== 'string') continue
      const inner = stmt.type === 'ExportNamedDeclaration' || stmt.type === 'ExportDefaultDeclaration'
        ? stmt.declaration
        : stmt
      if (!inner) continue
      if (inner.type === 'VariableDeclaration') {
        for (const d of inner.declarations) collectPattern(d.id, 'local')
      } else if (inner.type === 'FunctionDeclaration' || inner.type === 'ClassDeclaration') {
        if (inner.id) declare(inner.id.name, 'function')
      } else if (inner.type === 'ImportDeclaration') {
        for (const spec of inner.specifiers) declare(spec.local.name, 'import')
      }
    }
  }

  function collectPattern (pattern, kind) {
    if (!pattern) return
    switch (pattern.type) {
      case 'Identifier': declare(pattern.name, kind); break
      case 'ObjectPattern':
        for (const p of pattern.properties) {
          if (p.type === 'Property') collectPattern(p.value, kind)
          else if (p.type === 'RestElement') collectPattern(p.argument, kind)
        }
        break
      case 'ArrayPattern':
        for (const e of pattern.elements) collectPattern(e, kind)
        break
      case 'AssignmentPattern': collectPattern(pattern.left, kind); break
      case 'RestElement': collectPattern(pattern.argument, kind); break
    }
  }

  // --- writes and reads, collected for the carried-state pass (Z3).
  const writes = []   // { name, at, declaredAt }
  const reads = []    // { name, at }
  const declaredAt = new Map()

  function noteWrite (target, at) {
    const root = rootOf(target)
    if (!root || root.type !== 'Identifier') return null
    writes.push({ name: root.name, at })
    return root.name
  }

  // --- the walk
  let zoneStack = []
  let fnStack = []
  let classStack = []

  // Top-level code has to live somewhere or it is unreachable from any node in
  // the file view (spec L2): index.js has 87 operations outside every function.
  const moduleBodyId = filePath + ':module-body'
  let moduleBodyUsed = false

  // The container of anything in the interior: the nearest enclosing function,
  // else the module body. Never null, so "what is inside this node" is a lookup
  // and never a range comparison (L3/E4b).
  function container () {
    if (fnStack.length > 0) return fnStack[fnStack.length - 1].id
    moduleBodyUsed = true
    return moduleBodyId
  }

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

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

  function currentTry () {
    for (let i = zoneStack.length - 1; i >= 0; i--) {
      if (zoneStack[i].shape === 'try') return zoneStack[i]
    }
    return null
  }

  function landingFor (node) {
    const fn = fnStack[fnStack.length - 1]
    switch (node.type) {
      case 'ReturnStatement':
        return fn ? { on: 'function-output', id: fn.id } : { on: 'module-output', id: filePath }
      case 'ContinueStatement': {
        const loop = currentLoop()
        return loop ? { on: 'zone-entry', id: loop.id } : { on: 'unresolved', id: null }
      }
      case 'BreakStatement': {
        const target = currentBreakTarget()
        return target
          ? { on: target.shape === 'switch' ? 'zone-exit' : 'zone-exit', id: target.id }
          : { on: 'unresolved', id: null }
      }
      case 'ThrowStatement': {
        const tryZone = currentTry()
        if (tryZone) return { on: 'catch-entry', id: tryZone.id }
        return fn ? { on: 'function-error-output', id: fn.id } : { on: 'unresolved', id: null }
      }
      default:
        return { on: 'unresolved', id: null }
    }
  }

  // An `else if` chain is ONE multi-band zone, not N nested ones (Z1). Absorb
  // the chain here and return the alternates the caller must NOT re-enter as
  // zones of their own.
  function bandsOf (node, absorbed) {
    const bands = [{ kind: 'then', test: conditionOf(node, source), range: rangeOf(node.consequent) }]
    let alt = node.alternate
    while (alt && alt.type === 'IfStatement') {
      absorbed.add(alt)
      bands.push({ kind: 'else-if', test: conditionOf(alt, source), range: rangeOf(alt.consequent) })
      alt = alt.alternate
    }
    if (alt) bands.push({ kind: 'else', test: null, range: rangeOf(alt) })
    return bands
  }

  const absorbed = new Set()

  function walk (node, hint) {
    if (!node || typeof node.type !== 'string') return

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

    // --- functions: a new scope, and R6's group interface.
    if (FUNCS.has(node.type)) {
      const enclosing = fnStack.length > 0 ? fnStack[fnStack.length - 1] : null
      const fn = {
        id: idOf(filePath, node),
        name: node.id ? node.id.name : (hint || '(anonymous)'),
        kind: node.type,
        range: rangeOf(node),
        parent: enclosing ? enclosing.id : null,
        params: [],
        async: !!node.async,
        generator: !!node.generator
      }
      // A method belongs to its class, not to a function, so it is a member
      // rather than a file-level symbol.
      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
      for (const p of node.params) {
        collectPattern(p, 'param')
        const n = p.type === 'Identifier' ? p.name : (p.type === 'AssignmentPattern' && p.left.type === 'Identifier' ? p.left.name : '(pattern)')
        fn.params.push(n)
      }
      if (node.body && node.body.type === 'BlockStatement') hoist(node.body.body)
      model.functions.push(fn)
      fnStack.push(fn)
      pushedFn = fn
    }

    // --- classes. A class is a file-level symbol with an interior of methods,
    // which is the rung the old symbol view collapsed into one un-openable node
    // labelled "N methods".
    let pushedClass = null
    if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
      const sym = {
        id: idOf(filePath, node),
        kind: 'class',
        name: node.id ? node.id.name : (hint || '(anonymous class)'),
        range: rangeOf(node),
        fn: null,
        members: []
      }
      if (fnStack.length === 0 && classStack.length === 0) model.symbols.push(sym)
      classStack.push(sym)
      pushedClass = sym
    }

    // --- zones (R4). An absorbed `else if` link is a band of its parent.
    if (ZONES.has(node.type) && !absorbed.has(node)) {
      const parent = zoneStack.length > 0 ? zoneStack[zoneStack.length - 1] : null
      const zone = {
        id: idOf(filePath, node),
        type: node.type,
        kind: LOOPS.has(node.type) ? 'loop' : (node.type === 'TryStatement' ? 'try' : 'branch'),
        shape: shapeOfZone(node),
        range: rangeOf(node),
        depth: parent ? parent.depth + 1 : 1,
        parent: parent ? parent.id : null,
        container: container(),
        condition: conditionOf(node, source),
        item: itemOf(node),
        bands: node.type === 'IfStatement' && node.alternate ? bandsOf(node, absorbed) : null,
        carried: [],
        exits: []
      }
      model.zones.push(zone)
      zoneStack.push(zone)
      pushedZone = zone
      // A loop's binding lives in the loop's own scope.
      if (zone.item) {
        scopes.push(new Map())
        declare(zone.item, 'local')
        zone.__scoped = true
      }
    }

    // --- a block introduces a scope, and hoists into it.
    if (node.type === 'BlockStatement' && !(pushedFn)) {
      scopes.push(new Map())
      hoist(node.body)
      pushedScope = true
    }
    if (node.type === 'Program') hoist(node.body)

    // --- operations (R1)
    if (OPS.has(node.type)) {
      const op = {
        id: idOf(filePath, node),
        kind: node.type,
        label: labelFor(node),
        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, ctx)
        if (input) op.inputs.push(input)
      }
      // An assignment WRITES a wire: that is a new version of it (R3/Z3).
      if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') {
        const target = node.type === 'AssignmentExpression' ? node.left : node.argument
        const written = noteWrite(target, rangeOf(node))
        if (written) {
          op.writes = pathOf(target)
          op.writesRoot = written
        }
      }
      model.nodes.push(op)
    }

    // --- declarations name wires (R3), not nodes.
    if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier') {
      const inLoopHead = zoneStack.length > 0 &&
        zoneStack[zoneStack.length - 1].item === node.id.name &&
        zoneStack[zoneStack.length - 1].range.startByte <= node.range[0] &&
        node.range[0] < (zoneStack[zoneStack.length - 1].condition
          ? zoneStack[zoneStack.length - 1].condition.range.endByte
          : zoneStack[zoneStack.length - 1].range.endByte)

      // A loop binding is the zone's ITEM socket, not a named wire of its own.
      if (!inLoopHead) {
        const entry = {
          name: node.id.name,
          range: rangeOf(node),
          container: container(),
          from: 'none',
          node: null
        }
        const init = node.init ? unwrap(node.init).node : null
        if (!init) entry.from = 'none'
        else if (init.type === 'Literal') entry.from = 'literal'
        else if (OPS.has(init.type)) { entry.from = 'node'; entry.node = idOf(filePath, init) }
        else if (FUNCS.has(init.type)) { entry.from = 'closure'; entry.node = idOf(filePath, init) }
        else entry.from = 'wire'
        model.names.push(entry)
        declaredAt.set(node.id.name, node.range[0])
      }
    }

    // --- exits (Z4)
    if (JUMPS.has(node.type)) {
      model.exits.push({
        id: idOf(filePath, node) + ':exit',
        kind: node.type === 'ReturnStatement' ? 'return'
          : node.type === 'BreakStatement' ? 'break'
            : node.type === 'ContinueStatement' ? 'continue' : 'throw',
        node: OPS.has(node.type) ? idOf(filePath, node) : null,
        range: rangeOf(node),
        zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null,
        container: container(),
        lands: landingFor(node)
      })
      if (zoneStack.length > 0) {
        zoneStack[zoneStack.length - 1].exits.push(idOf(filePath, node) + ':exit')
      }
    }

    // --- reads, for the carried-state pass
    if (node.type === 'Identifier') reads.push({ name: node.name, at: node.range[0] })

    // A top-level declaration whose value is NOT a function is a const symbol.
    // (The function case already became a symbol when the function was created.)
    if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier' &&
        fnStack.length === 0 && classStack.length === 0) {
      const init = node.init ? unwrap(node.init).node : null
      const isCallable = init && (FUNCS.has(init.type) ||
        init.type === 'ClassDeclaration' || init.type === 'ClassExpression')
      if (!isCallable) {
        model.symbols.push({
          id: idOf(filePath, node),
          kind: 'const',
          name: node.id.name,
          range: rangeOf(node),
          fn: null,
          members: []
        })
      }
    }

    // --- recurse, carrying a name hint exactly one level down
    for (const key of Object.keys(node)) {
      if (SKIP_KEYS.has(key)) continue
      const value = node[key]
      const childHint = hintFor(node, key)
      if (Array.isArray(value)) {
        for (const child of value) walk(child, childHint)
      } else if (value && typeof value === 'object' && typeof value.type === 'string') {
        walk(value, childHint)
      }
    }

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

  walk(ast, null)

  // The module body is emitted only if something actually landed in it — a file
  // of pure declarations has no top-level code to show.
  if (moduleBodyUsed) {
    model.symbols.push({
      id: moduleBodyId,
      kind: 'module-body',
      name: '(module body)',
      range: rangeOf(ast),
      fn: moduleBodyId,
      members: []
    })
  }

  // Source order, so the file view is stable and diffable.
  model.symbols.sort((a, b) => a.range.startByte - b.range.startByte)

  // --- Z3: a zone's outputs are its phis. A name written inside the zone, whose
  // declaration is OUTSIDE it, and which is read after the zone closes, converges
  // out of it. Same rule for a loop's carried state and an if/else's convergence —
  // which is the whole point of treating both as regions.
  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 decl = declaredAt.get(w.name)
      if (decl !== undefined && decl >= zone.range.startByte) continue  // declared inside: cannot escape
      const readAfter = reads.some(r => r.name === w.name && r.at > zone.range.endByte)
      if (readAfter) carried.add(w.name)
    }
    zone.carried = [...carried].sort()
  }

  return model
}

/**
 * Pick a derivation by extension.
 *
 * The model shape is identical whichever language answers — that is the whole
 * point, because the renderer positions nodes by source range and must not care
 * what produced them. A language we cannot derive yet says so in `error` rather
 * than returning an empty model that looks like a file with no code in it.
 */
export function deriveFile (source, filePath) {
  const path = String(filePath || '')
  const dot = path.lastIndexOf('.')
  const ext = dot === -1 ? '' : path.slice(dot).toLowerCase()

  switch (ext) {
    case '.js':
    case '.mjs':
    case '.cjs':
      return deriveJS(source, filePath)
    case '.gd':
      return deriveGDScript(source, filePath)
    default:
      return {
        filePath,
        language: null,
        functions: [],
        nodes: [],
        names: [],
        zones: [],
        exits: [],
        error: 'no derivation for ' + (ext || 'a file with no extension') +
          ' — JS and GDScript only (C++ and GLSL keep a full tree and can follow)'
      }
  }
}