@chenglou/freerange 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,553 @@
1
+ import * as ts from 'typescript'
2
+ import type {BlockID, ValueID} from '../ir/ids.ts'
3
+ import {
4
+ addInstruction,
5
+ addSite,
6
+ bindingsVisibleAfterBranch,
7
+ createBlock,
8
+ mergeAtContinuation,
9
+ requiredBranchBinding,
10
+ requiredSymbol,
11
+ terminate,
12
+ unsupported,
13
+ type FunctionContext,
14
+ type MutableBlock,
15
+ } from './context.ts'
16
+ import {taggedUnionTagRead, identifierAssignment, lowerBranchingCondition, lowerExpression, lowerStatementExpression, requireBooleanCondition, valueKind} from './expression.ts'
17
+ import {declaredOnlyInDeclarationFiles} from './platform.ts'
18
+
19
+ export function lowerStatements(statements: readonly ts.Statement[], context: FunctionContext): void {
20
+ for (const statement of statements) {
21
+ if (context.currentBlock.terminator != null) throw unsupported(statement, {kind: 'statementAfterReturn'})
22
+ lowerStatement(statement, context)
23
+ }
24
+ }
25
+
26
+ export function lowerStatement(statement: ts.Statement, context: FunctionContext): void {
27
+ if (ts.isVariableStatement(statement)) {
28
+ lowerVariableDeclarationList(statement.declarationList, context)
29
+ return
30
+ }
31
+ if (ts.isReturnStatement(statement)) {
32
+ let value = statement.expression == null ? null : lowerExpression(statement.expression, context)
33
+ // A bare `return` in a function that returns a value IS `return undefined` — the
34
+ // common early exit in a `T | undefined` function. In void functions (and the module
35
+ // initializer) the return stays valueless.
36
+ if (value == null) {
37
+ const enclosing = ts.findAncestor(statement, ts.isFunctionDeclaration)
38
+ const signature = enclosing == null ? undefined : context.checker.getSignatureFromDeclaration(enclosing)
39
+ const returnType = signature == null ? null : context.checker.getReturnTypeOfSignature(signature)
40
+ const returnsVoid = returnType == null || (returnType.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined)) !== 0
41
+ if (!returnsVoid) {
42
+ value = addInstruction(context, statement, {kind: 'nullishConstant', sentinel: 'undefined'})
43
+ }
44
+ }
45
+ terminate(context.currentBlock, {kind: 'return', value, site: addSite(context, statement)})
46
+ return
47
+ }
48
+ if (ts.isExpressionStatement(statement)) {
49
+ lowerStatementExpression(statement.expression, context)
50
+ return
51
+ }
52
+ if (ts.isIfStatement(statement)) {
53
+ lowerIfStatement(statement, context)
54
+ return
55
+ }
56
+ if (ts.isForOfStatement(statement)) {
57
+ lowerForOfStatement(statement, context)
58
+ return
59
+ }
60
+ if (ts.isForStatement(statement)) {
61
+ lowerForStatement(statement, context)
62
+ return
63
+ }
64
+ if (ts.isWhileStatement(statement)) {
65
+ lowerWhileStatement(statement, context)
66
+ return
67
+ }
68
+ if (ts.isContinueStatement(statement)) {
69
+ lowerContinueStatement(statement, context)
70
+ return
71
+ }
72
+ if (ts.isBlock(statement)) {
73
+ lowerStatements(statement.statements, context)
74
+ return
75
+ }
76
+ if (ts.isSwitchStatement(statement)) {
77
+ lowerSwitchStatement(statement, context)
78
+ return
79
+ }
80
+ // Guard clauses: `if (columns === 0) throw new Error('bad grid')`. The branch
81
+ // refinement then proves the fall-through nonzero, and the thrown path simply ends.
82
+ if (ts.isThrowStatement(statement)) {
83
+ terminate(context.currentBlock, {kind: 'thrown', site: addSite(context, statement)})
84
+ return
85
+ }
86
+ throw unsupported(statement, {kind: 'statementForm', syntax: ts.SyntaxKind[statement.kind]})
87
+ }
88
+
89
+ function lowerIfStatement(statement: ts.IfStatement, context: FunctionContext): void {
90
+ const bindingsBeforeBranch = new Map(context.bindings)
91
+ const whenTrue = createBlock(context)
92
+ const whenFalse = createBlock(context)
93
+ lowerBranchingCondition(statement.expression, whenTrue, whenFalse, context)
94
+
95
+ const trueBranch = lowerBranch(statement.thenStatement, whenTrue, bindingsBeforeBranch, context)
96
+ const falseBranch = statement.elseStatement == null
97
+ ? {block: context.blocks[whenFalse]!, bindings: new Map(bindingsBeforeBranch)}
98
+ : lowerBranch(statement.elseStatement, whenFalse, bindingsBeforeBranch, context)
99
+ const continuingBranches = [trueBranch, falseBranch].filter(branch => branch.block.terminator == null)
100
+ if (continuingBranches.length === 0) {
101
+ context.currentBlock = trueBranch.block
102
+ context.bindings = bindingsBeforeBranch
103
+ return
104
+ }
105
+ if (continuingBranches.length === 1) {
106
+ const continuing = continuingBranches[0]!
107
+ context.currentBlock = continuing.block
108
+ context.bindings = bindingsVisibleAfterBranch(bindingsBeforeBranch, continuing.bindings)
109
+ return
110
+ }
111
+
112
+ mergeAtContinuation([trueBranch, falseBranch], bindingsBeforeBranch, statement, context)
113
+ }
114
+
115
+ // Switch without fallthrough (owner decision): every non-empty case body must end in a
116
+ // top-level break or a return, stacked empty labels share the next body, default comes
117
+ // last. Under that rule a switch is exactly an if/else chain on ===, so the lowering is
118
+ // pure reuse — number subjects get the comparison narrowing (case 4 knows the subject is
119
+ // 4), string subjects get the unknown-boolean dispatch with both branches analyzed, and
120
+ // bodies that break merge at the exit with the same block-parameter machinery as if/else.
121
+ function lowerSwitchStatement(statement: ts.SwitchStatement, context: FunctionContext): void {
122
+ const subjectType = context.checker.getTypeAtLocation(statement.expression)
123
+ const subjectKind = valueKind(subjectType, context.checker)
124
+ // switch (route.type) is tagged-union dispatch: the subject becomes the union value
125
+ // itself and every case emits a tag check, so each body knows its exact shape — the
126
+ // same narrowing the === spelling gets.
127
+ const tagUnionExpression = taggedUnionTagRead(statement.expression, context)
128
+ // A possibly-missing string subject (mode: string | undefined) dispatches like the ===
129
+ // spelling does: the missing value simply matches no case, so the unknown-boolean arm
130
+ // covers it. Every non-missing member must be a string for that to hold.
131
+ const missingFlags = ts.TypeFlags.Null | ts.TypeFlags.Undefined
132
+ const nullableOpaqueSubject = subjectKind === 'nullable' && subjectType.isUnion()
133
+ && subjectType.types.every(member =>
134
+ (member.flags & missingFlags) !== 0 || valueKind(member, context.checker) === 'opaque')
135
+ if (tagUnionExpression == null && subjectKind !== 'number' && subjectKind !== 'opaque' && !nullableOpaqueSubject) {
136
+ throw unsupported(statement.expression, {kind: 'switchSubject', typeText: context.checker.typeToString(subjectType)})
137
+ }
138
+ const subject = lowerExpression(tagUnionExpression ?? statement.expression, context)
139
+
140
+ // Group stacked empty labels with the body they share; reject a default that is not the
141
+ // last clause (JS would test later cases before running it — supporting that order buys
142
+ // nothing over writing default last).
143
+ type CaseGroup = {labels: ts.Expression[]; statements: ts.Statement[]; clause: ts.CaseOrDefaultClause}
144
+ const groups: CaseGroup[] = []
145
+ let pendingLabels: ts.Expression[] = []
146
+ let defaultGroup: CaseGroup | null = null
147
+ const clauses = statement.caseBlock.clauses
148
+ for (let index = 0; index < clauses.length; index++) {
149
+ const clause = clauses[index]!
150
+ if (ts.isDefaultClause(clause)) {
151
+ if (index !== clauses.length - 1 || pendingLabels.length > 0) {
152
+ throw unsupported(clause, {kind: 'switchDefaultNotLast'})
153
+ }
154
+ defaultGroup = {labels: [], statements: [...clause.statements], clause}
155
+ continue
156
+ }
157
+ pendingLabels.push(clause.expression)
158
+ if (clause.statements.length > 0) {
159
+ groups.push({labels: pendingLabels, statements: [...clause.statements], clause})
160
+ pendingLabels = []
161
+ }
162
+ }
163
+ // Trailing empty labels with no body to share fall out of the switch at runtime; the
164
+ // no-fallthrough rule wants that written as an explicit body, so they reject too.
165
+ if (pendingLabels.length > 0) {
166
+ throw unsupported(statement, {kind: 'switchFallthrough'})
167
+ }
168
+
169
+ const bindingsBefore = new Map(context.bindings)
170
+ // Bodies that ended in a break continue after the switch, as does the no-match path
171
+ // when there is no default; all of them merge at the continuation.
172
+ const exits: Array<{block: MutableBlock; bindings: Map<ts.Symbol, ValueID>}> = []
173
+
174
+ const lowerBody = (group: CaseGroup): void => {
175
+ const body = group.statements
176
+ const last = body[body.length - 1]
177
+ const endsWithBreak = last != null && ts.isBreakStatement(last)
178
+ lowerStatements(endsWithBreak ? body.slice(0, -1) : body, context)
179
+ if (context.currentBlock.terminator == null) {
180
+ if (!endsWithBreak) throw unsupported(group.clause, {kind: 'switchFallthrough'})
181
+ exits.push({block: context.currentBlock, bindings: context.bindings})
182
+ }
183
+ }
184
+
185
+ for (const group of groups) {
186
+ const bodyBlock = createBlock(context)
187
+ // Chain of label tests: each label's false edge goes to the next label, the last
188
+ // label's false edge to the next group (or the default / the no-match exit).
189
+ for (const label of group.labels) {
190
+ const labelKind = valueKind(context.checker.getTypeAtLocation(label), context.checker)
191
+ const effectiveSubjectKind = nullableOpaqueSubject ? 'opaque' : subjectKind
192
+ if (tagUnionExpression == null && labelKind !== effectiveSubjectKind) {
193
+ throw unsupported(label, {kind: 'switchLabel', typeText: context.checker.typeToString(context.checker.getTypeAtLocation(label))})
194
+ }
195
+ let condition: ValueID
196
+ if (tagUnionExpression != null) {
197
+ const unwrappedLabel = label
198
+ if (!ts.isStringLiteral(unwrappedLabel) && !ts.isNoSubstitutionTemplateLiteral(unwrappedLabel)) {
199
+ throw unsupported(label, {kind: 'switchLabel', typeText: context.checker.typeToString(context.checker.getTypeAtLocation(label))})
200
+ }
201
+ condition = addInstruction(context, label, {kind: 'tagCheck', union: subject, tagValue: unwrappedLabel.text, negated: false})
202
+ } else {
203
+ const labelValue = lowerExpression(label, context)
204
+ condition = effectiveSubjectKind === 'number'
205
+ ? addInstruction(context, label, {kind: 'compare', operator: 'equal', left: subject, right: labelValue})
206
+ : addInstruction(context, label, {kind: 'unknownBoolean'})
207
+ }
208
+ const nextTest = createBlock(context)
209
+ terminate(context.currentBlock, {
210
+ kind: 'branch',
211
+ condition,
212
+ whenTrue: {block: bodyBlock, arguments: []},
213
+ whenFalse: {block: nextTest, arguments: []},
214
+ site: addSite(context, label),
215
+ })
216
+ context.currentBlock = context.blocks[nextTest]!
217
+ }
218
+ const afterTests = context.currentBlock
219
+ const bindingsAtTests = new Map(context.bindings)
220
+ context.currentBlock = context.blocks[bodyBlock]!
221
+ context.bindings = new Map(bindingsBefore)
222
+ lowerBody(group)
223
+ context.currentBlock = afterTests
224
+ context.bindings = bindingsAtTests
225
+ }
226
+
227
+ if (defaultGroup == null) {
228
+ exits.push({block: context.currentBlock, bindings: context.bindings})
229
+ } else {
230
+ lowerBody(defaultGroup)
231
+ }
232
+
233
+ if (exits.length === 0) {
234
+ // Every path returned; subsequent statements land in a terminated block, where the
235
+ // statement-after-return rejection already speaks.
236
+ return
237
+ }
238
+ if (exits.length === 1) {
239
+ context.currentBlock = exits[0]!.block
240
+ context.bindings = bindingsVisibleAfterBranch(bindingsBefore, exits[0]!.bindings)
241
+ return
242
+ }
243
+ mergeAtContinuation(exits, bindingsBefore, statement, context)
244
+ }
245
+
246
+ // `for (const x of arr)` desugars to a counter loop: a synthetic counter rides the loop
247
+ // header as an extra block parameter (block parameters are plain ValueIDs, no symbol
248
+ // needed), the header compares it against the array's length, and the body's element read
249
+ // is in bounds by construction — the guard, the 0 start, and the +1 step are all
250
+ // synthetic values nothing can reassign. An empty array prunes the body entirely.
251
+ function lowerForOfStatement(statement: ts.ForOfStatement, context: FunctionContext): void {
252
+ if (!ts.isVariableDeclarationList(statement.initializer)
253
+ || statement.initializer.declarations.length !== 1
254
+ || !ts.isIdentifier(statement.initializer.declarations[0]!.name)) {
255
+ throw unsupported(statement.initializer, {kind: 'variableDeclarationShape'})
256
+ }
257
+ const elementName = statement.initializer.declarations[0]!.name
258
+ const elementType = context.checker.getTypeAtLocation(elementName)
259
+ if (valueKind(elementType, context.checker) == null) {
260
+ throw unsupported(elementName, {kind: 'valueType', typeText: context.checker.typeToString(elementType)})
261
+ }
262
+ const arrayType = context.checker.getTypeAtLocation(statement.expression)
263
+ const arrayKind = valueKind(arrayType, context.checker)
264
+ if (arrayKind !== 'array' && arrayKind !== 'tuple') {
265
+ throw unsupported(statement.expression, {kind: 'valueType', typeText: context.checker.typeToString(arrayType)})
266
+ }
267
+ const array = lowerExpression(statement.expression, context)
268
+ const zero = addInstruction(context, statement, {kind: 'constant', value: 0})
269
+
270
+ const bindingsBeforeLoop = new Map(context.bindings)
271
+ const assigned = assignedSymbols([statement.statement], context.checker)
272
+ const carried = [...bindingsBeforeLoop.keys()].filter(symbol => assigned.has(symbol))
273
+ const header = createBlock(context, carried.length + 1, addSite(context, statement))
274
+ terminate(context.currentBlock, {
275
+ kind: 'jump',
276
+ target: {
277
+ block: header,
278
+ arguments: [...carried.map(symbol => requiredBranchBinding(symbol, bindingsBeforeLoop)), zero],
279
+ },
280
+ site: addSite(context, statement),
281
+ })
282
+
283
+ context.currentBlock = context.blocks[header]!
284
+ context.bindings = new Map(bindingsBeforeLoop)
285
+ for (let index = 0; index < carried.length; index++) {
286
+ context.bindings.set(carried[index]!, context.currentBlock.parameters[index]!)
287
+ }
288
+ const counter = context.currentBlock.parameters[carried.length]!
289
+ const length = addInstruction(context, statement, {kind: 'arrayLength', array})
290
+ const condition = addInstruction(context, statement, {kind: 'compare', operator: 'lessThan', left: counter, right: length})
291
+ const conditionBindings = new Map(context.bindings)
292
+ const body = createBlock(context)
293
+ const exit = createBlock(context)
294
+ terminate(context.currentBlock, {
295
+ kind: 'branch',
296
+ condition,
297
+ whenTrue: {block: body, arguments: []},
298
+ whenFalse: {block: exit, arguments: []},
299
+ site: addSite(context, statement),
300
+ })
301
+
302
+ // The counter is a raw header parameter, not a symbol binding, so a continue cannot
303
+ // observe a stale value: the bump is emitted fresh at each back edge (the body end here,
304
+ // and every continue site).
305
+ const advance = (advanceContext: FunctionContext): ValueID[] => {
306
+ const one = addInstruction(advanceContext, statement, {kind: 'constant', value: 1})
307
+ const next = addInstruction(advanceContext, statement, {kind: 'binary', operator: 'add', left: counter, right: one})
308
+ return [next]
309
+ }
310
+
311
+ context.currentBlock = context.blocks[body]!
312
+ context.bindings = new Map(conditionBindings)
313
+ context.bindings.set(
314
+ requiredSymbol(elementName, context.checker),
315
+ addInstruction(context, statement, {
316
+ kind: 'arrayIndex',
317
+ array,
318
+ index: counter,
319
+ mode: 'bare',
320
+ }),
321
+ )
322
+ context.loops.push({header, carried, advance})
323
+ lowerStatement(statement.statement, context)
324
+ context.loops.pop()
325
+ if (context.currentBlock.terminator == null) {
326
+ const extra = advance(context)
327
+ terminate(context.currentBlock, {
328
+ kind: 'jump',
329
+ target: {
330
+ block: header,
331
+ arguments: [...carried.map(symbol => requiredBranchBinding(symbol, context.bindings)), ...extra],
332
+ },
333
+ site: addSite(context, statement),
334
+ })
335
+ }
336
+
337
+ context.currentBlock = context.blocks[exit]!
338
+ context.bindings = new Map(conditionBindings)
339
+ }
340
+
341
+ function lowerForStatement(statement: ts.ForStatement, context: FunctionContext): void {
342
+ if (statement.initializer != null) {
343
+ if (ts.isVariableDeclarationList(statement.initializer)) {
344
+ lowerVariableDeclarationList(statement.initializer, context)
345
+ } else {
346
+ lowerStatementExpression(statement.initializer, context)
347
+ }
348
+ }
349
+ if (statement.condition == null) throw unsupported(statement, {kind: 'forLoopWithoutCondition'})
350
+ requireBooleanCondition(statement.condition, context.checker)
351
+
352
+ const bindingsBeforeLoop = new Map(context.bindings)
353
+ const scanned = statement.incrementor == null
354
+ ? [statement.condition, statement.statement]
355
+ : [statement.condition, statement.statement, statement.incrementor]
356
+ const assigned = assignedSymbols(scanned, context.checker)
357
+ const carried = [...bindingsBeforeLoop.keys()].filter(symbol => assigned.has(symbol))
358
+ const header = createBlock(context, carried.length, addSite(context, statement))
359
+ terminate(context.currentBlock, {
360
+ kind: 'jump',
361
+ target: {block: header, arguments: carried.map(symbol => requiredBranchBinding(symbol, bindingsBeforeLoop))},
362
+ site: addSite(context, statement),
363
+ })
364
+
365
+ context.currentBlock = context.blocks[header]!
366
+ context.bindings = new Map(bindingsBeforeLoop)
367
+ for (let index = 0; index < carried.length; index++) {
368
+ context.bindings.set(carried[index]!, context.currentBlock.parameters[index]!)
369
+ }
370
+ const conditionBindings = new Map(context.bindings)
371
+ const body = createBlock(context)
372
+ const exit = createBlock(context)
373
+ lowerBranchingCondition(statement.condition, body, exit, context)
374
+
375
+ // A continue runs the incrementor before jumping back, same as the normal body end —
376
+ // JavaScript's order (continue in a for loop still advances the counter). An absent
377
+ // incrementor makes the loop while-shaped; progress then lives in the body.
378
+ const advance = (advanceContext: FunctionContext): ValueID[] => {
379
+ if (statement.incrementor != null) lowerStatementExpression(statement.incrementor, advanceContext)
380
+ return []
381
+ }
382
+
383
+ context.currentBlock = context.blocks[body]!
384
+ context.bindings = new Map(conditionBindings)
385
+ context.loops.push({header, carried, advance})
386
+ lowerStatement(statement.statement, context)
387
+ context.loops.pop()
388
+ if (context.currentBlock.terminator == null) {
389
+ advance(context)
390
+ terminate(context.currentBlock, {
391
+ kind: 'jump',
392
+ target: {block: header, arguments: carried.map(symbol => requiredBranchBinding(symbol, context.bindings))},
393
+ site: addSite(context, statement),
394
+ })
395
+ }
396
+
397
+ context.currentBlock = context.blocks[exit]!
398
+ context.bindings = conditionBindings
399
+ }
400
+
401
+ // `while (cond) body` is a for loop with no initializer and no incrementor: the same
402
+ // header/body/exit blocks, the same carried-binding block parameters, the same widening
403
+ // and fixed point at the header. Progress lives in the body's own assignments, so a body
404
+ // that never changes the condition's inputs converges to the non-exiting-loop stop.
405
+ function lowerWhileStatement(statement: ts.WhileStatement, context: FunctionContext): void {
406
+ requireBooleanCondition(statement.expression, context.checker)
407
+
408
+ const bindingsBeforeLoop = new Map(context.bindings)
409
+ const assigned = assignedSymbols([statement.expression, statement.statement], context.checker)
410
+ const carried = [...bindingsBeforeLoop.keys()].filter(symbol => assigned.has(symbol))
411
+ const header = createBlock(context, carried.length, addSite(context, statement))
412
+ terminate(context.currentBlock, {
413
+ kind: 'jump',
414
+ target: {block: header, arguments: carried.map(symbol => requiredBranchBinding(symbol, bindingsBeforeLoop))},
415
+ site: addSite(context, statement),
416
+ })
417
+
418
+ context.currentBlock = context.blocks[header]!
419
+ context.bindings = new Map(bindingsBeforeLoop)
420
+ for (let index = 0; index < carried.length; index++) {
421
+ context.bindings.set(carried[index]!, context.currentBlock.parameters[index]!)
422
+ }
423
+ const conditionBindings = new Map(context.bindings)
424
+ const body = createBlock(context)
425
+ const exit = createBlock(context)
426
+ lowerBranchingCondition(statement.expression, body, exit, context)
427
+
428
+ context.currentBlock = context.blocks[body]!
429
+ context.bindings = new Map(conditionBindings)
430
+ context.loops.push({header, carried, advance: () => []})
431
+ lowerStatement(statement.statement, context)
432
+ context.loops.pop()
433
+ if (context.currentBlock.terminator == null) {
434
+ terminate(context.currentBlock, {
435
+ kind: 'jump',
436
+ target: {block: header, arguments: carried.map(symbol => requiredBranchBinding(symbol, context.bindings))},
437
+ site: addSite(context, statement),
438
+ })
439
+ }
440
+
441
+ context.currentBlock = context.blocks[exit]!
442
+ context.bindings = conditionBindings
443
+ }
444
+
445
+ // `continue` ends the current path the way `return` does, except the jump targets the
446
+ // innermost loop's header instead of leaving the function. The loop's advance step runs
447
+ // first (a for loop's incrementor, the for-of counter bump), then the carried bindings
448
+ // are read at their current values — exactly what the normal body-end back edge does.
449
+ function lowerContinueStatement(statement: ts.ContinueStatement, context: FunctionContext): void {
450
+ if (statement.label != null) {
451
+ throw unsupported(statement, {kind: 'statementForm', syntax: 'ContinueStatement with a label'})
452
+ }
453
+ const loop = context.loops[context.loops.length - 1]
454
+ // TypeScript already rejects continue outside a loop; the guard keeps lowering total if
455
+ // such a file ever reaches this point.
456
+ if (loop == null) throw unsupported(statement, {kind: 'statementForm', syntax: 'ContinueStatement'})
457
+ const extra = loop.advance(context)
458
+ terminate(context.currentBlock, {
459
+ kind: 'jump',
460
+ target: {
461
+ block: loop.header,
462
+ arguments: [...loop.carried.map(symbol => requiredBranchBinding(symbol, context.bindings)), ...extra],
463
+ },
464
+ site: addSite(context, statement),
465
+ })
466
+ }
467
+
468
+ function lowerBranch(
469
+ statement: ts.Statement,
470
+ block: BlockID,
471
+ bindings: Map<ts.Symbol, ValueID>,
472
+ context: FunctionContext,
473
+ ): {block: MutableBlock; bindings: Map<ts.Symbol, ValueID>} {
474
+ context.currentBlock = context.blocks[block]!
475
+ context.bindings = new Map(bindings)
476
+ lowerStatement(statement, context)
477
+ return {block: context.currentBlock, bindings: context.bindings}
478
+ }
479
+
480
+ function lowerVariableDeclarationList(declarations: ts.VariableDeclarationList, context: FunctionContext): void {
481
+ for (const declaration of declarations.declarations) {
482
+ // `const {pos, dest} = config` lowers to one read of the source and one property read
483
+ // per name. Only plain shorthand or renamed identifier elements — no defaults, no rest.
484
+ if (ts.isObjectBindingPattern(declaration.name) && declaration.initializer != null) {
485
+ const source = lowerExpression(declaration.initializer, context)
486
+ for (const element of declaration.name.elements) {
487
+ if (!ts.isIdentifier(element.name) || element.dotDotDotToken != null || element.initializer != null) {
488
+ throw unsupported(element, {kind: 'variableDeclarationShape'})
489
+ }
490
+ const property = element.propertyName == null
491
+ ? element.name.text
492
+ : ts.isIdentifier(element.propertyName)
493
+ ? element.propertyName.text
494
+ : null
495
+ if (property == null) throw unsupported(element, {kind: 'variableDeclarationShape'})
496
+ const elementType = context.checker.getTypeAtLocation(element.name)
497
+ const sourceType = context.checker.getTypeAtLocation(declaration.initializer)
498
+ const propertySymbol = context.checker.getPropertyOfType(sourceType, property)
499
+ // Optional properties destructure like any other read now that the filling
500
+ // invariant guarantees the record value carries them as maybe-undefined (the old
501
+ // gate here predated the optionals milestone). Prototype members stay out via
502
+ // the same ownership rule property reads use.
503
+ if (propertySymbol != null && declaredOnlyInDeclarationFiles(propertySymbol)) {
504
+ throw unsupported(element, {kind: 'prototypeMemberRead', property})
505
+ }
506
+ if (valueKind(elementType, context.checker) == null) {
507
+ throw unsupported(element, {kind: 'valueType', typeText: context.checker.typeToString(elementType)})
508
+ }
509
+ const value = addInstruction(context, element, {kind: 'property', object: source, property})
510
+ context.bindings.set(requiredSymbol(element.name, context.checker), value)
511
+ }
512
+ continue
513
+ }
514
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer == null) {
515
+ throw unsupported(declaration, {kind: 'variableDeclarationShape'})
516
+ }
517
+ const value = lowerExpression(declaration.initializer, context)
518
+ // A variable whose declared type mixes kinds, e.g. `let u: unknown = 5` later reassigned
519
+ // to a boolean, lets branches rebind it to different kinds that would meet at the
520
+ // engine's block join instead of stopping here. The check runs after the initializer
521
+ // lowers, so an unsupported construct inside the initializer keeps its own more precise
522
+ // site (a ternary mixing kinds reports the ternary, not the whole declaration).
523
+ const declaredType = context.checker.getTypeAtLocation(declaration.name)
524
+ const declaredValueKind = valueKind(declaredType, context.checker)
525
+ if (declaredValueKind == null) {
526
+ throw unsupported(declaration.type ?? declaration.name, {
527
+ kind: 'valueType',
528
+ typeText: context.checker.typeToString(declaredType),
529
+ })
530
+ }
531
+ // An opaque-declared binding (`let u: unknown = 5`) erases its stored value: later
532
+ // branches may write other kinds, and opaque ⊔ opaque joins where number ⊔ boolean
533
+ // would crash. The initializer still lowered above, so its constructs stay vetted.
534
+ const stored = declaredValueKind === 'opaque'
535
+ ? addInstruction(context, declaration, {kind: 'opaqueConstant'})
536
+ : value
537
+ context.bindings.set(requiredSymbol(declaration.name, context.checker), stored)
538
+ }
539
+ }
540
+
541
+ function assignedSymbols(nodes: ts.Node[], checker: ts.TypeChecker): Set<ts.Symbol> {
542
+ const symbols = new Set<ts.Symbol>()
543
+ const visit = (node: ts.Node): void => {
544
+ if (ts.isFunctionLike(node)) return
545
+ // Shares the lowering's recognizer, so a form that lowers an assignment is carried
546
+ // across loop back edges by construction.
547
+ const assignment = identifierAssignment(node)
548
+ if (assignment != null) symbols.add(requiredSymbol(assignment.target, checker))
549
+ ts.forEachChild(node, visit)
550
+ }
551
+ for (const node of nodes) visit(node)
552
+ return symbols
553
+ }
@@ -0,0 +1,87 @@
1
+ import * as ts from 'typescript'
2
+
3
+ type StaticAnnotationRole = 'requirement' | 'assertion'
4
+
5
+ export type StaticAnnotation =
6
+ | {kind: 'valid'; call: ts.CallExpression; role: StaticAnnotationRole; condition: ts.Expression}
7
+ | {
8
+ kind: 'invalid'
9
+ call: ts.CallExpression
10
+ role: StaticAnnotationRole
11
+ node: ts.Node
12
+ problem: 'argumentCount' | 'position' | 'optionalCall'
13
+ }
14
+
15
+ type StaticAnnotationScan = {
16
+ functions: StaticAnnotation[][]
17
+ outsideTopLevelFunctions: ts.CallExpression[]
18
+ }
19
+
20
+ export function scanStaticAnnotations(
21
+ sourceFile: ts.SourceFile,
22
+ declarations: ts.FunctionDeclaration[],
23
+ checker: ts.TypeChecker,
24
+ ): StaticAnnotationScan {
25
+ const ownerIndex = new Map<ts.FunctionDeclaration, number>(
26
+ declarations.map((declaration, index) => [declaration, index]),
27
+ )
28
+ const callsByFunction = declarations.map(() => [] as ts.CallExpression[])
29
+ const outsideTopLevelFunctions: ts.CallExpression[] = []
30
+
31
+ const visit = (node: ts.Node): void => {
32
+ if (ts.isCallExpression(node) && isStaticIntent(node, checker)) {
33
+ const owner = ts.findAncestor(node, ts.isFunctionLike)
34
+ const index = owner != null && ts.isFunctionDeclaration(owner) ? ownerIndex.get(owner) : undefined
35
+ if (index == null) outsideTopLevelFunctions.push(node)
36
+ else callsByFunction[index]!.push(node)
37
+ }
38
+ ts.forEachChild(node, visit)
39
+ }
40
+ visit(sourceFile)
41
+
42
+ return {
43
+ functions: declarations.map((declaration, index) => {
44
+ const calls = callsByFunction[index]!
45
+ const callSet = new Set(calls)
46
+ const leading = new Set<ts.CallExpression>()
47
+ if (declaration.body != null) {
48
+ for (const statement of declaration.body.statements) {
49
+ if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)
50
+ || !callSet.has(statement.expression)) break
51
+ leading.add(statement.expression)
52
+ }
53
+ }
54
+ return calls.map(call => annotationForCall(call, leading.has(call) ? 'requirement' : 'assertion'))
55
+ }),
56
+ outsideTopLevelFunctions,
57
+ }
58
+ }
59
+
60
+ function annotationForCall(call: ts.CallExpression, role: StaticAnnotationRole): StaticAnnotation {
61
+ if (call.arguments.length !== 1) {
62
+ return {kind: 'invalid', call, role, node: call, problem: 'argumentCount'}
63
+ }
64
+ if (!ts.isExpressionStatement(call.parent) || call.parent.expression !== call) {
65
+ return {kind: 'invalid', call, role, node: call, problem: 'position'}
66
+ }
67
+ const callee = call.expression
68
+ if (call.questionDotToken != null
69
+ || (ts.isPropertyAccessExpression(callee) && callee.questionDotToken != null)) {
70
+ return {kind: 'invalid', call, role, node: call, problem: 'optionalCall'}
71
+ }
72
+ return {kind: 'valid', call, role, condition: call.arguments[0]!}
73
+ }
74
+
75
+ function isStaticIntent(
76
+ call: ts.CallExpression,
77
+ checker: ts.TypeChecker,
78
+ ): boolean {
79
+ if (!ts.isPropertyAccessExpression(call.expression)) return false
80
+ const access = call.expression
81
+ if (!ts.isIdentifier(access.expression)
82
+ || access.expression.text !== 'console'
83
+ || access.name.text !== 'assert') return false
84
+ const resolved = checker.getSymbolAtLocation(access.expression)
85
+ const globalConsole = checker.resolveName('console', undefined, ts.SymbolFlags.Value, false)
86
+ return resolved != null && resolved === globalConsole
87
+ }