@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,446 @@
1
+ import {relative} from 'node:path'
2
+ import type * as ts from 'typescript'
3
+ import {finiteInputNumber, unknownNumber, type AbstractNumber} from '../domain/number.ts'
4
+ import {recordValue, unknownBoolean, type AbstractValue, type TaggedVariant} from '../domain/value.ts'
5
+ import type {BlockID, SiteID, ValueID} from './ids.ts'
6
+ import type {InstructionIR, TerminatorIR} from './instructions.ts'
7
+
8
+ export type ParameterIR = {
9
+ value: ValueID
10
+ name: string
11
+ type: DeclaredKind
12
+ site: SiteID
13
+ // Destructured parameters still receive one runtime argument. Direct bindings let
14
+ // reports use the local names the author wrote instead of printing the pattern as an
15
+ // expression. Null distinguishes an ordinary named parameter.
16
+ bindings: Array<{property: string; local: string}> | null
17
+ }
18
+
19
+ // Parameters share the module bindings' declared-kind language: one recursive
20
+ // classification covers numbers, booleans, records, nullable wrappers, arrays, tuples,
21
+ // and opaque (string) leaves — a label or id in a parameter record no longer rejects the
22
+ // function around it.
23
+
24
+ // UTF-16 offsets into the analyzed source, from ts.Node.getStart/getEnd. Line and column
25
+ // are computed only at message-formatting time. Spans may repeat across sites (the constant 1
26
+ // and the add that `count++` lowers to share a span); identity is the SiteID, never the span.
27
+ export type SourceSpan = {
28
+ start: number
29
+ end: number
30
+ }
31
+
32
+ export type BlockIR = {
33
+ // Non-null exactly on loop headers. The site spans the whole loop statement, so a
34
+ // non-converging analysis is reported on the loop, not on a back-edge jump.
35
+ loopHeader: SiteID | null
36
+ parameters: ValueID[]
37
+ instructions: InstructionIR[]
38
+ terminator: TerminatorIR
39
+ }
40
+
41
+ export type StaticAssertionIR = {site: SiteID; text: string}
42
+
43
+ export type FunctionIR = {
44
+ kind: 'lowered'
45
+ name: string
46
+ assertions: StaticAssertionIR[]
47
+ parameters: ParameterIR[]
48
+ // Property names of the declared return type when it is a record, else null. Reports
49
+ // omit wider runtime properties that a type-checked caller cannot read.
50
+ returnPropertyNames: string[] | null
51
+ entry: BlockID
52
+ blocks: BlockIR[]
53
+ }
54
+
55
+ export type StaticAssertionProblem =
56
+ | 'argumentCount'
57
+ | 'position'
58
+ | 'optionalCall'
59
+ | 'directCheck'
60
+ | 'bindValueFirst'
61
+ | 'functionCall'
62
+ | 'callerRequirement'
63
+
64
+ // Why one function's lowering stopped. String fields are display data (identifier text,
65
+ // operator text, checker.typeToString results captured while the checker is alive). Code
66
+ // that needs a decision uses a separate boolean or tagged field. Prose is composed only in
67
+ // src/report; nothing may branch on display text.
68
+ export type UnsupportedReason =
69
+ // An identifier with no lowered binding: module-level state, globals, captured outer
70
+ // locals. E.g. reading a module-level `let` inside a function.
71
+ | {kind: 'unknownIdentifier'; name: string}
72
+ // The checker returned no symbol for a node that needs one (identifier expressions,
73
+ // shorthand object properties). Believed unreachable after the whole-file type gate, but
74
+ // user source is a shaky boundary, so the case is recorded rather than crashed on.
75
+ | {kind: 'missingSymbol'}
76
+ | {kind: 'functionWithoutSignature'}
77
+ // Overload signatures and ambient declarations have no body to lower.
78
+ | {kind: 'functionWithoutBody'}
79
+ | {kind: 'destructuredParameter'}
80
+
81
+ // optionalOrRestTuple marks a parameter type that failed classification because it is a
82
+ // tuple with an optional or rest position ([number, number?], [number, ...number[]]) —
83
+ // the runtime length is a range the exact positional model cannot carry, and the
84
+ // message names the rewrite toward number[] or a fixed tuple.
85
+ | {kind: 'parameterType'; typeText: string; optionalOrRestTuple: boolean}
86
+ // A default value outside the represented literal subset. Literal defaults are applied
87
+ // exactly; a computed or non-finite initializer could falsify parameter assumptions or
88
+ // hide unsupported behavior, so the declaration rejects.
89
+ | {kind: 'parameterDefaultValue'; name: string}
90
+ // A non-void function has a path that falls off the end without returning.
91
+ | {kind: 'missingReturn'}
92
+ // Method or accessor in an object literal.
93
+ | {kind: 'objectPropertyForm'}
94
+ | {kind: 'computedPropertyName'}
95
+ | {kind: 'objectSpread'}
96
+ | {kind: 'asyncOrGeneratorFunction'}
97
+ | {kind: 'typePredicate'}
98
+ | {kind: 'protoProperty'}
99
+ | {kind: 'enumMemberRead'}
100
+ // point.toString and friends: a prototype member the record value cannot answer.
101
+ | {kind: 'prototypeMemberRead'; property: string}
102
+ // e.g. '**', '>>', 'instanceof' in value position — the operators arithmetic and
103
+ // comparison lowering do not claim ('%' and '??' lower now and no longer land here)
104
+ | {kind: 'binaryOperator'; operator: string}
105
+ // Callee is neither a top-level function in this file nor supported Math. `callee` is a
106
+ // short display name — an identifier, dotted pair, or (…).method for computed receivers.
107
+ // arrayMethod classifies a method called on an array value. Only reduce has a checked
108
+ // guide; every other method stays distinct from a general unknown call without routing
109
+ // on the display-only callee text.
110
+ | {kind: 'call'; callee: string; arrayMethod?: 'reduce' | 'other'}
111
+ // A call omitting a required parameter, or a parameter whose default initializer is
112
+ // outside the supported literal subset. Supported optional and literal-defaulted
113
+ // parameters are filled before the call instruction is emitted.
114
+ | {kind: 'callWithFewerArguments'; callee: string}
115
+ // An overload accepts more arguments than its implementation names. JavaScript still
116
+ // evaluates them, but the fixed-arity call model has no corresponding parameters.
117
+ | {kind: 'callWithMoreArguments'; callee: string}
118
+ // A position that must hold a number (operand, supported Math argument) typed otherwise,
119
+ // e.g. the left side of `events.keydown == null` with type KeyboardEvent | null. The site
120
+ // points at the exact operand, so no role tag is needed.
121
+ | {kind: 'nonNumberOperand'; typeText: string}
122
+ // A branch condition whose type is not boolean, e.g. `if (width)` truthiness on a number.
123
+ | {kind: 'nonBooleanCondition'; conditionKind: 'number' | 'other'; typeText: string}
124
+ // The acceptance rules (see current-decisions.md): `var` hoists, so one variable can
125
+ // have several declaration sites and the binding model does not apply.
126
+ | {kind: 'varDeclaration'}
127
+ // The identifier `eval` appears somewhere in the file. An eval string can rewrite any
128
+ // binding in the file at runtime, so every function in the file carries this reason —
129
+ // rejecting only the function containing the call would not protect the others' reports.
130
+ | {kind: 'evalInFile'}
131
+ // A `@ts-ignore`, `@ts-expect-error`, or `@ts-nocheck` comment appears somewhere in the
132
+ // file. The directive turns off type checking, and every guarantee is built on the
133
+ // checker's word, so the whole file is rejected like the eval case above.
134
+ | {kind: 'typeCheckSuppressed'}
135
+ // A value position whose type mixes kinds or is outside numbers, booleans, and objects —
136
+ // e.g. a ternary with one number arm and one boolean arm, a string return type, or a
137
+ // variable declared `let u: unknown` and reassigned across kinds. Left ungated, mixed
138
+ // kinds would meet at a join deep in the engine instead of stopping here.
139
+ | {kind: 'valueType'; typeText: string}
140
+ // A non-null assertion that changes the value kind, e.g. `x!` with `x: number | null`.
141
+ // Past the assertion, the static type stops describing the value the analysis models.
142
+ // (`as` and angle-bracket assertions erase to a claim-free opaque instead, so only `!`
143
+ // reaches this reason.)
144
+ | {kind: 'kindChangingAssertion'; fromText: string; toText: string}
145
+ | {kind: 'propertyReadOnNonObject'; typeText: string}
146
+ | {kind: 'statementAfterReturn'}
147
+ // An assignment used as a value inside a larger expression, e.g. `cond ? (x = 1) : 2` or
148
+ // `a = b = 5`. Assignments lower only in statement position; write it as its own
149
+ // statement.
150
+ | {kind: 'assignmentInValuePosition'}
151
+ // A write into an object, e.g. `config.pos = 1` or `count.total += n`. Values are
152
+ // immutable after construction (owner-locked): update state by rebinding a variable to a
153
+ // fresh object with explicit fields, e.g. `config = {pos: 1, dest: config.dest}`.
154
+ | {kind: 'propertyWrite'}
155
+ // A direct global console.assert spelling expressed static intent, but its arguments,
156
+ // position, optional form, or condition is outside the accepted grammar.
157
+ | {kind: 'staticAssertionForm'; problem: StaticAssertionProblem}
158
+ | {kind: 'forLoopWithoutCondition'}
159
+ // Destructuring pattern or a declaration without an initializer.
160
+ | {kind: 'variableDeclarationShape'}
161
+ // Catch-alls carry the ts.SyntaxKind name, e.g. 'FalseKeyword', 'WhileStatement'.
162
+ | {kind: 'expressionForm'; syntax: string}
163
+ | {kind: 'statementForm'; syntax: string}
164
+ // Switch is supported without fallthrough (owner decision): every non-empty case body
165
+ // must end in break or return, stacked empty labels share the next body, default comes
166
+ // last. These three name the rejections that remain.
167
+ | {kind: 'switchFallthrough'}
168
+ | {kind: 'switchDefaultNotLast'}
169
+ | {kind: 'switchSubject'; typeText: string}
170
+ | {kind: 'switchLabel'; typeText: string}
171
+
172
+ // A function whose lowering stopped. The half-built CFG is discarded wholesale so nothing
173
+ // downstream can mistake this record for analyzable IR. Sites already pushed while lowering
174
+ // the discarded blocks stay in ProgramIR.sites; do not roll the array back — that would
175
+ // invalidate the SiteID recorded here.
176
+ export type UnsupportedFunctionIR = {
177
+ kind: 'unsupported'
178
+ name: string
179
+ hasStaticAnnotations: boolean
180
+ site: SiteID
181
+ reason: UnsupportedReason
182
+ }
183
+
184
+ export type FunctionLowering = FunctionIR | UnsupportedFunctionIR
185
+
186
+ // What a binding's declared type promises in each value position: a number, a boolean, or
187
+ // a record with a fixed property shape (shapes nest — module state is a tree of records).
188
+ // The promise is an assumption, not a guarantee: TypeScript accepts an `any`-typed value in
189
+ // any write position, so the report prints a condition for every read that rests on one.
190
+ export type DeclaredVariant = {tagValue: string | boolean; properties: Array<{name: string; declared: DeclaredKind}>}
191
+
192
+ export type DeclaredNumberInterval = {
193
+ lower: number
194
+ upper: number
195
+ integer: boolean
196
+ }
197
+
198
+ export type DeclaredKind =
199
+ | {kind: 'number'; interval: DeclaredNumberInterval | null}
200
+ | {kind: 'boolean'}
201
+ | {kind: 'record'; properties: Array<{name: string; declared: DeclaredKind}>}
202
+ | {kind: 'nullish'; inner: DeclaredKind; sentinels: 'null' | 'undefined' | 'both'}
203
+ // A tuple type: fixed length, one declared kind per position — the module config table
204
+ // `const gapSizes = [4, 8, 24] as const` publishes like a record.
205
+ | {kind: 'tuple'; elements: DeclaredKind[]}
206
+ // A homogeneous array of the element's kind.
207
+ | {kind: 'array'; element: DeclaredKind}
208
+ // A kind the analysis carries without claims — strings. Present so a record with an id
209
+ // or label keeps its numeric contract instead of rejecting wholesale.
210
+ | {kind: 'opaque'}
211
+ // One of several record shapes told apart by a shared string-literal property
212
+ // (route.type is 'explore' or 'lightbox'). The variant list is written in the type;
213
+ // analysis only ever removes variants. The tag rides inside each variant's properties
214
+ // as an ordinary opaque leaf; tagProperty and tagValue carry which one it is.
215
+ | {kind: 'taggedUnion'; tagProperty: string; variants: [DeclaredVariant, ...DeclaredVariant[]]}
216
+
217
+ // What a function may assume about a module-level binding, decided once by a whole-file
218
+ // scan before any lowering. The rule: trust a value only when every possible write to it
219
+ // is accounted for. A const collapses into the no-outside-write check, since TypeScript
220
+ // already rejects assigning a const anywhere.
221
+ export type ModuleBindingCategory =
222
+ // A binding of a representable declared kind that nothing outside the initializer
223
+ // writes. Its initialized value flows into every function, e.g. `const boxesGapX = 24`
224
+ // reads as 24 and `const gaps = {small: 4, large: 24}` reads as that exact record.
225
+ | {kind: 'value'; declaredKind: DeclaredKind}
226
+ // A binding of a representable declared kind that some function writes. Functions see
227
+ // only the declared kind — some finite number, some boolean, some record of the declared
228
+ // shape — and the report prints that as an assumption.
229
+ | {kind: 'kind'; declaredKind: DeclaredKind}
230
+ // An imported binding. Single-file analysis knows nothing about the other module.
231
+ | {kind: 'import'}
232
+ // An imported binding whose target declaration is a const with a plain numeric-literal
233
+ // initializer in a project .ts file, e.g. `export const INPUT_ROW_HEIGHT = 54`. The
234
+ // literal is trusted exactly, without analyzing the exporting module; the soundness
235
+ // argument sits on importedCategory in src/lower/module.ts.
236
+ | {kind: 'importedConstant'; value: number}
237
+ // Every other declared type (unions with null, arrays, strings, functions, records with
238
+ // optional or unrepresentable properties). Reads stop.
239
+ | {kind: 'opaque'}
240
+
241
+ // The declared kind a binding contributes when its exact value is unpublished — the single
242
+ // definition of the seeding rule, consumed by the engine's slot seeding, the havoc arm,
243
+ // and the report's assumption lines, so they cannot drift apart.
244
+ export function declaredKindOf(category: ModuleBindingCategory): DeclaredKind | null {
245
+ switch (category.kind) {
246
+ case 'value':
247
+ case 'kind':
248
+ return category.declaredKind
249
+ // An imported constant needs no declared-kind hedge: its slot is always seeded with
250
+ // the exact literal, so no assumes line and no havoc reset value apply.
251
+ case 'importedConstant':
252
+ case 'import':
253
+ case 'opaque':
254
+ return null
255
+ }
256
+ }
257
+
258
+ // The abstract value a declared kind seeds at function entry: any finite number, any
259
+ // boolean, or a record of the declared shape with each leaf seeded the same way. The
260
+ // finite-non-NaN part is an ASSUMPTION — every function whose result rests on such a read
261
+ // prints an assumes line, and that machinery is what makes this value honest. Code without
262
+ // the assumes plumbing must use coveringKindValue below instead.
263
+ export function declaredKindValue(declared: DeclaredKind): AbstractValue {
264
+ return valueFromDeclaredKind(declared, finiteInputNumber, true)
265
+ }
266
+
267
+ // Inside a declared variant, the tag property holds exactly its tag value — the string
268
+ // content or the pinned boolean — rather than the walked hedge. This is what lets the
269
+ // rebuild idiom keep its variant: after `frame.type === 'sidebar'` narrows the union,
270
+ // `{type: frame.type, width}` reads a tag whose VALUE still says 'sidebar', and the
271
+ // engine's object arm pins from that value (an unnarrowed multi-variant union joins its
272
+ // tags to bare opaque or an unknown boolean, so nothing pins — correctly).
273
+ function exactTagValue(tagValue: string | boolean): AbstractValue {
274
+ if (typeof tagValue === 'string') return {kind: 'opaque', content: tagValue}
275
+ return {kind: 'boolean', canBeTrue: tagValue, canBeFalse: !tagValue}
276
+ }
277
+
278
+ // Whether values of this declared kind can be mutated through an alias: a record, tuple,
279
+ // or array anywhere inside, including behind a nullish wrapper (`number[] | null`).
280
+ // Rejected function bodies and skipped statements run at runtime too, and they can mutate
281
+ // such a value with no write-position mention of its binding — `queue?.push(x)` and
282
+ // `Object.assign(config, overrides)` both hold the binding in receiver or argument
283
+ // position, invisible to the whole-file write scan. Scalars are copied on read, so only a
284
+ // write-position form can change one, and the scan sees those even in rejected bodies.
285
+ export function holdsMutableStructure(declared: DeclaredKind): boolean {
286
+ switch (declared.kind) {
287
+ case 'record':
288
+ case 'tuple':
289
+ case 'array':
290
+ case 'taggedUnion':
291
+ return true
292
+ case 'nullish':
293
+ return holdsMutableStructure(declared.inner)
294
+ case 'number':
295
+ case 'boolean':
296
+ case 'opaque':
297
+ return false
298
+ }
299
+ }
300
+
301
+ // The truly covering value of a declared kind: any number INCLUDING NaN and infinities.
302
+ // This is what a havocked slot resets to — a skipped statement can put NaN in a number
303
+ // binding (e.g. `scale = Number.parseFloat(text)`), and later top-level statements compute
304
+ // published values from the slot with no assumes line to carry a finiteness condition, so
305
+ // the reset value must cover everything the skipped code could have produced.
306
+ export function coveringKindValue(declared: DeclaredKind): AbstractValue {
307
+ return valueFromDeclaredKind(declared, unknownNumber, false)
308
+ }
309
+
310
+ // The two declared-kind values have identical recursive structure and differ only at
311
+ // number leaves: function inputs use the written literal range when one exists and
312
+ // otherwise assume a finite number, while havoc must cover every number. Keeping the walk
313
+ // here makes a new DeclaredKind arm impossible to add to one conversion but not the other.
314
+ function valueFromDeclaredKind(
315
+ declared: DeclaredKind,
316
+ numberValue: () => AbstractNumber,
317
+ preserveLiteralIntervals: boolean,
318
+ ): AbstractValue {
319
+ switch (declared.kind) {
320
+ case 'number': return preserveLiteralIntervals && declared.interval != null
321
+ ? {
322
+ kind: 'number',
323
+ lower: declared.interval.lower,
324
+ upper: declared.interval.upper,
325
+ integer: declared.interval.integer,
326
+ mayBeNaN: false,
327
+ }
328
+ : numberValue()
329
+ case 'boolean': return unknownBoolean()
330
+ case 'record': return recordValue(declared.properties.map(property => ({
331
+ name: property.name,
332
+ value: valueFromDeclaredKind(property.declared, numberValue, preserveLiteralIntervals),
333
+ })))
334
+ case 'nullish': return {
335
+ kind: 'maybeNullish',
336
+ inner: valueFromDeclaredKind(declared.inner, numberValue, preserveLiteralIntervals),
337
+ sentinels: declared.sentinels,
338
+ }
339
+ case 'tuple': return {
340
+ kind: 'tuple',
341
+ elements: declared.elements.map(element =>
342
+ valueFromDeclaredKind(element, numberValue, preserveLiteralIntervals)),
343
+ }
344
+ case 'array': return {
345
+ kind: 'array',
346
+ element: valueFromDeclaredKind(declared.element, numberValue, preserveLiteralIntervals),
347
+ length: {kind: 'number', lower: 0, upper: 4294967295, integer: true, mayBeNaN: false},
348
+ }
349
+ case 'taggedUnion': {
350
+ const convertVariant = (variant: DeclaredVariant): TaggedVariant => ({
351
+ tagValue: variant.tagValue,
352
+ record: recordValue(variant.properties.map(property => ({
353
+ name: property.name,
354
+ value: property.name === declared.tagProperty
355
+ ? exactTagValue(variant.tagValue)
356
+ : valueFromDeclaredKind(property.declared, numberValue, preserveLiteralIntervals),
357
+ }))),
358
+ })
359
+ const [firstVariant, ...restVariants] = declared.variants
360
+ return {
361
+ kind: 'taggedUnion',
362
+ tagProperty: declared.tagProperty,
363
+ variants: [convertVariant(firstVariant), ...restVariants.map(convertVariant)],
364
+ }
365
+ }
366
+ case 'opaque': return {kind: 'opaque'}
367
+ }
368
+ }
369
+
370
+ // One top-level binding visible to every function in the file: a top-level variable
371
+ // declarator with an identifier name, or a named import.
372
+ export type ModuleBindingIR = {
373
+ name: string
374
+ category: ModuleBindingCategory
375
+ }
376
+
377
+ export type ProgramIR = {
378
+ file: string
379
+ // What report paths are made relative to; CLI commands use their working directory,
380
+ // matching TypeScript diagnostics. See reportPath.
381
+ baseDirectory: string
382
+ // Offset of each line's first character, copied from ts.SourceFile.getLineStarts(), so
383
+ // locations can be formatted after the TypeScript objects are gone (analyzeSource inputs
384
+ // never exist on disk, so re-reading the file is not an option).
385
+ lineStarts: number[]
386
+ // Indexed by SiteID. Push-only during lowering, immutable afterward.
387
+ sites: SourceSpan[]
388
+ // Still indexed by FunctionID, assigned from declaration order before any body lowers, so
389
+ // call instructions may reference an index that later turns out unsupported.
390
+ functions: FunctionLowering[]
391
+ // Direct global console.assert calls outside named top-level function
392
+ // declarations. Project reporting treats every entry as an error.
393
+ staticAnnotationIssues: Array<{kind: 'outsideTopLevelFunction'; site: SiteID}>
394
+ // Indexed by ModuleBindingID.
395
+ moduleBindings: ModuleBindingIR[]
396
+ // The synthetic function holding the module's top-level runtime code, evaluated once
397
+ // before any declared function so its results can seed their module slots. Always
398
+ // present; a file without top-level runtime code gets a trivial one. Not part of
399
+ // `functions`, so no call instruction can reference it. When its lowering stops, writes
400
+ // in the never-lowered statements demote the affected bindings' categories directly, so
401
+ // no separate record of the remainder is needed.
402
+ initializer: FunctionIR
403
+ // Top-level statements the initializer's lowering skipped instead of stopping at.
404
+ initializerSkips: InitializerSkip[]
405
+ }
406
+
407
+ // The synthetic initializer's display and IR name, shared by its two producers and read
408
+ // back by the report, so the strings cannot drift apart.
409
+ export const moduleInitializerName = 'module initialization'
410
+
411
+ // A top-level statement the initializer's lowering skipped, with the construct that made it
412
+ // unsupported. The report lists these on the module initialization entry.
413
+ export type InitializerSkip = {site: SiteID; reason: UnsupportedReason}
414
+
415
+ // The span an AST node covers, for pushing into ProgramIR.sites.
416
+ export function nodeSpan(sourceFile: ts.SourceFile, node: ts.Node): SourceSpan {
417
+ return {start: node.getStart(sourceFile), end: node.getEnd()}
418
+ }
419
+
420
+ // A site rendered as file:line:column, the form every report line uses.
421
+ export function formatSite(program: ProgramIR, site: SiteID): string {
422
+ const {line, column} = siteLocation(program, site)
423
+ return `${reportPath(program)}:${line}:${column}`
424
+ }
425
+
426
+ // Report lines name files relative to the analysis base. CLI commands use their working
427
+ // directory, matching TypeScript diagnostics and producing stable relative paths without
428
+ // absolute machine-specific prefixes.
429
+ export function reportPath(program: ProgramIR): string {
430
+ return relative(program.baseDirectory, program.file)
431
+ }
432
+
433
+ // 1-based line and column of a site's start offset.
434
+ export function siteLocation(program: ProgramIR, site: SiteID): {line: number; column: number} {
435
+ const span = program.sites[site]
436
+ if (span == null) throw new Error(`Unknown site ${site}`)
437
+ const lineStarts = program.lineStarts
438
+ let low = 0
439
+ let high = lineStarts.length - 1
440
+ while (low < high) {
441
+ const middle = Math.ceil((low + high) / 2)
442
+ if (lineStarts[middle]! <= span.start) low = middle
443
+ else high = middle - 1
444
+ }
445
+ return {line: low + 1, column: span.start - lineStarts[low]! + 1}
446
+ }
@@ -0,0 +1,119 @@
1
+ import * as ts from 'typescript'
2
+ import {unsupported} from './context.ts'
3
+
4
+ // The early acceptance check from current-decisions.md ("What TypeScript code does the
5
+ // analyzer accept?"): the wholesale structural rules — property writes (values are
6
+ // immutable after construction) and `var` — checked before lowering ever sees the code.
7
+ // (`any`-typed values and type assertions used to be rejected here too; both are carried
8
+ // claim-free now — see valueKind's opaque arm and unwrap's assertion peeling.) Called once
9
+ // per function declaration and once per top-level statement of the module initializer; a
10
+ // violation throws LoweringStop and is caught like any other rejection.
11
+ export function assertAccepted(root: ts.Node): void {
12
+ const visit = (node: ts.Node): void => {
13
+ // Type annotations hold no runtime values, so there is nothing to check inside them.
14
+ if (ts.isTypeNode(node)) return
15
+ // JSX never lowers (the expression catch-all names it), but its tag names and
16
+ // attribute slots answer `any` to getTypeAtLocation even in diagnostic-clean files —
17
+ // an SVG-heavy component would otherwise misfile under any-typed instead of its real
18
+ // JSX rejection. Only the embedded {expressions} hold checkable runtime values.
19
+ // (`any`-typed values themselves are carried claim-free since the opaque-carry
20
+ // change, so this skip is about honest rejection reasons, not soundness.)
21
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
22
+ visitJsxValues(node)
23
+ return
24
+ }
25
+ // Values are immutable after construction: any write through a property access —
26
+ // plain, compound, or ++/-- — is rejected. Reports may mention rebuilding a plain
27
+ // record conditionally, but the acceptance rule does not assume that rewrite is safe.
28
+ if (
29
+ ts.isBinaryExpression(node)
30
+ && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment
31
+ && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment
32
+ && ts.isPropertyAccessExpression(node.left)
33
+ ) {
34
+ throw unsupported(node, {kind: 'propertyWrite'})
35
+ }
36
+ if (
37
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node))
38
+ && (node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken)
39
+ && ts.isPropertyAccessExpression(node.operand)
40
+ ) {
41
+ throw unsupported(node, {kind: 'propertyWrite'})
42
+ }
43
+ // `var` hoists: one variable can have several declaration sites, and a nested
44
+ // redeclaration writes a binding declared elsewhere. `let` and `const` express the
45
+ // same programs without that.
46
+ if (ts.isVariableDeclarationList(node) && (node.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) === 0) {
47
+ throw unsupported(node, {kind: 'varDeclaration'})
48
+ }
49
+ ts.forEachChild(node, visit)
50
+ }
51
+ const visitJsxValues = (jsx: ts.Node): void => {
52
+ if (ts.isJsxExpression(jsx)) {
53
+ if (jsx.expression != null) visit(jsx.expression)
54
+ return
55
+ }
56
+ ts.forEachChild(jsx, visitJsxValues)
57
+ }
58
+ visit(root)
59
+ }
60
+
61
+ // The other file-wide rule: a directive that actually suppresses TypeScript checking
62
+ // voids the checker word every guarantee relies on. Match TypeScript's comment syntax,
63
+ // rather than raw source text: documentation strings and `// // @ts-ignore` do nothing.
64
+ const lineSuppression = /^\/\/\/?\s*@(ts-expect-error|ts-ignore)/
65
+ const blockSuppression = /^(?:\/|\*)*\s*@(ts-expect-error|ts-ignore)/
66
+ const noCheck = /^\/\/\/?\s*@ts-nocheck/
67
+ export function typeCheckSuppressionMention(sourceFile: ts.SourceFile): {start: number; end: number} | null {
68
+ const text = sourceFile.text
69
+ const matchRanges = (
70
+ ranges: readonly ts.CommentRange[] | undefined,
71
+ includeNoCheck: boolean,
72
+ ): {start: number; end: number} | null => {
73
+ for (const range of ranges ?? []) {
74
+ const comment = text.slice(range.pos, range.end)
75
+ const lastLine = comment.slice(Math.max(comment.lastIndexOf('\n'), comment.lastIndexOf('\r')) + 1)
76
+ if (lineSuppression.test(comment)
77
+ || blockSuppression.test(lastLine)
78
+ || (includeNoCheck && noCheck.test(comment))) {
79
+ return {start: range.pos, end: range.end}
80
+ }
81
+ }
82
+ return null
83
+ }
84
+
85
+ const firstStatement = sourceFile.statements[0]
86
+ const topLevel = matchRanges(
87
+ ts.getLeadingCommentRanges(text, firstStatement?.getFullStart() ?? 0),
88
+ true,
89
+ )
90
+ if (topLevel != null) return topLevel
91
+
92
+ let found: {start: number; end: number} | null = null
93
+ const visit = (node: ts.Node): void => {
94
+ if (found != null) return
95
+ found = matchRanges(ts.getLeadingCommentRanges(text, node.getFullStart()), false)
96
+ if (found == null) ts.forEachChild(node, visit)
97
+ }
98
+ visit(sourceFile)
99
+ return found
100
+ }
101
+
102
+ // The eval file-wide rule: any mention of `eval` puts the whole file outside the subset,
103
+ // because an eval string can rewrite bindings that every function's report depends on.
104
+ // A plain identifier scan deliberately over-rejects (e.g. a variable named eval shadowing
105
+ // the global) — the spellings that matter, like `(eval)(...)`, all contain the identifier,
106
+ // and no detection of call shapes or TypeScript wrappers is needed.
107
+ export function evalMention(sourceFile: ts.SourceFile): ts.Node | null {
108
+ let found: ts.Node | null = null
109
+ const visit = (node: ts.Node): void => {
110
+ if (found != null) return
111
+ if (ts.isIdentifier(node) && node.text === 'eval') {
112
+ found = node
113
+ return
114
+ }
115
+ ts.forEachChild(node, visit)
116
+ }
117
+ visit(sourceFile)
118
+ return found
119
+ }