@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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/fr.ts +33 -0
- package/package.json +39 -0
- package/src/analyze.ts +16 -0
- package/src/audit.ts +468 -0
- package/src/domain/number.ts +444 -0
- package/src/domain/value.ts +445 -0
- package/src/engine/analyze.ts +745 -0
- package/src/engine/outcome.ts +137 -0
- package/src/engine/state.ts +155 -0
- package/src/engine/transfer.ts +1723 -0
- package/src/index.ts +23 -0
- package/src/ir/finite-inputs.ts +51 -0
- package/src/ir/function-usage.ts +50 -0
- package/src/ir/ids.ts +10 -0
- package/src/ir/instructions.ts +164 -0
- package/src/ir/program.ts +446 -0
- package/src/lower/accept.ts +119 -0
- package/src/lower/context.ts +250 -0
- package/src/lower/expression.ts +1742 -0
- package/src/lower/literals.ts +84 -0
- package/src/lower/module.ts +748 -0
- package/src/lower/platform.ts +81 -0
- package/src/lower/program.ts +310 -0
- package/src/lower/statements.ts +553 -0
- package/src/lower/static-intrinsics.ts +87 -0
- package/src/project.ts +500 -0
- package/src/report/format-requirement.ts +110 -0
- package/src/report/index.ts +1132 -0
- package/src/requirements/infer.ts +334 -0
- package/src/requirements/model.ts +77 -0
- package/src/typescript/check.ts +56 -0
- package/src/typescript/diagnostics.ts +69 -0
- package/src/typescript/project.ts +101 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import type {SiteID, ValueID} from '../ir/ids.ts'
|
|
2
|
+
import type {InstructionIR} from '../ir/instructions.ts'
|
|
3
|
+
import type {FunctionIR} from '../ir/program.ts'
|
|
4
|
+
import type {InferredPrecondition, NumericExpression} from './model.ts'
|
|
5
|
+
|
|
6
|
+
export type ExpressionContext = {
|
|
7
|
+
parameterExpressions: Array<NumericExpression | null>
|
|
8
|
+
// Calls pass the caller's value keys directly, so duplicate arguments and facts created
|
|
9
|
+
// in a callee refer to the same identity as the caller. Local keys use a nested namespace
|
|
10
|
+
// to avoid colliding with the caller's ValueIDs.
|
|
11
|
+
parameterIdentityKeys: string[]
|
|
12
|
+
identityNamespace: string
|
|
13
|
+
parameterIndexByValue: Array<number | undefined>
|
|
14
|
+
instructionByValue: Array<InstructionIR | undefined>
|
|
15
|
+
instructionCount: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createExpressionContext(
|
|
19
|
+
fn: FunctionIR,
|
|
20
|
+
parameterExpressions: Array<NumericExpression | null>,
|
|
21
|
+
parameterIdentityKeys?: string[],
|
|
22
|
+
identityNamespace = `${fn.name}/`,
|
|
23
|
+
): ExpressionContext {
|
|
24
|
+
const identityKeys = parameterIdentityKeys ?? fn.parameters.map((_, index) => `p${index}`)
|
|
25
|
+
if (identityKeys.length !== fn.parameters.length) {
|
|
26
|
+
throw new Error(`Expected ${fn.parameters.length} parameter identity keys for ${fn.name}`)
|
|
27
|
+
}
|
|
28
|
+
const context: ExpressionContext = {
|
|
29
|
+
parameterExpressions,
|
|
30
|
+
parameterIdentityKeys: identityKeys,
|
|
31
|
+
identityNamespace,
|
|
32
|
+
parameterIndexByValue: [],
|
|
33
|
+
instructionByValue: [],
|
|
34
|
+
instructionCount: 0,
|
|
35
|
+
}
|
|
36
|
+
for (let index = 0; index < fn.parameters.length; index++) {
|
|
37
|
+
context.parameterIndexByValue[fn.parameters[index]!.value] = index
|
|
38
|
+
}
|
|
39
|
+
for (const block of fn.blocks) {
|
|
40
|
+
for (const instruction of block.instructions) {
|
|
41
|
+
context.instructionByValue[instruction.result] = instruction
|
|
42
|
+
context.instructionCount += 1
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return context
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Follow assignments and reads through records built in this function. The returned IR
|
|
49
|
+
// value is the value that was actually stored, so ordinary analysis, assertion proofs,
|
|
50
|
+
// and requirement expressions all use the same definition of identity.
|
|
51
|
+
export function resolveStoredValue(value: ValueID, context: ExpressionContext): ValueID {
|
|
52
|
+
const producer = context.instructionByValue[value]
|
|
53
|
+
if (producer?.kind === 'moduleWrite') return resolveStoredValue(producer.value, context)
|
|
54
|
+
if (producer?.kind === 'property') {
|
|
55
|
+
const object = resolveStoredValue(producer.object, context)
|
|
56
|
+
const objectProducer = context.instructionByValue[object]
|
|
57
|
+
if (objectProducer?.kind === 'object') {
|
|
58
|
+
const property = objectProducer.properties.find(candidate => candidate.name === producer.property)
|
|
59
|
+
if (property != null) return resolveStoredValue(property.value, context)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return value
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The producer walk expands a value's defining DAG into an expression tree, and a value
|
|
66
|
+
// used twice appears twice — chained squaring (`const b = a * a; const c = b * b`) doubles
|
|
67
|
+
// per level, so tree size is exponential in the worst case while the DAG stays linear.
|
|
68
|
+
// The budget is by construction, not a magic number: each visit charges against the
|
|
69
|
+
// function's own instruction count, so a requirement can never be more complex than the
|
|
70
|
+
// function that produced it. Exhaustion returns null, which surfaces as the nonzero-divisor
|
|
71
|
+
// assumes line (or the element-in-bounds assumes line for element reads) — analysis keeps
|
|
72
|
+
// going either way.
|
|
73
|
+
export function numericExpression(value: ValueID, context: ExpressionContext): NumericExpression | null {
|
|
74
|
+
let remainingVisits = context.instructionCount
|
|
75
|
+
const walk = (current: ValueID): NumericExpression | null => {
|
|
76
|
+
const stored = resolveStoredValue(current, context)
|
|
77
|
+
if (stored !== current) return walk(stored)
|
|
78
|
+
const parameterIndex = context.parameterIndexByValue[current]
|
|
79
|
+
if (parameterIndex != null) return context.parameterExpressions[parameterIndex] ?? null
|
|
80
|
+
const instruction = context.instructionByValue[current]
|
|
81
|
+
if (instruction == null) return null
|
|
82
|
+
// Only an instruction expansion is charged — re-expanding the same instruction is
|
|
83
|
+
// exactly what the duplication blowup repeats, while parameter and constant leaves are
|
|
84
|
+
// bounded by the expansions' own fan-in.
|
|
85
|
+
if (remainingVisits <= 0) return null
|
|
86
|
+
remainingVisits -= 1
|
|
87
|
+
switch (instruction.kind) {
|
|
88
|
+
case 'constant': return {kind: 'constant', value: instruction.value}
|
|
89
|
+
case 'binary': {
|
|
90
|
+
const left = walk(instruction.left)
|
|
91
|
+
const right = walk(instruction.right)
|
|
92
|
+
return left == null || right == null
|
|
93
|
+
? null
|
|
94
|
+
: {kind: 'binary', operator: instruction.operator, left, right}
|
|
95
|
+
}
|
|
96
|
+
case 'floor': {
|
|
97
|
+
const operand = walk(instruction.value)
|
|
98
|
+
return operand == null ? null : {kind: 'floor', operand}
|
|
99
|
+
}
|
|
100
|
+
// A module write's result is the assigned value, so the written expression carries over.
|
|
101
|
+
case 'moduleWrite': return walk(instruction.value)
|
|
102
|
+
// Requirement expressions name only the function's own parameters; a module binding is
|
|
103
|
+
// not caller-visible, so a requirement cannot name it.
|
|
104
|
+
case 'moduleRead':
|
|
105
|
+
case 'moduleHavoc':
|
|
106
|
+
case 'platformValue':
|
|
107
|
+
case 'booleanConstant':
|
|
108
|
+
case 'not':
|
|
109
|
+
case 'absolute':
|
|
110
|
+
case 'call':
|
|
111
|
+
case 'compare':
|
|
112
|
+
case 'maximum':
|
|
113
|
+
case 'minimum':
|
|
114
|
+
case 'object':
|
|
115
|
+
case 'nullishConstant':
|
|
116
|
+
case 'opaqueConstant':
|
|
117
|
+
case 'unknownBoolean':
|
|
118
|
+
case 'mathUnary':
|
|
119
|
+
case 'stringLength':
|
|
120
|
+
case 'parsedNumber':
|
|
121
|
+
case 'numberCheck':
|
|
122
|
+
case 'staticRequire':
|
|
123
|
+
case 'staticAssert':
|
|
124
|
+
case 'tagCheck':
|
|
125
|
+
case 'nullishCheck':
|
|
126
|
+
case 'arrayLiteral':
|
|
127
|
+
case 'arrayIndex': return null
|
|
128
|
+
// An array's length is fixed at construction (no push in the subset), so a length
|
|
129
|
+
// read over a nameable array could join the expression language later; not yet.
|
|
130
|
+
case 'arrayLength': return null
|
|
131
|
+
case 'property': {
|
|
132
|
+
const base = walk(instruction.object)
|
|
133
|
+
return base == null ? null : {kind: 'property', base, name: instruction.property}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return walk(value)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function staticRequirement(
|
|
141
|
+
instruction: InstructionIR | undefined,
|
|
142
|
+
site: SiteID,
|
|
143
|
+
context: ExpressionContext,
|
|
144
|
+
purpose?: 'finiteInput',
|
|
145
|
+
): Extract<InferredPrecondition, {kind: 'declaredComparison' | 'declaredNumberCheck'}> | null {
|
|
146
|
+
if (instruction?.kind === 'compare') {
|
|
147
|
+
const left = numericExpression(instruction.left, context)
|
|
148
|
+
const right = numericExpression(instruction.right, context)
|
|
149
|
+
return left == null || right == null
|
|
150
|
+
? null
|
|
151
|
+
: {kind: 'declaredComparison', operator: instruction.operator, left, right, site}
|
|
152
|
+
}
|
|
153
|
+
if (instruction?.kind === 'numberCheck') {
|
|
154
|
+
const expression = numericExpression(instruction.value, context)
|
|
155
|
+
return expression == null
|
|
156
|
+
? null
|
|
157
|
+
: {kind: 'declaredNumberCheck', predicate: instruction.predicate, expression, site, ...(purpose == null ? {} : {purpose})}
|
|
158
|
+
}
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A stable name for the runtime value an IR value holds. Forward value facts and exact
|
|
163
|
+
// same-value operations share this rule instead of maintaining separate notions of
|
|
164
|
+
// identity. Property and array reads are stable under the accepted subset's immutability
|
|
165
|
+
// rules; module and platform reads stay value-keyed because they may change between reads.
|
|
166
|
+
export function canonicalValueKey(value: ValueID, context: ExpressionContext): string {
|
|
167
|
+
const stored = resolveStoredValue(value, context)
|
|
168
|
+
if (stored !== value) return canonicalValueKey(stored, context)
|
|
169
|
+
const parameterIndex = context.parameterIndexByValue[value]
|
|
170
|
+
if (parameterIndex != null) return context.parameterIdentityKeys[parameterIndex] ?? `p${parameterIndex}`
|
|
171
|
+
const producer = context.instructionByValue[value]
|
|
172
|
+
if (producer?.kind === 'property') {
|
|
173
|
+
return `${canonicalValueKey(producer.object, context)}.${JSON.stringify(producer.property)}`
|
|
174
|
+
}
|
|
175
|
+
if (producer?.kind === 'arrayLength') return `${canonicalValueKey(producer.array, context)}.length`
|
|
176
|
+
if (producer?.kind === 'stringLength') return `${canonicalValueKey(producer.value, context)}.length`
|
|
177
|
+
if (producer?.kind === 'arrayIndex') {
|
|
178
|
+
return `${canonicalValueKey(producer.array, context)}[${canonicalValueKey(producer.index, context)}]`
|
|
179
|
+
}
|
|
180
|
+
return `v:${context.identityNamespace}${value}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function sameRuntimeValue(left: ValueID, right: ValueID, context: ExpressionContext): boolean {
|
|
184
|
+
return left === right || canonicalValueKey(left, context) === canonicalValueKey(right, context)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function addPrecondition(preconditions: InferredPrecondition[], candidate: InferredPrecondition): void {
|
|
188
|
+
if (candidate.kind === 'declaredNumberCheck') {
|
|
189
|
+
if (candidate.predicate === 'finite' && preconditions.some(precondition =>
|
|
190
|
+
precondition.kind === 'declaredNumberCheck'
|
|
191
|
+
&& (precondition.predicate === 'integer' || precondition.predicate === 'finite')
|
|
192
|
+
&& sameExpression(precondition.expression, candidate.expression))) return
|
|
193
|
+
if (candidate.predicate === 'integer') {
|
|
194
|
+
const redundantFinite = preconditions.findIndex(precondition =>
|
|
195
|
+
precondition.kind === 'declaredNumberCheck'
|
|
196
|
+
&& precondition.predicate === 'finite'
|
|
197
|
+
&& sameExpression(precondition.expression, candidate.expression))
|
|
198
|
+
if (redundantFinite >= 0) preconditions.splice(redundantFinite, 1)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!preconditions.some(precondition => samePrecondition(precondition, candidate))) preconditions.push(candidate)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function numericParameterPath(
|
|
205
|
+
expression: NumericExpression,
|
|
206
|
+
): {parameter: number; properties: string[]} | null {
|
|
207
|
+
if (expression.kind === 'parameter') return {parameter: expression.index, properties: []}
|
|
208
|
+
if (expression.kind !== 'property') return null
|
|
209
|
+
const base = numericParameterPath(expression.base)
|
|
210
|
+
return base == null ? null : {...base, properties: [...base.properties, expression.name]}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function constantRequirementStatus(
|
|
214
|
+
requirement: Extract<InferredPrecondition, {kind: 'declaredComparison' | 'declaredNumberCheck'}>,
|
|
215
|
+
): boolean | null {
|
|
216
|
+
if (requirement.kind === 'declaredNumberCheck') {
|
|
217
|
+
const value = constantNumericExpression(requirement.expression)
|
|
218
|
+
if (value == null) return null
|
|
219
|
+
switch (requirement.predicate) {
|
|
220
|
+
case 'finite': return Number.isFinite(value)
|
|
221
|
+
case 'integer': return Number.isInteger(value)
|
|
222
|
+
case 'nan': return Number.isNaN(value)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const left = constantNumericExpression(requirement.left)
|
|
226
|
+
const right = constantNumericExpression(requirement.right)
|
|
227
|
+
if (left == null || right == null) return null
|
|
228
|
+
switch (requirement.operator) {
|
|
229
|
+
case 'lessThan': return left < right
|
|
230
|
+
case 'lessThanOrEqual': return left <= right
|
|
231
|
+
case 'greaterThan': return left > right
|
|
232
|
+
case 'greaterThanOrEqual': return left >= right
|
|
233
|
+
case 'equal': return left === right
|
|
234
|
+
case 'notEqual': return left !== right
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function constantNumericExpression(expression: NumericExpression): number | null {
|
|
239
|
+
switch (expression.kind) {
|
|
240
|
+
case 'constant': return expression.value
|
|
241
|
+
case 'parameter':
|
|
242
|
+
case 'property': return null
|
|
243
|
+
case 'floor': {
|
|
244
|
+
const operand = constantNumericExpression(expression.operand)
|
|
245
|
+
return operand == null ? null : Math.floor(operand)
|
|
246
|
+
}
|
|
247
|
+
case 'binary': {
|
|
248
|
+
const left = constantNumericExpression(expression.left)
|
|
249
|
+
const right = constantNumericExpression(expression.right)
|
|
250
|
+
if (left == null || right == null) return null
|
|
251
|
+
switch (expression.operator) {
|
|
252
|
+
case 'add': return left + right
|
|
253
|
+
case 'subtract': return left - right
|
|
254
|
+
case 'multiply': return left * right
|
|
255
|
+
case 'divide': return left / right
|
|
256
|
+
case 'remainder': return left % right
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Rewrites a nonzero obligation into the simplest condition the caller can read, peeling
|
|
263
|
+
// only float-EXACT layers so the biconditional survives:
|
|
264
|
+
// - X - c is nonzero <=> X is not c (IEEE subtraction is zero only on exact equality)
|
|
265
|
+
// - X + c is nonzero <=> X is not -c (same argument)
|
|
266
|
+
// - c * X is nonzero <=> X is nonzero, when |c| >= 1 and finite (|c * x| >= |x| can
|
|
267
|
+
// never underflow to zero; small constants CAN — 1e-200 * 1e-200 is 0 — so those stay)
|
|
268
|
+
// - X / c never peels: a tiny dividend over a huge divisor underflows to zero.
|
|
269
|
+
// The multiply case recurses (still a nonzero form); a peel against a constant ends the
|
|
270
|
+
// chain (width is not 4 is an endpoint — further peeling through rounding would lie).
|
|
271
|
+
// Termination is structural: every step shrinks the expression.
|
|
272
|
+
export function peelNonzero(expression: NumericExpression, site: SiteID, operation: 'division' | 'remainder'): InferredPrecondition {
|
|
273
|
+
if (expression.kind === 'binary') {
|
|
274
|
+
const {operator, left, right} = expression
|
|
275
|
+
const constantSide = right.kind === 'constant' ? right : left.kind === 'constant' ? left : null
|
|
276
|
+
const otherSide = right.kind === 'constant' ? left : right
|
|
277
|
+
if (constantSide != null && Number.isFinite(constantSide.value)) {
|
|
278
|
+
if (operator === 'subtract') {
|
|
279
|
+
// c - X and X - c both peel to X is not c.
|
|
280
|
+
return {kind: 'notEqualConstant', expression: otherSide, value: constantSide.value, operation, site}
|
|
281
|
+
}
|
|
282
|
+
if (operator === 'add') {
|
|
283
|
+
return {kind: 'notEqualConstant', expression: otherSide, value: -constantSide.value, operation, site}
|
|
284
|
+
}
|
|
285
|
+
if (operator === 'multiply' && Math.abs(constantSide.value) >= 1) {
|
|
286
|
+
return peelNonzero(otherSide, site, operation)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return {kind: 'nonzero', expression, operation, site}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Keep one condition per originating operation. Propagated requirements retain the
|
|
294
|
+
// operation's site, so repeated calls with the same substituted expression collapse while
|
|
295
|
+
// separate operations that need the same condition remain separate findings.
|
|
296
|
+
function samePrecondition(left: InferredPrecondition, right: InferredPrecondition): boolean {
|
|
297
|
+
if (left.site !== right.site) return false
|
|
298
|
+
if (left.kind !== right.kind) return false
|
|
299
|
+
if (left.kind === 'inBounds' && right.kind === 'inBounds') {
|
|
300
|
+
return sameExpression(left.index, right.index) && sameExpression(left.sequence, right.sequence)
|
|
301
|
+
}
|
|
302
|
+
if (left.kind === 'inBounds' || right.kind === 'inBounds') return false
|
|
303
|
+
if (left.kind === 'declaredComparison' && right.kind === 'declaredComparison') {
|
|
304
|
+
return left.operator === right.operator
|
|
305
|
+
&& sameExpression(left.left, right.left)
|
|
306
|
+
&& sameExpression(left.right, right.right)
|
|
307
|
+
}
|
|
308
|
+
if (left.kind === 'declaredComparison' || right.kind === 'declaredComparison') return false
|
|
309
|
+
if (left.kind === 'declaredNumberCheck' && right.kind === 'declaredNumberCheck') {
|
|
310
|
+
return left.predicate === right.predicate && sameExpression(left.expression, right.expression)
|
|
311
|
+
}
|
|
312
|
+
if (left.kind === 'declaredNumberCheck' || right.kind === 'declaredNumberCheck') return false
|
|
313
|
+
if (left.kind === 'notEqualConstant' && right.kind === 'notEqualConstant' && left.value !== right.value) return false
|
|
314
|
+
return sameExpression(left.expression, right.expression)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function sameExpression(left: NumericExpression, right: NumericExpression): boolean {
|
|
318
|
+
if (left.kind !== right.kind) return false
|
|
319
|
+
switch (left.kind) {
|
|
320
|
+
case 'parameter': return left.index === (right as Extract<NumericExpression, {kind: 'parameter'}>).index
|
|
321
|
+
case 'constant': return left.value === (right as Extract<NumericExpression, {kind: 'constant'}>).value
|
|
322
|
+
case 'binary': {
|
|
323
|
+
const other = right as Extract<NumericExpression, {kind: 'binary'}>
|
|
324
|
+
return left.operator === other.operator
|
|
325
|
+
&& sameExpression(left.left, other.left)
|
|
326
|
+
&& sameExpression(left.right, other.right)
|
|
327
|
+
}
|
|
328
|
+
case 'floor': return sameExpression(left.operand, (right as Extract<NumericExpression, {kind: 'floor'}>).operand)
|
|
329
|
+
case 'property': {
|
|
330
|
+
const other = right as Extract<NumericExpression, {kind: 'property'}>
|
|
331
|
+
return left.name === other.name && sameExpression(left.base, other.base)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type {SiteID} from '../ir/ids.ts'
|
|
2
|
+
import type {ArithmeticOperator, ComparisonOperator} from '../ir/instructions.ts'
|
|
3
|
+
|
|
4
|
+
export type NumericExpression =
|
|
5
|
+
| {kind: 'parameter'; index: number}
|
|
6
|
+
| {kind: 'constant'; value: number}
|
|
7
|
+
| {kind: 'binary'; operator: ArithmeticOperator; left: NumericExpression; right: NumericExpression}
|
|
8
|
+
// Math.floor over a nameable expression. Lets a division by a floored value mint a
|
|
9
|
+
// requirement instead of stopping — and a floored divisor is an integer, so under the
|
|
10
|
+
// nonzero requirement its magnitude is at least 1 and the quotient stays finite.
|
|
11
|
+
| {kind: 'floor'; operand: NumericExpression}
|
|
12
|
+
// A property read off a nameable record, e.g. grid.columnCount. Sound to name because
|
|
13
|
+
// values are immutable after construction: the property cannot change between function
|
|
14
|
+
// entry and the operation that needs the requirement.
|
|
15
|
+
| {kind: 'property'; base: NumericExpression; name: string}
|
|
16
|
+
|
|
17
|
+
// An element read the engine could not prove in bounds: arr[i]! asserts presence, and
|
|
18
|
+
// when the index interval does not sit inside the length interval, the entry's guarantees
|
|
19
|
+
// rest on the read actually being in bounds. The peer of InferredPrecondition, minus the
|
|
20
|
+
// expression language (an assumption line needs only its site).
|
|
21
|
+
export type BoundsAssumption = {
|
|
22
|
+
site: SiteID
|
|
23
|
+
// What is accepted without proof at the site: an asserted element read is in bounds, or a
|
|
24
|
+
// divisor the requirement language cannot express over the caller's arguments (a join,
|
|
25
|
+
// a module read, an element read, a call result — or an exhausted expression walk) is
|
|
26
|
+
// nonzero. The divisor case is the fallback for what used to be the divisorUnknown
|
|
27
|
+
// stop: one honest assumes line instead of losing everything downstream of the division.
|
|
28
|
+
kind: 'elementInBounds' | 'nonzeroDivisor'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type InferredPrecondition =
|
|
32
|
+
| {
|
|
33
|
+
kind: 'nonzero'
|
|
34
|
+
expression: NumericExpression
|
|
35
|
+
// Which operation needs the divisor nonzero — division or remainder — for the prose.
|
|
36
|
+
operation: 'division' | 'remainder'
|
|
37
|
+
// Propagated records keep the callee's site, so a caller's report points at the
|
|
38
|
+
// actual operation even when the requirement surfaces two calls up.
|
|
39
|
+
site: SiteID
|
|
40
|
+
}
|
|
41
|
+
// An asserted element read (data[i]!) whose bounds the engine could not prove, with
|
|
42
|
+
// both the index and the sequence nameable over the caller's arguments. The caller-
|
|
43
|
+
// actionable upgrade of BoundsAssumption below: the condition is
|
|
44
|
+
// Number.isInteger(index) && 0 <= index < sequence.length.
|
|
45
|
+
| {
|
|
46
|
+
kind: 'inBounds'
|
|
47
|
+
index: NumericExpression
|
|
48
|
+
sequence: NumericExpression
|
|
49
|
+
site: SiteID
|
|
50
|
+
}
|
|
51
|
+
// The peeled form of a nonzero obligation: dividing by `width - 4` requires width to
|
|
52
|
+
// not be 4. Produced only by float-exact peeling (see peelNonzero), so the biconditional
|
|
53
|
+
// holds: the printed condition is neither weaker nor stronger than the divisor being
|
|
54
|
+
// nonzero.
|
|
55
|
+
| {
|
|
56
|
+
kind: 'notEqualConstant'
|
|
57
|
+
expression: NumericExpression
|
|
58
|
+
value: number
|
|
59
|
+
operation: 'division' | 'remainder'
|
|
60
|
+
site: SiteID
|
|
61
|
+
}
|
|
62
|
+
// A leading console.assert requirement after substituting the current caller's
|
|
63
|
+
// expressions. Structured operands keep propagation independent of source text.
|
|
64
|
+
| {
|
|
65
|
+
kind: 'declaredComparison'
|
|
66
|
+
operator: ComparisonOperator
|
|
67
|
+
left: NumericExpression
|
|
68
|
+
right: NumericExpression
|
|
69
|
+
site: SiteID
|
|
70
|
+
}
|
|
71
|
+
| {
|
|
72
|
+
kind: 'declaredNumberCheck'
|
|
73
|
+
predicate: 'integer' | 'finite' | 'nan'
|
|
74
|
+
expression: NumericExpression
|
|
75
|
+
site: SiteID
|
|
76
|
+
purpose?: 'finiteInput'
|
|
77
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {resolve} from 'node:path'
|
|
2
|
+
import * as ts from 'typescript'
|
|
3
|
+
import {TypeScriptDiagnosticsError} from './diagnostics.ts'
|
|
4
|
+
|
|
5
|
+
export type CheckedSource = {
|
|
6
|
+
sourceFile: ts.SourceFile
|
|
7
|
+
checker: ts.TypeChecker
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// When no tsconfig is in scope, single-file analysis gets the recommended authoring
|
|
11
|
+
// checks. Project file mode uses the project's existing Program instead of this helper.
|
|
12
|
+
const fallbackOptions: ts.CompilerOptions = {
|
|
13
|
+
target: ts.ScriptTarget.ESNext,
|
|
14
|
+
module: ts.ModuleKind.ESNext,
|
|
15
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
16
|
+
moduleDetection: ts.ModuleDetectionKind.Force,
|
|
17
|
+
strict: true,
|
|
18
|
+
noUncheckedIndexedAccess: true,
|
|
19
|
+
exactOptionalPropertyTypes: true,
|
|
20
|
+
noEmit: true,
|
|
21
|
+
skipLibCheck: true,
|
|
22
|
+
types: [],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function checkFile(file: string): CheckedSource {
|
|
26
|
+
const absoluteFile = resolve(file)
|
|
27
|
+
const program = ts.createProgram([absoluteFile], fallbackOptions)
|
|
28
|
+
return checkedSource(program, absoluteFile, fallbackOptions)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function checkSource(file: string, source: string): CheckedSource {
|
|
32
|
+
const absoluteFile = resolve(file)
|
|
33
|
+
const sourceFile = ts.createSourceFile(absoluteFile, source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS)
|
|
34
|
+
const defaultHost = ts.createCompilerHost(fallbackOptions)
|
|
35
|
+
const host: ts.CompilerHost = {
|
|
36
|
+
...defaultHost,
|
|
37
|
+
getSourceFile: (requestedFile, languageVersion, onError, shouldCreateNewSourceFile) => {
|
|
38
|
+
if (resolve(requestedFile) === absoluteFile) return sourceFile
|
|
39
|
+
return defaultHost.getSourceFile(requestedFile, languageVersion, onError, shouldCreateNewSourceFile)
|
|
40
|
+
},
|
|
41
|
+
fileExists: requestedFile => resolve(requestedFile) === absoluteFile || defaultHost.fileExists(requestedFile),
|
|
42
|
+
readFile: requestedFile => resolve(requestedFile) === absoluteFile ? source : defaultHost.readFile(requestedFile),
|
|
43
|
+
}
|
|
44
|
+
const program = ts.createProgram([absoluteFile], fallbackOptions, host)
|
|
45
|
+
return checkedSource(program, absoluteFile, fallbackOptions)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function checkedSource(program: ts.Program, file: string, options: ts.CompilerOptions): CheckedSource {
|
|
49
|
+
const diagnostics = ts.getPreEmitDiagnostics(program)
|
|
50
|
+
if (diagnostics.length > 0) {
|
|
51
|
+
throw new TypeScriptDiagnosticsError(diagnostics, options, process.cwd())
|
|
52
|
+
}
|
|
53
|
+
const sourceFile = program.getSourceFile(file)
|
|
54
|
+
if (sourceFile == null) throw new Error(`TypeScript did not load ${file}`)
|
|
55
|
+
return {sourceFile, checker: program.getTypeChecker()}
|
|
56
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
export type DiagnosticLevel = 'error' | 'warning' | 'suggestion'
|
|
4
|
+
|
|
5
|
+
type DiagnosticLocation = {
|
|
6
|
+
file: string
|
|
7
|
+
line: number
|
|
8
|
+
column: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class TypeScriptDiagnosticsError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly diagnostics: readonly ts.Diagnostic[],
|
|
14
|
+
readonly options: ts.CompilerOptions,
|
|
15
|
+
readonly currentDirectory: string,
|
|
16
|
+
) {
|
|
17
|
+
super(formatTypeScriptDiagnostics(diagnostics, {...options, pretty: false}, currentDirectory))
|
|
18
|
+
this.name = 'TypeScriptDiagnosticsError'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatTypeScriptDiagnostics(
|
|
23
|
+
diagnostics: readonly ts.Diagnostic[],
|
|
24
|
+
options: ts.CompilerOptions,
|
|
25
|
+
currentDirectory: string,
|
|
26
|
+
): string {
|
|
27
|
+
const host: ts.FormatDiagnosticsHost = {
|
|
28
|
+
getCurrentDirectory: () => currentDirectory,
|
|
29
|
+
getCanonicalFileName: ts.sys.useCaseSensitiveFileNames ? file => file : file => file.toLowerCase(),
|
|
30
|
+
getNewLine: () => ts.sys.newLine,
|
|
31
|
+
}
|
|
32
|
+
return usePrettyOutput(options['pretty'])
|
|
33
|
+
? ts.formatDiagnosticsWithColorAndContext(diagnostics, host)
|
|
34
|
+
: ts.formatDiagnostics(diagnostics, host)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function usePrettyOutput(configured?: unknown): boolean {
|
|
38
|
+
if (typeof configured === 'boolean') return configured
|
|
39
|
+
const noColor = process.env['NO_COLOR']
|
|
40
|
+
if (noColor != null && noColor !== '') return false
|
|
41
|
+
const forceColor = process.env['FORCE_COLOR']
|
|
42
|
+
if (forceColor != null && forceColor !== '') return true
|
|
43
|
+
return ts.sys.writeOutputIsTTY?.() === true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function color(code: number, text: string | number): string {
|
|
47
|
+
return `\u001B[${code}m${text}\u001B[0m`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatDiagnosticPrefix(
|
|
51
|
+
location: DiagnosticLocation,
|
|
52
|
+
level: DiagnosticLevel,
|
|
53
|
+
rule: string,
|
|
54
|
+
pretty: boolean,
|
|
55
|
+
): string {
|
|
56
|
+
const formattedLocation = formatDiagnosticLocation(location, pretty)
|
|
57
|
+
const separator = pretty ? ' - ' : ': '
|
|
58
|
+
const levelColor = level === 'error' ? 91 : level === 'warning' ? 93 : 96
|
|
59
|
+
const formattedLevel = pretty ? color(levelColor, level) : level
|
|
60
|
+
const ruleLabel = ` [${rule}]: `
|
|
61
|
+
return `${formattedLocation}${separator}${formattedLevel}${pretty ? color(90, ruleLabel) : ruleLabel}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function formatDiagnosticLocation(location: DiagnosticLocation, pretty: boolean): string {
|
|
65
|
+
const {file, line, column} = location
|
|
66
|
+
return pretty
|
|
67
|
+
? `${color(96, file)}:${color(93, line)}:${color(93, column)}`
|
|
68
|
+
: `${file}(${line},${column})`
|
|
69
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {dirname, isAbsolute, relative, resolve, sep} from 'node:path'
|
|
2
|
+
import * as ts from 'typescript'
|
|
3
|
+
import {TypeScriptDiagnosticsError} from './diagnostics.ts'
|
|
4
|
+
|
|
5
|
+
export type LoadedTypeScriptProject = {
|
|
6
|
+
rootDirectory: string
|
|
7
|
+
parsed: ts.ParsedCommandLine
|
|
8
|
+
program: ts.Program
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ProjectSource = {
|
|
12
|
+
project: LoadedTypeScriptProject
|
|
13
|
+
sourceFile: ts.SourceFile
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function findTypeScriptConfig(searchFrom: string): string | null {
|
|
17
|
+
return ts.findConfigFile(resolve(searchFrom), file => ts.sys.fileExists(file), 'tsconfig.json') ?? null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function loadTypeScriptProjectGraph(configPath: string): LoadedTypeScriptProject[] {
|
|
21
|
+
const loaded: LoadedTypeScriptProject[] = []
|
|
22
|
+
const byConfigPath = new Map<string, LoadedTypeScriptProject | null>()
|
|
23
|
+
|
|
24
|
+
const load = (requestedConfigPath: string): LoadedTypeScriptProject => {
|
|
25
|
+
const absoluteConfigPath = resolve(requestedConfigPath)
|
|
26
|
+
const existing = byConfigPath.get(absoluteConfigPath)
|
|
27
|
+
if (existing === null) {
|
|
28
|
+
throw new Error(`Circular TypeScript project reference involving ${absoluteConfigPath}`)
|
|
29
|
+
}
|
|
30
|
+
if (existing !== undefined) return existing
|
|
31
|
+
byConfigPath.set(absoluteConfigPath, null)
|
|
32
|
+
const parsed = parseConfig(absoluteConfigPath)
|
|
33
|
+
requireStrictNullChecks(parsed.options, absoluteConfigPath)
|
|
34
|
+
for (const reference of parsed.projectReferences ?? []) load(ts.resolveProjectReferencePath(reference))
|
|
35
|
+
const program = ts.createProgram({
|
|
36
|
+
rootNames: parsed.fileNames,
|
|
37
|
+
options: parsed.options,
|
|
38
|
+
configFileParsingDiagnostics: parsed.errors,
|
|
39
|
+
...(parsed.projectReferences == null ? {} : {projectReferences: parsed.projectReferences}),
|
|
40
|
+
})
|
|
41
|
+
const project = {
|
|
42
|
+
rootDirectory: dirname(absoluteConfigPath),
|
|
43
|
+
parsed,
|
|
44
|
+
program,
|
|
45
|
+
}
|
|
46
|
+
byConfigPath.set(absoluteConfigPath, project)
|
|
47
|
+
loaded.push(project)
|
|
48
|
+
return project
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
load(configPath)
|
|
52
|
+
return loaded
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function projectSources(projects: LoadedTypeScriptProject[]): ProjectSource[] {
|
|
56
|
+
const sources = new Map<string, ProjectSource>()
|
|
57
|
+
for (const project of projects) {
|
|
58
|
+
for (const sourceFile of project.program.getSourceFiles()) {
|
|
59
|
+
if (sourceFile.isDeclarationFile || sourceFile.fileName.includes(`${sep}node_modules${sep}`)) continue
|
|
60
|
+
const absoluteFile = resolve(sourceFile.fileName)
|
|
61
|
+
const existing = sources.get(absoluteFile)
|
|
62
|
+
const candidate = {project, sourceFile}
|
|
63
|
+
if (existing == null
|
|
64
|
+
|| ownershipScore(project, absoluteFile) > ownershipScore(existing.project, absoluteFile)) {
|
|
65
|
+
sources.set(absoluteFile, candidate)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...sources.values()]
|
|
70
|
+
.sort((left, right) => left.sourceFile.fileName.localeCompare(right.sourceFile.fileName))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseConfig(configPath: string): ts.ParsedCommandLine {
|
|
74
|
+
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, undefined, {
|
|
75
|
+
...ts.sys,
|
|
76
|
+
onUnRecoverableConfigFileDiagnostic: diagnostic => {
|
|
77
|
+
throw new TypeScriptDiagnosticsError([diagnostic], {}, dirname(configPath))
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
if (parsed == null) throw new Error(`TypeScript could not parse ${configPath}`)
|
|
81
|
+
if (parsed.errors.length > 0) {
|
|
82
|
+
throw new TypeScriptDiagnosticsError(parsed.errors, parsed.options, dirname(configPath))
|
|
83
|
+
}
|
|
84
|
+
return parsed
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function requireStrictNullChecks(options: ts.CompilerOptions, configPath: string): void {
|
|
88
|
+
// TypeScript 6 defaults strict mode on. An explicit strictNullChecks setting wins;
|
|
89
|
+
// otherwise strict:false is the only way the effective option is disabled.
|
|
90
|
+
const enabled = options.strictNullChecks ?? options.strict !== false
|
|
91
|
+
if (enabled) return
|
|
92
|
+
throw new Error(
|
|
93
|
+
`freerange requires strictNullChecks. Enable "strict": true or "strictNullChecks": true in ${configPath}.`,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ownershipScore(project: LoadedTypeScriptProject, file: string): number {
|
|
98
|
+
const path = relative(project.rootDirectory, file)
|
|
99
|
+
const inside = path === '' || (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${sep}`))
|
|
100
|
+
return inside ? project.rootDirectory.length : -1
|
|
101
|
+
}
|