@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,81 @@
1
+ import * as ts from 'typescript'
2
+
3
+ // The platform catalog: numeric facts about browser globals, the analyzer's trusted base
4
+ // the way lib.dom.d.ts is the type system's. Each entry is a deliberate, written decision —
5
+ // a wrong range here poisons everything downstream — and the ranges deliberately include
6
+ // platform quirks (scroll positions go negative during Safari rubber-banding, so no scroll
7
+ // entry claims nonnegativity). A matched read produces a FRESH value within the range on
8
+ // every evaluation: these are reads of outside mutable state, so two reads of clientWidth
9
+ // may differ and must never be treated as equal.
10
+ //
11
+ // Only number-valued entries live here; a nullable API would need an explicit guard before
12
+ // its numeric result could use a catalog fact.
13
+ // (Related known gap for then: lib.dom types document.body as non-null, but it is null
14
+ // before the body parses — a script running in <head> can throw on the scroll entries.
15
+ // Partial correctness tolerates the throw; nullability work should revisit the entry.)
16
+
17
+ export type PlatformFact = {
18
+ lower: number
19
+ upper: number
20
+ integer: boolean
21
+ }
22
+
23
+ type PlatformEntry = {
24
+ // Property path from a global root, e.g. ['document', 'documentElement', 'clientWidth'].
25
+ path: string[]
26
+ // Whether the final path element is called, e.g. performance.now().
27
+ call: boolean
28
+ fact: PlatformFact
29
+ }
30
+
31
+ const anyFinite = {lower: -Number.MAX_VALUE, upper: Number.MAX_VALUE}
32
+
33
+ const catalog: PlatformEntry[] = [
34
+ // Element layout sizes are nonnegative integers (the spec rounds them).
35
+ {path: ['document', 'documentElement', 'clientWidth'], call: false, fact: {lower: 0, upper: Number.MAX_VALUE, integer: true}},
36
+ {path: ['document', 'documentElement', 'clientHeight'], call: false, fact: {lower: 0, upper: Number.MAX_VALUE, integer: true}},
37
+ // Window sizes can be fractional under browser zoom.
38
+ {path: ['window', 'innerWidth'], call: false, fact: {lower: 0, upper: Number.MAX_VALUE, integer: false}},
39
+ {path: ['window', 'innerHeight'], call: false, fact: {lower: 0, upper: Number.MAX_VALUE, integer: false}},
40
+ // Scroll positions are finite but NOT nonnegative: Safari rubber-banding reports negative
41
+ // values at the edges, and fractional values appear under zoom.
42
+ {path: ['window', 'scrollX'], call: false, fact: {...anyFinite, integer: false}},
43
+ {path: ['window', 'scrollY'], call: false, fact: {...anyFinite, integer: false}},
44
+ {path: ['document', 'body', 'scrollTop'], call: false, fact: {...anyFinite, integer: false}},
45
+ {path: ['document', 'body', 'scrollLeft'], call: false, fact: {...anyFinite, integer: false}},
46
+ // Monotonic clocks: finite, nonnegative, fractional (performance.now has sub-millisecond
47
+ // resolution); Date.now is an integer count of milliseconds.
48
+ {path: ['performance', 'now'], call: true, fact: {lower: 0, upper: Number.MAX_VALUE, integer: false}},
49
+ {path: ['Date', 'now'], call: true, fact: {lower: 0, upper: Number.MAX_VALUE, integer: true}},
50
+ ]
51
+
52
+ // Matches a property chain rooted at a global whose symbol resolves into a declaration
53
+ // file — the same shadowing defense the Math dispatch uses, so a local variable named
54
+ // `document` never matches.
55
+ export function platformFact(expression: ts.Expression, call: boolean, checker: ts.TypeChecker): PlatformFact | null {
56
+ const parts: string[] = []
57
+ let current: ts.Expression = expression
58
+ while (ts.isPropertyAccessExpression(current)) {
59
+ parts.unshift(current.name.text)
60
+ current = current.expression
61
+ }
62
+ if (!ts.isIdentifier(current)) return null
63
+ parts.unshift(current.text)
64
+ const entry = catalog.find(candidate =>
65
+ candidate.call === call
66
+ && candidate.path.length === parts.length
67
+ && candidate.path.every((segment, index) => segment === parts[index]))
68
+ if (entry == null) return null
69
+ if (!declaredOnlyInDeclarationFiles(checker.getSymbolAtLocation(current))) return null
70
+ return entry.fact
71
+ }
72
+
73
+ // The shadowing defense shared by every trusted-global dispatch (this catalog, `Math`,
74
+ // `Infinity`): the identifier counts as the real global only when every declaration of its
75
+ // symbol lives in a declaration file, so a local or module binding of the same name never
76
+ // matches.
77
+ export function declaredOnlyInDeclarationFiles(symbol: ts.Symbol | undefined): boolean {
78
+ const declarations = symbol?.declarations
79
+ if (declarations == null || declarations.length === 0) return false
80
+ return declarations.every(declaration => declaration.getSourceFile().isDeclarationFile)
81
+ }
@@ -0,0 +1,310 @@
1
+ import * as ts from 'typescript'
2
+ import {finiteInputPaths} from '../ir/finite-inputs.ts'
3
+ import {moduleInitializerName, nodeSpan, type DeclaredKind, type FunctionIR, type FunctionLowering, type ProgramIR, type SourceSpan, type UnsupportedReason} from '../ir/program.ts'
4
+ import type {CheckedSource} from '../typescript/check.ts'
5
+ import {assertAccepted, evalMention, typeCheckSuppressionMention} from './accept.ts'
6
+ import {addInstructionAtSite, addSite, createFunctionContext, LoweringStop, requiredSymbol, sealBlocks, terminate, unsupported, type MutableBlock, type TopLevelFunction} from './context.ts'
7
+ import {valueKind} from './expression.ts'
8
+ import {parameterDefaultFits, parameterDefaultLiteral, type ParameterDefaultLiteral} from './literals.ts'
9
+ import {declaredKind, lowerModuleInitializer, scanModuleBindings, tupleHasOptionalOrRestPositions, type ModuleScan} from './module.ts'
10
+ import {scanStaticAnnotations, type StaticAnnotation} from './static-intrinsics.ts'
11
+ import {lowerStatements} from './statements.ts'
12
+
13
+ export function lowerSource(checked: CheckedSource, baseDirectory: string = process.cwd()): ProgramIR {
14
+ const {sourceFile, checker} = checked
15
+ const declarations: ts.FunctionDeclaration[] = []
16
+ for (const statement of sourceFile.statements) {
17
+ if (ts.isFunctionDeclaration(statement) && statement.name != null) declarations.push(statement)
18
+ }
19
+ const staticScan = scanStaticAnnotations(sourceFile, declarations, checker)
20
+ const recordStaticAnnotationIssues = (sites: SourceSpan[]): ProgramIR['staticAnnotationIssues'] =>
21
+ staticScan.outsideTopLevelFunctions.map(call => {
22
+ sites.push(nodeSpan(sourceFile, call))
23
+ return {kind: 'outsideTopLevelFunction', site: sites.length - 1}
24
+ })
25
+ // The two file-wide rejections. An eval string can rewrite bindings that every
26
+ // function's report depends on, and a type-check suppression comment voids the checker's
27
+ // word that every guarantee is built on — in both cases, no function in the file is
28
+ // analyzed.
29
+ const rejectFile = (span: SourceSpan, reason: UnsupportedReason): ProgramIR => {
30
+ const sites = [span]
31
+ const staticAnnotationIssues = recordStaticAnnotationIssues(sites)
32
+ return {
33
+ file: sourceFile.fileName,
34
+ baseDirectory,
35
+ lineStarts: [...sourceFile.getLineStarts()],
36
+ sites,
37
+ functions: declarations.map((declaration, index) => ({
38
+ kind: 'unsupported',
39
+ name: declaration.name!.text,
40
+ hasStaticAnnotations: staticScan.functions[index]!.length > 0,
41
+ site: 0,
42
+ reason,
43
+ })),
44
+ staticAnnotationIssues,
45
+ moduleBindings: [],
46
+ initializer: {
47
+ kind: 'lowered',
48
+ name: moduleInitializerName,
49
+ assertions: [],
50
+ parameters: [],
51
+ returnPropertyNames: null,
52
+ entry: 0,
53
+ blocks: [{loopHeader: null, parameters: [], instructions: [], terminator: {kind: 'stop', site: 0, reason}}],
54
+ },
55
+ initializerSkips: [],
56
+ }
57
+ }
58
+ const suppression = typeCheckSuppressionMention(sourceFile)
59
+ if (suppression != null) return rejectFile(suppression, {kind: 'typeCheckSuppressed'})
60
+ const evalNode = evalMention(sourceFile)
61
+ if (evalNode != null) {
62
+ return rejectFile(nodeSpan(sourceFile, evalNode), {kind: 'evalInFile'})
63
+ }
64
+ const functionsBySymbol = new Map<ts.Symbol, TopLevelFunction>()
65
+ for (let index = 0; index < declarations.length; index++) {
66
+ const declaration = declarations[index]!
67
+ // This loop runs outside the per-function catch below, so a missing symbol here is an
68
+ // invariant crash, not a recorded reason: a declaration name that type-checked always
69
+ // has a symbol.
70
+ const symbol = checker.getSymbolAtLocation(declaration.name!)
71
+ if (symbol == null) throw new Error(`Function declaration ${declaration.name!.text} has no TypeScript symbol`)
72
+ functionsBySymbol.set(symbol, {id: index, declaration})
73
+ }
74
+ const scan = scanModuleBindings(sourceFile, checker)
75
+ const sites: SourceSpan[] = []
76
+ const staticAnnotationIssues = recordStaticAnnotationIssues(sites)
77
+ const functions: FunctionLowering[] = []
78
+ for (let index = 0; index < declarations.length; index++) {
79
+ const declaration = declarations[index]!
80
+ const staticAnnotations = staticScan.functions[index]!
81
+ // A failed function lowering discards the half-built FunctionContext wholesale; only
82
+ // the name, annotation presence, offending node's site, and tagged reason survive.
83
+ try {
84
+ functions.push(lowerFunction(declaration, staticAnnotations, sourceFile, checker, functionsBySymbol, scan, sites))
85
+ } catch (error) {
86
+ if (!(error instanceof LoweringStop)) throw error
87
+ sites.push(nodeSpan(sourceFile, error.node))
88
+ functions.push({
89
+ kind: 'unsupported',
90
+ name: declaration.name!.text,
91
+ hasStaticAnnotations: staticAnnotations.length > 0,
92
+ site: sites.length - 1,
93
+ reason: error.reason,
94
+ })
95
+ }
96
+ }
97
+ const {initializer, skips} = lowerModuleInitializer(sourceFile, checker, functionsBySymbol, scan, sites)
98
+ return {
99
+ file: sourceFile.fileName,
100
+ baseDirectory,
101
+ lineStarts: [...sourceFile.getLineStarts()],
102
+ sites,
103
+ functions,
104
+ staticAnnotationIssues,
105
+ moduleBindings: scan.bindings,
106
+ initializer,
107
+ initializerSkips: skips,
108
+ }
109
+ }
110
+
111
+ function lowerFunction(
112
+ declaration: ts.FunctionDeclaration,
113
+ staticAnnotations: StaticAnnotation[],
114
+ sourceFile: ts.SourceFile,
115
+ checker: ts.TypeChecker,
116
+ functionsBySymbol: Map<ts.Symbol, TopLevelFunction>,
117
+ scan: ModuleScan,
118
+ sites: SourceSpan[],
119
+ ): FunctionIR {
120
+ for (const annotation of staticAnnotations) {
121
+ if (annotation.kind === 'invalid') {
122
+ throw unsupported(annotation.node, {kind: 'staticAssertionForm', problem: annotation.problem})
123
+ }
124
+ }
125
+ if (declaration.body == null) throw unsupported(declaration, {kind: 'functionWithoutBody'})
126
+ // An async body returns a Promise and a generator returns an iterator; lowering either
127
+ // as if it ran synchronously would publish the body's values as the caller-visible
128
+ // result. Rejected wholesale.
129
+ if (declaration.asteriskToken != null || declaration.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) === true) {
130
+ throw unsupported(declaration, {kind: 'asyncOrGeneratorFunction'})
131
+ }
132
+ assertAccepted(declaration)
133
+ const signature = checker.getSignatureFromDeclaration(declaration)
134
+ // A type predicate (`shape is Circle`, `asserts x`) is the checker taking the author's
135
+ // word: callers' narrowing then exposes properties the analysis cannot confirm the
136
+ // value carries. Rejecting the declaring function stops every caller at the call.
137
+ if (signature != null && checker.getTypePredicateOfSignature(signature) != null) {
138
+ throw unsupported(declaration, {kind: 'typePredicate'})
139
+ }
140
+ const returnType = functionReturnType(declaration, checker)
141
+ // `never` counts as returning nothing: the idiomatic annotation for an always-throwing
142
+ // helper (`function fail(code: number): never`), whose paths all end in throw — the
143
+ // always-throws analysis and the calleeAlwaysThrows caller stop handle the rest.
144
+ const returnsVoid = (returnType.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0
145
+ // Mixed return kinds (e.g. one branch returning a number and another a boolean) would
146
+ // otherwise meet at the engine's return join instead of stopping here.
147
+ if (!returnsVoid && valueKind(returnType, checker) == null) {
148
+ throw unsupported(declaration.type ?? declaration, {kind: 'valueType', typeText: checker.typeToString(returnType)})
149
+ }
150
+ const context = createFunctionContext(
151
+ sourceFile,
152
+ checker,
153
+ functionsBySymbol,
154
+ scan.bindingsBySymbol,
155
+ sites,
156
+ staticAnnotations,
157
+ )
158
+ const entry = context.currentBlock
159
+ for (const parameter of declaration.parameters) {
160
+ // `function area({width, height}: Size)` lowers as a synthetic record parameter plus
161
+ // one property read per name — the same classification named parameters use, the
162
+ // same reads body destructuring uses. The report metadata below keeps the local
163
+ // names, so a condition says `width` rather than `{width, height}.width`. Defaults
164
+ // and rest inside the pattern stay out, like the body form.
165
+ if (ts.isObjectBindingPattern(parameter.name)) {
166
+ const type = lowerParameterType(parameter, checker)
167
+ // The pattern text becomes the parameter's report name; a pattern the author wrapped
168
+ // across source lines would otherwise break the one-fact-per-line report format
169
+ // (`assumes: {` and orphan fragments — a corpus census caught eight of these).
170
+ const patternName = parameter.name.getText(sourceFile).replace(/\s+/g, ' ')
171
+ if (parameter.initializer != null) {
172
+ throw unsupported(parameter, {kind: 'parameterDefaultValue', name: patternName})
173
+ }
174
+ const value = context.nextValue++
175
+ const bindings: Array<{property: string; local: string}> = []
176
+ context.parameters.push({value, name: patternName, type, site: addSite(context, parameter), bindings})
177
+ for (const element of parameter.name.elements) {
178
+ if (!ts.isIdentifier(element.name) || element.dotDotDotToken != null || element.initializer != null) {
179
+ throw unsupported(element, {kind: 'destructuredParameter'})
180
+ }
181
+ const property = element.propertyName == null
182
+ ? element.name.text
183
+ : ts.isIdentifier(element.propertyName) ? element.propertyName.text : null
184
+ if (property == null) throw unsupported(element, {kind: 'destructuredParameter'})
185
+ bindings.push({property, local: element.name.text})
186
+ const read: MutableBlock['instructions'][number] = {
187
+ kind: 'property',
188
+ object: value,
189
+ property,
190
+ result: context.nextValue++,
191
+ site: addSite(context, element),
192
+ }
193
+ entry.instructions.push(read)
194
+ context.bindings.set(requiredSymbol(element.name, checker), read.result)
195
+ }
196
+ continue
197
+ }
198
+ if (!ts.isIdentifier(parameter.name)) throw unsupported(parameter.name, {kind: 'destructuredParameter'})
199
+ // A rest parameter is one declaration for any number of arguments; the engine's
200
+ // one-value-per-parameter seeding cannot represent that.
201
+ if (parameter.dotDotDotToken != null) {
202
+ throw unsupported(parameter, {kind: 'parameterType', typeText: `...${checker.typeToString(checker.getTypeAtLocation(parameter))}`, optionalOrRestTuple: false})
203
+ }
204
+ let type = lowerParameterType(parameter, checker)
205
+ // A default value applies whenever a caller omits the argument. Literal defaults can
206
+ // be represented exactly and checked against the declared assumptions: `zoom: number
207
+ // = 5` supplies a finite number. Anything else — `= Infinity`, `= readConfig()` —
208
+ // rejects because it could falsify those assumptions or hide unsupported behavior.
209
+ if (parameter.initializer != null) {
210
+ const default_ = parameterDefaultLiteral(parameter.initializer, checker)
211
+ if (default_ == null || !parameterDefaultFits(default_, type)) {
212
+ throw unsupported(parameter, {kind: 'parameterDefaultValue', name: parameter.name.text})
213
+ }
214
+ type = parameterBodyKind(type, default_)
215
+ }
216
+ const value = context.nextValue++
217
+ context.bindings.set(requiredSymbol(parameter.name, checker), value)
218
+ context.parameters.push({value, name: parameter.name.text, type, site: addSite(context, parameter), bindings: null})
219
+ }
220
+ lowerFiniteInputRequirements(context)
221
+ lowerStatements(declaration.body.statements, context)
222
+ if (context.currentBlock.terminator == null) {
223
+ if (!returnsVoid) {
224
+ // A non-void path reaching the end without a return is a per-path STOP, not a
225
+ // whole-function rejection: an exhaustive switch (or if-chain) over a tagged
226
+ // union's variants makes the fall-out edge provably unreachable — the engine's tag
227
+ // narrowing prunes it and the function analyzes clean, matching how TypeScript's
228
+ // exhaustiveness accepts the same shape under noImplicitReturns. A genuinely
229
+ // reachable fall-out reports as a stop with the returning paths' evidence kept.
230
+ terminate(context.currentBlock, {kind: 'stop', site: addSite(context, declaration), reason: {kind: 'missingReturn'}})
231
+ } else {
232
+ terminate(context.currentBlock, {kind: 'return', value: null, site: addSite(context, declaration)})
233
+ }
234
+ }
235
+ return {
236
+ kind: 'lowered',
237
+ name: declaration.name!.text,
238
+ assertions: context.assertions,
239
+ parameters: context.parameters,
240
+ returnPropertyNames: declaredRecordReturnNames(returnType, checker),
241
+ entry: 0,
242
+ blocks: sealBlocks(context.blocks, declaration.name!.text),
243
+ }
244
+ }
245
+
246
+ function lowerFiniteInputRequirements(context: ReturnType<typeof createFunctionContext>): void {
247
+ for (const parameter of context.parameters) {
248
+ for (const properties of finiteInputPaths(parameter.type)) {
249
+ let value = parameter.value
250
+ for (const property of properties) {
251
+ value = addInstructionAtSite(context, parameter.site, {kind: 'property', object: value, property})
252
+ }
253
+ const check = addInstructionAtSite(context, parameter.site, {
254
+ kind: 'numberCheck',
255
+ predicate: 'finite',
256
+ value,
257
+ purpose: 'finiteInput',
258
+ })
259
+ addInstructionAtSite(context, parameter.site, {kind: 'staticRequire', value: check, purpose: 'finiteInput'})
260
+ }
261
+ }
262
+ }
263
+
264
+ // JavaScript replaces undefined before the function body begins. A default of undefined
265
+ // leaves it possible; every other default removes it. Null remains an ordinary argument.
266
+ function parameterBodyKind(declared: DeclaredKind, default_: ParameterDefaultLiteral): DeclaredKind {
267
+ if (declared.kind !== 'nullish' || declared.sentinels === 'null') return declared
268
+ if (default_.kind === 'nullish' && default_.sentinel === 'undefined') return declared
269
+ if (declared.sentinels === 'undefined') return declared.inner
270
+ return {kind: 'nullish', inner: declared.inner, sentinels: 'null'}
271
+ }
272
+
273
+ function declaredRecordReturnNames(returnType: ts.Type, checker: ts.TypeChecker): string[] | null {
274
+ const kind = valueKind(returnType, checker)
275
+ if (kind === 'object') return checker.getPropertiesOfType(returnType).map(property => property.name)
276
+ if (kind === 'nullable' && returnType.isUnion()) {
277
+ const missing = ts.TypeFlags.Null | ts.TypeFlags.Undefined
278
+ const members = returnType.types.filter(member => (member.flags & missing) === 0)
279
+ if (members.length > 0 && members.every(member => valueKind(member, checker) === 'object')) {
280
+ const names = new Set<string>()
281
+ for (const member of members) {
282
+ for (const property of checker.getPropertiesOfType(member)) names.add(property.name)
283
+ }
284
+ return [...names]
285
+ }
286
+ }
287
+ return null
288
+ }
289
+
290
+ function lowerParameterType(parameter: ts.ParameterDeclaration, checker: ts.TypeChecker): DeclaredKind {
291
+ // The same recursive classification module bindings use: numbers, booleans, records
292
+ // (opaque leaves included — an id: string property is carried, not rejected), nullable
293
+ // wrappers, arrays, tuples, and bare opaque (a plain string parameter).
294
+ const type = checker.getTypeAtLocation(parameter)
295
+ const declared = declaredKind(type, checker, [])
296
+ if (declared == null) {
297
+ throw unsupported(parameter, {
298
+ kind: 'parameterType',
299
+ typeText: checker.typeToString(type),
300
+ optionalOrRestTuple: tupleHasOptionalOrRestPositions(type, checker),
301
+ })
302
+ }
303
+ return declared
304
+ }
305
+
306
+ function functionReturnType(declaration: ts.FunctionDeclaration, checker: ts.TypeChecker): ts.Type {
307
+ const signature = checker.getSignatureFromDeclaration(declaration)
308
+ if (signature == null) throw unsupported(declaration, {kind: 'functionWithoutSignature'})
309
+ return checker.getReturnTypeOfSignature(signature)
310
+ }