/**
* 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
}