@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,745 @@
|
|
|
1
|
+
import {constantNumber} from '../domain/number.ts'
|
|
2
|
+
import {joinValues, type AbstractValue} from '../domain/value.ts'
|
|
3
|
+
import type {BlockID, FunctionID, ModuleBindingID, SiteID} from '../ir/ids.ts'
|
|
4
|
+
import {functionUsage, transitiveModuleBindings} from '../ir/function-usage.ts'
|
|
5
|
+
import {finiteInputExpression, finiteInputs} from '../ir/finite-inputs.ts'
|
|
6
|
+
import type {EdgeIR} from '../ir/instructions.ts'
|
|
7
|
+
import {declaredKindOf, declaredKindValue, holdsMutableStructure, type FunctionIR, type ProgramIR} from '../ir/program.ts'
|
|
8
|
+
import {addPrecondition, constantRequirementStatus, createExpressionContext, staticRequirement} from '../requirements/infer.ts'
|
|
9
|
+
import type {BoundsAssumption, InferredPrecondition, NumericExpression} from '../requirements/model.ts'
|
|
10
|
+
import {
|
|
11
|
+
completedEvaluation,
|
|
12
|
+
type AssertionVerdict,
|
|
13
|
+
type FunctionAnalysis,
|
|
14
|
+
type FunctionEvaluation,
|
|
15
|
+
type LoweredFunctionAnalysis,
|
|
16
|
+
type ProgramAnalysis,
|
|
17
|
+
type Stop,
|
|
18
|
+
} from './outcome.ts'
|
|
19
|
+
import {
|
|
20
|
+
cloneSharedState,
|
|
21
|
+
cloneState,
|
|
22
|
+
emptySharedState,
|
|
23
|
+
intersectValueFacts,
|
|
24
|
+
joinModuleSlots,
|
|
25
|
+
mergeStates,
|
|
26
|
+
type ExecutionState,
|
|
27
|
+
type SharedState,
|
|
28
|
+
type ValueFact,
|
|
29
|
+
} from './state.ts'
|
|
30
|
+
import {
|
|
31
|
+
asRefinableCheck,
|
|
32
|
+
branchConditionOutcome,
|
|
33
|
+
evaluateInstruction,
|
|
34
|
+
refineCheck,
|
|
35
|
+
requiredValue,
|
|
36
|
+
} from './transfer.ts'
|
|
37
|
+
|
|
38
|
+
// A termination backstop, not an iteration budget: the count is fixed-point rounds of one
|
|
39
|
+
// loop header's abstract state, unrelated to runtime iteration counts. Widening makes
|
|
40
|
+
// ordinary counting loops converge in two or three rounds.
|
|
41
|
+
const maximumLoopHeaderUpdates = 16
|
|
42
|
+
|
|
43
|
+
export function analyzeProgram(program: ProgramIR): ProgramAnalysis {
|
|
44
|
+
// The initializer's slots start uninitialized — a top-level read before the writing
|
|
45
|
+
// declaration must stop — except imported constants: the exporting module ran before
|
|
46
|
+
// this module's first statement, so the slot already holds the literal. (A cycle read
|
|
47
|
+
// that beats the exporting declaration throws instead of yielding a stale value; see
|
|
48
|
+
// importedCategory in src/lower/module.ts.)
|
|
49
|
+
const initializerState = emptySharedState(program.moduleBindings.length)
|
|
50
|
+
for (let binding = 0; binding < program.moduleBindings.length; binding++) {
|
|
51
|
+
const category = program.moduleBindings[binding]!.category
|
|
52
|
+
if (category.kind === 'importedConstant') {
|
|
53
|
+
initializerState[binding] = constantNumber(category.value)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// The initializer runs first, so top-level calls into declared functions see the module
|
|
57
|
+
// state built so far, and its results decide what later function analysis may trust.
|
|
58
|
+
const initializer = runEvaluation(
|
|
59
|
+
program.initializer,
|
|
60
|
+
null,
|
|
61
|
+
[],
|
|
62
|
+
[],
|
|
63
|
+
initializerState,
|
|
64
|
+
program,
|
|
65
|
+
[],
|
|
66
|
+
{identityNamespace: 'module/'},
|
|
67
|
+
)
|
|
68
|
+
const moduleValues = publishedModuleValues(program, initializer.run, initializer.evaluation)
|
|
69
|
+
const functionEntrySharedState = seedModuleSlots(program, moduleValues)
|
|
70
|
+
const moduleReads = transitiveModuleBindings(functionUsage(program))
|
|
71
|
+
const initializerBounds = initializer.evaluation.boundsAssumptions
|
|
72
|
+
const functions: FunctionAnalysis[] = []
|
|
73
|
+
for (let functionID = 0; functionID < program.functions.length; functionID++) {
|
|
74
|
+
const fn = program.functions[functionID]!
|
|
75
|
+
if (fn.kind === 'unsupported') {
|
|
76
|
+
functions.push({kind: 'notLowered', lowering: fn})
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
const arguments_: AbstractValue[] = []
|
|
80
|
+
const argumentExpressions: Array<NumericExpression | null> = []
|
|
81
|
+
const sharedState = cloneSharedState(functionEntrySharedState)
|
|
82
|
+
for (let index = 0; index < fn.parameters.length; index++) {
|
|
83
|
+
const parameter = fn.parameters[index]!
|
|
84
|
+
// Seeded from the declared kind — the same assumed-finite constructor module hedges
|
|
85
|
+
// use, with the assumes lines carrying the conditionality. Every parameter is
|
|
86
|
+
// nameable in requirement expressions; only numeric operations ever surface one, so
|
|
87
|
+
// a non-numeric parameter's expression is simply never printed.
|
|
88
|
+
arguments_.push(declaredKindValue(parameter.type))
|
|
89
|
+
argumentExpressions.push({kind: 'parameter', index})
|
|
90
|
+
}
|
|
91
|
+
const {evaluation} = runEvaluation(
|
|
92
|
+
fn,
|
|
93
|
+
functionID,
|
|
94
|
+
arguments_,
|
|
95
|
+
argumentExpressions,
|
|
96
|
+
sharedState,
|
|
97
|
+
program,
|
|
98
|
+
[],
|
|
99
|
+
{
|
|
100
|
+
boundsAssumptions: moduleReads[functionID]!.size > 0 ? initializerBounds : [],
|
|
101
|
+
identityNamespace: `function:${functionID}/`,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
functions.push(publishedAnalysis(fn, evaluation))
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
functions,
|
|
108
|
+
initializer: publishedAnalysis(program.initializer, initializer.evaluation),
|
|
109
|
+
moduleValues,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function publishedAnalysis(fn: FunctionIR, evaluation: FunctionEvaluation): LoweredFunctionAnalysis {
|
|
114
|
+
const completed = completedEvaluation(evaluation)
|
|
115
|
+
if (completed != null) {
|
|
116
|
+
return {
|
|
117
|
+
kind: 'analyzed',
|
|
118
|
+
lowering: fn,
|
|
119
|
+
preconditions: publishedPreconditions(fn, completed.preconditions),
|
|
120
|
+
boundsAssumptions: completed.boundsAssumptions,
|
|
121
|
+
returnValue: completed.returnValue,
|
|
122
|
+
assertions: evaluation.assertions,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const [firstStop, ...laterStops] = evaluation.stops
|
|
126
|
+
// Every path throws: the function is fully analyzed, it just never returns normally —
|
|
127
|
+
// no ensures lines exist to print, and callers stop honestly at the call.
|
|
128
|
+
if (firstStop == null && evaluation.normal == null) {
|
|
129
|
+
return {
|
|
130
|
+
kind: 'analyzed',
|
|
131
|
+
lowering: fn,
|
|
132
|
+
preconditions: publishedPreconditions(fn, evaluation.preconditions),
|
|
133
|
+
boundsAssumptions: evaluation.boundsAssumptions,
|
|
134
|
+
returnValue: {kind: 'void'},
|
|
135
|
+
assertions: evaluation.assertions,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (firstStop == null) throw new Error(`Function ${fn.name} has no reachable return`)
|
|
139
|
+
return {
|
|
140
|
+
kind: 'partial',
|
|
141
|
+
lowering: fn,
|
|
142
|
+
stops: [firstStop, ...laterStops],
|
|
143
|
+
observedReturn: evaluation.normal == null ? null : {value: evaluation.normal.returnValue},
|
|
144
|
+
observedNeeds: evaluation.preconditions,
|
|
145
|
+
observedBoundsAssumptions: evaluation.boundsAssumptions,
|
|
146
|
+
assertions: evaluation.assertions,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function publishedPreconditions(
|
|
151
|
+
fn: FunctionIR,
|
|
152
|
+
evaluated: InferredPrecondition[],
|
|
153
|
+
): InferredPrecondition[] {
|
|
154
|
+
const preconditions: InferredPrecondition[] = []
|
|
155
|
+
for (const input of finiteInputs(fn)) {
|
|
156
|
+
preconditions.push({
|
|
157
|
+
kind: 'declaredNumberCheck',
|
|
158
|
+
predicate: 'finite',
|
|
159
|
+
expression: finiteInputExpression(input),
|
|
160
|
+
site: input.site,
|
|
161
|
+
purpose: 'finiteInput',
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
const expressionContext = createExpressionContext(
|
|
165
|
+
fn,
|
|
166
|
+
fn.parameters.map((_, index) => ({kind: 'parameter', index})),
|
|
167
|
+
)
|
|
168
|
+
for (const block of fn.blocks) {
|
|
169
|
+
for (const instruction of block.instructions) {
|
|
170
|
+
if (instruction.kind !== 'staticRequire' || instruction.purpose === 'finiteInput') continue
|
|
171
|
+
const requirement = staticRequirement(
|
|
172
|
+
expressionContext.instructionByValue[instruction.value],
|
|
173
|
+
instruction.site,
|
|
174
|
+
expressionContext,
|
|
175
|
+
)
|
|
176
|
+
if (requirement != null && constantRequirementStatus(requirement) == null) {
|
|
177
|
+
addPrecondition(preconditions, requirement)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const precondition of evaluated) addPrecondition(preconditions, precondition)
|
|
182
|
+
return preconditions
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// What each function's module slots start from. A published value is trusted exactly, and
|
|
186
|
+
// so is an imported constant's literal; otherwise a binding of representable declared kind
|
|
187
|
+
// (number, boolean, record shape) contributes that kind, and every other binding stays
|
|
188
|
+
// uninitialized so reads stop.
|
|
189
|
+
function seedModuleSlots(program: ProgramIR, moduleValues: Array<AbstractValue | null>): SharedState {
|
|
190
|
+
return program.moduleBindings.map((binding, index) => {
|
|
191
|
+
const published = moduleValues[index]
|
|
192
|
+
if (published != null) return published
|
|
193
|
+
if (binding.category.kind === 'importedConstant') {
|
|
194
|
+
return constantNumber(binding.category.value)
|
|
195
|
+
}
|
|
196
|
+
const declaredKind = declaredKindOf(binding.category)
|
|
197
|
+
if (declaredKind == null) return null
|
|
198
|
+
return declaredKindValue(declaredKind)
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The values functions may trust, per binding: the binding's category must allow a value,
|
|
203
|
+
// the slot must be initialized at every path end of the initializer (stops included), and
|
|
204
|
+
// no write to the binding may sit where the analysis stopped following — inside the
|
|
205
|
+
// stopping block past the stop, or in any block still reachable from it (loops included,
|
|
206
|
+
// since a stop can first appear on a late widening round).
|
|
207
|
+
function publishedModuleValues(
|
|
208
|
+
program: ProgramIR,
|
|
209
|
+
run: EvaluationRun,
|
|
210
|
+
evaluation: FunctionEvaluation,
|
|
211
|
+
): Array<AbstractValue | null> {
|
|
212
|
+
const fn = program.initializer
|
|
213
|
+
const end = evaluation.normal == null
|
|
214
|
+
? run.moduleEnd
|
|
215
|
+
: run.moduleEnd == null
|
|
216
|
+
? evaluation.normal.sharedState
|
|
217
|
+
: joinModuleSlots(run.moduleEnd, evaluation.normal.sharedState)
|
|
218
|
+
|
|
219
|
+
const demoted = new Set<ModuleBindingID>()
|
|
220
|
+
const successors = blockSuccessors(fn)
|
|
221
|
+
const stoppingBlocks: BlockID[] = []
|
|
222
|
+
for (let blockID = 0; blockID < fn.blocks.length; blockID++) {
|
|
223
|
+
const stopIndex = run.blocks[blockID]!.stopIndex
|
|
224
|
+
if (stopIndex == null) continue
|
|
225
|
+
stoppingBlocks.push(blockID)
|
|
226
|
+
const instructions = fn.blocks[blockID]!.instructions
|
|
227
|
+
for (let index = stopIndex; index < instructions.length; index++) {
|
|
228
|
+
const instruction = instructions[index]!
|
|
229
|
+
if (instruction.kind === 'moduleWrite') demoted.add(instruction.binding)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const reachedAfterStops = reachableAfter(successors, stoppingBlocks)
|
|
233
|
+
for (let target = 0; target < fn.blocks.length; target++) {
|
|
234
|
+
if (reachedAfterStops[target] !== true) continue
|
|
235
|
+
for (const instruction of fn.blocks[target]!.instructions) {
|
|
236
|
+
if (instruction.kind === 'moduleWrite') demoted.add(instruction.binding)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Exact structural publishing (records, tuples, arrays — nullish-wrapped included)
|
|
241
|
+
// additionally requires the whole file to be fully analyzed. Analyzed code cannot write
|
|
242
|
+
// into an object, but rejected function bodies and skipped statements run at runtime
|
|
243
|
+
// too, and they can mutate a structure through any alias — `Object.assign(config, ...)`
|
|
244
|
+
// or `queue?.push(x)` inside a function that never lowered, invisible to the whole-file
|
|
245
|
+
// write scan because the binding sits in argument or receiver position, not write
|
|
246
|
+
// position. Scalars are unaffected: a number is copied on read, so only a write-position
|
|
247
|
+
// form on the binding itself can change it, and the scan sees those even in rejected
|
|
248
|
+
// bodies. When the file is not fully analyzed, structural bindings fall back to their
|
|
249
|
+
// declared-shape hedge with per-leaf assumes lines.
|
|
250
|
+
const fullyAnalyzed = evaluation.stops.length === 0
|
|
251
|
+
&& program.initializerSkips.length === 0
|
|
252
|
+
&& program.functions.every(lowered => lowered.kind === 'lowered')
|
|
253
|
+
|
|
254
|
+
return program.moduleBindings.map((binding, index) => {
|
|
255
|
+
if (binding.category.kind !== 'value' || demoted.has(index)) return null
|
|
256
|
+
// holdsMutableStructure, not a top-level tag check: a `number[] | null` binding is
|
|
257
|
+
// nullish at the top level yet the array inside is exactly as alias-mutable.
|
|
258
|
+
if (holdsMutableStructure(binding.category.declaredKind) && !fullyAnalyzed) return null
|
|
259
|
+
const slot = end?.[index]
|
|
260
|
+
return slot ?? null
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// One entry per reachable block: the joined state flowing into the block, and how many
|
|
265
|
+
// times that state has been updated (loop headers widen from the second update on).
|
|
266
|
+
type IncomingState = {
|
|
267
|
+
state: ExecutionState
|
|
268
|
+
updateCount: number
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// One block's bookkeeping for the run; every field lives and dies with the evaluation, so
|
|
272
|
+
// they share one record per block instead of parallel arrays that could drift apart.
|
|
273
|
+
type BlockRun = {
|
|
274
|
+
incoming: IncomingState | null
|
|
275
|
+
// The instruction index where the block first stopped (instructions.length for a stop
|
|
276
|
+
// terminator); null when no visit stopped. The module publish rule demotes writes from
|
|
277
|
+
// here onward, and the failed-header closure treats the block as cut.
|
|
278
|
+
stopIndex: number | null
|
|
279
|
+
// A loop header whose state never stabilized. Returns reachable from a failed header
|
|
280
|
+
// are not evidence — they were computed from a state short of its fixed point.
|
|
281
|
+
failedHeader: boolean
|
|
282
|
+
// The latest return recorded from the block; overwritten on re-visits (incoming states
|
|
283
|
+
// grow monotonically, so the last visit supersedes earlier ones) and joined only after
|
|
284
|
+
// the worklist drains.
|
|
285
|
+
pendingReturn: {value: AbstractValue; shared: SharedState; valueFacts: ValueFact[]} | null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
type AssertionObservation = {
|
|
289
|
+
sawDefinitelyTrue: boolean
|
|
290
|
+
sawDefinitelyFalse: boolean
|
|
291
|
+
sawMaybeFalse: boolean
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Everything one evaluation accumulates; created and discarded together.
|
|
295
|
+
type EvaluationRun = {
|
|
296
|
+
fn: FunctionIR
|
|
297
|
+
// Dense, indexed by BlockID.
|
|
298
|
+
blocks: BlockRun[]
|
|
299
|
+
queue: BlockID[]
|
|
300
|
+
stops: Stop[]
|
|
301
|
+
// Dense by FunctionIR.assertions index when present.
|
|
302
|
+
assertionObservations: Array<AssertionObservation | undefined>
|
|
303
|
+
// Module slots joined across every stop, then with the normal end by the publish rule.
|
|
304
|
+
moduleEnd: SharedState | null
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
type EvaluationSeed = {
|
|
308
|
+
boundsAssumptions?: BoundsAssumption[]
|
|
309
|
+
valueFacts?: ValueFact[]
|
|
310
|
+
parameterIdentityKeys?: string[]
|
|
311
|
+
identityNamespace?: string
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function runEvaluation(
|
|
315
|
+
fn: FunctionIR,
|
|
316
|
+
functionID: FunctionID | null,
|
|
317
|
+
arguments_: AbstractValue[],
|
|
318
|
+
argumentExpressions: Array<NumericExpression | null>,
|
|
319
|
+
sharedState: SharedState,
|
|
320
|
+
program: ProgramIR,
|
|
321
|
+
callStack: FunctionID[],
|
|
322
|
+
seed: EvaluationSeed = {},
|
|
323
|
+
): {evaluation: FunctionEvaluation; run: EvaluationRun} {
|
|
324
|
+
if (arguments_.length !== fn.parameters.length) throw new Error(`Expected ${fn.parameters.length} arguments for ${fn.name}`)
|
|
325
|
+
if (argumentExpressions.length !== fn.parameters.length) throw new Error(`Expected ${fn.parameters.length} argument expressions for ${fn.name}`)
|
|
326
|
+
const initial: ExecutionState = {
|
|
327
|
+
values: [],
|
|
328
|
+
shared: cloneSharedState(sharedState),
|
|
329
|
+
valueFacts: seed.valueFacts?.slice() ?? [],
|
|
330
|
+
}
|
|
331
|
+
for (let index = 0; index < fn.parameters.length; index++) {
|
|
332
|
+
initial.values[fn.parameters[index]!.value] = arguments_[index]!
|
|
333
|
+
}
|
|
334
|
+
const expressionContext = createExpressionContext(
|
|
335
|
+
fn,
|
|
336
|
+
argumentExpressions,
|
|
337
|
+
seed.parameterIdentityKeys,
|
|
338
|
+
seed.identityNamespace ?? fn.name,
|
|
339
|
+
)
|
|
340
|
+
const preconditions: InferredPrecondition[] = []
|
|
341
|
+
const boundsAssumptions: BoundsAssumption[] = [...(seed.boundsAssumptions ?? [])]
|
|
342
|
+
const successors = blockSuccessors(fn)
|
|
343
|
+
const run: EvaluationRun = {
|
|
344
|
+
fn,
|
|
345
|
+
blocks: fn.blocks.map(() => ({incoming: null, stopIndex: null, failedHeader: false, pendingReturn: null})),
|
|
346
|
+
queue: [fn.entry],
|
|
347
|
+
stops: [],
|
|
348
|
+
assertionObservations: [],
|
|
349
|
+
moduleEnd: null,
|
|
350
|
+
}
|
|
351
|
+
run.blocks[fn.entry]!.incoming = {state: initial, updateCount: 0}
|
|
352
|
+
// Invariant for the whole evaluation (engineering.md's loop-invariant rule): built once
|
|
353
|
+
// instead of allocating a context object and closure per instruction per fixed-point
|
|
354
|
+
// round. preconditions is shared by reference and accumulates.
|
|
355
|
+
const transferContext = {
|
|
356
|
+
program,
|
|
357
|
+
callStack: functionID == null ? callStack : [...callStack, functionID],
|
|
358
|
+
expressionContext,
|
|
359
|
+
preconditions,
|
|
360
|
+
boundsAssumptions,
|
|
361
|
+
evaluateFunction: (
|
|
362
|
+
callee: FunctionID,
|
|
363
|
+
values: AbstractValue[],
|
|
364
|
+
expressions: Array<NumericExpression | null>,
|
|
365
|
+
calleeState: SharedState,
|
|
366
|
+
stack: FunctionID[],
|
|
367
|
+
valueFacts: ValueFact[],
|
|
368
|
+
parameterIdentityKeys: string[],
|
|
369
|
+
identityNamespace: string,
|
|
370
|
+
) => {
|
|
371
|
+
const calleeFn = program.functions[callee]
|
|
372
|
+
if (calleeFn == null) throw new Error(`Unknown function ${callee}`)
|
|
373
|
+
// Callers turn calls to unlowered functions into calleeStopped records first.
|
|
374
|
+
if (calleeFn.kind !== 'lowered') throw new Error(`Analysis reached unlowered function ${calleeFn.name}`)
|
|
375
|
+
return runEvaluation(
|
|
376
|
+
calleeFn,
|
|
377
|
+
callee,
|
|
378
|
+
values,
|
|
379
|
+
expressions,
|
|
380
|
+
calleeState,
|
|
381
|
+
program,
|
|
382
|
+
stack,
|
|
383
|
+
{valueFacts, parameterIdentityKeys, identityNamespace},
|
|
384
|
+
).evaluation
|
|
385
|
+
},
|
|
386
|
+
}
|
|
387
|
+
let queueIndex = 0
|
|
388
|
+
while (queueIndex < run.queue.length) {
|
|
389
|
+
const blockID = run.queue[queueIndex++]!
|
|
390
|
+
const block = fn.blocks[blockID]
|
|
391
|
+
const entry = run.blocks[blockID]?.incoming
|
|
392
|
+
if (block == null || entry == null) throw new Error(`Missing block ${blockID} in ${fn.name}`)
|
|
393
|
+
const state = cloneState(entry.state)
|
|
394
|
+
let stopped = false
|
|
395
|
+
instructionLoop:
|
|
396
|
+
for (let index = 0; index < block.instructions.length; index++) {
|
|
397
|
+
const instruction = block.instructions[index]!
|
|
398
|
+
const result = evaluateInstruction(instruction, state, transferContext)
|
|
399
|
+
switch (result.kind) {
|
|
400
|
+
case 'ends':
|
|
401
|
+
// The path terminates like an inline throw: nothing recorded, nothing returned.
|
|
402
|
+
run.blocks[blockID]!.pendingReturn = null
|
|
403
|
+
stopped = true
|
|
404
|
+
break instructionLoop
|
|
405
|
+
case 'stop':
|
|
406
|
+
addStop(
|
|
407
|
+
run,
|
|
408
|
+
blockID,
|
|
409
|
+
result.stop,
|
|
410
|
+
state.shared.slice(),
|
|
411
|
+
index,
|
|
412
|
+
)
|
|
413
|
+
// A return recorded by an earlier visit of this block described a smaller incoming
|
|
414
|
+
// state; the stop supersedes it.
|
|
415
|
+
run.blocks[blockID]!.pendingReturn = null
|
|
416
|
+
stopped = true
|
|
417
|
+
break instructionLoop
|
|
418
|
+
case 'assertion':
|
|
419
|
+
addAssertionObservation(run, result.assertion, result.observation)
|
|
420
|
+
state.values[instruction.result] = result.value
|
|
421
|
+
break
|
|
422
|
+
case 'value':
|
|
423
|
+
state.values[instruction.result] = result.value
|
|
424
|
+
break
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (stopped) continue
|
|
428
|
+
switch (block.terminator.kind) {
|
|
429
|
+
case 'return': {
|
|
430
|
+
const value = block.terminator.value == null
|
|
431
|
+
? {kind: 'void'} as const
|
|
432
|
+
: requiredValue(state, block.terminator.value)
|
|
433
|
+
run.blocks[blockID]!.pendingReturn = {
|
|
434
|
+
value,
|
|
435
|
+
shared: cloneSharedState(state.shared),
|
|
436
|
+
valueFacts: state.valueFacts.slice(),
|
|
437
|
+
}
|
|
438
|
+
break
|
|
439
|
+
}
|
|
440
|
+
// A thrown path ends without contributing: no return value, no stop record. The
|
|
441
|
+
// exception would propagate past every analyzed caller (no catch in the subset),
|
|
442
|
+
// so no analyzed continuation observes anything from this path.
|
|
443
|
+
case 'thrown':
|
|
444
|
+
break
|
|
445
|
+
case 'stop': {
|
|
446
|
+
addStop(
|
|
447
|
+
run,
|
|
448
|
+
blockID,
|
|
449
|
+
{site: block.terminator.site, reason: {kind: 'unsupportedCode', reason: block.terminator.reason}},
|
|
450
|
+
state.shared.slice(),
|
|
451
|
+
block.instructions.length,
|
|
452
|
+
)
|
|
453
|
+
break
|
|
454
|
+
}
|
|
455
|
+
case 'jump': {
|
|
456
|
+
propagate(state, blockID, block.terminator.target, run)
|
|
457
|
+
break
|
|
458
|
+
}
|
|
459
|
+
case 'branch': {
|
|
460
|
+
const conditionOutcome = branchConditionOutcome(
|
|
461
|
+
state,
|
|
462
|
+
block.terminator.condition,
|
|
463
|
+
block.terminator.site,
|
|
464
|
+
expressionContext,
|
|
465
|
+
)
|
|
466
|
+
if (conditionOutcome.kind === 'stop') {
|
|
467
|
+
addStop(
|
|
468
|
+
run,
|
|
469
|
+
blockID,
|
|
470
|
+
conditionOutcome.stop,
|
|
471
|
+
state.shared.slice(),
|
|
472
|
+
block.instructions.length,
|
|
473
|
+
)
|
|
474
|
+
run.blocks[blockID]!.pendingReturn = null
|
|
475
|
+
break
|
|
476
|
+
}
|
|
477
|
+
const condition = conditionOutcome.value
|
|
478
|
+
// expressionContext.instructionByValue is the one which-instruction-produced-this
|
|
479
|
+
// table; a condition refines only when that instruction is a check (refineCheck
|
|
480
|
+
// dispatches over the check kinds in one place).
|
|
481
|
+
const check = asRefinableCheck(expressionContext.instructionByValue[block.terminator.condition])
|
|
482
|
+
if (condition.canBeTrue) {
|
|
483
|
+
// refineCheck clones internally; the bare-condition arm clones only when the
|
|
484
|
+
// other arm still needs the working state.
|
|
485
|
+
const branch = check != null
|
|
486
|
+
? refineCheck(state, check, true, expressionContext)
|
|
487
|
+
: condition.canBeFalse ? cloneState(state) : state
|
|
488
|
+
if (branch != null) propagate(branch, blockID, block.terminator.whenTrue, run)
|
|
489
|
+
}
|
|
490
|
+
if (condition.canBeFalse) {
|
|
491
|
+
const branch = check != null
|
|
492
|
+
? refineCheck(state, check, false, expressionContext)
|
|
493
|
+
: state
|
|
494
|
+
if (branch != null) propagate(branch, blockID, block.terminator.whenFalse, run)
|
|
495
|
+
}
|
|
496
|
+
break
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// A stop inside a loop cuts the back edge, freezing the header short of its fixed point —
|
|
502
|
+
// and the stop may first appear on a late widening round, after earlier rounds already
|
|
503
|
+
// propagated returns downstream. Any header on a cycle through a stopping block is
|
|
504
|
+
// therefore failed too. Slightly conservative: evidence from the path where the loop body
|
|
505
|
+
// runs zero times is also suppressed when the stop existed from the first round.
|
|
506
|
+
// Reverse edges answer whether a stopping block can return to each header without a
|
|
507
|
+
// separate traversal from every stop. The whole pass is skipped when nothing stopped.
|
|
508
|
+
const suppressed: boolean[] = []
|
|
509
|
+
if (run.stops.length > 0) {
|
|
510
|
+
const predecessors = reverseEdges(successors)
|
|
511
|
+
for (let headerID = 0; headerID < fn.blocks.length; headerID++) {
|
|
512
|
+
if (fn.blocks[headerID]!.loopHeader == null) continue
|
|
513
|
+
const header = run.blocks[headerID]!
|
|
514
|
+
const reachedFromHeader = header.failedHeader ? undefined : reachableFrom(successors, headerID)
|
|
515
|
+
if (reachedFromHeader != null) {
|
|
516
|
+
const returnsToHeader = reachableFrom(predecessors, headerID)
|
|
517
|
+
for (let stopBlock = 0; stopBlock < run.blocks.length; stopBlock++) {
|
|
518
|
+
if (run.blocks[stopBlock]!.stopIndex == null || reachedFromHeader[stopBlock] !== true) continue
|
|
519
|
+
if (returnsToHeader[stopBlock] === true) {
|
|
520
|
+
header.failedHeader = true
|
|
521
|
+
break
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (!header.failedHeader) continue
|
|
526
|
+
const reached = reachedFromHeader ?? reachableFrom(successors, headerID)
|
|
527
|
+
for (let block = 0; block < fn.blocks.length; block++) {
|
|
528
|
+
if (reached[block] === true) suppressed[block] = true
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
let normal: FunctionEvaluation['normal'] = null
|
|
534
|
+
for (let blockID = 0; blockID < fn.blocks.length; blockID++) {
|
|
535
|
+
const pending = run.blocks[blockID]!.pendingReturn
|
|
536
|
+
if (pending == null || suppressed[blockID] === true) continue
|
|
537
|
+
if (normal == null) {
|
|
538
|
+
normal = {returnValue: pending.value, sharedState: pending.shared, valueFacts: pending.valueFacts}
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
normal = {
|
|
542
|
+
returnValue: joinValues(normal.returnValue, pending.value),
|
|
543
|
+
sharedState: joinModuleSlots(normal.sharedState, pending.shared),
|
|
544
|
+
valueFacts: intersectValueFacts(normal.valueFacts, pending.valueFacts),
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// A loop whose exit is abstractly never taken — e.g. `for (let index = 0; true;
|
|
549
|
+
// index += 1) {}` — converges with every path still inside the loop: no return, no stop.
|
|
550
|
+
// Record a stop on each such header so the result is a partial entry, not a crash on the
|
|
551
|
+
// missing return. A header belongs to a non-exiting loop when every reached block it can
|
|
552
|
+
// reach can also reach it back: the analysis went around the cycle and never left.
|
|
553
|
+
// Checking the header's own branch would not be enough — a ternary in the loop condition
|
|
554
|
+
// (e.g. `for (; index < 10 ? true : index >= 0; )`) puts the body/exit branch in a
|
|
555
|
+
// continuation block, not on the tagged header.
|
|
556
|
+
if (normal == null && run.stops.length === 0) {
|
|
557
|
+
const predecessors = reverseEdges(successors)
|
|
558
|
+
for (let headerID = 0; headerID < fn.blocks.length; headerID++) {
|
|
559
|
+
const header = fn.blocks[headerID]!
|
|
560
|
+
const entry_ = run.blocks[headerID]!.incoming
|
|
561
|
+
if (header.loopHeader == null || entry_ == null) continue
|
|
562
|
+
const downstream = reachableFrom(successors, headerID)
|
|
563
|
+
const returnsToHeader = reachableFrom(predecessors, headerID)
|
|
564
|
+
let visitedDownstream = false
|
|
565
|
+
let stuckInCycle = true
|
|
566
|
+
for (let block = 0; block < fn.blocks.length; block++) {
|
|
567
|
+
if (downstream[block] !== true || run.blocks[block]!.incoming == null) continue
|
|
568
|
+
visitedDownstream = true
|
|
569
|
+
if (returnsToHeader[block] !== true) {
|
|
570
|
+
stuckInCycle = false
|
|
571
|
+
break
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (visitedDownstream && stuckInCycle) {
|
|
575
|
+
addStop(
|
|
576
|
+
run,
|
|
577
|
+
headerID,
|
|
578
|
+
{site: header.loopHeader, reason: {kind: 'nonExitingLoop'}},
|
|
579
|
+
entry_.state.shared.slice(),
|
|
580
|
+
0,
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
evaluation: {
|
|
588
|
+
normal,
|
|
589
|
+
preconditions,
|
|
590
|
+
boundsAssumptions,
|
|
591
|
+
assertions: classifyAssertions(run, run.stops.length === 0 && boundsAssumptions.length === 0),
|
|
592
|
+
stops: run.stops,
|
|
593
|
+
},
|
|
594
|
+
run,
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function requiredAssertion(run: EvaluationRun, assertionIndex: number): {site: SiteID; text: string} {
|
|
599
|
+
const assertion = run.fn.assertions[assertionIndex]
|
|
600
|
+
if (assertion == null) {
|
|
601
|
+
throw new Error(`Unknown assertion ${assertionIndex} in ${run.fn.name}`)
|
|
602
|
+
}
|
|
603
|
+
return assertion
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function addAssertionObservation(
|
|
607
|
+
run: EvaluationRun,
|
|
608
|
+
assertionIndex: number,
|
|
609
|
+
observation: {canBeTrue: boolean; canBeFalse: boolean},
|
|
610
|
+
): void {
|
|
611
|
+
requiredAssertion(run, assertionIndex)
|
|
612
|
+
if (!observation.canBeTrue && !observation.canBeFalse) {
|
|
613
|
+
throw new Error(`Assertion ${assertionIndex} in ${run.fn.name} has no possible boolean value`)
|
|
614
|
+
}
|
|
615
|
+
const aggregate = run.assertionObservations[assertionIndex] ?? {
|
|
616
|
+
sawDefinitelyTrue: false,
|
|
617
|
+
sawDefinitelyFalse: false,
|
|
618
|
+
sawMaybeFalse: false,
|
|
619
|
+
}
|
|
620
|
+
if (!observation.canBeTrue) aggregate.sawDefinitelyFalse = true
|
|
621
|
+
else if (observation.canBeFalse) aggregate.sawMaybeFalse = true
|
|
622
|
+
else aggregate.sawDefinitelyTrue = true
|
|
623
|
+
run.assertionObservations[assertionIndex] = aggregate
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function classifyAssertions(run: EvaluationRun, proofComplete: boolean): AssertionVerdict[] {
|
|
627
|
+
return run.fn.assertions.map((assertion, assertionIndex) => {
|
|
628
|
+
const observation = run.assertionObservations[assertionIndex]
|
|
629
|
+
const verdict: AssertionVerdict['verdict'] = observation?.sawDefinitelyFalse === true
|
|
630
|
+
? 'refuted'
|
|
631
|
+
: observation?.sawMaybeFalse === true
|
|
632
|
+
? 'unproven'
|
|
633
|
+
: !proofComplete
|
|
634
|
+
? 'blocked'
|
|
635
|
+
: observation?.sawDefinitelyTrue === true
|
|
636
|
+
? 'proven'
|
|
637
|
+
: 'dead'
|
|
638
|
+
return {site: assertion.site, text: assertion.text, verdict}
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function addStop(
|
|
643
|
+
run: EvaluationRun,
|
|
644
|
+
blockID: BlockID,
|
|
645
|
+
stop: Stop,
|
|
646
|
+
moduleCapture: SharedState,
|
|
647
|
+
instructionIndex: number,
|
|
648
|
+
): void {
|
|
649
|
+
const block = run.blocks[blockID]!
|
|
650
|
+
if (block.stopIndex == null || instructionIndex < block.stopIndex) {
|
|
651
|
+
block.stopIndex = instructionIndex
|
|
652
|
+
}
|
|
653
|
+
run.moduleEnd = run.moduleEnd == null ? moduleCapture : joinModuleSlots(run.moduleEnd, moduleCapture)
|
|
654
|
+
// The first stop at a site wins, so re-visits (loop rounds, both arms of a branch
|
|
655
|
+
// reaching one call) cannot grow the list past the function's site count. A linear scan,
|
|
656
|
+
// like the precondition and bounds-assumption dedups: the list is small by the same bound.
|
|
657
|
+
if (run.stops.some(existing => existing.site === stop.site)) return
|
|
658
|
+
run.stops.push(stop)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Takes ownership of `state`: callers pass the working state (dead after its terminator)
|
|
662
|
+
// or an already-fresh clone from a branch arm, so no defensive copy is needed here.
|
|
663
|
+
function propagate(
|
|
664
|
+
state: ExecutionState,
|
|
665
|
+
sourceBlock: BlockID,
|
|
666
|
+
edge: EdgeIR,
|
|
667
|
+
run: EvaluationRun,
|
|
668
|
+
): void {
|
|
669
|
+
const target = run.fn.blocks[edge.block]
|
|
670
|
+
if (target == null) throw new Error(`Missing block ${edge.block} in ${run.fn.name}`)
|
|
671
|
+
if (edge.arguments.length !== target.parameters.length) {
|
|
672
|
+
throw new Error(`Expected ${target.parameters.length} arguments for block ${edge.block} in ${run.fn.name}`)
|
|
673
|
+
}
|
|
674
|
+
// Read every edge argument before writing any parameter: on a loop back edge an argument
|
|
675
|
+
// can be one of the target's own parameter IDs (an unchanged carried binding), so the
|
|
676
|
+
// reads and writes share one value array.
|
|
677
|
+
const argumentValues = edge.arguments.map(argument => requiredValue(state, argument))
|
|
678
|
+
const candidate = state
|
|
679
|
+
for (let index = 0; index < target.parameters.length; index++) {
|
|
680
|
+
candidate.values[target.parameters[index]!] = argumentValues[index]!
|
|
681
|
+
}
|
|
682
|
+
const previous = run.blocks[edge.block]!.incoming
|
|
683
|
+
if (previous == null) {
|
|
684
|
+
run.blocks[edge.block]!.incoming = {state: candidate, updateCount: 0}
|
|
685
|
+
run.queue.push(edge.block)
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
const update = mergeStates(previous.state, candidate, target.loopHeader != null && previous.updateCount >= 1)
|
|
689
|
+
if (update.changed) {
|
|
690
|
+
if (target.loopHeader != null && previous.updateCount >= maximumLoopHeaderUpdates) {
|
|
691
|
+
addStop(
|
|
692
|
+
run,
|
|
693
|
+
sourceBlock,
|
|
694
|
+
{site: target.loopHeader, reason: {kind: 'loopLimit', updates: maximumLoopHeaderUpdates}},
|
|
695
|
+
state.shared.slice(),
|
|
696
|
+
0,
|
|
697
|
+
)
|
|
698
|
+
run.blocks[edge.block]!.failedHeader = true
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
run.blocks[edge.block]!.incoming = {state: update.state, updateCount: previous.updateCount + 1}
|
|
702
|
+
run.queue.push(edge.block)
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function blockSuccessors(fn: FunctionIR): BlockID[][] {
|
|
707
|
+
return fn.blocks.map(block => {
|
|
708
|
+
switch (block.terminator.kind) {
|
|
709
|
+
case 'return': return []
|
|
710
|
+
case 'stop': return []
|
|
711
|
+
case 'thrown': return []
|
|
712
|
+
case 'jump': return [block.terminator.target.block]
|
|
713
|
+
case 'branch': return [block.terminator.whenTrue.block, block.terminator.whenFalse.block]
|
|
714
|
+
}
|
|
715
|
+
})
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function reverseEdges(successors: BlockID[][]): BlockID[][] {
|
|
719
|
+
const predecessors: BlockID[][] = successors.map(() => [])
|
|
720
|
+
for (let source = 0; source < successors.length; source++) {
|
|
721
|
+
for (const target of successors[source]!) predecessors[target]!.push(source)
|
|
722
|
+
}
|
|
723
|
+
return predecessors
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function reachableAfter(successors: BlockID[][], starts: BlockID[]): boolean[] {
|
|
727
|
+
const reached: boolean[] = []
|
|
728
|
+
const queue: BlockID[] = []
|
|
729
|
+
for (const start of starts) queue.push(...successors[start]!)
|
|
730
|
+
let index = 0
|
|
731
|
+
while (index < queue.length) {
|
|
732
|
+
const block = queue[index++]!
|
|
733
|
+
if (reached[block] === true) continue
|
|
734
|
+
reached[block] = true
|
|
735
|
+
queue.push(...successors[block]!)
|
|
736
|
+
}
|
|
737
|
+
return reached
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Every block reachable from `start` through one or more static CFG edges. Static rather
|
|
741
|
+
// than visited-during-analysis edges: a body whose back edge never fired because the body
|
|
742
|
+
// stopped must still count as inside its loop.
|
|
743
|
+
function reachableFrom(successors: BlockID[][], start: BlockID): boolean[] {
|
|
744
|
+
return reachableAfter(successors, [start])
|
|
745
|
+
}
|