@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,1742 @@
1
+ import * as ts from 'typescript'
2
+ import type {ValueID} from '../ir/ids.ts'
3
+ import type {ComparisonOperator, InstructionIR} from '../ir/instructions.ts'
4
+ import type {StaticAssertionProblem} from '../ir/program.ts'
5
+ import {declaredOnlyInDeclarationFiles, platformFact} from './platform.ts'
6
+ import {
7
+ addInstruction,
8
+ addInstructionAtSite,
9
+ addSite,
10
+ createBlock,
11
+ requiredSymbol,
12
+ terminate,
13
+ unsupported,
14
+ type FunctionContext,
15
+ } from './context.ts'
16
+ import type {StaticAnnotation} from './static-intrinsics.ts'
17
+ import {isUndefinedGlobal, numericLiteralValue, parameterDefaultLiteral, type ParameterDefaultLiteral} from './literals.ts'
18
+
19
+ // The only entry point through which assignments lower. Statement positions (expression
20
+ // statements, for-loop incrementors) call this; everything else goes through
21
+ // lowerExpression, which rejects assignment forms — so an assignment used as a value
22
+ // inside a larger expression cannot lower by construction, and ternary/logical arms are
23
+ // provably assignment-free (their join carries exactly one parameter, the result).
24
+ export function lowerStatementExpression(expression: ts.Expression, context: FunctionContext): void {
25
+ const current = unwrap(expression, context.checker)
26
+ const assignment = identifierAssignment(current)
27
+ if (assignment != null) {
28
+ const symbol = requiredSymbol(assignment.target, context.checker)
29
+ switch (assignment.form) {
30
+ case 'assign': {
31
+ const moduleBinding = context.moduleBindingsBySymbol.get(symbol)
32
+ if (!context.bindings.has(symbol) && moduleBinding == null) {
33
+ throw unsupported(assignment.target, {kind: 'unknownIdentifier', name: assignment.target.text})
34
+ }
35
+ // Rebinding is only sound when the target's declared type holds a single value
36
+ // kind — otherwise branches could bind different kinds that meet at a block join.
37
+ // Function locals with mixed-kind declared types already stop at their declaration;
38
+ // a module binding can still hold one (a top-level `let config: unknown`
39
+ // initializes through the initializer's own declarator path), so for those the
40
+ // write itself stops here. The checker returns the declared type at an assignment
41
+ // target, not a narrowed one: narrowing does not apply to write positions.
42
+ const targetType = context.checker.getTypeAtLocation(assignment.target)
43
+ const targetKind = valueKind(targetType, context.checker)
44
+ if (targetKind == null) {
45
+ throw unsupported(assignment.target, {kind: 'valueType', typeText: context.checker.typeToString(targetType)})
46
+ }
47
+ const value = lowerExpression(assignment.node.right, context)
48
+ // A binding declared opaque (unknown, a function type) admits writes of any kind;
49
+ // the stored value erases to opaque so a number written on one branch and a
50
+ // boolean on another meet as opaque ⊔ opaque instead of crashing the join. The
51
+ // right side still lowered above, so its constructs stay vetted.
52
+ const stored = targetKind === 'opaque'
53
+ ? addInstruction(context, current, {kind: 'opaqueConstant'})
54
+ : value
55
+ assignIdentifier(symbol, assignment.target, stored, current, context)
56
+ return
57
+ }
58
+ case 'logical': {
59
+ // `x ??= v` / `x ||= v` / `x &&= v` in statement position: the target rebinds to
60
+ // the same value branch the expression spellings lower to — ?? through the
61
+ // missing-value machinery for any carried kind, || and && over booleans.
62
+ const currentValue = identifierValue(symbol, assignment.target, context)
63
+ const targetType = context.checker.getTypeAtLocation(assignment.target)
64
+ let condition: ValueID
65
+ if (assignment.logical === 'nullish') {
66
+ condition = addInstruction(context, current, {kind: 'nullishCheck', value: currentValue, sentinel: 'nullish', negated: true})
67
+ } else {
68
+ const targetKind = valueKind(targetType, context.checker)
69
+ if (targetKind !== 'boolean') {
70
+ throw unsupported(assignment.target, {
71
+ kind: 'nonBooleanCondition',
72
+ conditionKind: targetKind === 'number' ? 'number' : 'other',
73
+ typeText: context.checker.typeToString(targetType),
74
+ })
75
+ }
76
+ condition = currentValue
77
+ }
78
+ // For ??= and ||= the kept arm is the current value; for &&= the kept arm is the
79
+ // false side. lowerValueBranch orders (whenTrue, whenFalse).
80
+ const keepOnTrue = assignment.logical !== 'and'
81
+ const rebound = lowerValueBranch(
82
+ current,
83
+ condition,
84
+ keepOnTrue ? () => currentValue : () => lowerExpression(assignment.node.right, context),
85
+ keepOnTrue ? () => lowerExpression(assignment.node.right, context) : () => currentValue,
86
+ context,
87
+ )
88
+ assignIdentifier(symbol, assignment.target, rebound, current, context)
89
+ return
90
+ }
91
+ case 'compound': {
92
+ // `message += suffix` is string concatenation when the checker types the result
93
+ // as a string — the target rebinds to an opaque value, like `width + 'px'` in
94
+ // value position. Any other non-number operand rejects here exactly as the
95
+ // value-position binary arm does, instead of slipping an untyped add through to
96
+ // the engine's kind-mismatch backstop.
97
+ if (assignment.operator === 'add'
98
+ && (context.checker.getTypeAtLocation(current).flags & ts.TypeFlags.StringLike) !== 0) {
99
+ lowerExpression(assignment.node.right, context)
100
+ const concatenated = addInstruction(context, current, {kind: 'opaqueConstant'})
101
+ assignIdentifier(symbol, assignment.target, concatenated, current, context)
102
+ return
103
+ }
104
+ requireNumberType(assignment.target, context.checker)
105
+ requireNumberType(assignment.node.right, context.checker)
106
+ const left = identifierValue(symbol, assignment.target, context)
107
+ const right = lowerExpression(assignment.node.right, context)
108
+ const value = addInstruction(context, current, {kind: 'binary', operator: assignment.operator, left, right})
109
+ assignIdentifier(symbol, assignment.target, value, current, context)
110
+ return
111
+ }
112
+ case 'update': {
113
+ // In statement position the expression's own value is discarded, so the prefix
114
+ // versus postfix result distinction does not exist here.
115
+ const previous = identifierValue(symbol, assignment.target, context)
116
+ const one = addInstruction(context, current, {kind: 'constant', value: 1})
117
+ const value = addInstruction(context, current, {
118
+ kind: 'binary',
119
+ operator: assignment.node.operator === ts.SyntaxKind.PlusPlusToken ? 'add' : 'subtract',
120
+ left: previous,
121
+ right: one,
122
+ })
123
+ assignIdentifier(symbol, assignment.target, value, current, context)
124
+ return
125
+ }
126
+ }
127
+ }
128
+ lowerExpression(expression, context)
129
+ }
130
+
131
+ export function lowerExpression(expression: ts.Expression, context: FunctionContext): ValueID {
132
+ const current = unwrap(expression, context.checker)
133
+ if (ts.isNumericLiteral(current)) {
134
+ return addInstruction(context, current, {kind: 'constant', value: Number(current.text)})
135
+ }
136
+ if (current.kind === ts.SyntaxKind.TrueKeyword || current.kind === ts.SyntaxKind.FalseKeyword) {
137
+ return addInstruction(context, current, {kind: 'booleanConstant', value: current.kind === ts.SyntaxKind.TrueKeyword})
138
+ }
139
+ if (ts.isPrefixUnaryExpression(current) && current.operator === ts.SyntaxKind.PlusToken) {
140
+ const positive = unwrap(current.operand, context.checker)
141
+ if (ts.isNumericLiteral(positive)) {
142
+ return addInstruction(context, current, {kind: 'constant', value: Number(positive.text)})
143
+ }
144
+ throw unsupported(current, {kind: 'expressionForm', syntax: ts.SyntaxKind[current.kind]})
145
+ }
146
+ if (ts.isPrefixUnaryExpression(current) && current.operator === ts.SyntaxKind.MinusToken) {
147
+ // A negated literal folds into one constant instead of lowering as `0 - operand`.
148
+ // For finite literals both are exact; for `-Infinity` the fold is the difference
149
+ // between an exact constant and a collapse to unknown, because interval arithmetic
150
+ // deliberately gives up on non-finite operands (Infinity - Infinity is NaN).
151
+ const negated = unwrap(current.operand, context.checker)
152
+ if (ts.isNumericLiteral(negated)) {
153
+ return addInstruction(context, current, {kind: 'constant', value: -Number(negated.text)})
154
+ }
155
+ if (isGlobalInfinity(negated, context.checker)) {
156
+ return addInstruction(context, current, {kind: 'constant', value: Number.NEGATIVE_INFINITY})
157
+ }
158
+ const zero = addInstruction(context, current, {kind: 'constant', value: 0})
159
+ const value = lowerExpression(current.operand, context)
160
+ return addInstruction(context, current, {kind: 'binary', operator: 'subtract', left: zero, right: value})
161
+ }
162
+ if (ts.isPrefixUnaryExpression(current) && current.operator === ts.SyntaxKind.ExclamationToken) {
163
+ requireBooleanCondition(current.operand, context.checker)
164
+ const value = lowerExpression(current.operand, context)
165
+ return addInstruction(context, current, {kind: 'not', value})
166
+ }
167
+ if (ts.isConditionalExpression(current)) {
168
+ return lowerConditionalExpression(current, context)
169
+ }
170
+ if (ts.isIdentifier(current)) {
171
+ return identifierValue(requiredSymbol(current, context.checker), current, context)
172
+ }
173
+ if (ts.isArrayLiteralExpression(current)) {
174
+ const literalType = context.checker.getTypeAtLocation(current)
175
+ const literalKind = valueKind(literalType, context.checker)
176
+ // A literal whose own type does not classify — `[1, true]` types as
177
+ // (number | boolean)[], whose element hull no read gate could ever describe — rejects
178
+ // here, covering every position a literal can appear in (declarators have their own
179
+ // gate, but object property values and call arguments do not).
180
+ // The empty literal is exempt: its never[] element type classifies as nothing, but
181
+ // there are no elements to mix.
182
+ if (literalKind !== 'array' && literalKind !== 'tuple' && current.elements.length > 0) {
183
+ throw unsupported(current, {kind: 'valueType', typeText: context.checker.typeToString(literalType)})
184
+ }
185
+ const elements: ValueID[] = []
186
+ for (const element of current.elements) {
187
+ if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) {
188
+ throw unsupported(element, {kind: 'expressionForm', syntax: ts.SyntaxKind[element.kind]})
189
+ }
190
+ elements.push(lowerExpression(element, context))
191
+ }
192
+ // The literal's static type decides the form: `[4, 8, 24] as const` is a tuple and
193
+ // stays exact per position; a plain literal is an array and joins its elements.
194
+ return addInstruction(context, current, {kind: 'arrayLiteral', elements, form: literalKind === 'tuple' ? 'tuple' : 'array'})
195
+ }
196
+ if (ts.isNonNullExpression(current) && ts.isElementAccessExpression(current.expression)) {
197
+ // `arr[i]!` — asserts presence; an unproven read becomes an in-bounds assumption line.
198
+ return lowerElementAccess(current.expression, true, context)
199
+ }
200
+ if (ts.isElementAccessExpression(current)) {
201
+ // Bare arr[i] types T | undefined; the result honestly carries the possible miss.
202
+ return lowerElementAccess(current, false, context)
203
+ }
204
+ if (ts.isObjectLiteralExpression(current)) {
205
+ // Map insertion order keeps the first position while set keeps the last value, which
206
+ // matches object-literal evaluation and overwrite order.
207
+ const properties = new Map<string, {name: string; value: ValueID}>()
208
+ for (const property of current.properties) {
209
+ if (ts.isShorthandPropertyAssignment(property)) {
210
+ const symbol = context.checker.getShorthandAssignmentValueSymbol(property)
211
+ if (symbol == null) throw unsupported(property, {kind: 'missingSymbol'})
212
+ properties.set(property.name.text, {
213
+ name: property.name.text,
214
+ value: identifierValue(symbol, property.name, context),
215
+ })
216
+ continue
217
+ }
218
+ if (ts.isPropertyAssignment(property)) {
219
+ const name = propertyName(property.name)
220
+ // `__proto__: value` in a literal is prototype-setting syntax at runtime — no own
221
+ // property is created — while the checker types it as a plain property.
222
+ if (name === '__proto__') throw unsupported(property, {kind: 'protoProperty'})
223
+ properties.set(name, {name, value: lowerExpression(property.initializer, context)})
224
+ continue
225
+ }
226
+ if (ts.isSpreadAssignment(property)) throw unsupported(property, {kind: 'objectSpread'})
227
+ throw unsupported(property, {kind: 'objectPropertyForm'})
228
+ }
229
+ // A literal written where a tagged union is expected ({type: 'sidebar', width: 240}
230
+ // returned as Frame) records which variant it builds, so branches building different
231
+ // variants join per tag instead of dropping every mismatched property. The tag VALUE
232
+ // comes from the literal's own checked type, not its syntax, so the rebuild idiom
233
+ // {type: frame.type, width: frame.width + 40} — where the tag arrives via a property
234
+ // read of the narrowed union — is recognized too.
235
+ const contextual = context.checker.getContextualType(current)
236
+ // Omitted optionals become explicit undefined values, keeping the invariant that a
237
+ // record value carries every property its static type declares — a join between a
238
+ // branch that set the property and one that omitted it must not drop it, and reads
239
+ // must find the honest maybe-missing value rather than crash. (A literal with no
240
+ // contextual record type has no optionals to fill.)
241
+ const fillOptionalsFrom = (recordType: ts.Type): void => {
242
+ for (const member of context.checker.getPropertiesOfType(recordType)) {
243
+ if ((member.flags & ts.SymbolFlags.Optional) === 0 || properties.has(member.name)) continue
244
+ const absent = addInstruction(context, current, {kind: 'nullishConstant', sentinel: 'undefined'})
245
+ properties.set(member.name, {name: member.name, value: absent})
246
+ }
247
+ }
248
+ // The contextual type may sit behind a nullable wrapper (`const config: Config |
249
+ // null = flag ? {...} : null`): the literal builds the non-missing part, so the
250
+ // filling and tag detection look through the wrapper at those members.
251
+ const contextMembers: readonly ts.Type[] = contextual == null
252
+ ? []
253
+ : contextual.isUnion()
254
+ ? nonMissingUnionMembers(contextual)
255
+ : [contextual]
256
+ if (contextMembers.length === 1 && valueKind(contextMembers[0]!, context.checker) === 'object') {
257
+ fillOptionalsFrom(contextMembers[0]!)
258
+ }
259
+ let tag: {property: string} | null = null
260
+ if (contextMembers.length > 1) {
261
+ const tagProperty = taggedUnionProperty(contextMembers, context.checker)
262
+ if (tagProperty != null) {
263
+ // WHICH property is the tag comes from the type (a property name carries no
264
+ // claim); WHICH VARIANT the literal builds is decided by the engine from the tag
265
+ // property's runtime-tracked value, never from the checker's type of the tag —
266
+ // the type channel is assertion-taintable at any distance (a review round chained
267
+ // `{kind: raw as 'lightbox'}`, its quoted-key spelling, and a spread of a
268
+ // cast-tagged template), while value-carried content only ever originates from
269
+ // written literals and declared-variant seeding.
270
+ tag = {property: tagProperty}
271
+ // Optional filling still needs the tag value at lowering; a literal WRITTEN in
272
+ // the tag position provides it (the same trust rule tag-check comparisons and
273
+ // switch labels follow). The variant's own optionals fill from every contextual
274
+ // member whose tag values include the written one, so a duplicate-tag literal
275
+ // covers both shapes' optionals. An optional property reads as possibly undefined
276
+ // either way.
277
+ const ownLiteral = writtenTagLiteral(current, tagProperty, context)
278
+ if (ownLiteral != null) {
279
+ for (const member of contextMembers) {
280
+ const memberTag = context.checker.getPropertyOfType(member, tagProperty)
281
+ const memberTagType = memberTag == null ? null : context.checker.getTypeOfSymbol(memberTag)
282
+ const memberLiterals = memberTagType == null ? null : tagLiteralValues(memberTagType)
283
+ if (memberLiterals != null && memberLiterals.includes(ownLiteral)) {
284
+ fillOptionalsFrom(member)
285
+ }
286
+ }
287
+ }
288
+ }
289
+ }
290
+ return addInstruction(context, current, {kind: 'object', properties: [...properties.values()], ...(tag == null ? {} : {tag})})
291
+ }
292
+ if (identifierAssignment(current) != null) {
293
+ throw unsupported(current, {kind: 'assignmentInValuePosition'})
294
+ }
295
+ if (
296
+ ts.isBinaryExpression(current)
297
+ && (current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
298
+ || current.operatorToken.kind === ts.SyntaxKind.BarBarToken)
299
+ ) {
300
+ return lowerLogicalExpression(current, context)
301
+ }
302
+ if (current.kind === ts.SyntaxKind.NullKeyword) {
303
+ return addInstruction(context, current, {kind: 'nullishConstant', sentinel: 'null'})
304
+ }
305
+ if (ts.isStringLiteral(current) || ts.isNoSubstitutionTemplateLiteral(current)) {
306
+ // The exact text rides along: its one consumer is the tagged-union variant pin, which
307
+ // trusts only value-carried content (see the engine's object arm).
308
+ return addInstruction(context, current, {kind: 'opaqueConstant', content: current.text})
309
+ }
310
+ // Any assertion except `as const` (which unwrap peels). The operand still lowers (an
311
+ // unsupported construct inside it rejects as usual), but its claims are erased:
312
+ // downstream sees a claim-free value whose uses stop at the gates and whose joins
313
+ // absorb, which is what keeps `true as unknown as number` — and every comparability
314
+ // spelling of the same launder — from carrying a boolean into number positions.
315
+ if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) {
316
+ lowerExpression(current.expression, context)
317
+ return addInstruction(context, current, {kind: 'opaqueConstant'})
318
+ }
319
+ if (ts.isTemplateExpression(current)) {
320
+ // `${width}px` — the interpolated expressions lower (they must be representable), the
321
+ // result is carried without claims.
322
+ for (const span of current.templateSpans) lowerExpression(span.expression, context)
323
+ return addInstruction(context, current, {kind: 'opaqueConstant'})
324
+ }
325
+ if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) {
326
+ // `a ?? b` is `a` when not missing, else `b`. The whole expression's type must be a
327
+ // representable kind — `(record | null) ?? 0` mixes record and number arms.
328
+ const resultType = context.checker.getTypeAtLocation(current)
329
+ if (valueKind(resultType, context.checker) == null) {
330
+ throw unsupported(current, {kind: 'valueType', typeText: context.checker.typeToString(resultType)})
331
+ }
332
+ const left = lowerExpression(current.left, context)
333
+ const notMissing = addInstruction(context, current, {kind: 'nullishCheck', value: left, sentinel: 'nullish', negated: true})
334
+ // The true arm uses the left value refined by the nullish check.
335
+ return lowerValueBranch(
336
+ current,
337
+ notMissing,
338
+ () => left,
339
+ () => lowerExpression(current.right, context),
340
+ context,
341
+ )
342
+ }
343
+ if (ts.isBinaryExpression(current)) {
344
+ const missingCheck = missingSentinelCheck(current, context)
345
+ if (missingCheck != null) return missingCheck
346
+ // `el instanceof HTMLDivElement` on a carried value: no narrowing (the analyzer does
347
+ // not model classes), but the check itself is an effect-free operator, so it answers
348
+ // unknown and both branches analyze — the function's other paths survive. The
349
+ // declaration-file check is the same shadowing defense Math uses.
350
+ if (current.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword
351
+ && ts.isIdentifier(current.right)
352
+ && declaredOnlyInDeclarationFiles(context.checker.getSymbolAtLocation(current.right))) {
353
+ lowerExpression(current.left, context)
354
+ return addInstruction(context, current, {kind: 'unknownBoolean'})
355
+ }
356
+ const tagComparison = tagCheckComparison(current, context)
357
+ if (tagComparison != null) return tagComparison
358
+ const opaqueComparison = opaqueEqualityCheck(current, context)
359
+ if (opaqueComparison != null) return opaqueComparison
360
+ // `width + 'px'`: string building with + is everywhere in UI code, and the template
361
+ // spelling `${width}px` is already carried — when the checker types the result as a
362
+ // string, the result is an opaque value. Both operands still lower, so an unsupported
363
+ // construct inside one rejects as usual.
364
+ if (current.operatorToken.kind === ts.SyntaxKind.PlusToken
365
+ && (context.checker.getTypeAtLocation(current).flags & ts.TypeFlags.StringLike) !== 0) {
366
+ lowerExpression(current.left, context)
367
+ lowerExpression(current.right, context)
368
+ return addInstruction(context, current, {kind: 'opaqueConstant'})
369
+ }
370
+ const arithmetic = arithmeticOperator(current.operatorToken.kind)
371
+ const comparison = comparisonOperator(current.operatorToken.kind)
372
+ if (arithmetic == null && comparison == null) {
373
+ throw unsupported(current, {kind: 'binaryOperator', operator: current.operatorToken.getText(context.sourceFile)})
374
+ }
375
+ // flag === true and flag !== other: booleans are modeled exactly, so equality over
376
+ // them answers exactly too (the engine's compare arm dispatches on the operand kind).
377
+ if ((comparison === 'equal' || comparison === 'notEqual')
378
+ && valueKind(context.checker.getTypeAtLocation(current.left), context.checker) === 'boolean'
379
+ && valueKind(context.checker.getTypeAtLocation(current.right), context.checker) === 'boolean') {
380
+ const left = lowerExpression(current.left, context)
381
+ const right = lowerExpression(current.right, context)
382
+ return addInstruction(context, current, {kind: 'compare', operator: comparison, left, right})
383
+ }
384
+ requireNumberType(current.left, context.checker)
385
+ requireNumberType(current.right, context.checker)
386
+ const left = lowerExpression(current.left, context)
387
+ const right = lowerExpression(current.right, context)
388
+ return arithmetic != null
389
+ ? addInstruction(context, current, {kind: 'binary', operator: arithmetic, left, right})
390
+ : addInstruction(context, current, {kind: 'compare', operator: comparison!, left, right})
391
+ }
392
+ if (ts.isCallExpression(current)) {
393
+ const staticAnnotation = context.staticAnnotations.get(current)
394
+ if (staticAnnotation != null) return lowerStaticAnnotation(staticAnnotation, context)
395
+ if (ts.isIdentifier(current.expression)) {
396
+ // Global parseFloat / parseInt / Number(x): honest NaN-carrying results, like their
397
+ // Number.* spellings below (the declaration-file check defends against shadowing).
398
+ const globalName = current.expression.text
399
+ if ((globalName === 'parseFloat' || globalName === 'parseInt' || globalName === 'Number')
400
+ && declaredOnlyInDeclarationFiles(context.checker.getSymbolAtLocation(current.expression))) {
401
+ for (const argument of current.arguments) lowerExpression(argument, context)
402
+ return addInstruction(context, current, {kind: 'parsedNumber', integer: globalName === 'parseInt'})
403
+ }
404
+ const symbol = resolvedSymbol(context.checker.getSymbolAtLocation(current.expression), context.checker)
405
+ const callee = symbol == null ? undefined : context.functionsBySymbol.get(symbol)
406
+ if (callee == null) throw unsupported(current, {kind: 'call', callee: current.expression.text})
407
+ if (current.arguments.length > callee.declaration.parameters.length) {
408
+ throw unsupported(current, {kind: 'callWithMoreArguments', callee: current.expression.text})
409
+ }
410
+ const arguments_: ValueID[] = []
411
+ for (let index = 0; index < callee.declaration.parameters.length; index++) {
412
+ const parameter = callee.declaration.parameters[index]!
413
+ const argument = current.arguments[index]
414
+ if (argument == null) {
415
+ if (parameter.initializer != null) {
416
+ const default_ = parameterDefaultLiteral(parameter.initializer, context.checker)
417
+ if (default_ == null) {
418
+ throw unsupported(current, {kind: 'callWithFewerArguments', callee: current.expression.text})
419
+ }
420
+ arguments_.push(lowerParameterDefault(default_, parameter.initializer, context))
421
+ continue
422
+ }
423
+ if (parameter.questionToken != null) {
424
+ arguments_.push(addInstruction(context, parameter, {kind: 'nullishConstant', sentinel: 'undefined'}))
425
+ continue
426
+ }
427
+ throw unsupported(current, {kind: 'callWithFewerArguments', callee: current.expression.text})
428
+ }
429
+ const value = lowerExpression(argument, context)
430
+ const default_ = parameter.initializer == null
431
+ ? null
432
+ : parameterDefaultLiteral(parameter.initializer, context.checker)
433
+ if (default_ == null
434
+ || !typeCanIncludeUndefined(context.checker.getTypeAtLocation(argument))) {
435
+ arguments_.push(value)
436
+ continue
437
+ }
438
+ // JavaScript applies a parameter default when the supplied value is undefined,
439
+ // not only when the argument is omitted. The normal value-producing branch keeps
440
+ // that rule exact for `number | undefined` arguments without a new IR operation.
441
+ const supplied = addInstruction(context, argument, {
442
+ kind: 'nullishCheck',
443
+ value,
444
+ sentinel: 'undefined',
445
+ negated: true,
446
+ })
447
+ arguments_.push(lowerValueBranch(
448
+ argument,
449
+ supplied,
450
+ () => value,
451
+ () => lowerParameterDefault(default_, parameter.initializer!, context),
452
+ context,
453
+ ))
454
+ }
455
+ return addInstruction(context, current, {kind: 'call', function: callee.id, arguments: arguments_})
456
+ }
457
+ if (ts.isPropertyAccessExpression(current.expression)) {
458
+ const platformCall = current.arguments.length === 0 ? platformFact(current.expression, true, context.checker) : null
459
+ if (platformCall != null) {
460
+ return addInstruction(context, current, {kind: 'platformValue', ...platformCall})
461
+ }
462
+ const method = current.expression.name.text
463
+ const standardMath = isStandardMathObject(current.expression.expression, context.checker)
464
+ if (standardMath && method === 'floor' && current.arguments.length === 1) {
465
+ requireNumberType(current.arguments[0]!, context.checker)
466
+ const value = lowerExpression(current.arguments[0]!, context)
467
+ return addInstruction(context, current, {kind: 'floor', value})
468
+ }
469
+ if (standardMath && method === 'abs' && current.arguments.length === 1) {
470
+ requireNumberType(current.arguments[0]!, context.checker)
471
+ const value = lowerExpression(current.arguments[0]!, context)
472
+ return addInstruction(context, current, {kind: 'absolute', value})
473
+ }
474
+ if (standardMath && (method === 'ceil' || method === 'round' || method === 'trunc' || method === 'sqrt')
475
+ && current.arguments.length === 1) {
476
+ requireNumberType(current.arguments[0]!, context.checker)
477
+ const value = lowerExpression(current.arguments[0]!, context)
478
+ return addInstruction(context, current, {kind: 'mathUnary', operator: method, value})
479
+ }
480
+ if (standardMath && (method === 'min' || method === 'max') && current.arguments.length > 0) {
481
+ for (const argument of current.arguments) requireNumberType(argument, context.checker)
482
+ const values = current.arguments.map(argument => lowerExpression(argument, context))
483
+ return addInstruction(context, current, {kind: method === 'min' ? 'minimum' : 'maximum', values})
484
+ }
485
+ // Number.isInteger / Number.isFinite: predicate checks whose branches narrow — the
486
+ // missing halves of the bounds-check idiom (`i >= 0 && i < arr.length` proves the
487
+ // range; Number.isInteger(i) proves the read hits an element rather than arr[1.5]).
488
+ const standardNumber = isStandardNumberObject(current.expression.expression, context.checker)
489
+ // Number.parseFloat / Number.parseInt: honest NaN sources — the result is any
490
+ // number including NaN, and the isFinite/isNaN/isInteger narrowing downstream is
491
+ // exactly what launders it. Arguments still lower (opaque strings carry).
492
+ if (standardNumber && (method === 'parseFloat' || method === 'parseInt') && current.arguments.length >= 1) {
493
+ for (const argument of current.arguments) lowerExpression(argument, context)
494
+ return addInstruction(context, current, {kind: 'parsedNumber', integer: method === 'parseInt'})
495
+ }
496
+ if (standardNumber && (method === 'isInteger' || method === 'isFinite' || method === 'isNaN') && current.arguments.length === 1) {
497
+ requireNumberType(current.arguments[0]!, context.checker)
498
+ const value = lowerExpression(current.arguments[0]!, context)
499
+ return addInstruction(context, current, {
500
+ kind: 'numberCheck',
501
+ predicate: method === 'isInteger' ? 'integer' : method === 'isFinite' ? 'finite' : 'nan',
502
+ value,
503
+ })
504
+ }
505
+ const arrayMethod = ts.isPropertyAccessExpression(current.expression)
506
+ && context.checker.isArrayType(context.checker.getTypeAtLocation(current.expression.expression))
507
+ ? current.expression.name.text === 'reduce' ? 'reduce' : 'other'
508
+ : null
509
+ throw unsupported(current, {
510
+ kind: 'call',
511
+ callee: calleeDisplayName(current.expression, context.sourceFile),
512
+ ...(arrayMethod == null ? {} : {arrayMethod}),
513
+ })
514
+ }
515
+ }
516
+ if (ts.isPropertyAccessExpression(current)) {
517
+ const platform = platformFact(current, false, context.checker)
518
+ if (platform != null) {
519
+ return addInstruction(context, current, {kind: 'platformValue', ...platform})
520
+ }
521
+ // config?.volume: read when the receiver is present, undefined when missing — the
522
+ // nullish machinery's value branch, with the true arm reading through the narrowed
523
+ // receiver. Each ?. link carries its own check, so config?.inner?.volume works
524
+ // link by link; the mixed spelling a?.b.c keeps rejecting at the .c receiver gate.
525
+ if (current.questionDotToken != null) {
526
+ const receiver = lowerExpression(current.expression, context)
527
+ const present = addInstruction(context, current, {kind: 'nullishCheck', value: receiver, sentinel: 'nullish', negated: true})
528
+ return lowerValueBranch(
529
+ current,
530
+ present,
531
+ () => {
532
+ // The branch refinement unwrapped the receiver's slot; the read must still
533
+ // pass the same gates a plain read does, against the non-missing part.
534
+ requireAccessedPropertyKind(current, context.checker)
535
+ return addInstruction(context, current, {kind: 'property', object: receiver, property: current.name.text})
536
+ },
537
+ () => addInstruction(context, current, {kind: 'nullishConstant', sentinel: 'undefined'}),
538
+ context,
539
+ )
540
+ }
541
+ const objectType = context.checker.getTypeAtLocation(current.expression)
542
+ const receiverKind = valueKind(objectType, context.checker)
543
+ if ((receiverKind === 'array' || receiverKind === 'tuple') && current.name.text === 'length') {
544
+ const array = lowerExpression(current.expression, context)
545
+ return addInstruction(context, current, {kind: 'arrayLength', array})
546
+ }
547
+ // A string's length is the one modeled read on an opaque string. Carry the immutable
548
+ // receiver so repeated reads can be recognized as the same number.
549
+ if (receiverKind === 'opaque' && current.name.text === 'length'
550
+ && (objectType.flags & ts.TypeFlags.StringLike) !== 0) {
551
+ const value = lowerExpression(current.expression, context)
552
+ return addInstruction(context, current, {kind: 'stringLength', value})
553
+ }
554
+ // An enum member read gets its own name and rewrite; the generic receiver prose
555
+ // ("property read from typeof Direction") names the checker's type, not the construct.
556
+ const receiverSymbol = ts.isIdentifier(current.expression)
557
+ ? context.checker.getSymbolAtLocation(current.expression)
558
+ : undefined
559
+ if (receiverSymbol != null && (receiverSymbol.flags & (ts.SymbolFlags.RegularEnum | ts.SymbolFlags.ConstEnum)) !== 0) {
560
+ throw unsupported(current, {kind: 'enumMemberRead'})
561
+ }
562
+ // Through valueKind: single record types and unions of one recursive shape both read
563
+ // fine (an admitted union joins losslessly, so every member's property is present),
564
+ // while index signatures, callables, and mixed shapes reject. A tagged-union receiver
565
+ // reads too: the engine answers reads of the tag and of properties every variant
566
+ // carries, and stops honestly on a partial property no check narrowed first.
567
+ if (receiverKind !== 'object' && receiverKind !== 'taggedUnion') {
568
+ throw unsupported(current.expression, {kind: 'propertyReadOnNonObject', typeText: context.checker.typeToString(objectType)})
569
+ }
570
+ requireAccessedPropertyKind(current, context.checker)
571
+ const object = lowerExpression(current.expression, context)
572
+ return addInstruction(context, current, {kind: 'property', object, property: current.name.text})
573
+ }
574
+ throw unsupported(current, {kind: 'expressionForm', syntax: ts.SyntaxKind[current.kind]})
575
+ }
576
+
577
+ function lowerStaticAnnotation(annotation: StaticAnnotation, context: FunctionContext): ValueID {
578
+ if (annotation.kind === 'invalid') {
579
+ throw unsupported(annotation.node, {kind: 'staticAssertionForm', problem: annotation.problem})
580
+ }
581
+ const condition = annotation.condition
582
+ requireBooleanCondition(condition, context.checker)
583
+ const originalBlock = context.currentBlock
584
+ const originalBlockCount = context.blocks.length
585
+ const originalInstructionCount = originalBlock.instructions.length
586
+ let value: ValueID
587
+ if (annotation.role === 'requirement') {
588
+ const requirement = writtenRequirement(condition, context)
589
+ if (requirement == null) {
590
+ throw unsupported(condition, {
591
+ kind: 'staticAssertionForm',
592
+ problem: staticAssertionProblem(condition, context.checker, 'callerRequirement'),
593
+ })
594
+ }
595
+ value = lowerWrittenRequirement(requirement, condition, context)
596
+ } else {
597
+ if (!supportedWrittenAssertion(condition, context.checker)) {
598
+ throw unsupported(condition, {
599
+ kind: 'staticAssertionForm',
600
+ problem: staticAssertionProblem(condition, context.checker, 'directCheck'),
601
+ })
602
+ }
603
+ value = lowerExpression(condition, context)
604
+ }
605
+ if (context.currentBlock !== originalBlock || context.blocks.length !== originalBlockCount) {
606
+ throw unsupported(condition, {kind: 'staticAssertionForm', problem: 'bindValueFirst'})
607
+ }
608
+ const conditionInstructions = originalBlock.instructions.slice(originalInstructionCount)
609
+ if (conditionInstructions.some(instruction => !removableStaticConditionInstruction(instruction))) {
610
+ throw unsupported(condition, {kind: 'staticAssertionForm', problem: 'bindValueFirst'})
611
+ }
612
+ const site = addSite(context, annotation.call)
613
+ if (annotation.role === 'requirement') {
614
+ return addInstructionAtSite(context, site, {kind: 'staticRequire', value})
615
+ }
616
+ const assertion = context.assertions.length
617
+ context.assertions.push({site, text: condition.getText(context.sourceFile)})
618
+ return addInstructionAtSite(context, site, {kind: 'staticAssert', value, assertion})
619
+ }
620
+
621
+ type WrittenRequirementOperand = {kind: 'parameter'; value: ValueID} | {kind: 'constant'; value: number}
622
+
623
+ type WrittenRequirement =
624
+ | {kind: 'numberCheck'; predicate: 'integer' | 'finite'; value: ValueID}
625
+ | {
626
+ kind: 'comparison'
627
+ left: WrittenRequirementOperand
628
+ right: WrittenRequirementOperand
629
+ operator: ComparisonOperator
630
+ }
631
+
632
+ function writtenRequirement(condition: ts.Expression, context: FunctionContext): WrittenRequirement | null {
633
+ const current = unwrapParentheses(condition)
634
+ if (ts.isCallExpression(current) && current.questionDotToken == null
635
+ && current.arguments.length === 1 && ts.isPropertyAccessExpression(current.expression)
636
+ && current.expression.questionDotToken == null
637
+ && isStandardNumberObject(current.expression.expression, context.checker)
638
+ && (current.expression.name.text === 'isInteger' || current.expression.name.text === 'isFinite')) {
639
+ const argument = current.arguments[0]!
640
+ const value = staticRequirementParameterPathValue(argument, context)
641
+ return value == null ? null : {
642
+ kind: 'numberCheck',
643
+ predicate: current.expression.name.text === 'isInteger' ? 'integer' : 'finite',
644
+ value,
645
+ }
646
+ }
647
+ if (!ts.isBinaryExpression(current) || !staticAssertionComparison(current.operatorToken.kind)) return null
648
+ const leftParameter = staticRequirementParameterPathValue(current.left, context)
649
+ const rightParameter = staticRequirementParameterPathValue(current.right, context)
650
+ const leftConstant = leftParameter == null ? staticFiniteValue(current.left, context) : null
651
+ const rightConstant = rightParameter == null ? staticFiniteValue(current.right, context) : null
652
+ const left = leftParameter != null
653
+ ? {kind: 'parameter' as const, value: leftParameter}
654
+ : leftConstant != null ? {kind: 'constant' as const, value: leftConstant} : null
655
+ const right = rightParameter != null
656
+ ? {kind: 'parameter' as const, value: rightParameter}
657
+ : rightConstant != null ? {kind: 'constant' as const, value: rightConstant} : null
658
+ const operator = comparisonOperator(current.operatorToken.kind)
659
+ if (left == null || right == null || operator == null
660
+ || (leftParameter != null && rightParameter != null)) return null
661
+ return {kind: 'comparison', left, right, operator}
662
+ }
663
+
664
+ function staticRequirementParameterPathValue(expression: ts.Expression, context: FunctionContext): ValueID | null {
665
+ requireNumberType(expression, context.checker)
666
+ let root = unwrapParentheses(expression)
667
+ while (ts.isPropertyAccessExpression(root) && root.questionDotToken == null) {
668
+ root = unwrapParentheses(root.expression)
669
+ }
670
+ if (!ts.isIdentifier(root)) return null
671
+ const symbol = context.checker.getSymbolAtLocation(root)
672
+ const rootValue = symbol == null ? null : context.bindings.get(symbol)
673
+ if (rootValue == null || !valueComesFromParameter(rootValue, context)) return null
674
+ return lowerExpression(expression, context)
675
+ }
676
+
677
+ function valueComesFromParameter(value: ValueID, context: FunctionContext): boolean {
678
+ if (context.parameters.some(parameter => parameter.value === value)) return true
679
+ for (const block of context.blocks) {
680
+ const producer = block.instructions.find(instruction => instruction.result === value)
681
+ if (producer != null) {
682
+ return producer.kind === 'property' && valueComesFromParameter(producer.object, context)
683
+ }
684
+ }
685
+ return false
686
+ }
687
+
688
+ function staticFiniteValue(
689
+ expression: ts.Expression,
690
+ context: FunctionContext,
691
+ ): number | null {
692
+ const seen = new Set<ts.Symbol>()
693
+ let current = expression
694
+ while (true) {
695
+ const literal = numericLiteralValue(current)
696
+ if (literal != null) return Number.isFinite(literal) ? literal : null
697
+ const unwrapped = unwrapParentheses(current)
698
+ if (!ts.isIdentifier(unwrapped)) return null
699
+ const symbol = resolvedSymbol(context.checker.getSymbolAtLocation(unwrapped), context.checker)
700
+ if (symbol == null || seen.has(symbol)) return null
701
+ seen.add(symbol)
702
+ const declaration = symbol.valueDeclaration
703
+ if (declaration == null || !ts.isVariableDeclaration(declaration)
704
+ || (ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) === 0
705
+ || declaration.getSourceFile().isDeclarationFile
706
+ || declaration.initializer == null) return null
707
+ current = declaration.initializer
708
+ }
709
+ }
710
+
711
+ function lowerWrittenRequirement(
712
+ requirement: WrittenRequirement,
713
+ condition: ts.Expression,
714
+ context: FunctionContext,
715
+ ): ValueID {
716
+ if (requirement.kind === 'numberCheck') {
717
+ return addInstruction(context, condition, {
718
+ kind: 'numberCheck',
719
+ predicate: requirement.predicate,
720
+ value: requirement.value,
721
+ })
722
+ }
723
+ const lowerOperand = (operand: WrittenRequirementOperand): ValueID => {
724
+ if (operand.kind === 'parameter') return operand.value
725
+ return addInstruction(context, condition, {kind: 'constant', value: operand.value})
726
+ }
727
+ return addInstruction(context, condition, {
728
+ kind: 'compare',
729
+ operator: requirement.operator,
730
+ left: lowerOperand(requirement.left),
731
+ right: lowerOperand(requirement.right),
732
+ })
733
+ }
734
+
735
+ // The static assertion language is deliberately smaller than ordinary expressions. A
736
+ // calculation is written and checked as normal code, then the assertion names its result.
737
+ // This gives agents one syntax boundary instead of exposing whichever instructions happen
738
+ // to be removable after general expression lowering.
739
+ function supportedWrittenAssertion(condition: ts.Expression, checker: ts.TypeChecker): boolean {
740
+ const current = unwrapParentheses(condition)
741
+ if (ts.isBinaryExpression(current) && staticAssertionComparison(current.operatorToken.kind)) {
742
+ return staticAssertionNumericAtom(current.left, checker) && staticAssertionNumericAtom(current.right, checker)
743
+ }
744
+ const operand = staticNumberCheckOperand(current, checker)
745
+ return operand != null && staticAssertionNumericAtom(operand, checker)
746
+ }
747
+
748
+ function staticAssertionNumericAtom(expression: ts.Expression, checker: ts.TypeChecker): boolean {
749
+ return staticAssertionAtom(expression) && valueKind(checker.getTypeAtLocation(expression), checker) === 'number'
750
+ }
751
+
752
+ function staticAssertionComparison(kind: ts.SyntaxKind): boolean {
753
+ switch (kind) {
754
+ case ts.SyntaxKind.LessThanToken:
755
+ case ts.SyntaxKind.LessThanEqualsToken:
756
+ case ts.SyntaxKind.GreaterThanToken:
757
+ case ts.SyntaxKind.GreaterThanEqualsToken:
758
+ case ts.SyntaxKind.EqualsEqualsEqualsToken:
759
+ case ts.SyntaxKind.ExclamationEqualsEqualsToken: return true
760
+ default: return false
761
+ }
762
+ }
763
+
764
+ function staticAssertionAtomProblem(expression: ts.Expression): StaticAssertionProblem | null {
765
+ const current = unwrapParentheses(expression)
766
+ if (ts.isIdentifier(current) || ts.isNumericLiteral(current)) return null
767
+ if (ts.isPrefixUnaryExpression(current)
768
+ && (current.operator === ts.SyntaxKind.PlusToken || current.operator === ts.SyntaxKind.MinusToken)) {
769
+ return ts.isNumericLiteral(unwrapParentheses(current.operand)) ? null : 'bindValueFirst'
770
+ }
771
+ if (ts.isElementAccessExpression(current)) return 'bindValueFirst'
772
+ if (ts.isNonNullExpression(current)) return staticAssertionAtomProblem(current.expression)
773
+ if (ts.isCallExpression(current)) return 'functionCall'
774
+ if (ts.isBinaryExpression(current)) return 'bindValueFirst'
775
+ if (ts.isPropertyAccessExpression(current)) {
776
+ return current.questionDotToken == null
777
+ ? staticAssertionAtomProblem(current.expression)
778
+ : 'directCheck'
779
+ }
780
+ return 'directCheck'
781
+ }
782
+
783
+ function staticAssertionAtom(expression: ts.Expression): boolean {
784
+ const current = unwrapParentheses(expression)
785
+ if (ts.isIdentifier(current) || ts.isNumericLiteral(current)) return true
786
+ if (ts.isPrefixUnaryExpression(current)
787
+ && (current.operator === ts.SyntaxKind.PlusToken || current.operator === ts.SyntaxKind.MinusToken)) {
788
+ return ts.isNumericLiteral(unwrapParentheses(current.operand))
789
+ }
790
+ return ts.isPropertyAccessExpression(current)
791
+ && current.questionDotToken == null
792
+ && staticAssertionAtom(current.expression)
793
+ }
794
+
795
+ function staticAssertionProblem(
796
+ condition: ts.Expression,
797
+ checker: ts.TypeChecker,
798
+ fallback: 'directCheck' | 'callerRequirement',
799
+ ): StaticAssertionProblem {
800
+ const current = unwrapParentheses(condition)
801
+ if (ts.isBinaryExpression(current) && staticAssertionComparison(current.operatorToken.kind)) {
802
+ return staticAssertionAtomProblem(current.left) ?? staticAssertionAtomProblem(current.right) ?? fallback
803
+ }
804
+ const operand = staticNumberCheckOperand(current, checker)
805
+ if (operand != null) return staticAssertionAtomProblem(operand) ?? fallback
806
+ if (ts.isCallExpression(current)) return 'functionCall'
807
+ return 'directCheck'
808
+ }
809
+
810
+ function staticNumberCheckOperand(expression: ts.Expression, checker: ts.TypeChecker): ts.Expression | null {
811
+ if (!ts.isCallExpression(expression) || expression.questionDotToken != null
812
+ || expression.arguments.length !== 1 || !ts.isPropertyAccessExpression(expression.expression)
813
+ || expression.expression.questionDotToken != null) return null
814
+ const callee = expression.expression
815
+ return isStandardNumberObject(callee.expression, checker)
816
+ && (callee.name.text === 'isInteger' || callee.name.text === 'isFinite' || callee.name.text === 'isNaN')
817
+ ? expression.arguments[0]!
818
+ : null
819
+ }
820
+
821
+ function unwrapParentheses(expression: ts.Expression): ts.Expression {
822
+ let current = expression
823
+ while (ts.isParenthesizedExpression(current)) current = current.expression
824
+ return current
825
+ }
826
+
827
+ // An application may erase static conditions from a production build. Normal expression
828
+ // lowering owns their JavaScript evaluation order; this list only accepts instructions
829
+ // whose removal cannot change program state. Compound control flow was rejected above.
830
+ function removableStaticConditionInstruction(instruction: InstructionIR): boolean {
831
+ switch (instruction.kind) {
832
+ case 'constant':
833
+ case 'arrayLength':
834
+ case 'moduleRead':
835
+ case 'compare':
836
+ case 'platformValue':
837
+ case 'numberCheck':
838
+ case 'property': return true
839
+ default: return false
840
+ }
841
+ }
842
+
843
+ // The single recognizer for the three forms that assign through a plain identifier. The
844
+ // lowering arms and the loop-carry detection in statements.ts both dispatch on this, so a
845
+ // new assigning form cannot lower without also being carried across loop back edges (a
846
+ // binding rebound in a loop body but not carried would silently analyze later iterations
847
+ // with the stale pre-loop value).
848
+ export type IdentifierAssignment =
849
+ | {form: 'assign'; target: ts.Identifier; node: ts.BinaryExpression}
850
+ | {form: 'compound'; target: ts.Identifier; node: ts.BinaryExpression; operator: Extract<InstructionIR, {kind: 'binary'}>['operator']}
851
+ | {form: 'logical'; target: ts.Identifier; node: ts.BinaryExpression; logical: 'nullish' | 'or' | 'and'}
852
+ | {form: 'update'; target: ts.Identifier; node: ts.PrefixUnaryExpression | ts.PostfixUnaryExpression}
853
+
854
+ export function identifierAssignment(node: ts.Node): IdentifierAssignment | null {
855
+ if (ts.isBinaryExpression(node) && ts.isIdentifier(node.left)) {
856
+ if (node.operatorToken.kind === ts.SyntaxKind.EqualsToken) return {form: 'assign', target: node.left, node}
857
+ const operator = compoundAssignmentOperator(node.operatorToken.kind)
858
+ if (operator != null) return {form: 'compound', target: node.left, node, operator}
859
+ const logical = node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionEqualsToken ? 'nullish'
860
+ : node.operatorToken.kind === ts.SyntaxKind.BarBarEqualsToken ? 'or'
861
+ : node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandEqualsToken ? 'and'
862
+ : null
863
+ if (logical != null) return {form: 'logical', target: node.left, node, logical}
864
+ }
865
+ if (
866
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node))
867
+ && (node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken)
868
+ && ts.isIdentifier(node.operand)
869
+ ) {
870
+ return {form: 'update', target: node.operand, node}
871
+ }
872
+ return null
873
+ }
874
+
875
+ export function compoundAssignmentOperator(kind: ts.SyntaxKind): Extract<InstructionIR, {kind: 'binary'}>['operator'] | null {
876
+ switch (kind) {
877
+ case ts.SyntaxKind.PlusEqualsToken: return 'add'
878
+ case ts.SyntaxKind.MinusEqualsToken: return 'subtract'
879
+ case ts.SyntaxKind.AsteriskEqualsToken: return 'multiply'
880
+ case ts.SyntaxKind.SlashEqualsToken: return 'divide'
881
+ default: return null
882
+ }
883
+ }
884
+
885
+ // The shared value-producing branch shape: branch on the condition, lower each arm in its
886
+ // own block, and join at a continuation whose single parameter carries the result. Arms are
887
+ // provably assignment-free — assignments lower only through lowerStatementExpression — so
888
+ // no bindings can change across the arms and the join needs no binding merge. Ternaries and
889
+ // the logical operators are the two consumers; lowerIfStatement stays separate (no result
890
+ // value, arms may terminate, and assignments are allowed there).
891
+ function lowerValueBranch(
892
+ node: ts.Expression,
893
+ condition: ValueID,
894
+ lowerTrueArm: () => ValueID,
895
+ lowerFalseArm: () => ValueID,
896
+ context: FunctionContext,
897
+ ): ValueID {
898
+ const whenTrue = createBlock(context)
899
+ const whenFalse = createBlock(context)
900
+ terminate(context.currentBlock, {
901
+ kind: 'branch',
902
+ condition,
903
+ whenTrue: {block: whenTrue, arguments: []},
904
+ whenFalse: {block: whenFalse, arguments: []},
905
+ site: addSite(context, node),
906
+ })
907
+ context.currentBlock = context.blocks[whenTrue]!
908
+ const trueValue = lowerTrueArm()
909
+ const trueBlock = context.currentBlock
910
+ context.currentBlock = context.blocks[whenFalse]!
911
+ const falseValue = lowerFalseArm()
912
+ const falseBlock = context.currentBlock
913
+ const continuation = createBlock(context, 1)
914
+ terminate(trueBlock, {
915
+ kind: 'jump',
916
+ target: {block: continuation, arguments: [trueValue]},
917
+ site: addSite(context, node),
918
+ })
919
+ terminate(falseBlock, {
920
+ kind: 'jump',
921
+ target: {block: continuation, arguments: [falseValue]},
922
+ site: addSite(context, node),
923
+ })
924
+ context.currentBlock = context.blocks[continuation]!
925
+ return context.currentBlock.parameters[0]!
926
+ }
927
+
928
+ function lowerConditionalExpression(expression: ts.ConditionalExpression, context: FunctionContext): ValueID {
929
+ requireBooleanCondition(expression.condition, context.checker)
930
+ const resultType = context.checker.getTypeAtLocation(expression)
931
+ if (valueKind(resultType, context.checker) == null) {
932
+ throw unsupported(expression, {kind: 'valueType', typeText: context.checker.typeToString(resultType)})
933
+ }
934
+ // Through the same short-circuit branching statement ifs use, so an `&&`-joined
935
+ // condition refines each conjunct in the arms — `a > 0 && b > 0 ? a / b : 0`
936
+ // discharges b's nonzero exactly like the if-statement spelling does. Lowering the
937
+ // condition as one boolean expression (the previous shape) hid the conjuncts behind a
938
+ // joined block parameter no branch refinement could see through; a conversion pass on
939
+ // the owner's repo caught the asymmetry.
940
+ const whenTrue = createBlock(context)
941
+ const whenFalse = createBlock(context)
942
+ lowerBranchingCondition(expression.condition, whenTrue, whenFalse, context)
943
+ context.currentBlock = context.blocks[whenTrue]!
944
+ const trueValue = lowerExpression(expression.whenTrue, context)
945
+ const trueBlock = context.currentBlock
946
+ context.currentBlock = context.blocks[whenFalse]!
947
+ const falseValue = lowerExpression(expression.whenFalse, context)
948
+ const falseBlock = context.currentBlock
949
+ const continuation = createBlock(context, 1)
950
+ terminate(trueBlock, {
951
+ kind: 'jump',
952
+ target: {block: continuation, arguments: [trueValue]},
953
+ site: addSite(context, expression),
954
+ })
955
+ terminate(falseBlock, {
956
+ kind: 'jump',
957
+ target: {block: continuation, arguments: [falseValue]},
958
+ site: addSite(context, expression),
959
+ })
960
+ context.currentBlock = context.blocks[continuation]!
961
+ return context.currentBlock.parameters[0]!
962
+ }
963
+
964
+ // `a && b` evaluates b only when a is true and yields false otherwise; `a || b` mirrors it —
965
+ // the shared value-branch shape with one arm being a boolean constant.
966
+ // Lowers a statement-position condition into branch terminators with short-circuit CFG:
967
+ // `if (a && b)` becomes two chained branches sharing the false target, so each simple
968
+ // condition is its own branch producer and narrows on its own — nested guards and inline
969
+ // && guards refine identically, by construction. Conditions are assignment-free (see
970
+ // lowerStatementExpression), so the intermediate blocks carry no parameters and bindings
971
+ // never change inside.
972
+ export function lowerBranchingCondition(
973
+ expression: ts.Expression,
974
+ whenTrue: number,
975
+ whenFalse: number,
976
+ context: FunctionContext,
977
+ ): void {
978
+ const current = unwrap(expression, context.checker)
979
+ if (ts.isBinaryExpression(current)
980
+ && (current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
981
+ || current.operatorToken.kind === ts.SyntaxKind.BarBarToken)) {
982
+ const isAnd = current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
983
+ const middle = createBlock(context)
984
+ if (isAnd) {
985
+ lowerBranchingCondition(current.left, middle, whenFalse, context)
986
+ } else {
987
+ lowerBranchingCondition(current.left, whenTrue, middle, context)
988
+ }
989
+ context.currentBlock = context.blocks[middle]!
990
+ lowerBranchingCondition(current.right, whenTrue, whenFalse, context)
991
+ return
992
+ }
993
+ if (ts.isPrefixUnaryExpression(current) && current.operator === ts.SyntaxKind.ExclamationToken) {
994
+ lowerBranchingCondition(current.operand, whenFalse, whenTrue, context)
995
+ return
996
+ }
997
+ requireBooleanCondition(current, context.checker)
998
+ // `if (result.ok)` where ok is a boolean-valued tag: truthiness of the tag IS the
999
+ // tag check against true, so the branches narrow the variant list exactly like the
1000
+ // `result.ok === true` spelling. Only boolean tags take this route — a string tag's
1001
+ // truthiness would additionally hinge on the empty string, which requireBooleanCondition
1002
+ // rejects anyway.
1003
+ const tagUnion = taggedUnionTagRead(current, context)
1004
+ const condition = tagUnion != null
1005
+ ? addInstruction(context, current, {kind: 'tagCheck', union: lowerExpression(tagUnion, context), tagValue: true, negated: false})
1006
+ : lowerExpression(current, context)
1007
+ terminate(context.currentBlock, {
1008
+ kind: 'branch',
1009
+ condition,
1010
+ whenTrue: {block: whenTrue, arguments: []},
1011
+ whenFalse: {block: whenFalse, arguments: []},
1012
+ site: addSite(context, current),
1013
+ })
1014
+ }
1015
+
1016
+ function lowerLogicalExpression(expression: ts.BinaryExpression, context: FunctionContext): ValueID {
1017
+ requireBooleanCondition(expression.left, context.checker)
1018
+ requireBooleanCondition(expression.right, context.checker)
1019
+ const isAnd = expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
1020
+ const condition = lowerExpression(expression.left, context)
1021
+ return lowerValueBranch(
1022
+ expression,
1023
+ condition,
1024
+ () => isAnd
1025
+ ? lowerExpression(expression.right, context)
1026
+ : addInstruction(context, expression, {kind: 'booleanConstant', value: true}),
1027
+ () => isAnd
1028
+ ? addInstruction(context, expression, {kind: 'booleanConstant', value: false})
1029
+ : lowerExpression(expression.right, context),
1030
+ context,
1031
+ )
1032
+ }
1033
+
1034
+ function arithmeticOperator(kind: ts.SyntaxKind): Extract<InstructionIR, {kind: 'binary'}>['operator'] | null {
1035
+ switch (kind) {
1036
+ case ts.SyntaxKind.PlusToken: return 'add'
1037
+ case ts.SyntaxKind.MinusToken: return 'subtract'
1038
+ case ts.SyntaxKind.AsteriskToken: return 'multiply'
1039
+ case ts.SyntaxKind.SlashToken: return 'divide'
1040
+ case ts.SyntaxKind.PercentToken: return 'remainder'
1041
+ default: return null
1042
+ }
1043
+ }
1044
+
1045
+ function comparisonOperator(kind: ts.SyntaxKind): ComparisonOperator | null {
1046
+ switch (kind) {
1047
+ case ts.SyntaxKind.LessThanToken: return 'lessThan'
1048
+ case ts.SyntaxKind.LessThanEqualsToken: return 'lessThanOrEqual'
1049
+ case ts.SyntaxKind.GreaterThanToken: return 'greaterThan'
1050
+ case ts.SyntaxKind.GreaterThanEqualsToken: return 'greaterThanOrEqual'
1051
+ case ts.SyntaxKind.EqualsEqualsToken:
1052
+ case ts.SyntaxKind.EqualsEqualsEqualsToken: return 'equal'
1053
+ // Loose != on two numbers is exactly strict !== (no coercion between numbers); the
1054
+ // nullish and opaque spellings of both tokens are claimed by their own handlers
1055
+ // before the operator classification runs.
1056
+ case ts.SyntaxKind.ExclamationEqualsToken:
1057
+ case ts.SyntaxKind.ExclamationEqualsEqualsToken: return 'notEqual'
1058
+ default: return null
1059
+ }
1060
+ }
1061
+
1062
+ function requireNumberType(node: ts.Node, checker: ts.TypeChecker): void {
1063
+ const type = checker.getTypeAtLocation(node)
1064
+ // Through valueKind, not a raw flag test, so there is one definition of "number":
1065
+ // a literal union like `1 | 2` — the numeric discriminant of a tagged record — is a
1066
+ // number here exactly as it is at the declarator and destructuring gates.
1067
+ // TypeScript permits `any` in a numeric operation, but its static permission proves
1068
+ // nothing about the runtime value. Let the claim-free value reach the evaluator, whose
1069
+ // numeric gate stops only the path that executes the operation. Other non-number types
1070
+ // remain lowering rejections because diagnostic-clean TypeScript does not license them.
1071
+ if (valueKind(type, checker) !== 'number' && (type.flags & ts.TypeFlags.Any) === 0) {
1072
+ throw unsupported(node, {kind: 'nonNumberOperand', typeText: checker.typeToString(type)})
1073
+ }
1074
+ }
1075
+
1076
+ function typeCanIncludeUndefined(type: ts.Type): boolean {
1077
+ if ((type.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) return true
1078
+ return type.isUnion() && type.types.some(typeCanIncludeUndefined)
1079
+ }
1080
+
1081
+ function lowerParameterDefault(
1082
+ default_: ParameterDefaultLiteral,
1083
+ node: ts.Expression,
1084
+ context: FunctionContext,
1085
+ ): ValueID {
1086
+ switch (default_.kind) {
1087
+ case 'number': return addInstruction(context, node, {kind: 'constant', value: default_.value})
1088
+ case 'boolean': return addInstruction(context, node, {kind: 'booleanConstant', value: default_.value})
1089
+ case 'opaque': return addInstruction(context, node, {kind: 'opaqueConstant', content: default_.content})
1090
+ case 'nullish': return addInstruction(context, node, {kind: 'nullishConstant', sentinel: default_.sentinel})
1091
+ }
1092
+ }
1093
+
1094
+ // The single value kind a type describes, or null when the type mixes kinds (a union like
1095
+ // number | boolean), mixes object shapes without a supported tag (a union like {x} |
1096
+ // {x, y}), or falls outside the accepted kinds entirely (e.g. bigint or symbol).
1097
+ type ValueKindResult = 'number' | 'boolean' | 'object' | 'nullable' | 'array' | 'tuple' | 'opaque' | 'taggedUnion' | null
1098
+
1099
+ // Exact memoization keyed on (interned type, remaining depth budget): the walk is pure
1100
+ // over both, so the cache cannot change any answer — it only stops the same type being
1101
+ // re-walked from every expression node that mentions it. A profiling pass measured one
1102
+ // context-bag file issuing 757 million checker queries over ~216 distinct types, ~93% of
1103
+ // the whole survey's wall time, precisely because these walks recompute per call site.
1104
+ // (declaredKind has had the same cache since the tagged-union milestone; valueKind and
1105
+ // taggedUnionProperty gain theirs here.)
1106
+ const valueKindCache = new WeakMap<ts.Type, ValueKindResult[]>()
1107
+
1108
+ export function valueKind(type: ts.Type, checker: ts.TypeChecker, depth = 0): ValueKindResult {
1109
+ // The depth guard bounds recursion into element types (a recursive `type T = T[]` would
1110
+ // otherwise loop); past it, nothing classifies.
1111
+ if (depth > 8) return null
1112
+ let byDepth = valueKindCache.get(type)
1113
+ if (byDepth == null) {
1114
+ byDepth = []
1115
+ valueKindCache.set(type, byDepth)
1116
+ }
1117
+ const cached = byDepth[depth]
1118
+ if (cached !== undefined) return cached
1119
+ const result = valueKindUncached(type, checker, depth)
1120
+ byDepth[depth] = result
1121
+ return result
1122
+ }
1123
+
1124
+ function valueKindUncached(type: ts.Type, checker: ts.TypeChecker, depth: number): ValueKindResult {
1125
+ if ((type.flags & ts.TypeFlags.NumberLike) !== 0) return 'number'
1126
+ if ((type.flags & ts.TypeFlags.BooleanLike) !== 0) return 'boolean'
1127
+ // Strings are carried without claims: a label or id must not reject the numeric
1128
+ // contract of the function around it.
1129
+ if ((type.flags & ts.TypeFlags.StringLike) !== 0) return 'opaque'
1130
+ // The type system's own split, mirrored: tuple types are positional and exact, array
1131
+ // types are homogeneous. Checked before the general object arm (both carry the Object
1132
+ // flag and index signatures). An array classifies only when its ELEMENT does — a
1133
+ // (number | boolean)[] value's element hull is nothing any read gate could describe.
1134
+ if (checker.isTupleType(type)) return 'tuple'
1135
+ if (checker.isArrayType(type)) {
1136
+ const element = checker.getIndexTypeOfType(type, ts.IndexKind.Number)
1137
+ return element != null && valueKind(element, checker, depth + 1) != null ? 'array' : null
1138
+ }
1139
+ // Object types and intersections of object types (`Base & {subPage: 'select'}` — the
1140
+ // extends idiom for route variants) run the same classification: the checker's property
1141
+ // and signature queries answer for an intersection's merged view, so one body serves
1142
+ // both. A member outside the object kind keeps the whole intersection out.
1143
+ const objectLike = (type.flags & ts.TypeFlags.Object) !== 0
1144
+ || (type.isIntersection() && type.types.every(member => valueKind(member, checker, depth + 1) === 'object'))
1145
+ if (objectLike) {
1146
+ // An index signature, e.g. Record<string, number>, admits properties the type never
1147
+ // names: a value typed with one can carry any key set at runtime, so the abstract
1148
+ // record — built from a specific literal — cannot honor reads or spreads the signature
1149
+ // licenses. `stats.misses` type-checks against Record<string, number> while the value
1150
+ // is `{clicks: 1}`, and `{...defaults, ...overrides}` would copy nothing from an
1151
+ // override map whose type names no properties. A callable or constructable type is
1152
+ // not a record either: `point.toString` type-checks on every object literal, but the
1153
+ // record value built from the literal carries no such property, and a class's static
1154
+ // side is a constructor, not plain data. Finally, the type must have at least one
1155
+ // required non-callable property, or primitives inhabit it — every non-null value
1156
+ // satisfies `{}`, and a number satisfies `{toString(): string}` — letting a number
1157
+ // and a record meet at a join.
1158
+ if (checker.getIndexInfosOfType(type).length > 0) return null
1159
+ if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) {
1160
+ // A pure function type — call signatures and nothing else — is carried opaquely,
1161
+ // like a callback stored in a record already is: calls to it reject at the call
1162
+ // gate (the callee must be a top-level function), so carrying makes callback
1163
+ // PARAMETERS as cheap as callback properties. Hybrid callable-objects keep out:
1164
+ // their data properties would invite reads the carried value cannot answer.
1165
+ const dataProperties = checker.getPropertiesOfType(type).some(property =>
1166
+ checker.getTypeOfSymbol(property).getCallSignatures().length === 0)
1167
+ return dataProperties ? null : 'opaque'
1168
+ }
1169
+ // Optional properties anchor too, now that they model as maybe-undefined values: an
1170
+ // all-optional config record ({volume?: number}) is a weak type TypeScript refuses to
1171
+ // assign primitives to, so the primitive-inhabitation worry that bars `{}` does not
1172
+ // apply — only a type with NO data properties at all stays out.
1173
+ const anchored = checker.getPropertiesOfType(type).some(property =>
1174
+ checker.getTypeOfSymbol(property).getCallSignatures().length === 0)
1175
+ return anchored ? 'object' : null
1176
+ }
1177
+ // `unknown` and `any` both carry without claims. For unknown the checker forces
1178
+ // narrowing before any use, so its word stays intact; for any the checker's word is
1179
+ // void, and claim-free is the one honest reading — nothing numeric is ever said about
1180
+ // the value, operations that require a concrete kind stop at their gates, and a write
1181
+ // into a typed binding leaves the binding opaque instead of re-minting claims.
1182
+ if ((type.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.Any)) !== 0) return 'opaque'
1183
+ if (type.isUnion()) {
1184
+ // `T | null`, `T | undefined`, and `T | null | undefined` classify as nullable when T
1185
+ // itself classifies to one kind. Gates that cannot carry a missing value keep
1186
+ // rejecting ('nullable' matches neither 'number' nor 'object'); kind-agnostic gates
1187
+ // (declarators, ternary results, destructure elements, returns) accept.
1188
+ const missingFlags = ts.TypeFlags.Null | ts.TypeFlags.Undefined
1189
+ if (type.types.some(member => (member.flags & missingFlags) !== 0)) {
1190
+ const rest = nonMissingUnionMembers(type)
1191
+ // The non-missing rest classifies as a group, so `4 | 8 | 24 | undefined` — an
1192
+ // as-const table's bare dynamic read — is nullable like `number | undefined`. A
1193
+ // rest that is itself a tagged union (`null | LightboxOwnerRoute`) is nullable too.
1194
+ const restKind = classifyUnionMembers(rest, checker, depth + 1)
1195
+ if (restKind != null) return 'nullable'
1196
+ return taggedUnionProperty(rest, checker, depth) == null ? null : 'nullable'
1197
+ }
1198
+ // A shared string- or boolean-literal property makes a record union tagged. Other
1199
+ // structural unions reject; scalar literal unions still collapse to one shared kind.
1200
+ if (taggedUnionProperty(type.types, checker, depth) != null) return 'taggedUnion'
1201
+ return classifyUnionMembers(type.types, checker, depth + 1)
1202
+ }
1203
+ return null
1204
+ }
1205
+
1206
+ // Filtering a nullable union creates a new array. That array is also the exact cache key
1207
+ // for taggedUnionProperty, so rebuilding it during every recursive declared-kind walk
1208
+ // defeats that cache. TypeScript interns union types, which makes their filtered member
1209
+ // lists safe to reuse as well.
1210
+ const nonMissingUnionMembersCache = new WeakMap<ts.UnionType, readonly ts.Type[]>()
1211
+
1212
+ export function nonMissingUnionMembers(type: ts.UnionType): readonly ts.Type[] {
1213
+ const cached = nonMissingUnionMembersCache.get(type)
1214
+ if (cached != null) return cached
1215
+ const missingFlags = ts.TypeFlags.Null | ts.TypeFlags.Undefined
1216
+ const members = type.types.filter(member => (member.flags & missingFlags) === 0)
1217
+ nonMissingUnionMembersCache.set(type, members)
1218
+ return members
1219
+ }
1220
+
1221
+ // The property that tells a union of record shapes apart: present and required in every
1222
+ // member, typed as a single string literal in each. The first property (in the first
1223
+ // member's declaration order) that qualifies wins — by convention the tag comes first
1224
+ // (`type: 'lightbox'`). Two members MAY share a tag value (`{type: 'updates'; tab} |
1225
+ // {type: 'updates'; article}`): a tag check then keeps both. Code that must tell them apart
1226
+ // needs a distinct tag value; `in` checks are outside the subset because width subtyping
1227
+ // permits undeclared extra properties. Null when no property qualifies.
1228
+ // Keyed on the members ARRAY: a union type's .types array is interned by the checker, so
1229
+ // the reference identifies the member set exactly. Nullable unions use the stable array
1230
+ // from nonMissingUnionMembers; any other freshly built array simply misses.
1231
+ const taggedUnionPropertyCache = new WeakMap<readonly ts.Type[], Array<string | null>>()
1232
+
1233
+ export function taggedUnionProperty(members: readonly ts.Type[], checker: ts.TypeChecker, depth = 0): string | null {
1234
+ if (members.length < 2) return null
1235
+ let byDepth = taggedUnionPropertyCache.get(members)
1236
+ if (byDepth == null) {
1237
+ byDepth = []
1238
+ taggedUnionPropertyCache.set(members, byDepth)
1239
+ }
1240
+ const cached = byDepth[depth]
1241
+ if (cached !== undefined) return cached
1242
+ const result = taggedUnionPropertyUncached(members, checker, depth)
1243
+ byDepth[depth] = result
1244
+ return result
1245
+ }
1246
+
1247
+ function taggedUnionPropertyUncached(members: readonly ts.Type[], checker: ts.TypeChecker, depth: number): string | null {
1248
+ for (const member of members) {
1249
+ if (valueKind(member, checker, depth + 1) !== 'object') return null
1250
+ }
1251
+ // Two passes: a property whose tag is a SINGLE literal per member (`ok: true` /
1252
+ // `ok: false`, `type: 'lightbox'`) is a real discriminant and wins first. Only then do
1253
+ // multi-literal tags qualify (`type: 'desktopCollapsedNav' | 'desktopExpandedNav'` in
1254
+ // one variant, or a plain boolean property every member carries) — otherwise a
1255
+ // non-discriminating `enabled: boolean` shared by all members could shadow the actual
1256
+ // tag declared after it.
1257
+ const first = members[0]!
1258
+ const qualifies = (candidateName: string, singleLiteralOnly: boolean): boolean => {
1259
+ for (const member of members) {
1260
+ const property = checker.getPropertyOfType(member, candidateName)
1261
+ if (property == null || (property.flags & ts.SymbolFlags.Optional) !== 0) return false
1262
+ const literals = tagLiteralValues(checker.getTypeOfSymbol(property))
1263
+ if (literals == null || (singleLiteralOnly && literals.length !== 1)) return false
1264
+ }
1265
+ return true
1266
+ }
1267
+ for (const singleLiteralOnly of [true, false]) {
1268
+ for (const candidate of checker.getPropertiesOfType(first)) {
1269
+ if ((candidate.flags & ts.SymbolFlags.Optional) !== 0) continue
1270
+ if (qualifies(candidate.name, singleLiteralOnly)) return candidate.name
1271
+ }
1272
+ }
1273
+ return null
1274
+ }
1275
+
1276
+ // The callee as a short display name for the call rejection. A method on a simple
1277
+ // receiver reads naturally (localStorage.getItem, Math.max — one or two identifiers); a
1278
+ // method on a computed receiver — a call result, a regex literal, a chained pipeline —
1279
+ // collapses to (…).method. Raw source text carried newlines into the report (breaking
1280
+ // the one-fact-per-line format) and made the survey tally fragment into one bucket per
1281
+ // call site; the collapsed form keeps lines whole and groups the tally by method.
1282
+ function calleeDisplayName(expression: ts.Expression, sourceFile: ts.SourceFile): string {
1283
+ if (ts.isPropertyAccessExpression(expression)) {
1284
+ const receiver = expression.expression
1285
+ const receiverName = ts.isIdentifier(receiver)
1286
+ ? receiver.text
1287
+ : ts.isPropertyAccessExpression(receiver) && ts.isIdentifier(receiver.expression)
1288
+ ? `${receiver.expression.text}.${receiver.name.text}`
1289
+ : '(…)'
1290
+ return `${receiverName}.${expression.name.text}`
1291
+ }
1292
+ return expression.getText(sourceFile).replace(/\s+/g, ' ').slice(0, 60)
1293
+ }
1294
+
1295
+ // The literal written in an object literal's tag position, or null: a string literal, a
1296
+ // no-substitution template, or the true/false keywords, seen through parens, satisfies,
1297
+ // and as-const (unwrap peels exactly those). Quoted property names count — the rule is
1298
+ // about the VALUE being a written literal, not about how the key is spelled.
1299
+ function writtenTagLiteral(
1300
+ literal: ts.Expression,
1301
+ tagProperty: string,
1302
+ context: FunctionContext,
1303
+ ): string | boolean | null {
1304
+ if (!ts.isObjectLiteralExpression(literal)) return null
1305
+ for (const property of literal.properties) {
1306
+ if (!ts.isPropertyAssignment(property)) continue
1307
+ const name = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) ? property.name.text : null
1308
+ if (name !== tagProperty) continue
1309
+ const initializer = unwrap(property.initializer, context.checker)
1310
+ if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) return initializer.text
1311
+ if (initializer.kind === ts.SyntaxKind.TrueKeyword) return true
1312
+ if (initializer.kind === ts.SyntaxKind.FalseKeyword) return false
1313
+ return null
1314
+ }
1315
+ return null
1316
+ }
1317
+
1318
+ // The literal tag values a tag property's type covers: a string or boolean literal gives
1319
+ // one, a union of such literals gives one per member — and `ok: boolean` arrives here as
1320
+ // the checker's `true | false` union, so it gives both. Null when any member is not such
1321
+ // a literal (a number tag, a full string). The list is bounded by what the author wrote
1322
+ // in the type.
1323
+ export function tagLiteralValues(type: ts.Type): Array<string | boolean> | null {
1324
+ const single = (member: ts.Type): string | boolean | null => {
1325
+ if (member.isStringLiteral()) return member.value
1326
+ if ((member.flags & ts.TypeFlags.BooleanLiteral) !== 0) {
1327
+ return (member as unknown as {intrinsicName: string}).intrinsicName === 'true'
1328
+ }
1329
+ return null
1330
+ }
1331
+ const members = type.isUnion() ? type.types : [type]
1332
+ const literals: Array<string | boolean> = []
1333
+ for (const member of members) {
1334
+ const literal = single(member)
1335
+ if (literal == null) return null
1336
+ literals.push(literal)
1337
+ }
1338
+ return literals
1339
+ }
1340
+
1341
+ // One shared kind for a group of scalar union members, or null. Multiple object, array,
1342
+ // or tuple members need a string or boolean tag and are handled before this function.
1343
+ // A one-member list can occur after removing null and undefined; retain the ordinary
1344
+ // depth and cycle check for that structural member without comparing it to another shape.
1345
+ function classifyUnionMembers(
1346
+ members: readonly ts.Type[],
1347
+ checker: ts.TypeChecker,
1348
+ depth: number,
1349
+ ): 'number' | 'boolean' | 'object' | 'array' | 'tuple' | 'opaque' | null {
1350
+ if (depth > 8) return null
1351
+ let shared: 'number' | 'boolean' | 'object' | 'array' | 'tuple' | 'opaque' | null = null
1352
+ for (const member of members) {
1353
+ const kind = valueKind(member, checker, depth)
1354
+ // A nullable or tagged-union member cannot arise here (TypeScript flattens nested
1355
+ // unions), but the type system cannot see that; both fail the shared-kind rule.
1356
+ if (kind == null || kind === 'nullable' || kind === 'taggedUnion' || (shared != null && kind !== shared)) return null
1357
+ if (kind === 'object' || kind === 'array' || kind === 'tuple') {
1358
+ if (members.length > 1 || !structuralTypeWalkCompletes(member, checker, [])) return null
1359
+ }
1360
+ shared = kind
1361
+ }
1362
+ return shared
1363
+ }
1364
+
1365
+ // Nullable records still need the same bounded walk as other declared structures. A
1366
+ // recursive or excessively deep member rejects instead of producing an enormous partial
1367
+ // record. This walk only checks that traversal completes; it does not compare shapes.
1368
+ function structuralTypeWalkCompletes(type: ts.Type, checker: ts.TypeChecker, seen: ts.Type[]): boolean {
1369
+ if (type.isIntersection() && valueKind(type, checker) === 'object') {
1370
+ if (seen.length >= 8 || seen.includes(type)) return false
1371
+ return structuralPropertiesComplete(type, checker, seen)
1372
+ }
1373
+ if (type.isUnion()) return type.types.every(member => structuralTypeWalkCompletes(member, checker, seen))
1374
+ if (checker.isTupleType(type)) {
1375
+ if (seen.length >= 8 || seen.includes(type)) return false
1376
+ return checker.getTypeArguments(type as ts.TypeReference)
1377
+ .every(member => structuralTypeWalkCompletes(member, checker, [...seen, type]))
1378
+ }
1379
+ if (checker.isArrayType(type)) {
1380
+ if (seen.length >= 8 || seen.includes(type)) return false
1381
+ const element = checker.getIndexTypeOfType(type, ts.IndexKind.Number)
1382
+ return element == null || structuralTypeWalkCompletes(element, checker, [...seen, type])
1383
+ }
1384
+ if ((type.flags & ts.TypeFlags.Object) === 0) return true
1385
+ if (seen.length >= 8 || seen.includes(type)) return false
1386
+ if (declaredOnlyInDeclarationFiles(type.getSymbol() ?? type.aliasSymbol)) return true
1387
+ if (checker.getIndexInfosOfType(type).length > 0) return true
1388
+ if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) return true
1389
+ return structuralPropertiesComplete(type, checker, seen)
1390
+ }
1391
+
1392
+ function structuralPropertiesComplete(type: ts.Type, checker: ts.TypeChecker, seen: ts.Type[]): boolean {
1393
+ const nextSeen = [...seen, type]
1394
+ for (const property of checker.getPropertiesOfType(type)) {
1395
+ if ((property.flags & ts.SymbolFlags.Optional) !== 0) continue
1396
+ if (!structuralTypeWalkCompletes(checker.getTypeOfSymbol(property), checker, nextSeen)) return false
1397
+ }
1398
+ return true
1399
+ }
1400
+
1401
+ // Truthiness conditions like `if (width)` on a number are legal TypeScript but outside the
1402
+ // accepted subset; the engine represents conditions as booleans only.
1403
+ export function requireBooleanCondition(node: ts.Node, checker: ts.TypeChecker): void {
1404
+ const type = checker.getTypeAtLocation(node)
1405
+ const kind = valueKind(type, checker)
1406
+ if (kind === 'boolean') return
1407
+ throw unsupported(node, {
1408
+ kind: 'nonBooleanCondition',
1409
+ conditionKind: kind === 'number' ? 'number' : 'other',
1410
+ typeText: checker.typeToString(type),
1411
+ })
1412
+ }
1413
+
1414
+ function requireAccessedPropertyKind(access: ts.PropertyAccessExpression, checker: ts.TypeChecker): void {
1415
+ // An optional property reads as its maybe-undefined value: declared kinds wrap it in
1416
+ // the undefined sentinel and object literals fill omitted ones explicitly, so a record
1417
+ // value always carries every property its static type declares — there is always
1418
+ // something honest to read.
1419
+ const receiverType = checker.getTypeAtLocation(access.expression)
1420
+ // For an optional read the receiver includes the missing sentinels; the property lives
1421
+ // on the non-missing part, which getNonNullableType strips to.
1422
+ const presentType = access.questionDotToken != null ? checker.getNonNullableType(receiverType) : receiverType
1423
+ const property = checker.getPropertyOfType(presentType, access.name.text)
1424
+ // point.toString type-checks on every object literal, but the record value carries only
1425
+ // its own properties — an inherited prototype member has no honest answer. The
1426
+ // ownership test is the .d.ts rule: a property symbol declared only in declaration
1427
+ // files was not written by the project, and on a project record that means prototype.
1428
+ if (valueKind(presentType, checker) === 'object' && property != null && declaredOnlyInDeclarationFiles(property)) {
1429
+ throw unsupported(access, {kind: 'prototypeMemberRead', property: access.name.text})
1430
+ }
1431
+ const type = checker.getTypeAtLocation(access)
1432
+ if (valueKind(type, checker) != null) return
1433
+ throw unsupported(access, {kind: 'valueType', typeText: checker.typeToString(type)})
1434
+ }
1435
+
1436
+ function resolvedSymbol(symbol: ts.Symbol | undefined, checker: ts.TypeChecker): ts.Symbol | null {
1437
+ if (symbol == null) return null
1438
+ return (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : checker.getAliasedSymbol(symbol)
1439
+ }
1440
+
1441
+ function isStandardMathObject(expression: ts.Expression, checker: ts.TypeChecker): boolean {
1442
+ if (!ts.isIdentifier(expression) || expression.text !== 'Math') return false
1443
+ return declaredOnlyInDeclarationFiles(checker.getSymbolAtLocation(expression))
1444
+ }
1445
+
1446
+ function isStandardNumberObject(expression: ts.Expression, checker: ts.TypeChecker): boolean {
1447
+ if (!ts.isIdentifier(expression) || expression.text !== 'Number') return false
1448
+ return declaredOnlyInDeclarationFiles(checker.getSymbolAtLocation(expression))
1449
+ }
1450
+
1451
+ function lowerElementAccess(access: ts.ElementAccessExpression, asserted: boolean, context: FunctionContext): ValueID {
1452
+ const receiverType = context.checker.getTypeAtLocation(access.expression)
1453
+ const receiverKind = valueKind(receiverType, context.checker)
1454
+ if (receiverKind !== 'array' && receiverKind !== 'tuple') {
1455
+ throw unsupported(access.expression, {kind: 'propertyReadOnNonObject', typeText: context.checker.typeToString(receiverType)})
1456
+ }
1457
+ const resultType = context.checker.getTypeAtLocation(access)
1458
+ if (valueKind(resultType, context.checker) == null) {
1459
+ throw unsupported(access, {kind: 'valueType', typeText: context.checker.typeToString(resultType)})
1460
+ }
1461
+ requireNumberType(access.argumentExpression, context.checker)
1462
+ const array = lowerExpression(access.expression, context)
1463
+ const index = lowerExpression(access.argumentExpression, context)
1464
+ const missingFlag = ts.TypeFlags.Undefined
1465
+ const staticTypeAllowsUndefined = (resultType.flags & missingFlag) !== 0
1466
+ || (resultType.isUnion() && resultType.types.some(member => (member.flags & missingFlag) !== 0))
1467
+ return addInstruction(context, access, {
1468
+ kind: 'arrayIndex',
1469
+ array,
1470
+ index,
1471
+ mode: asserted ? 'asserted' : staticTypeAllowsUndefined ? 'bare' : 'bareUnchecked',
1472
+ })
1473
+ }
1474
+
1475
+ // A read of the union's tag property (`route.type` where route is one of several
1476
+ // shapes): the recognizer both the === form and the switch subject share. Returns the
1477
+ // union expression, or null when the expression is not a tag read.
1478
+ export function taggedUnionTagRead(expression: ts.Expression, context: FunctionContext): ts.Expression | null {
1479
+ const unwrapped = unwrap(expression, context.checker)
1480
+ if (!ts.isPropertyAccessExpression(unwrapped)) return null
1481
+ const objectType = context.checker.getTypeAtLocation(unwrapped.expression)
1482
+ if (valueKind(objectType, context.checker) !== 'taggedUnion' || !objectType.isUnion()) return null
1483
+ const tagProperty = taggedUnionProperty(objectType.types, context.checker)
1484
+ return tagProperty === unwrapped.name.text ? unwrapped.expression : null
1485
+ }
1486
+
1487
+ // route.type === 'lightbox' (and !==, the loose spellings, and result.ok === true): the
1488
+ // check consumes the union value directly and the branches narrow its variant list — the
1489
+ // same move the null checks make, pointed at the tag. The compared side must be a string
1490
+ // or boolean literal; comparing two tag reads to each other stays an unknown boolean
1491
+ // through the opaque path.
1492
+ function tagCheckComparison(expression: ts.BinaryExpression, context: FunctionContext): ValueID | null {
1493
+ const operator = expression.operatorToken.kind
1494
+ const equals = operator === ts.SyntaxKind.EqualsEqualsEqualsToken || operator === ts.SyntaxKind.EqualsEqualsToken
1495
+ const notEquals = operator === ts.SyntaxKind.ExclamationEqualsEqualsToken || operator === ts.SyntaxKind.ExclamationEqualsToken
1496
+ if (!equals && !notEquals) return null
1497
+ const literalOf = (side: ts.Expression): string | boolean | null => {
1498
+ const unwrapped = unwrap(side, context.checker)
1499
+ if (ts.isStringLiteral(unwrapped) || ts.isNoSubstitutionTemplateLiteral(unwrapped)) return unwrapped.text
1500
+ if (unwrapped.kind === ts.SyntaxKind.TrueKeyword) return true
1501
+ if (unwrapped.kind === ts.SyntaxKind.FalseKeyword) return false
1502
+ return null
1503
+ }
1504
+ const sides = [
1505
+ {union: taggedUnionTagRead(expression.left, context), literal: literalOf(expression.right)},
1506
+ {union: taggedUnionTagRead(expression.right, context), literal: literalOf(expression.left)},
1507
+ ]
1508
+ for (const side of sides) {
1509
+ if (side.union != null && side.literal != null) {
1510
+ const union = lowerExpression(side.union, context)
1511
+ return addInstruction(context, expression, {kind: 'tagCheck', union, tagValue: side.literal, negated: notEquals})
1512
+ }
1513
+ }
1514
+ return null
1515
+ }
1516
+
1517
+ // `mode === 'compact'`: comparing two carried-without-claims values yields a boolean the
1518
+ // analysis knows nothing about — both branches stay analyzed, which is sound and keeps
1519
+ // string-keyed control flow from rejecting the function. A possibly-missing string
1520
+ // qualifies too (`mode === 'wide'` where mode is string | undefined): a missing value
1521
+ // simply compares unequal, so the unknown-boolean result stays sound without a null guard
1522
+ // first. The operands still lower, so an unsupported construct inside one rejects as
1523
+ // usual. This check runs AFTER missingSentinelCheck, so `mode === null` is already claimed
1524
+ // by the sentinel narrowing before either side is classified here.
1525
+ function opaqueEqualityCheck(expression: ts.BinaryExpression, context: FunctionContext): ValueID | null {
1526
+ const operator = expression.operatorToken.kind
1527
+ const isEquality = operator === ts.SyntaxKind.EqualsEqualsEqualsToken
1528
+ || operator === ts.SyntaxKind.ExclamationEqualsEqualsToken
1529
+ || operator === ts.SyntaxKind.EqualsEqualsToken
1530
+ || operator === ts.SyntaxKind.ExclamationEqualsToken
1531
+ if (!isEquality) return null
1532
+ const opaqueOrMissingOpaque = (side: ts.Expression): boolean => {
1533
+ const type = context.checker.getTypeAtLocation(side)
1534
+ const kind = valueKind(type, context.checker)
1535
+ if (kind === 'opaque') return true
1536
+ if (kind === 'nullable' && type.isUnion()) {
1537
+ const missing = ts.TypeFlags.Null | ts.TypeFlags.Undefined
1538
+ const rest = type.types.filter(member => (member.flags & missing) === 0)
1539
+ return rest.length >= 1 && rest.every(member => valueKind(member, context.checker) === 'opaque')
1540
+ }
1541
+ return false
1542
+ }
1543
+ if (!opaqueOrMissingOpaque(expression.left) || !opaqueOrMissingOpaque(expression.right)) return null
1544
+ lowerExpression(expression.left, context)
1545
+ lowerExpression(expression.right, context)
1546
+ return addInstruction(context, expression, {kind: 'unknownBoolean'})
1547
+ }
1548
+
1549
+ // Recognizes `x === null`, `x !== undefined`, `x == null`, and friends. The loose forms
1550
+ // test both sentinels at once; the strict forms test one, and the refinement consults the
1551
+ // VALUE's own possible sentinels, so `x !== null` on a possibly-undefined value narrows
1552
+ // null away while undefined honestly survives.
1553
+ function missingSentinelCheck(expression: ts.BinaryExpression, context: FunctionContext): ValueID | null {
1554
+ const operator = expression.operatorToken.kind
1555
+ const strict = operator === ts.SyntaxKind.EqualsEqualsEqualsToken || operator === ts.SyntaxKind.ExclamationEqualsEqualsToken
1556
+ const loose = operator === ts.SyntaxKind.EqualsEqualsToken || operator === ts.SyntaxKind.ExclamationEqualsToken
1557
+ if (!strict && !loose) return null
1558
+ const negated = operator === ts.SyntaxKind.ExclamationEqualsEqualsToken || operator === ts.SyntaxKind.ExclamationEqualsToken
1559
+ const sentinelOf = (side: ts.Expression): 'null' | 'undefined' | null => {
1560
+ const unwrapped = unwrap(side, context.checker)
1561
+ if (unwrapped.kind === ts.SyntaxKind.NullKeyword) return 'null'
1562
+ if (isUndefinedGlobal(unwrapped, context.checker)) return 'undefined'
1563
+ return null
1564
+ }
1565
+ // `typeof x === 'undefined'` is the classic guard spelling; it is the undefined
1566
+ // sentinel check with the checked side inside the typeof.
1567
+ const isUndefinedString = (side: ts.Expression): boolean => {
1568
+ const unwrapped = unwrap(side, context.checker)
1569
+ return ts.isStringLiteral(unwrapped) && unwrapped.text === 'undefined'
1570
+ }
1571
+ if (ts.isTypeOfExpression(expression.left) && isUndefinedString(expression.right)) {
1572
+ const value = lowerExpression(expression.left.expression, context)
1573
+ return addInstruction(context, expression, {kind: 'nullishCheck', value, sentinel: 'undefined', negated})
1574
+ }
1575
+ if (ts.isTypeOfExpression(expression.right) && isUndefinedString(expression.left)) {
1576
+ const value = lowerExpression(expression.right.expression, context)
1577
+ return addInstruction(context, expression, {kind: 'nullishCheck', value, sentinel: 'undefined', negated})
1578
+ }
1579
+ // `typeof x === 'number'` (or 'string', or 'boolean') on a value whose only
1580
+ // non-missing kind matches the literal equals "x is not missing" — the common
1581
+ // narrowing spelling for number | undefined and friends.
1582
+ const primitiveTypeofFlags = (side: ts.Expression): ts.TypeFlags | null => {
1583
+ const unwrapped = unwrap(side, context.checker)
1584
+ if (!ts.isStringLiteral(unwrapped)) return null
1585
+ switch (unwrapped.text) {
1586
+ case 'number': return ts.TypeFlags.NumberLike
1587
+ case 'string': return ts.TypeFlags.StringLike
1588
+ case 'boolean': return ts.TypeFlags.BooleanLike
1589
+ default: return null
1590
+ }
1591
+ }
1592
+ const rightFlags = primitiveTypeofFlags(expression.right)
1593
+ const leftFlags = primitiveTypeofFlags(expression.left)
1594
+ const typeofSide = ts.isTypeOfExpression(expression.left) && rightFlags != null
1595
+ ? {operand: expression.left, flags: rightFlags}
1596
+ : ts.isTypeOfExpression(expression.right) && leftFlags != null
1597
+ ? {operand: expression.right, flags: leftFlags}
1598
+ : null
1599
+ if (typeofSide != null) {
1600
+ const operandType = context.checker.getTypeAtLocation(typeofSide.operand.expression)
1601
+ // The TYPE FLAGS decide, not the analyzer kind: unknown classifies opaque like
1602
+ // strings do, but typeof unknown === 'string' is genuinely unknown — treating the
1603
+ // kinds as equivalent would answer it definitely-true. Only when every non-missing
1604
+ // member's flags name the checked primitive does the check translate to
1605
+ // "not missing"; everything else (unknown operands, mixed unions) answers an
1606
+ // unknown boolean — typeof is an effect-free operator, so both branches analyzing
1607
+ // is always sound.
1608
+ const missing = ts.TypeFlags.Null | ts.TypeFlags.Undefined
1609
+ const members = operandType.isUnion() ? operandType.types : [operandType]
1610
+ const restMatches = members.every(member =>
1611
+ (member.flags & missing) !== 0 || (member.flags & typeofSide.flags) !== 0)
1612
+ && members.some(member => (member.flags & missing) === 0)
1613
+ const value = lowerExpression(typeofSide.operand.expression, context)
1614
+ if (restMatches) {
1615
+ return addInstruction(context, expression, {kind: 'nullishCheck', value, sentinel: 'nullish', negated: !negated})
1616
+ }
1617
+ return addInstruction(context, expression, {kind: 'unknownBoolean'})
1618
+ }
1619
+ const leftSentinel = sentinelOf(expression.left)
1620
+ const rightSentinel = sentinelOf(expression.right)
1621
+ const sentinel = leftSentinel ?? rightSentinel
1622
+ if (sentinel == null || (leftSentinel != null && rightSentinel != null)) return null
1623
+ const checked = leftSentinel == null ? expression.left : expression.right
1624
+ // The checked side must be a kind the analysis represents: `voidCall() == null` is TRUE
1625
+ // at runtime (a void function returns undefined), but the void abstract value carries no
1626
+ // sentinel, so admitting it would prune the wrong branch. A pure-sentinel type
1627
+ // (`null | undefined`, after an outer `== null` narrowed everything else away) is fine:
1628
+ // the abstract value carries exactly those sentinels.
1629
+ const checkedType = context.checker.getTypeAtLocation(checked)
1630
+ const missingFlags = ts.TypeFlags.Null | ts.TypeFlags.Undefined
1631
+ const pureSentinel = (checkedType.flags & missingFlags) !== 0
1632
+ || (checkedType.isUnion() && checkedType.types.every(member => (member.flags & missingFlags) !== 0))
1633
+ if (!pureSentinel && valueKind(checkedType, context.checker) == null) {
1634
+ throw unsupported(checked, {kind: 'valueType', typeText: context.checker.typeToString(checkedType)})
1635
+ }
1636
+ const value = lowerExpression(checked, context)
1637
+ return addInstruction(context, expression, {
1638
+ kind: 'nullishCheck',
1639
+ value,
1640
+ sentinel: loose ? 'nullish' : sentinel,
1641
+ negated,
1642
+ })
1643
+ }
1644
+
1645
+ function isGlobalInfinity(expression: ts.Expression, checker: ts.TypeChecker): boolean {
1646
+ if (!ts.isIdentifier(expression) || expression.text !== 'Infinity') return false
1647
+ return declaredOnlyInDeclarationFiles(checker.getSymbolAtLocation(expression))
1648
+ }
1649
+
1650
+ // Resolves an identifier read: a function-local binding first, then a module binding
1651
+ // (which reads the binding's slot), then the global `Infinity` as an exact constant,
1652
+ // else the identifier is unknown. A local or module binding named Infinity has a
1653
+ // different symbol and wins above; the global check is the same declaration-file
1654
+ // defense the Math and platform dispatches use.
1655
+ function identifierValue(symbol: ts.Symbol, node: ts.Identifier, context: FunctionContext): ValueID {
1656
+ const local = context.bindings.get(symbol)
1657
+ if (local != null) return local
1658
+ const binding = context.moduleBindingsBySymbol.get(symbol)
1659
+ if (binding != null) return addInstruction(context, node, {kind: 'moduleRead', binding})
1660
+ if (isGlobalInfinity(node, context.checker)) {
1661
+ return addInstruction(context, node, {kind: 'constant', value: Number.POSITIVE_INFINITY})
1662
+ }
1663
+ if (isUndefinedGlobal(node, context.checker)) {
1664
+ return addInstruction(context, node, {kind: 'nullishConstant', sentinel: 'undefined'})
1665
+ }
1666
+ throw unsupported(node, {kind: 'unknownIdentifier', name: node.text})
1667
+ }
1668
+
1669
+ // Assigns an identifier: rebinding for a local, a slot write for a module binding.
1670
+ function assignIdentifier(
1671
+ symbol: ts.Symbol,
1672
+ node: ts.Identifier,
1673
+ value: ValueID,
1674
+ wholeExpression: ts.Expression,
1675
+ context: FunctionContext,
1676
+ ): ValueID {
1677
+ if (context.bindings.has(symbol)) {
1678
+ context.bindings.set(symbol, value)
1679
+ return value
1680
+ }
1681
+ const binding = context.moduleBindingsBySymbol.get(symbol)
1682
+ if (binding != null) return addInstruction(context, wholeExpression, {kind: 'moduleWrite', binding, value})
1683
+ throw unsupported(node, {kind: 'unknownIdentifier', name: node.text})
1684
+ }
1685
+
1686
+ function propertyName(name: ts.PropertyName): string {
1687
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text
1688
+ throw unsupported(name, {kind: 'computedPropertyName'})
1689
+ }
1690
+
1691
+ function unwrap(expression: ts.Expression, checker: ts.TypeChecker): ts.Expression {
1692
+ let current = expression
1693
+ while (true) {
1694
+ if (ts.isParenthesizedExpression(current) || ts.isSatisfiesExpression(current)) {
1695
+ // Neither changes the expression's type.
1696
+ current = current.expression
1697
+ continue
1698
+ }
1699
+ // Only `as const` peels: TypeScript permits it solely on literals and it narrows the
1700
+ // literal to its own literal type, so the value kind provably cannot change. Every
1701
+ // other as/angle assertion is an erasure point (see the as/angle arm in
1702
+ // lowerExpression) — an assertion is exactly where the checker's word and the runtime
1703
+ // value may diverge, and claim-free is the one honest reading. Three review rounds
1704
+ // settled this: each attempt to LICENSE carrying (asserted kind matches operand kind;
1705
+ // then recursive type-shape comparisons) was defeated by another diagnostic-clean aliasing
1706
+ // route (`true as {} as number` via comparability, `flags as unknown[] as number[]`
1707
+ // at the element level, optional-property and heterogeneous-union comparison
1708
+ // collisions). No type-level test can be finer than TypeScript's own cast
1709
+ // permissiveness, so the license is gone rather than repaired again.
1710
+ if ((ts.isAsExpression(current) || ts.isTypeAssertionExpression(current))
1711
+ && ts.isConstTypeReference(current.type)) {
1712
+ current = current.expression
1713
+ continue
1714
+ }
1715
+ // The non-null assertion `x!` peels only while the value kind is unchanged underneath —
1716
+ // on a nullable type, e.g. `x!` with `x: number | null`, the static type stops
1717
+ // describing the value the analysis models, so stop. The one blessed kind-changing
1718
+ // form is `arr[i]!`: the syntax itself requests asserted-read treatment — an in-bounds
1719
+ // assumption line, or a bounds proof when the loop supplies one. Bare reads carry
1720
+ // possible undefined in the engine regardless of the project's TypeScript options.
1721
+ if (ts.isNonNullExpression(current)) {
1722
+ const assertedType = checker.getTypeAtLocation(current)
1723
+ const operandType = checker.getTypeAtLocation(current.expression)
1724
+ // The one blessed kind-changing assertion is `arr[i]!`: the asserted read gets its
1725
+ // explicit treatment in lowering — an in-bounds assumption line, or a bounds proof
1726
+ // when a loop supplies one. It stays wrapped so lowering can see the assertion.
1727
+ if (ts.isElementAccessExpression(current.expression) && valueKind(assertedType, checker) != null) {
1728
+ return current
1729
+ }
1730
+ if (valueKind(assertedType, checker) !== valueKind(operandType, checker)) {
1731
+ throw unsupported(current, {
1732
+ kind: 'kindChangingAssertion',
1733
+ fromText: checker.typeToString(operandType),
1734
+ toText: checker.typeToString(assertedType),
1735
+ })
1736
+ }
1737
+ current = current.expression
1738
+ continue
1739
+ }
1740
+ return current
1741
+ }
1742
+ }