@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,748 @@
1
+ import * as ts from 'typescript'
2
+ import type {ModuleBindingID} from '../ir/ids.ts'
3
+ import {
4
+ declaredKindOf,
5
+ holdsMutableStructure,
6
+ moduleInitializerName,
7
+ type DeclaredKind,
8
+ type DeclaredNumberInterval,
9
+ type DeclaredVariant,
10
+ type FunctionIR,
11
+ type InitializerSkip,
12
+ type ModuleBindingCategory,
13
+ type ModuleBindingIR,
14
+ type SourceSpan,
15
+ } from '../ir/program.ts'
16
+ import {assertAccepted} from './accept.ts'
17
+ import {numericLiteralValue} from './literals.ts'
18
+ import {declaredOnlyInDeclarationFiles} from './platform.ts'
19
+ import {addInstruction, addSite, createFunctionContext, LoweringStop, restoreLowering, sealBlocks, snapshotLowering, terminate, type FunctionContext, type TopLevelFunction} from './context.ts'
20
+ import {lowerExpression, nonMissingUnionMembers, tagLiteralValues, taggedUnionProperty, valueKind} from './expression.ts'
21
+ import {lowerStatement} from './statements.ts'
22
+
23
+ export type ModuleScan = {
24
+ bindings: ModuleBindingIR[]
25
+ bindingsBySymbol: Map<ts.Symbol, ModuleBindingID>
26
+ }
27
+
28
+ // Classifies every top-level binding by one rule: a function may trust the binding's value
29
+ // only when every possible write to it is accounted for. The scan reads the entire file's
30
+ // text — bodies of functions the analyzer rejects included — so a write hiding inside
31
+ // unsupported code still demotes the binding.
32
+ export function scanModuleBindings(sourceFile: ts.SourceFile, checker: ts.TypeChecker): ModuleScan {
33
+ const bindings: ModuleBindingIR[] = []
34
+ const bindingsBySymbol = new Map<ts.Symbol, ModuleBindingID>()
35
+ const register = (name: ts.Identifier, category: ModuleBindingCategory): void => {
36
+ const symbol = checker.getSymbolAtLocation(name)
37
+ if (symbol == null) return
38
+ bindingsBySymbol.set(symbol, bindings.length)
39
+ bindings.push({name: name.text, category})
40
+ }
41
+
42
+ for (const statement of sourceFile.statements) {
43
+ if (ts.isVariableStatement(statement)) {
44
+ // `var` is outside the accepted subset, so its names never become module bindings;
45
+ // the statement itself is skipped when the initializer reaches it, and functions
46
+ // reading the name are rejected as unknown identifiers.
47
+ if ((statement.declarationList.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) === 0) continue
48
+ for (const declarator of statement.declarationList.declarations) {
49
+ if (ts.isIdentifier(declarator.name)) {
50
+ register(declarator.name, declaredCategory(declarator.name, checker))
51
+ continue
52
+ }
53
+ // `const {cols} = gridSize` at the top level: each destructured name is its own
54
+ // module binding, categorized by its element type like any declarator.
55
+ if (ts.isObjectBindingPattern(declarator.name)) {
56
+ for (const element of declarator.name.elements) {
57
+ if (ts.isIdentifier(element.name)) register(element.name, declaredCategory(element.name, checker))
58
+ }
59
+ }
60
+ }
61
+ continue
62
+ }
63
+ if (ts.isImportDeclaration(statement)) {
64
+ const clause = statement.importClause
65
+ if (clause == null || clause.isTypeOnly) continue
66
+ if (clause.name != null) register(clause.name, importedCategory(clause.name, checker))
67
+ const named = clause.namedBindings
68
+ if (named != null && ts.isNamedImports(named)) {
69
+ for (const element of named.elements) {
70
+ if (!element.isTypeOnly) register(element.name, importedCategory(element.name, checker))
71
+ }
72
+ }
73
+ // A namespace import reads as property accesses on the namespace object; no single
74
+ // constant value describes the binding, so it stays a plain import.
75
+ if (named != null && ts.isNamespaceImport(named)) register(named.name, {kind: 'import'})
76
+ }
77
+ }
78
+
79
+ // Demote bindings that functions write.
80
+ const visit = (node: ts.Node, insideFunction: boolean): void => {
81
+ if (insideFunction) demoteModuleWritesInNode(node, checker, bindingsBySymbol, bindings)
82
+ const enteringFunction = insideFunction || ts.isFunctionLike(node)
83
+ ts.forEachChild(node, child => { visit(child, enteringFunction) })
84
+ }
85
+ visit(sourceFile, false)
86
+ return {bindings, bindingsBySymbol}
87
+ }
88
+
89
+ // The category of one imported name. A named or default import whose target resolves to a
90
+ // const declarator with a plain numeric-literal initializer in a project .ts file, e.g.
91
+ // `export const INPUT_ROW_HEIGHT = 54` in a neighboring file, carries that exact value
92
+ // into this file. Everything else — `let` exports, computed initializers, .d.ts
93
+ // declarations, unresolved modules — stays a plain import whose reads stop.
94
+ //
95
+ // Soundness of trusting the literal WITHOUT analyzing the exporting module:
96
+ // - No rebinding. Assigning to a const throws a TypeError at runtime (module code is
97
+ // always strict), and a module binding is not a property of any reachable object, so
98
+ // no other code can alias-write it either. The binding holds the literal for the
99
+ // module's entire lifetime once initialized. (TypeScript separately flags writes to
100
+ // imports in the analyzed file, and the whole-file type gate already rejects those.)
101
+ // - No torn reads during module initialization. const bindings sit in the temporal dead
102
+ // zone until their declaration runs, so in an import cycle a read that beats the
103
+ // exporting declaration throws a ReferenceError rather than yielding undefined or a
104
+ // stale value. A throw ends the path: the module never finishes loading, so every
105
+ // claim about code past the read is vacuously true — the same argument the
106
+ // initializer-skip note in lowerModuleInitializer makes.
107
+ // - The exporting file's own analysis result (skipped statements, rejected functions,
108
+ // demoted bindings) cannot matter: the initializer IS the literal, so nothing that
109
+ // file computes feeds the value. An initializer beyond a literal (`export const
110
+ // ROW_HEIGHT_TOTAL = INPUT_ROW_HEIGHT + 8`) would depend on that file's module
111
+ // evaluation, which is exactly why the acceptance stops at literals.
112
+ // The remaining assumption, shared with the rest of the analyzer: the code runs under ES
113
+ // module semantics (or a transpilation that preserves const and live-binding behavior).
114
+ function importedCategory(name: ts.Identifier, checker: ts.TypeChecker): ModuleBindingCategory {
115
+ const symbol = checker.getSymbolAtLocation(name)
116
+ if (symbol == null || (symbol.flags & ts.SymbolFlags.Alias) === 0) return {kind: 'import'}
117
+ const target = checker.getAliasedSymbol(symbol)
118
+ const declaration = target.valueDeclaration
119
+ if (declaration == null || !ts.isVariableDeclaration(declaration)) return {kind: 'import'}
120
+ if ((ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) === 0) return {kind: 'import'}
121
+ if (declaration.getSourceFile().isDeclarationFile) return {kind: 'import'}
122
+ if (declaration.initializer == null) return {kind: 'import'}
123
+ const value = numericLiteralValue(declaration.initializer)
124
+ return value == null ? {kind: 'import'} : {kind: 'importedConstant', value}
125
+ }
126
+
127
+ // Demotes module bindings the given node itself writes (not its children's writes; the
128
+ // caller walks). Missing a write-position form would publish a stale value.
129
+ function demoteModuleWritesInNode(
130
+ node: ts.Node,
131
+ checker: ts.TypeChecker,
132
+ bindingsBySymbol: Map<ts.Symbol, ModuleBindingID>,
133
+ bindings: ModuleBindingIR[],
134
+ written?: boolean[],
135
+ ): void {
136
+ const record = (binding: ModuleBindingID): void => {
137
+ demote(bindings, binding)
138
+ if (written != null) written[binding] = true
139
+ }
140
+ const target = (expression: ts.Expression): void => {
141
+ // An assignment target can be a plain identifier or a destructuring pattern; every
142
+ // identifier inside a pattern is conservatively a write.
143
+ if (ts.isIdentifier(expression)) {
144
+ const symbol = checker.getSymbolAtLocation(expression)
145
+ const binding = symbol == null ? undefined : bindingsBySymbol.get(symbol)
146
+ if (binding != null) record(binding)
147
+ return
148
+ }
149
+ const visitPattern = (child: ts.Node): void => {
150
+ // The shorthand `x` in `({x} = source)` resolves to the contextual type's PROPERTY
151
+ // symbol via getSymbolAtLocation; the assigned variable needs the dedicated resolver.
152
+ if (ts.isShorthandPropertyAssignment(child)) {
153
+ const symbol = checker.getShorthandAssignmentValueSymbol(child)
154
+ const binding = symbol == null ? undefined : bindingsBySymbol.get(symbol)
155
+ if (binding != null) record(binding)
156
+ ts.forEachChild(child, visitPattern)
157
+ return
158
+ }
159
+ if (ts.isIdentifier(child)) {
160
+ const symbol = checker.getSymbolAtLocation(child)
161
+ const binding = symbol == null ? undefined : bindingsBySymbol.get(symbol)
162
+ if (binding != null) record(binding)
163
+ return
164
+ }
165
+ ts.forEachChild(child, visitPattern)
166
+ }
167
+ ts.forEachChild(expression, visitPattern)
168
+ }
169
+ if (ts.isBinaryExpression(node)) {
170
+ const kind = node.operatorToken.kind
171
+ if (kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment) target(node.left)
172
+ }
173
+ if (
174
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node))
175
+ && (node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken)
176
+ && ts.isExpression(node.operand)
177
+ ) {
178
+ target(node.operand)
179
+ }
180
+ if ((ts.isForOfStatement(node) || ts.isForInStatement(node)) && ts.isExpression(node.initializer)) {
181
+ target(node.initializer)
182
+ }
183
+ }
184
+
185
+ // Lowers the module's top-level runtime code into one synthetic function. A statement that
186
+ // cannot lower is skipped — rolled back, recorded as an InitializerSkip, its possible
187
+ // writes demoted and havocked — and lowering continues, so the initializer covers the
188
+ // whole file and ends with a plain return.
189
+ export function lowerModuleInitializer(
190
+ sourceFile: ts.SourceFile,
191
+ checker: ts.TypeChecker,
192
+ functionsBySymbol: Map<ts.Symbol, TopLevelFunction>,
193
+ scan: ModuleScan,
194
+ sites: SourceSpan[],
195
+ ): {initializer: FunctionIR; skips: InitializerSkip[]} {
196
+ const context = createFunctionContext(sourceFile, checker, functionsBySymbol, scan.bindingsBySymbol, sites)
197
+ const skips: InitializerSkip[] = []
198
+ const statements = sourceFile.statements
199
+ // Each statement gets its own catch: an unsupported one is skipped — its half-lowered
200
+ // instructions and blocks rolled back — every binding it could write is demoted, and the
201
+ // slots are havocked so later statements compute from covering values (owner-locked
202
+ // whole-file publish). Soundness of continuing past a skip: if the skipped statement
203
+ // throws or never returns at runtime, the module never finishes loading, so no exported
204
+ // function can be called and every claim about them is vacuously true.
205
+ for (const statement of statements) {
206
+ if (skippedAtTopLevel(statement)) continue
207
+ const recovery = snapshotLowering(context)
208
+ try {
209
+ assertAccepted(statement)
210
+ if (ts.isVariableStatement(statement)) {
211
+ lowerTopLevelDeclarations(statement, context, scan)
212
+ continue
213
+ }
214
+ lowerStatement(statement, context)
215
+ } catch (error) {
216
+ if (!(error instanceof LoweringStop)) throw error
217
+ restoreLowering(context, recovery)
218
+ lowerSupportedArgumentsOfSkippedTopLevelCall(statement, error, context)
219
+ skips.push({site: addSite(context, error.node), reason: error.reason})
220
+ // Demote what the statement writes directly, then reset every slot the statement
221
+ // could have changed — its own scalar targets and, when it can execute unknown
222
+ // code, scalars written by functions in this file. Without the reset, a later
223
+ // analyzed statement would compute from the stale pre-skip value and publish the
224
+ // result through a fresh binding that nothing demotes. Structural bindings (records,
225
+ // tuples, arrays — nullish-wrapped included) are additionally ALL havocked: a
226
+ // skipped statement can mutate one without any write-position mention of its
227
+ // binding — `Object.assign(config, overrides)` holds the binding in argument
228
+ // position, `scores.push(999)` in receiver position, and an alias variant mentions
229
+ // it nowhere — so no mention scan is sound for them. Scalars are copied on read.
230
+ // Reset one only when this statement writes it directly or can execute code that
231
+ // reaches one of the file's known scalar writes.
232
+ const effects = scanSkippedModuleEffects(
233
+ statement,
234
+ checker,
235
+ scan.bindingsBySymbol,
236
+ scan.bindings,
237
+ )
238
+ for (let binding = 0; binding < scan.bindings.length; binding++) {
239
+ const category = scan.bindings[binding]!.category
240
+ const declared = declaredKindOf(category)
241
+ if (effects.directWrites[binding] === true
242
+ || (category.kind === 'kind' && effects.invokesUnknownCode)
243
+ || (declared != null && holdsMutableStructure(declared))) {
244
+ addInstruction(context, statement, {kind: 'moduleHavoc', binding})
245
+ }
246
+ }
247
+ }
248
+ }
249
+ if (context.currentBlock.terminator == null) {
250
+ terminate(context.currentBlock, {kind: 'return', value: null, site: addSite(context, sourceFile)})
251
+ }
252
+ return {
253
+ initializer: {
254
+ kind: 'lowered',
255
+ name: moduleInitializerName,
256
+ assertions: [],
257
+ returnPropertyNames: null,
258
+ parameters: [],
259
+ entry: 0,
260
+ blocks: sealBlocks(context.blocks, moduleInitializerName),
261
+ },
262
+ skips,
263
+ }
264
+ }
265
+
266
+ function containsArrayLiteral(root: ts.Node): boolean {
267
+ if (ts.isArrayLiteralExpression(root)) return true
268
+ let found = false
269
+ ts.forEachChild(root, child => {
270
+ if (!found && containsArrayLiteral(child)) found = true
271
+ })
272
+ return found
273
+ }
274
+
275
+ // A function's body and parameter defaults run later. A computed object-method name runs
276
+ // while the surrounding object literal is built, so it remains part of the current walk.
277
+ function forEachImmediatelyEvaluatedChild(node: ts.Node, visit: (child: ts.Node) => void): void {
278
+ if (ts.isFunctionLike(node)) {
279
+ if (node.name != null && ts.isComputedPropertyName(node.name)) {
280
+ visit(node.name.expression)
281
+ }
282
+ return
283
+ }
284
+ ts.forEachChild(node, visit)
285
+ }
286
+
287
+ // A direct top-level call evaluates its arguments before invoking the callee. When the
288
+ // outer call is unsupported, keep each fully lowered argument up to the first unsupported
289
+ // one. The outer call remains an initializer skip, and later arguments are not retained
290
+ // because the unsupported argument may throw before JavaScript reaches them.
291
+ function lowerSupportedArgumentsOfSkippedTopLevelCall(
292
+ statement: ts.Statement,
293
+ stop: LoweringStop,
294
+ context: FunctionContext,
295
+ ): void {
296
+ if (!ts.isExpressionStatement(statement)) return
297
+ let expression = statement.expression
298
+ while (ts.isParenthesizedExpression(expression)) expression = expression.expression
299
+ if (!ts.isCallExpression(expression)
300
+ || expression !== stop.node
301
+ || expression.questionDotToken != null
302
+ || !plainCallTarget(expression.expression)) return
303
+ for (const argument of expression.arguments) {
304
+ const recovery = snapshotLowering(context)
305
+ try {
306
+ lowerExpression(argument, context)
307
+ } catch (error) {
308
+ if (!(error instanceof LoweringStop)) throw error
309
+ restoreLowering(context, recovery)
310
+ return
311
+ }
312
+ }
313
+ }
314
+
315
+ function plainCallTarget(expression: ts.Expression): boolean {
316
+ if (ts.isIdentifier(expression)) return true
317
+ if (ts.isParenthesizedExpression(expression)) return plainCallTarget(expression.expression)
318
+ return ts.isPropertyAccessExpression(expression)
319
+ && expression.questionDotToken == null
320
+ && plainCallTarget(expression.expression)
321
+ }
322
+
323
+ function lowerTopLevelDeclarations(statement: ts.VariableStatement, context: FunctionContext, scan: ModuleScan): void {
324
+ for (const declarator of statement.declarationList.declarations) {
325
+ if (declarator.initializer == null) throw new LoweringStop(declarator, {kind: 'variableDeclarationShape'})
326
+ // `const {cols} = gridSize`: one read of the source, one property read and module
327
+ // write per name — the same lowering destructuring gets inside functions, aimed at
328
+ // module slots.
329
+ if (ts.isObjectBindingPattern(declarator.name)) {
330
+ const source = lowerExpression(declarator.initializer, context)
331
+ for (const element of declarator.name.elements) {
332
+ if (!ts.isIdentifier(element.name) || element.dotDotDotToken != null || element.initializer != null) {
333
+ throw new LoweringStop(element, {kind: 'variableDeclarationShape'})
334
+ }
335
+ const property = element.propertyName == null
336
+ ? element.name.text
337
+ : ts.isIdentifier(element.propertyName) ? element.propertyName.text : null
338
+ if (property == null) throw new LoweringStop(element, {kind: 'variableDeclarationShape'})
339
+ const elementType = context.checker.getTypeAtLocation(element.name)
340
+ if (valueKind(elementType, context.checker) == null) {
341
+ throw new LoweringStop(element, {kind: 'valueType', typeText: context.checker.typeToString(elementType)})
342
+ }
343
+ const symbol = context.checker.getSymbolAtLocation(element.name)
344
+ const binding = symbol == null ? undefined : scan.bindingsBySymbol.get(symbol)
345
+ if (binding == null) throw new LoweringStop(element, {kind: 'variableDeclarationShape'})
346
+ const value = addInstruction(context, element, {kind: 'property', object: source, property})
347
+ addInstruction(context, element, {kind: 'moduleWrite', binding, value})
348
+ }
349
+ continue
350
+ }
351
+ if (!ts.isIdentifier(declarator.name)) {
352
+ throw new LoweringStop(declarator, {kind: 'variableDeclarationShape'})
353
+ }
354
+ const symbol = context.checker.getSymbolAtLocation(declarator.name)
355
+ const binding = symbol == null ? undefined : scan.bindingsBySymbol.get(symbol)
356
+ if (binding == null) throw new LoweringStop(declarator, {kind: 'variableDeclarationShape'})
357
+ const value = lowerExpression(declarator.initializer, context)
358
+ addInstruction(context, declarator, {kind: 'moduleWrite', binding, value})
359
+ }
360
+ }
361
+
362
+ function skippedAtTopLevel(statement: ts.Statement): boolean {
363
+ // `export {alreadyDeclaredName}` and import declarations create bindings but run nothing.
364
+ // Only NAMED function declarations pass: those become program.functions entries, so
365
+ // unsupported code inside them keeps the fully-analyzed publish gate honest. An
366
+ // anonymous `export default function` has no name to collect under, so it falls through
367
+ // to ordinary statement lowering, which records it as an initializer skip — otherwise
368
+ // its body would be runtime code invisible to every gate.
369
+ return (ts.isFunctionDeclaration(statement) && statement.name != null)
370
+ || ts.isImportDeclaration(statement)
371
+ || ts.isTypeAliasDeclaration(statement)
372
+ || ts.isInterfaceDeclaration(statement)
373
+ || ts.isExportDeclaration(statement)
374
+ }
375
+
376
+ function scanSkippedModuleEffects(
377
+ root: ts.Node,
378
+ checker: ts.TypeChecker,
379
+ bindingsBySymbol: Map<ts.Symbol, ModuleBindingID>,
380
+ bindings: ModuleBindingIR[],
381
+ ): {directWrites: boolean[]; invokesUnknownCode: boolean} {
382
+ const directWrites: boolean[] = []
383
+ let invokesUnknownCode = false
384
+ const visit = (node: ts.Node): void => {
385
+ demoteModuleWritesInNode(node, checker, bindingsBySymbol, bindings, directWrites)
386
+ // Property reads and primitive operators are absent on purpose: the accepted object
387
+ // model already excludes getters, Proxies, and custom coercion. `using` stays
388
+ // conservative; Freerange does not model end-of-scope disposal.
389
+ invokesUnknownCode ||= ts.isCallExpression(node)
390
+ || ts.isNewExpression(node)
391
+ || ts.isTaggedTemplateExpression(node)
392
+ || ts.isAwaitExpression(node)
393
+ || ts.isYieldExpression(node)
394
+ || ts.isForOfStatement(node)
395
+ || ts.isSpreadElement(node)
396
+ || ts.isArrayBindingPattern(node)
397
+ || (ts.isVariableDeclarationList(node) && (node.flags & ts.NodeFlags.Using) !== 0)
398
+ || (ts.isBinaryExpression(node)
399
+ && (node.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword
400
+ || (node.operatorToken.kind === ts.SyntaxKind.EqualsToken
401
+ && containsArrayLiteral(node.left))))
402
+ || ts.isJsxElement(node)
403
+ || ts.isJsxSelfClosingElement(node)
404
+ || ts.isJsxFragment(node)
405
+ || ts.isClassDeclaration(node)
406
+ || ts.isClassExpression(node)
407
+ // A never-lowered declarator counts as a write to its own binding.
408
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
409
+ const symbol = checker.getSymbolAtLocation(node.name)
410
+ const binding = symbol == null ? undefined : bindingsBySymbol.get(symbol)
411
+ if (binding != null) {
412
+ demote(bindings, binding)
413
+ directWrites[binding] = true
414
+ }
415
+ }
416
+ forEachImmediatelyEvaluatedChild(node, visit)
417
+ }
418
+ visit(root)
419
+ return {directWrites, invokesUnknownCode}
420
+ }
421
+
422
+ function declaredCategory(name: ts.Identifier, checker: ts.TypeChecker): ModuleBindingCategory {
423
+ const declared = declaredKind(checker.getTypeAtLocation(name), checker, [])
424
+ return declared == null ? {kind: 'opaque'} : {kind: 'value', declaredKind: declared}
425
+ }
426
+
427
+ // The properties of one record type. A property whose type cannot be represented becomes
428
+ // opaque so the record can keep claims about its supported properties. An empty property
429
+ // set is rejected: `{}` and index-signature-only types have no named values to track.
430
+ function declaredRecordProperties(
431
+ type: ts.Type,
432
+ checker: ts.TypeChecker,
433
+ seen: ts.Type[],
434
+ ): Array<{name: string; declared: DeclaredKind}> | null {
435
+ if (cutByAncestor(seen, type)) return null
436
+ const properties: Array<{name: string; declared: DeclaredKind}> = []
437
+ for (const property of checker.getPropertiesOfType(type)) {
438
+ const optional = (property.flags & ts.SymbolFlags.Optional) !== 0
439
+ const walked = declaredKind(
440
+ checker.getTypeOfSymbol(property),
441
+ checker,
442
+ [...seen, type],
443
+ )
444
+ // A property the walk cannot classify — a recursive route, a mixed-literal union, a
445
+ // DOM element — becomes an opaque leaf instead of vetoing the whole record: the value
446
+ // is carried without claims, and a read that needs more than carrying is gated at the
447
+ // read position (numeric use rejects at lowering; a modeled-kind read of the
448
+ // unclassified value stops at the kind-mismatch backstop). The record's NUMERIC
449
+ // contract survives its weird neighbors. Properties the project did not write —
450
+ // inherited from a lib interface the project type extends — are boundary leaves for
451
+ // the same reason whole lib types are: without this, `interface SizedElement extends
452
+ // HTMLElement` floods the report with assumes lines about clientWidth and friends.
453
+ const opaqueLeaf: DeclaredKind = {kind: 'opaque'}
454
+ const propertyDeclared = declaredOnlyInDeclarationFiles(property) ? opaqueLeaf : (walked ?? opaqueLeaf)
455
+ // `session?: boolean` reads as boolean | undefined, which is exactly what the missing-
456
+ // value machinery models. The analysis deliberately represents absence and explicit
457
+ // undefined alike: ordinary reads cannot distinguish them, and supported `in` checks
458
+ // treat an optional as unknown presence. Object literals fill omitted optionals with
459
+ // an explicit undefined value so joins keep the property.
460
+ properties.push({
461
+ name: property.name,
462
+ declared: optional ? wrapOptional(propertyDeclared) : propertyDeclared,
463
+ })
464
+ }
465
+ if (properties.length === 0) return null
466
+ return properties
467
+ }
468
+
469
+ // The declared kind of an optional property: its type with the undefined sentinel added.
470
+ // An already-nullable type gains the sentinel (folded into 'both' when null was there);
471
+ // everything else wraps.
472
+ function wrapOptional(declared: DeclaredKind): DeclaredKind {
473
+ if (declared.kind === 'nullish') {
474
+ return {
475
+ kind: 'nullish',
476
+ inner: declared.inner,
477
+ sentinels: declared.sentinels === 'null' || declared.sentinels === 'both' ? 'both' : 'undefined',
478
+ }
479
+ }
480
+ return {kind: 'nullish', inner: declared, sentinels: 'undefined'}
481
+ }
482
+
483
+ // One union member as variants: its values for the union's tag property plus its record
484
+ // walk. A member whose tag is a single literal gives one variant; a tag written as a
485
+ // union of literals (`type: 'desktopCollapsedNav' | 'desktopExpandedNav'`, or a plain
486
+ // boolean — the checker's `true | false`) expands into one variant per literal, all
487
+ // sharing the member's record shape, so the check machinery only ever sees single-literal
488
+ // tags. The expansion is bounded by the literals the author wrote. The tag rides along
489
+ // inside each variant's record as an ordinary leaf; the union structure carries which
490
+ // value it is.
491
+ function declaredTaggedVariants(
492
+ member: ts.Type,
493
+ tagProperty: string,
494
+ checker: ts.TypeChecker,
495
+ seen: ts.Type[],
496
+ ): DeclaredVariant[] | null {
497
+ const tag = checker.getPropertyOfType(member, tagProperty)
498
+ if (tag == null) return null
499
+ const literals = tagLiteralValues(checker.getTypeOfSymbol(tag))
500
+ if (literals == null) return null
501
+ const properties = declaredRecordProperties(member, checker, seen)
502
+ if (properties == null) return null
503
+ return literals.map(tagValue => ({tagValue, properties}))
504
+ }
505
+
506
+ // The classification walk is pure over the type, and the checker interns types, so one
507
+ // walk per (type, remaining depth) suffices. The type graph is a DAG with heavy sharing,
508
+ // and the walk previously ran once per PATH — exponential in the depth cap; a profile
509
+ // caught 35 million property resolutions over ~216 distinct types in one file, all of
510
+ // lowering's residual cost. Cached nulls matter as much as hits: rejection walks repeat
511
+ // too.
512
+ //
513
+ // Two disciplines make a memoized answer bit-identical to the walk it replaces. Depth is
514
+ // part of the key, because the cap makes deep results budget-dependent. A nested result is
515
+ // stored only when its walk never cut against an IN-PROGRESS ancestor (seen.includes) —
516
+ // such a cut can make the result depend on which path reached it. A top-level walk starts
517
+ // with no ancestors, so its answer is context-free even when the walk later encounters a
518
+ // cycle and may always be stored. Depth-cap cuts are deterministic per depth too.
519
+ const declaredKindByDepth = new WeakMap<ts.Type, Array<DeclaredKind | null>>()
520
+ // Bumped at every in-progress-ancestor cut; a walk whose subtree bumped it is not stored.
521
+ let ancestorCuts = 0
522
+
523
+ function cutByAncestor(seen: ts.Type[], type: ts.Type): boolean {
524
+ if (seen.length >= 8) return true
525
+ if (seen.includes(type)) {
526
+ ancestorCuts += 1
527
+ return true
528
+ }
529
+ return false
530
+ }
531
+
532
+ export function declaredKind(type: ts.Type, checker: ts.TypeChecker, seen: ts.Type[]): DeclaredKind | null {
533
+ const depth = seen.length
534
+ let byDepth = declaredKindByDepth.get(type)
535
+ if (byDepth == null) {
536
+ byDepth = []
537
+ declaredKindByDepth.set(type, byDepth)
538
+ }
539
+ const cached = byDepth[depth]
540
+ if (cached !== undefined) return cached
541
+ const cutsBefore = ancestorCuts
542
+ const walked = declaredKindUncached(type, checker, seen)
543
+ if (depth === 0 || ancestorCuts === cutsBefore) byDepth[depth] = walked
544
+ return walked
545
+ }
546
+
547
+ function declaredKindUncached(type: ts.Type, checker: ts.TypeChecker, seen: ts.Type[]): DeclaredKind | null {
548
+ switch (valueKind(type, checker)) {
549
+ case 'number': {
550
+ const interval = numericLiteralInterval(type)
551
+ return interval === 'nonFinite' ? null : {kind: 'number', interval}
552
+ }
553
+ case 'boolean': return {kind: 'boolean'}
554
+ // `number | null` and friends: the declared kind wraps the non-missing part, keeping
555
+ // which sentinels the type admits for seeding and report prose.
556
+ case 'nullable': {
557
+ if (!type.isUnion()) return null
558
+ const rest = nonMissingUnionMembers(type)
559
+ if (rest.length === 0) return null
560
+ // A nullable wrapper can hide the recursive edge from the exact-type ancestor
561
+ // check. Cut before expanding the same record or tagged union again.
562
+ if (rest.some(member => seen.includes(member))) {
563
+ ancestorCuts += 1
564
+ return null
565
+ }
566
+ let inner: DeclaredKind | null
567
+ if (rest.length === 1) {
568
+ inner = declaredKind(rest[0]!, checker, seen)
569
+ } else {
570
+ // `'compact' | 'wide' | undefined`, `4 | 8 | undefined`, `boolean | null` (the
571
+ // checker splits boolean into true | false): several non-missing members are
572
+ // fine when they collapse to one scalar kind, the same rule valueKind applies
573
+ // to the bare union. Structural members keep the exactly-one rule — two record
574
+ // shapes under a nullish wrapper are a tagged union, not a nullable record.
575
+ const members = rest.map(member => declaredKind(member, checker, seen))
576
+ inner = joinScalarDeclaredKinds(members)
577
+ // `owner: null | LightboxOwnerRoute` where the inner is itself a union of tagged
578
+ // shapes: the non-missing members classify as one tagged union, and maybeNullish
579
+ // carries it like any other inner.
580
+ const restTagProperty = inner == null ? taggedUnionProperty(rest, checker) : null
581
+ if (restTagProperty != null) {
582
+ const unionVariants: DeclaredVariant[] = []
583
+ let allClassified = true
584
+ for (const member of rest) {
585
+ const variants = declaredTaggedVariants(member, restTagProperty, checker, seen)
586
+ if (variants == null) {
587
+ allClassified = false
588
+ break
589
+ }
590
+ unionVariants.push(...variants)
591
+ }
592
+ const [firstVariant, ...restVariants] = unionVariants
593
+ if (allClassified && firstVariant != null) {
594
+ inner = {kind: 'taggedUnion', tagProperty: restTagProperty, variants: [firstVariant, ...restVariants]}
595
+ }
596
+ }
597
+ }
598
+ if (inner == null) return null
599
+ const admitsNull = type.types.some(member => (member.flags & ts.TypeFlags.Null) !== 0)
600
+ const admitsUndefined = type.types.some(member => (member.flags & ts.TypeFlags.Undefined) !== 0)
601
+ return {kind: 'nullish', inner, sentinels: admitsNull && admitsUndefined ? 'both' : admitsNull ? 'null' : 'undefined'}
602
+ }
603
+ // Strings and other claim-free kinds are carried, not rejected: a record with an id
604
+ // keeps its numeric contract.
605
+ case 'opaque':
606
+ return {kind: 'opaque'}
607
+ case 'array': {
608
+ const element = checker.getIndexTypeOfType(type, ts.IndexKind.Number)
609
+ if (element == null) return null
610
+ const elementKind = declaredKind(element, checker, [...seen, type])
611
+ return elementKind == null ? null : {kind: 'array', element: elementKind}
612
+ }
613
+ case 'tuple': {
614
+ if (cutByAncestor(seen, type)) return null
615
+ // The tuple target's elementFlags say what each written position is: required,
616
+ // optional ([number, number?]), or a rest element ([number, ...number[]]). The type
617
+ // ARGUMENTS alone cannot: an optional slot and a rest slot each contribute one
618
+ // argument, so counting arguments read both examples as fixed pairs — the engine
619
+ // seeded .length as exactly 2 and published 'return is a finite integer number
620
+ // from 2 through 2' for a bare pair.length return, which the LEGAL cast-free
621
+ // caller passing [5] falsifies with every printed assumes line holding. Only
622
+ // all-required tuples keep the exact positional model; a tuple with any optional,
623
+ // rest, or variadic position leaves the classified subset (owner decision: reject
624
+ // rather than model an arity range — no measured corpus function uses the shapes,
625
+ // and widening later is cheap).
626
+ if (tupleHasOptionalOrRestPositions(type, checker)) return null
627
+ const elements: DeclaredKind[] = []
628
+ for (const elementType of checker.getTypeArguments(type as ts.TypeReference)) {
629
+ const element = declaredKind(elementType, checker, [...seen, type])
630
+ if (element == null) return null
631
+ elements.push(element)
632
+ }
633
+ if (elements.length === 0) return null
634
+ return {kind: 'tuple', elements}
635
+ }
636
+ case 'object': {
637
+ // A record type the PROJECT did not write — HTMLDivElement, a library's config
638
+ // interface, anything declared only in .d.ts files — is carried as an opaque leaf,
639
+ // not contracted: walking a DOM interface would flood the report with hundreds of
640
+ // assumes lines about properties nobody reads, and the project cannot uphold
641
+ // contracts on shapes it does not own. (Math and friends never reach here — value
642
+ // reads of them are gated elsewhere.)
643
+ if (declaredOnlyInDeclarationFiles(type.getSymbol() ?? type.aliasSymbol)) return {kind: 'opaque'}
644
+ // A recursive property becomes opaque. The ancestor check catches direct recursion;
645
+ // the depth cap catches recursive generics, whose every level is a fresh
646
+ // instantiation that exact type identity cannot recognize.
647
+ const properties = declaredRecordProperties(type, checker, seen)
648
+ return properties == null ? null : {kind: 'record', properties}
649
+ }
650
+ case 'taggedUnion': {
651
+ if (!type.isUnion()) return null
652
+ const tagProperty = taggedUnionProperty(type.types, checker)
653
+ if (tagProperty == null) return null
654
+ const variants: DeclaredVariant[] = []
655
+ for (const member of type.types) {
656
+ const memberVariants = declaredTaggedVariants(member, tagProperty, checker, seen)
657
+ if (memberVariants == null) return null
658
+ variants.push(...memberVariants)
659
+ }
660
+ const [firstVariant, ...restVariants] = variants
661
+ if (firstVariant == null) return null
662
+ return {kind: 'taggedUnion', tagProperty, variants: [firstVariant, ...restVariants]}
663
+ }
664
+ case null: return null
665
+ }
666
+ }
667
+
668
+ function numericLiteralInterval(type: ts.Type): DeclaredNumberInterval | 'nonFinite' | null {
669
+ const members = type.isUnion() ? type.types : [type]
670
+ if (!members.every(member =>
671
+ (member.flags & ts.TypeFlags.NumberLiteral) !== 0
672
+ && (member.flags & ts.TypeFlags.EnumLiteral) === 0)) return null
673
+ let lower = Infinity
674
+ let upper = -Infinity
675
+ let integer = true
676
+ for (const member of members) {
677
+ const value = (member as ts.NumberLiteralType).value
678
+ if (!Number.isFinite(value)) return 'nonFinite'
679
+ lower = Math.min(lower, value)
680
+ upper = Math.max(upper, value)
681
+ integer = integer && Number.isInteger(value)
682
+ }
683
+ return members.length === 0 ? null : {lower, upper, integer}
684
+ }
685
+
686
+ function joinScalarDeclaredKinds(members: Array<DeclaredKind | null>): DeclaredKind | null {
687
+ const first = members[0]
688
+ if (first == null) return null
689
+ switch (first.kind) {
690
+ case 'number': {
691
+ let interval = first.interval
692
+ for (let index = 1; index < members.length; index++) {
693
+ const member = members[index]
694
+ if (member?.kind !== 'number') return null
695
+ if (interval == null || member.interval == null) {
696
+ interval = null
697
+ } else {
698
+ interval = {
699
+ lower: Math.min(interval.lower, member.interval.lower),
700
+ upper: Math.max(interval.upper, member.interval.upper),
701
+ integer: interval.integer && member.interval.integer,
702
+ }
703
+ }
704
+ }
705
+ return {kind: 'number', interval}
706
+ }
707
+ case 'boolean': return members.every(member => member?.kind === 'boolean') ? first : null
708
+ case 'opaque': return members.every(member => member?.kind === 'opaque') ? first : null
709
+ case 'record':
710
+ case 'nullish':
711
+ case 'tuple':
712
+ case 'array':
713
+ case 'taggedUnion': return null
714
+ }
715
+ }
716
+
717
+ // Whether a tuple type has a position that is not plainly required: optional
718
+ // ([number, number?]), rest ([number, ...number[]]), or an unresolved generic spread
719
+ // (TypeScript's Variadic flag). Such a tuple's runtime length is a range rather than the
720
+ // written position count, so the declared-kind classification leaves the type out; the
721
+ // parameter rejection calls this to attach the rewrite hint to its message.
722
+ export function tupleHasOptionalOrRestPositions(type: ts.Type, checker: ts.TypeChecker): boolean {
723
+ if (!checker.isTupleType(type)) return false
724
+ return (type as ts.TupleTypeReference).target.elementFlags.some(flags => (flags & ts.ElementFlags.Required) === 0)
725
+ }
726
+
727
+ // A binding with an unaccounted write cannot publish its value: it keeps only its declared
728
+ // kind — some finite number, some boolean, some record of the declared shape.
729
+ function demote(bindings: ModuleBindingIR[], binding: ModuleBindingID): void {
730
+ const category = bindings[binding]!.category
731
+ switch (category.kind) {
732
+ case 'value': {
733
+ bindings[binding]!.category = {kind: 'kind', declaredKind: category.declaredKind}
734
+ break
735
+ }
736
+ // A write to an import is a type error the whole-file gate rejects, so this arm should
737
+ // be unreachable — but a demoted constant must never keep publishing its value, so it
738
+ // falls back to a plain import whose reads stop.
739
+ case 'importedConstant': {
740
+ bindings[binding]!.category = {kind: 'import'}
741
+ break
742
+ }
743
+ case 'kind':
744
+ case 'import':
745
+ case 'opaque':
746
+ break
747
+ }
748
+ }