@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,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
+ }