@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,1132 @@
1
+ import {nextDown, nextUp, isFiniteNumber, type AbstractNumber} from '../domain/number.ts'
2
+ import {recordProperty, tryJoinValues, type AbstractValue} from '../domain/value.ts'
3
+ import type {AssertionVerdict, FunctionAnalysis, ProgramAnalysis, RequirementFailure, Stop} from '../engine/outcome.ts'
4
+ import {finiteInputs, type FiniteInput} from '../ir/finite-inputs.ts'
5
+ import type {FunctionID, ModuleBindingID, SiteID, ValueID} from '../ir/ids.ts'
6
+ import {functionUsage, transitiveModuleBindings} from '../ir/function-usage.ts'
7
+ import {forEachOperand} from '../ir/instructions.ts'
8
+ import {declaredKindOf, formatSite, type DeclaredKind, type FunctionIR, type ProgramIR, type UnsupportedReason} from '../ir/program.ts'
9
+ import {numericParameterPath} from '../requirements/infer.ts'
10
+ import type {BoundsAssumption, InferredPrecondition} from '../requirements/model.ts'
11
+ import {describePrecondition, formatObservedNeed, formatPrecondition} from './format-requirement.ts'
12
+
13
+ export type FunctionReport =
14
+ | {kind: 'analyzed'; name: string; assumptions: string[]; requires: string[]; ensures: string[]; assertions?: AssertionReport[]}
15
+ // e.g. 'unknown identifier scheduledRender at /abs/demo/index.ts:6:7'
16
+ | {kind: 'unsupported'; name: string; unsupported: string}
17
+ // Some paths could not be analyzed completely; `observed` lines are evidence from the
18
+ // paths that completed, never a contract. e.g. partialReasons:
19
+ // ['recursive call to countdown (call at /abs/file.ts:3:10)'],
20
+ // observed: 'return is a finite integer number from 0 through 0'.
21
+ | {kind: 'partial'; name: string; assumptions: string[]; partialReasons: string[]; skipped?: string[]; observed: string[]; assertions?: AssertionReport[]}
22
+
23
+ export type AssertionReport = {
24
+ verdict: AssertionVerdict['verdict']
25
+ text: string
26
+ location: string
27
+ }
28
+
29
+ export type AnalysisReport = {
30
+ functions: FunctionReport[]
31
+ }
32
+
33
+ export function createReport(program: ProgramIR, analysis: ProgramAnalysis): AnalysisReport {
34
+ const functions: FunctionReport[] = []
35
+ const assumedBindings = functionModuleAssumptions(program, analysis)
36
+ // An unproven asserted element read at the top level (`breakpoints[idx]!` with a
37
+ // platform-derived idx) conditions everything the initializer published, and the
38
+ // initializer usually prints no entry — so the assumption lines travel to every
39
+ // function that reads any module binding, the same way declared-kind assumptions do.
40
+ // Without this, a reader's ensures would publish unconditionally while the runtime
41
+ // read can miss.
42
+ const initializerBounds = analysis.initializer.kind === 'analyzed'
43
+ ? analysis.initializer.boundsAssumptions
44
+ : analysis.initializer.observedBoundsAssumptions
45
+ const initializerBoundsLines = initializerBounds.map(assumption => formatBoundsAssumption(assumption, program))
46
+ // Top-level code runs before any function, so its entry comes first — but only when it is
47
+ // partially supported or contains skipped statements. A fully analyzed initializer with
48
+ // nothing skipped is invisible: its results show up as the exact module values other entries
49
+ // report, with its bounds assumptions carried by the readers above.
50
+ // Like an unsupported function, show the first module blocker and let the coverage
51
+ // header carry the total. FileAudit.references retains every skip for structured use.
52
+ const firstSkip = program.initializerSkips[0]
53
+ const skippedLines = firstSkip == null
54
+ ? []
55
+ : [`${formatUnsupportedReason(firstSkip.reason)} at ${formatSite(program, firstSkip.site)}`]
56
+ if (analysis.initializer.kind === 'partial' || skippedLines.length > 0) {
57
+ const observed: string[] = []
58
+ if (analysis.initializer.kind === 'partial') {
59
+ for (const need of analysis.initializer.observedNeeds) observed.push(formatObservedNeed(need, [], program))
60
+ }
61
+ functions.push({
62
+ kind: 'partial',
63
+ name: program.initializer.name,
64
+ assumptions: initializerBoundsLines,
65
+ partialReasons: analysis.initializer.kind === 'partial'
66
+ ? analysis.initializer.stops.map(stop => formatStop(stop, program, analysis))
67
+ : [],
68
+ skipped: skippedLines,
69
+ observed,
70
+ })
71
+ }
72
+ for (let functionID = 0; functionID < analysis.functions.length; functionID++) {
73
+ const fn = analysis.functions[functionID]!
74
+ switch (fn.kind) {
75
+ case 'notLowered': {
76
+ const lowering = fn.lowering
77
+ functions.push({
78
+ kind: 'unsupported',
79
+ name: lowering.name,
80
+ unsupported: `${formatUnsupportedReason(lowering.reason)} at ${formatSite(program, lowering.site)}`,
81
+ })
82
+ break
83
+ }
84
+ case 'partial': {
85
+ const lowering = fn.lowering
86
+ const observed: string[] = []
87
+ if (fn.observedReturn != null) {
88
+ observed.push(...returnSummaries('return', declaredReturn(fn.observedReturn.value, lowering), program))
89
+ }
90
+ for (const need of fn.observedNeeds) observed.push(formatObservedNeed(need, lowering.parameters, program))
91
+ functions.push({
92
+ kind: 'partial',
93
+ name: lowering.name,
94
+ assumptions: assumptionLines(lowering, program, assumedBindings[functionID]!, fn.observedBoundsAssumptions, []),
95
+ partialReasons: fn.stops.map(stop => formatStop(stop, program, analysis)),
96
+ observed,
97
+ ...(fn.assertions.length === 0 ? {} : {assertions: assertionReports(fn.assertions, program)}),
98
+ })
99
+ break
100
+ }
101
+ case 'analyzed': {
102
+ const lowering = fn.lowering
103
+ const finite = finiteInputs(lowering)
104
+ const requires = requirementLines(lowering, finite, fn.preconditions, program)
105
+ const assumptions = assumptionLines(
106
+ lowering,
107
+ program,
108
+ assumedBindings[functionID]!,
109
+ fn.boundsAssumptions,
110
+ finiteAssumptionInputs(lowering, finite, fn.preconditions),
111
+ )
112
+ functions.push({
113
+ kind: 'analyzed',
114
+ name: lowering.name,
115
+ assumptions,
116
+ requires,
117
+ ensures: returnSummaries('return', declaredReturn(fn.returnValue, lowering), program),
118
+ ...(fn.assertions.length === 0 ? {} : {assertions: assertionReports(fn.assertions, program)}),
119
+ })
120
+ break
121
+ }
122
+ }
123
+ }
124
+ return {functions}
125
+ }
126
+
127
+ function finiteAssumptionInputs(
128
+ fn: FunctionIR,
129
+ automatic: FiniteInput[],
130
+ preconditions: InferredPrecondition[],
131
+ ): FiniteInput[] {
132
+ const inputs = [...automatic]
133
+ const paths = finitePathIndexes(fn, inputs)
134
+ for (const precondition of preconditions) {
135
+ if (precondition.kind !== 'declaredNumberCheck'
136
+ || (precondition.predicate !== 'finite' && precondition.predicate !== 'integer')) continue
137
+ const path = numericParameterPath(precondition.expression)
138
+ if (path == null || pathIndexHas(paths[path.parameter]!, path.properties)) continue
139
+ inputs.push({parameter: path.parameter, properties: path.properties, site: precondition.site})
140
+ pathIndexAdd(paths[path.parameter]!, path.properties)
141
+ }
142
+ return inputs
143
+ }
144
+
145
+ function requirementLines(
146
+ fn: FunctionIR,
147
+ inputs: FiniteInput[],
148
+ preconditions: InferredPrecondition[],
149
+ program: ProgramIR,
150
+ ): string[] {
151
+ const inputsByParameter = fn.parameters.map((): FiniteInput[] => [])
152
+ for (const input of inputs) inputsByParameter[input.parameter]!.push(input)
153
+ const folded: boolean[] = []
154
+ for (let parameter = 0; parameter < fn.parameters.length; parameter++) {
155
+ const current = fn.parameters[parameter]!
156
+ folded[parameter] = current.bindings != null || inputsByParameter[parameter]!.length >= 3
157
+ }
158
+ const lines: string[] = []
159
+ const emitted: boolean[] = []
160
+ for (const precondition of preconditions) {
161
+ const isFiniteInput = precondition.kind === 'declaredNumberCheck'
162
+ && precondition.predicate === 'finite'
163
+ && precondition.purpose === 'finiteInput'
164
+ const path = isFiniteInput
165
+ ? numericParameterPath(precondition.expression)
166
+ : null
167
+ if (path != null && folded[path.parameter]) {
168
+ if (!emitted[path.parameter]) {
169
+ const parameter = fn.parameters[path.parameter]!
170
+ const parameterInputs = inputsByParameter[path.parameter]!
171
+ const condition = parameter.bindings == null
172
+ ? `every number field in ${parameter.name} is finite`
173
+ : finiteBindingList(parameter, parameterInputs)
174
+ lines.push(`${condition} (input at ${formatSite(program, parameter.site)})`)
175
+ emitted[path.parameter] = true
176
+ }
177
+ continue
178
+ }
179
+ if (isFiniteInput) {
180
+ lines.push(`${describePrecondition(precondition, fn.parameters).condition} (input at ${formatSite(program, precondition.site)})`)
181
+ continue
182
+ }
183
+ lines.push(formatPrecondition(precondition, fn.parameters, program))
184
+ }
185
+ return lines
186
+ }
187
+
188
+ function finiteBindingList(parameter: FunctionIR['parameters'][number], inputs: FiniteInput[]): string {
189
+ const bindings = parameter.bindings == null
190
+ ? null
191
+ : new Map(parameter.bindings.map(binding => [binding.property, binding.local]))
192
+ const names = inputs.map(input => {
193
+ const [first, ...rest] = input.properties
194
+ const binding = first == null ? null : bindings?.get(first)
195
+ return binding == null ? input.properties.join('.') : [binding, ...rest].join('.')
196
+ })
197
+ if (names.length === 1) return `${names[0]} is finite`
198
+ if (names.length === 2) return `${names[0]} and ${names[1]} are finite`
199
+ return `${names.slice(0, -1).join(', ')}, and ${names.at(-1)} are finite`
200
+ }
201
+
202
+ // Within an entry, line kinds print rarest-and-most-actionable first: requires (what the
203
+ // caller must arrange), ensures (what it gets), assumes (what is being trusted — the
204
+ // bulkiest kind, and the one every reader scans past to reach the other two).
205
+ export function formatReport(report: AnalysisReport): string {
206
+ const lines: string[] = []
207
+ for (const fn of report.functions) {
208
+ if (lines.length > 0) lines.push('')
209
+ lines.push(fn.name)
210
+ switch (fn.kind) {
211
+ case 'analyzed': {
212
+ for (const precondition of fn.requires) lines.push(` requires: ${precondition}`)
213
+ for (const assertion of fn.assertions ?? []) lines.push(formatAssertionReport(assertion))
214
+ for (const guarantee of fn.ensures) lines.push(` ensures: ${guarantee}`)
215
+ for (const assumption of fn.assumptions) lines.push(` assumes: ${assumption}`)
216
+ break
217
+ }
218
+ case 'unsupported': {
219
+ lines.push(` unsupported: ${fn.unsupported}`)
220
+ break
221
+ }
222
+ case 'partial': {
223
+ for (const assertion of fn.assertions ?? []) lines.push(formatAssertionReport(assertion))
224
+ for (const assumption of fn.assumptions) lines.push(` assumes: ${assumption}`)
225
+ for (const reason of fn.partialReasons) lines.push(` partially supported: ${reason}`)
226
+ for (const skip of fn.skipped ?? []) lines.push(` skipped: ${skip}`)
227
+ for (const evidence of fn.observed) lines.push(` on analyzed paths: ${evidence}`)
228
+ break
229
+ }
230
+ }
231
+ }
232
+ return lines.join('\n')
233
+ }
234
+
235
+ function assertionReports(assertions: AssertionVerdict[], program: ProgramIR): AssertionReport[] {
236
+ return assertions.map(assertion => ({
237
+ verdict: assertion.verdict,
238
+ text: assertion.text,
239
+ location: formatSite(program, assertion.site),
240
+ }))
241
+ }
242
+
243
+ function formatAssertionReport(assertion: AssertionReport): string {
244
+ switch (assertion.verdict) {
245
+ case 'proven': return ` proves: ${assertion.text} (assertion at ${assertion.location})`
246
+ case 'refuted': return ` assertion can fail: ${assertion.text} (at ${assertion.location})`
247
+ case 'unproven': return ` assertion unproven: could not prove ${assertion.text} (at ${assertion.location})`
248
+ case 'dead': return ` unreachable assertion: ${assertion.text} (at ${assertion.location})`
249
+ case 'blocked': return ` assertion blocked: the function did not finish analysis without site-specific assumptions: ${assertion.text} (at ${assertion.location})`
250
+ }
251
+ }
252
+
253
+
254
+ // A string tag prints quoted ('lightbox'); a boolean tag prints bare (true), matching how
255
+ // each is written in the type.
256
+ function formatTagValue(tagValue: string | boolean): string {
257
+ return typeof tagValue === 'string' ? `'${tagValue}'` : String(tagValue)
258
+ }
259
+
260
+ // A parameter path the body read, as property-name segments from the parameter root ([]
261
+ // is the root itself). Reads never descend below an array or tuple: an element read marks
262
+ // the container's path, so every line inside the container prints or drops with it.
263
+ type ParameterRead = {parameter: number; segments: string[]}
264
+
265
+ // Which declared paths of each parameter the body touched, from a walk over the lowered
266
+ // IR. A declared-type assumes line prints only when its path was touched: a path no
267
+ // instruction ever read derived no value, so no printed requires/ensures can rest on its
268
+ // trust, and dropping its line removes no load-bearing condition. The soundness direction
269
+ // is asymmetric — printing an extra line is harmless, dropping a line a claim rests on is
270
+ // the hole the honesty work closed — so every approximation errs toward marking MORE
271
+ // paths as read:
272
+ // - Escapes keep everything at and below the escaping path: a value passed as a call
273
+ // argument (the callee may read anything under it), returned (the ensures describe
274
+ // it), written into module state, or consumed by any instruction the walk does not
275
+ // treat specially, marks its whole path as read.
276
+ // - Property reads are projections: `box.xs` extends the tracked path without marking
277
+ // `box` itself read, which is what lets an unread sibling property drop.
278
+ // - Values the walk loses sight of are marked at the point of loss: a tracked value
279
+ // passed as a block argument (a ternary or loop join) marks its path, because the
280
+ // block parameter it feeds is not tracked and anything read through it later sits at
281
+ // or below that path.
282
+ // - A tag check consumes the union directly and marks nothing: the tag property prints
283
+ // no line (a string tag is opaque, a boolean tag would restate the qualifier), so
284
+ // there is no line for the check to keep — this is what makes a function that only
285
+ // switches on a union tag print an empty assumes block.
286
+ // Any future instruction kind lands in the default arm, which marks every tracked
287
+ // operand read — over-printing, never under.
288
+ function parameterReadPaths(fn: FunctionIR): PathIndex[] {
289
+ const tracked = new Map<ValueID, ParameterRead>()
290
+ const projections = new Map<ValueID, Array<{result: ValueID; property: string}>>()
291
+ for (const block of fn.blocks) {
292
+ for (const instruction of block.instructions) {
293
+ if (instruction.kind !== 'property') continue
294
+ const dependents = projections.get(instruction.object) ?? []
295
+ dependents.push({result: instruction.result, property: instruction.property})
296
+ projections.set(instruction.object, dependents)
297
+ }
298
+ }
299
+ const queue: ValueID[] = []
300
+ for (let index = 0; index < fn.parameters.length; index++) {
301
+ const value = fn.parameters[index]!.value
302
+ tracked.set(value, {parameter: index, segments: []})
303
+ queue.push(value)
304
+ }
305
+ for (let current = 0; current < queue.length; current++) {
306
+ const value = queue[current]!
307
+ const base = tracked.get(value)!
308
+ for (const projection of projections.get(value) ?? []) {
309
+ if (tracked.has(projection.result)) continue
310
+ tracked.set(projection.result, {
311
+ parameter: base.parameter,
312
+ segments: [...base.segments, projection.property],
313
+ })
314
+ queue.push(projection.result)
315
+ }
316
+ }
317
+ const reads = fn.parameters.map((): PathIndex => ({terminal: false, children: new Map()}))
318
+ const markOperand = (operand: ValueID): void => {
319
+ const path = tracked.get(operand)
320
+ if (path != null) pathIndexAdd(reads[path.parameter]!, path.segments)
321
+ }
322
+ for (const block of fn.blocks) {
323
+ for (const instruction of block.instructions) {
324
+ switch (instruction.kind) {
325
+ // A projection, not a consumption: the derived value is tracked above, and
326
+ // whichever instruction consumes it marks the extended path then.
327
+ case 'property': break
328
+ // The tag read keeps nothing — see the rationale above.
329
+ case 'tagCheck': break
330
+ // Generated boundary checks are contracts, not evidence that the body used a value.
331
+ case 'numberCheck': {
332
+ if (instruction.purpose !== 'finiteInput') forEachOperand(instruction, markOperand)
333
+ break
334
+ }
335
+ default: forEachOperand(instruction, markOperand)
336
+ }
337
+ }
338
+ const terminator = block.terminator
339
+ switch (terminator.kind) {
340
+ case 'return': {
341
+ if (terminator.value != null) markOperand(terminator.value)
342
+ break
343
+ }
344
+ case 'jump': {
345
+ for (const argument of terminator.target.arguments) markOperand(argument)
346
+ break
347
+ }
348
+ case 'branch': {
349
+ markOperand(terminator.condition)
350
+ for (const argument of terminator.whenTrue.arguments) markOperand(argument)
351
+ for (const argument of terminator.whenFalse.arguments) markOperand(argument)
352
+ break
353
+ }
354
+ case 'stop':
355
+ case 'thrown':
356
+ break
357
+ }
358
+ }
359
+ return reads
360
+ }
361
+
362
+ // Whether a line about the declared path `segments` prints, given the paths the body
363
+ // read: yes when some read sits at, below, or above the line's path (one is a prefix of
364
+ // the other). A read below keeps the lines above it that its trust chains through
365
+ // (reading values[i] keeps the plain-array line on values), and a read at or above keeps
366
+ // everything under it (an escaped record keeps its whole subtree).
367
+ type KeepPath = (segments: string[]) => boolean
368
+
369
+ const keepEverything: KeepPath = () => true
370
+
371
+ function assumptionLines(
372
+ fn: FunctionIR,
373
+ program: ProgramIR,
374
+ assumedBindings: ReadonlySet<ModuleBindingID>,
375
+ boundsAssumptions: BoundsAssumption[],
376
+ finiteRequirements: FiniteInput[],
377
+ ): string[] {
378
+ const assumptions: string[] = []
379
+ const reads = parameterReadPaths(fn)
380
+ const finitePaths = finitePathIndexes(fn, finiteRequirements)
381
+ for (let index = 0; index < fn.parameters.length; index++) {
382
+ const parameter = fn.parameters[index]!
383
+ const keep = (path: string[]): boolean =>
384
+ !pathIndexHas(finitePaths[index]!, path) && pathIndexOverlaps(reads[index]!, path)
385
+ if (parameter.bindings == null) {
386
+ pushRootAssumptions(parameter.name, parameter.type, assumptions, keep)
387
+ continue
388
+ }
389
+ if (parameter.type.kind !== 'record') {
390
+ const start = assumptions.length
391
+ pushRootAssumptions(parameter.name, parameter.type, assumptions, keep)
392
+ for (let line = start; line < assumptions.length; line++) {
393
+ for (const binding of parameter.bindings) {
394
+ assumptions[line] = assumptions[line]!.replaceAll(
395
+ `${parameter.name}.${binding.property}`,
396
+ binding.local,
397
+ )
398
+ }
399
+ }
400
+ continue
401
+ }
402
+ const properties = new Map(parameter.type.properties.map(property => [property.name, property.declared]))
403
+ for (const binding of parameter.bindings) {
404
+ const property = properties.get(binding.property)
405
+ if (property == null) continue
406
+ pushRootAssumptions(
407
+ binding.local,
408
+ property,
409
+ assumptions,
410
+ path => keep([binding.property, ...path]),
411
+ )
412
+ }
413
+ }
414
+ // Module bindings filter at whole-binding granularity: assumedBindings already contains
415
+ // only the bindings this function (or a callee) reads, and a callee's reads arrive as a
416
+ // binding ID with no path detail, so a read binding keeps all its lines.
417
+ for (const bindingID of assumedBindings) {
418
+ const binding = program.moduleBindings[bindingID]!
419
+ const declaredKind = declaredKindOf(binding.category)
420
+ if (declaredKind == null) throw new Error(`Module binding ${binding.name} has no declared kind to assume`)
421
+ pushRootAssumptions(binding.name, declaredKind, assumptions, keepEverything)
422
+ }
423
+ for (const assumption of boundsAssumptions) {
424
+ // The engine could not prove the asserted element read in bounds; the entry's
425
+ // guarantees rest on it. E.g. `the element read at demo.ts:4:10 is in bounds`, or,
426
+ // for a divisor no requirement could name, `the divisor at demo.ts:4:10 is nonzero`.
427
+ assumptions.push(formatBoundsAssumption(assumption, program))
428
+ }
429
+ return assumptions
430
+ }
431
+
432
+ type PathIndex = {
433
+ terminal: boolean
434
+ children: Map<string, PathIndex>
435
+ }
436
+
437
+ function finitePathIndexes(fn: FunctionIR, inputs: FiniteInput[]): PathIndex[] {
438
+ const indexes = fn.parameters.map((): PathIndex => ({terminal: false, children: new Map()}))
439
+ for (const input of inputs) pathIndexAdd(indexes[input.parameter]!, input.properties)
440
+ return indexes
441
+ }
442
+
443
+ function pathIndexAdd(index: PathIndex, path: string[]): void {
444
+ let current = index
445
+ for (const segment of path) {
446
+ let child = current.children.get(segment)
447
+ if (child == null) {
448
+ child = {terminal: false, children: new Map()}
449
+ current.children.set(segment, child)
450
+ }
451
+ current = child
452
+ }
453
+ current.terminal = true
454
+ }
455
+
456
+ function pathIndexHas(index: PathIndex, path: string[]): boolean {
457
+ let current = index
458
+ for (const segment of path) {
459
+ const child = current.children.get(segment)
460
+ if (child == null) return false
461
+ current = child
462
+ }
463
+ return current.terminal
464
+ }
465
+
466
+ function pathIndexOverlaps(index: PathIndex, path: string[]): boolean {
467
+ let current = index
468
+ if (current.terminal) return true
469
+ for (const segment of path) {
470
+ const child = current.children.get(segment)
471
+ if (child == null) return false
472
+ current = child
473
+ if (current.terminal) return true
474
+ }
475
+ return current.children.size > 0
476
+ }
477
+
478
+ function formatBoundsAssumption(assumption: BoundsAssumption, program: ProgramIR): string {
479
+ switch (assumption.kind) {
480
+ case 'elementInBounds': return `the element read at ${formatSite(program, assumption.site)} is in bounds`
481
+ case 'nonzeroDivisor': return `the divisor at ${formatSite(program, assumption.site)} is nonzero`
482
+ }
483
+ }
484
+
485
+ // A record with many number leaves would print the same default once per leaf — a
486
+ // PreparedLayout parameter with a dozen numeric properties repeats "is finite and not NaN"
487
+ // a dozen times, on every function that takes one, and the repetition drowns the requires
488
+ // and ensures lines that carry actual information. The number default folds into one line
489
+ // per value, e.g. `every property declared as a number in prepared holds a finite non-NaN
490
+ // number` (array elements and tuple slots are index properties, so one word covers every
491
+ // leaf). The quantifier ranges over the DECLARED properties and demands a held value; a
492
+ // quantifier over runtime values would be vacuous for exactly the two smuggles that must
493
+ // violate the line — a non-number in a number slot (not a "number value") and an absent
494
+ // property (no value at all), both reachable through `any`: the per-leaf line `box.width is finite and not
495
+ // NaN` asserts that box.width actually holds a finite number, so a non-number smuggled
496
+ // through `any` violates the assumption and keeps the ensures vacuous rather than false —
497
+ // the folded line must keep exactly that force (a review round ran the counterexample).
498
+ // Nullish-wrapped leaves stay out of the fold and keep their exact lines: a `number |
499
+ // null` leaf legally holds null, which the folded assertion would wrongly forbid.
500
+ // Tagged-union leaves stay out too (see numberLeafCount's taggedUnion arm). Small
501
+ // values (one or two number leaves) keep their per-leaf lines, and non-number leaves
502
+ // (booleans, tagged-union qualifiers) always print exactly.
503
+ //
504
+ // Plain-array lines fold the same way, but membership is exactly the set the folded
505
+ // sentence names on its surface reading: the DIRECT NON-NULLABLE properties of a
506
+ // non-nullable record root whose declared type is an array. Three or more such
507
+ // properties fold into one quantified line, and only those properties' own plain-array
508
+ // lines are suppressed — nothing the folded sentence does not restate may be. Every
509
+ // other array position keeps its per-level line even when the fold triggers: a root
510
+ // that is itself an array or a nullable root (the sentence quantifies over properties
511
+ // IN the root, saying nothing about the root itself), nested element levels (`every
512
+ // prepared.grid element is a plain array — ...` — a genuine outer array can hold a
513
+ // lying inner row, and the nested line is the one such a row violates), arrays behind a nullable
514
+ // record (`options.config is null or options.config.grid is a plain array — ...` — the
515
+ // folded line's unconditional 'holds' would forbid the legal null at config), tuples
516
+ // (see the tuple arm below), and nullable array members themselves. Nullable members
517
+ // were the last to leave the fold: their folded coverage was a parenthetical blessing
518
+ // null AND undefined for every such member, while the engine seeds each member with
519
+ // only its DECLARED sentinel and prunes a branch testing the other — so a caller
520
+ // smuggling undefined into `overrides: number[] | null` (or null into `overrides?:
521
+ // number[]`, which any JSON-derived value does, since serializers write null for
522
+ // absent) satisfied every printed line while a printed ensures was false at runtime.
523
+ // The member's own disjunct line is sentinel-precise — `prepared.overrides is null or
524
+ // prepared.overrides is a plain array — ...` — and a wrong-sentinel smuggle violates it,
525
+ // so it always prints, and with no nullable member folded the parenthetical is gone.
526
+ // Review rounds falsified each looser membership the same way: a nullable root folded
527
+ // into a sentence that said nothing about the root, a suppressed nested line let a
528
+ // lying inner row through with every printed line holding, and the unconditional
529
+ // 'holds' was violated by a legal null behind a nullable record, so the whole report
530
+ // stopped applying to legitimate callers.
531
+ // Either fold prints only when the read filter keeps EVERY position its sentence covers:
532
+ // the sentence quantifies over the DECLARED properties ("every property declared as a
533
+ // number in prepared"), so printing it while a covered position is unread would claim
534
+ // trust nothing rests on — and re-restrict legal callers at the unread position, the
535
+ // exact over-restriction the read filter removes. When any covered position is unread,
536
+ // the kept positions print per-property and the unread ones stay silent.
537
+ function pushRootAssumptions(path: string, declared: DeclaredKind, assumptions: string[], keep: KeepPath): void {
538
+ const numberLeaves = numberLeafCount(declared, [], keep)
539
+ const folds = !hasLiteralNumberInterval(declared)
540
+ && numberLeaves.total >= 3
541
+ && numberLeaves.kept === numberLeaves.total
542
+ && declared.kind !== 'number'
543
+ if (folds) assumptions.push(`every property declared as a number in ${path} holds a finite non-NaN number`)
544
+ if (declared.kind === 'record') {
545
+ const arrayProperties = declared.properties.filter(property => property.declared.kind === 'array')
546
+ const keptArrayProperties = arrayProperties.filter(property => keep([property.name]))
547
+ if (arrayProperties.length >= 3 && keptArrayProperties.length === arrayProperties.length) {
548
+ assumptions.push(`every property declared as an array in ${path} holds a plain array — its length counts its elements, and every index below the length holds an element`)
549
+ for (const property of declared.properties) {
550
+ pushDeclaredAssumptions(`${path}.${property.name}`, [property.name], property.declared, assumptions, keep, {
551
+ skipNumberLeaves: folds,
552
+ skipOwnArrayLine: property.declared.kind === 'array',
553
+ })
554
+ }
555
+ return
556
+ }
557
+ }
558
+ pushDeclaredAssumptions(path, [], declared, assumptions, keep, {skipNumberLeaves: folds, skipOwnArrayLine: false})
559
+ }
560
+
561
+ // Counts the number leaves the fold may cover, and how many of them the read filter
562
+ // keeps. Nullish subtrees count zero: their leaves keep exact per-leaf lines whether or
563
+ // not the root folds.
564
+ function numberLeafCount(declared: DeclaredKind, segments: string[], keep: KeepPath): {total: number; kept: number} {
565
+ switch (declared.kind) {
566
+ case 'number': return {total: 1, kept: keep(segments) ? 1 : 0}
567
+ case 'boolean': return {total: 0, kept: 0}
568
+ case 'opaque': return {total: 0, kept: 0}
569
+ case 'nullish': return {total: 0, kept: 0}
570
+ case 'array': return numberLeafCount(declared.element, [...segments, '[each]'], keep)
571
+ case 'tuple': {
572
+ const count = {total: 0, kept: 0}
573
+ for (let index = 0; index < declared.elements.length; index++) {
574
+ const element = numberLeafCount(declared.elements[index]!, [...segments, String(index)], keep)
575
+ count.total += element.total
576
+ count.kept += element.kept
577
+ }
578
+ return count
579
+ }
580
+ case 'record': {
581
+ const count = {total: 0, kept: 0}
582
+ for (const property of declared.properties) {
583
+ const inner = numberLeafCount(property.declared, [...segments, property.name], keep)
584
+ count.total += inner.total
585
+ count.kept += inner.kept
586
+ }
587
+ return count
588
+ }
589
+ // Tagged unions never fold: their per-leaf lines carry the `(when route.type is ...)`
590
+ // qualifier scoping each assumption to its variant, which one folded line cannot
591
+ // express — a legal value of one variant has no values in the other variants' slots,
592
+ // so an unqualified declaration-wide assertion is violated by every legal value,
593
+ // while a presence-scoped one is vacuous for a wrong-shape smuggle (a review round
594
+ // ran both failures).
595
+ case 'taggedUnion': return {total: 0, kept: 0}
596
+ }
597
+ }
598
+
599
+ type AssumptionOptions = {
600
+ // True when the root already printed the folded number line; number leaves then add
601
+ // nothing, while boolean and qualified lines still print per leaf.
602
+ skipNumberLeaves: boolean
603
+ // True when the folded plain-array line already restates this value's own plain-array
604
+ // claim — set only on the direct non-nullable array properties of a folding record
605
+ // root. The suppression covers exactly one line: nested levels below the value still
606
+ // print theirs, because the folded sentence quantifies over the root's direct
607
+ // properties only.
608
+ skipOwnArrayLine: boolean
609
+ }
610
+
611
+ const exactLeaves: AssumptionOptions = {skipNumberLeaves: false, skipOwnArrayLine: false}
612
+
613
+ // One assumption line per leaf of the declared kind: a record binding's condition is a
614
+ // condition on each of its properties, e.g. `pointer.x is finite and not NaN`. `segments`
615
+ // is the same path as `path` in property-name segments, checked against the read filter
616
+ // at every line-emitting arm; a line whose path the body never touched stays silent.
617
+ function pushDeclaredAssumptions(path: string, segments: string[], declared: DeclaredKind, assumptions: string[], keep: KeepPath, options: AssumptionOptions = exactLeaves): void {
618
+ switch (declared.kind) {
619
+ case 'number': {
620
+ if (!options.skipNumberLeaves && keep(segments)) {
621
+ assumptions.push(`${path} is ${declaredNumberAssumption(declared)}`)
622
+ }
623
+ break
624
+ }
625
+ case 'boolean': {
626
+ if (keep(segments)) assumptions.push(`${path} is a boolean`)
627
+ break
628
+ }
629
+ case 'tuple': {
630
+ // The engine reads a declared tuple's length as its position count and every slot
631
+ // as present (a [number, number] parameter's .length is seeded as exactly 2) —
632
+ // boundary trust in a value the caller controls, stronger than the plain-array
633
+ // line, and previously unprinted. Type-checked callers can break it: strict tsc
634
+ // allows push on tuples, so `const grown: [number, number] = [1, 2]; grown.push(3)`
635
+ // legally builds a three-element value, and a Proxy over a genuine pair can answer
636
+ // 7 to a length read. The exact-count clause is what those callers violate; the
637
+ // trailing clauses carry the same length-vs-elements and presence trust as the
638
+ // array line. Only all-required tuples classify (optional and rest positions leave
639
+ // the subset at classification), so the count is always the position count. Tuples
640
+ // never join the plain-array fold: the folded sentence cannot state a different
641
+ // element count per property, and the count is the clause a grown tuple violates.
642
+ const count = declared.elements.length
643
+ if (keep(segments)) {
644
+ assumptions.push(`${path} is a plain array of exactly ${count} element${count === 1 ? '' : 's'} — its length counts its elements, and every index below the length holds an element`)
645
+ }
646
+ for (let index = 0; index < declared.elements.length; index++) {
647
+ pushDeclaredAssumptions(`${path}[${index}]`, [...segments, String(index)], declared.elements[index]!, assumptions, keep, options)
648
+ }
649
+ break
650
+ }
651
+ case 'array': {
652
+ // The plain-array line is the trust the element reads and length reads rest on: the
653
+ // engine treats an in-range read as finding a present value of the declared element
654
+ // type, and each length read as a genuine element count (an integer from 0 through
655
+ // 2^32 - 1). Both are boundary trust in a value the caller controls — a Proxy with a
656
+ // lying length trap, an array with holes, or an undefined smuggled into a nested row
657
+ // each violate this line, which is exactly what keeps the ensures lines honest (a
658
+ // review round ran all three falsifications against the previously silent trust).
659
+ // Element lines alone cannot carry it: `every grid[each] element is finite and not
660
+ // NaN` quantifies over the elements an array actually holds, so a lied-about length
661
+ // adds no elements and violates nothing on that line.
662
+ if (!options.skipOwnArrayLine && keep(segments)) {
663
+ assumptions.push(`${path} is a plain array — its length counts its elements, and every index below the length holds an element`)
664
+ }
665
+ // E.g. `every values element is finite and not NaN`. The recursion path uses
666
+ // `[each]` so nesting stays readable: a number[][] parameter prints
667
+ // `every grid[each] element is finite and not NaN`, and a record element prints
668
+ // its property path, e.g. `points[each].x is finite and not NaN`. The nested
669
+ // plain-array line rides the same sugar: `every grid element is a plain array — ...`.
670
+ const leaf: string[] = []
671
+ pushDeclaredAssumptions(`${path}[each]`, [...segments, '[each]'], declared.element, leaf, keep, {skipNumberLeaves: options.skipNumberLeaves, skipOwnArrayLine: false})
672
+ for (const line of leaf) {
673
+ const prefix = `${path}[each] is `
674
+ // The `every X element is` sugar only reads right when the element path appears
675
+ // once. A nullish element's disjunction mentions it again (`slots[each] is null
676
+ // or slots[each].x is finite and not NaN`), and rewriting only the first mention
677
+ // would mix the two quantifier styles in one line.
678
+ const mentionsOnce = line.split(`${path}[each]`).length === 2
679
+ assumptions.push(line.startsWith(prefix) && mentionsOnce
680
+ ? `every ${path} element is ${line.slice(prefix.length)}`
681
+ : line)
682
+ }
683
+ break
684
+ }
685
+ // No claims are made about an opaque leaf, so there is nothing to assume.
686
+ case 'opaque': break
687
+ case 'nullish': {
688
+ if (!keep(segments)) break
689
+ const sentinelWords = declared.sentinels === 'both' ? 'null or undefined' : declared.sentinels
690
+ if (declared.inner.kind === 'number') {
691
+ // E.g. `animatedUntilTime is null or a finite non-NaN number`. Never folded: the
692
+ // folded line's kind assertion would wrongly forbid the legal null.
693
+ const numberWords = declared.inner.interval == null
694
+ ? 'a finite non-NaN number'
695
+ : declaredNumberAssumption(declared.inner)
696
+ assumptions.push(`${path} is ${sentinelWords} or ${numberWords}`)
697
+ } else if (declared.inner.kind === 'boolean') {
698
+ assumptions.push(`${path} is ${sentinelWords} or a boolean`)
699
+ } else {
700
+ // One line per inner leaf, each carrying the missing-value caveat — e.g. a
701
+ // `Config | null` parameter prints `config is null or config.width is finite and
702
+ // not NaN`. The seeded finiteness of every leaf must reach the report: the
703
+ // ensures lines rest on it. An opaque inner (`string | null`) contributes no
704
+ // line, because nothing is claimed about the string either way. A nullish
705
+ // subtree never joins either fold, so every inner line prints exactly — in
706
+ // particular a nullable array member's own disjunct, `overrides is null or
707
+ // overrides is a plain array — ...`, whose named sentinel is the one the engine
708
+ // seeds and narrows by; a wrong-sentinel smuggle violates the disjunct.
709
+ const leaf: string[] = []
710
+ pushDeclaredAssumptions(path, segments, declared.inner, leaf, keep, exactLeaves)
711
+ for (const line of leaf) assumptions.push(`${path} is ${sentinelWords} or ${line}`)
712
+ }
713
+ break
714
+ }
715
+ case 'record': {
716
+ for (const property of declared.properties) {
717
+ pushDeclaredAssumptions(`${path}.${property.name}`, [...segments, property.name], property.declared, assumptions, keep, options)
718
+ }
719
+ break
720
+ }
721
+ case 'taggedUnion': {
722
+ // Per-variant leaf lines, each qualified by the tag — e.g. `route.index is finite
723
+ // and not NaN (when route.type is 'lightbox')`. The tag property itself is skipped:
724
+ // a string tag is an opaque leaf with no line anyway, and a boolean tag's "ok is a
725
+ // boolean" would restate what the qualifier already pins. When several variants
726
+ // share one tag value, or when a plain-boolean tag expands into several shapes, the
727
+ // tag alone does not pin the shape. Each line then adds a presence qualifier, so
728
+ // the assumption speaks only about values that actually carry the property.
729
+ // Reads mark a variant property's path with no variant attached (the narrow that
730
+ // preceded the read is not tracked), so a read of one variant's property keeps the
731
+ // same-named property's lines in every variant that declares it.
732
+ for (const variant of declared.variants) {
733
+ const sharedTag = declared.variants.filter(candidate => candidate.tagValue === variant.tagValue).length > 1
734
+ const leaf: string[] = []
735
+ for (const property of variant.properties) {
736
+ if (property.name === declared.tagProperty) continue
737
+ const qualifier = sharedTag
738
+ ? `when ${path}.${declared.tagProperty} is ${formatTagValue(variant.tagValue)} and ${path}.${property.name} is present`
739
+ : `when ${path}.${declared.tagProperty} is ${formatTagValue(variant.tagValue)}`
740
+ const perProperty: string[] = []
741
+ pushDeclaredAssumptions(`${path}.${property.name}`, [...segments, property.name], property.declared, perProperty, keep, exactLeaves)
742
+ for (const line of perProperty) leaf.push(`${line} (${qualifier})`)
743
+ }
744
+ for (const line of leaf) {
745
+ if (!assumptions.includes(line)) assumptions.push(line)
746
+ }
747
+ }
748
+ break
749
+ }
750
+ }
751
+ }
752
+
753
+ function hasLiteralNumberInterval(declared: DeclaredKind): boolean {
754
+ switch (declared.kind) {
755
+ case 'number': return declared.interval != null
756
+ case 'boolean':
757
+ case 'opaque': return false
758
+ case 'nullish': return hasLiteralNumberInterval(declared.inner)
759
+ case 'tuple': return declared.elements.some(hasLiteralNumberInterval)
760
+ case 'array': return hasLiteralNumberInterval(declared.element)
761
+ case 'record': return declared.properties.some(property => hasLiteralNumberInterval(property.declared))
762
+ case 'taggedUnion': return declared.variants.some(variant =>
763
+ variant.properties.some(property => hasLiteralNumberInterval(property.declared)))
764
+ }
765
+ }
766
+
767
+ function declaredNumberAssumption(declared: Extract<DeclaredKind, {kind: 'number'}>): string {
768
+ if (declared.interval == null) return 'finite and not NaN'
769
+ const integer = declared.interval.integer ? ' integer' : ''
770
+ return `a finite${integer} number from ${String(declared.interval.lower)} through ${String(declared.interval.upper)}`
771
+ }
772
+
773
+ // Per function, the module bindings whose declared-kind seeding the result rests on.
774
+ // The dependency travels through calls so callers print their callees' assumptions too.
775
+ function functionModuleAssumptions(
776
+ program: ProgramIR,
777
+ analysis: ProgramAnalysis,
778
+ ): Set<ModuleBindingID>[] {
779
+ const usage = functionUsage(program)
780
+ const direct = usage.map(fn => {
781
+ const reads = new Set<ModuleBindingID>()
782
+ for (const bindingID of fn.moduleBindings) {
783
+ if (analysis.moduleValues[bindingID] != null) continue
784
+ const binding = program.moduleBindings[bindingID]
785
+ if (binding == null) throw new Error(`Unknown module binding ${bindingID}`)
786
+ if (declaredKindOf(binding.category) != null) reads.add(bindingID)
787
+ }
788
+ return reads
789
+ })
790
+ return transitiveModuleBindings(usage, direct)
791
+ }
792
+
793
+ // The only place stop prose exists; everything else branches on reason.kind.
794
+ function formatStop(stop: Stop, program: ProgramIR, analysis: ProgramAnalysis): string {
795
+ const reason = stop.reason
796
+ switch (reason.kind) {
797
+ case 'recursion': {
798
+ return `recursive call to ${functionName(program, reason.callee)} (call at ${formatSite(program, stop.site)})`
799
+ }
800
+ case 'calleeStopped': {
801
+ // A partially supported callee did not necessarily hit syntax rejected during lowering;
802
+ // saying so would send an agent hunting through a body whose constructs all lower.
803
+ const calleeState = calleeStateText(analysis.functions[reason.callee])
804
+ return `calls ${functionName(program, reason.callee)}, ${calleeState} (call at ${formatSite(program, stop.site)})`
805
+ }
806
+ case 'kindMismatch': {
807
+ return `uses a value whose runtime kind the analysis cannot establish (at ${formatSite(program, stop.site)})`
808
+ }
809
+ case 'possiblyMissingElement': {
810
+ return `uses a possibly missing array element without handling undefined (at ${formatSite(program, stop.site)})`
811
+ }
812
+ case 'requirementFailure': {
813
+ return formatRequirementFailure(reason.failure, reason.callee, stop.site, program)
814
+ }
815
+ case 'loopLimit': {
816
+ return `the loop at ${formatSite(program, stop.site)} did not converge after ${reason.updates} updates`
817
+ }
818
+ case 'nonExitingLoop': {
819
+ return `the loop at ${formatSite(program, stop.site)} never exits on any analyzed path`
820
+ }
821
+ case 'unsupportedCode': {
822
+ return `${formatUnsupportedReason(reason.reason)} at ${formatSite(program, stop.site)}`
823
+ }
824
+ case 'moduleRead': {
825
+ const binding = program.moduleBindings[reason.binding]
826
+ if (binding == null) throw new Error(`Unknown module binding ${reason.binding}`)
827
+ switch (binding.category.kind) {
828
+ case 'import':
829
+ // An imported constant's slot is always seeded with its literal, so its reads never
830
+ // stop; the case exists for exhaustiveness (demotion rewrites the category to plain
831
+ // import before any stop could carry it here).
832
+ case 'importedConstant':
833
+ return `reads ${binding.name}, which is imported from another module (read at ${formatSite(program, stop.site)})`
834
+ case 'opaque':
835
+ return `reads ${binding.name}, whose value the analysis does not track (read at ${formatSite(program, stop.site)})`
836
+ // A value or kind binding is always seeded inside functions, so an uninitialized
837
+ // read of one can only happen in the initializer's own top-level code.
838
+ case 'value':
839
+ case 'kind':
840
+ return `reads ${binding.name} before it is initialized (read at ${formatSite(program, stop.site)})`
841
+ }
842
+ }
843
+ }
844
+ }
845
+
846
+ function formatRequirementFailure(
847
+ failure: RequirementFailure,
848
+ calleeID: FunctionID | null,
849
+ stopSite: SiteID,
850
+ program: ProgramIR,
851
+ ): string {
852
+ const origin = formatSite(program, failure.site)
853
+ if (calleeID == null) {
854
+ switch (failure.kind) {
855
+ case 'elementInBounds': return `reads an element provably outside the array (at ${origin})`
856
+ case 'nonzeroDivisor': return `${failure.operation} has a divisor that is definitely zero (at ${origin})`
857
+ case 'finiteInput': return failure.status === 'refuted'
858
+ ? `number input is definitely not finite (at ${origin})`
859
+ : `could not verify the number input (at ${origin})`
860
+ case 'declared': return failure.status === 'refuted'
861
+ ? `declared requirement is false (at ${origin})`
862
+ : `could not express or prove the declared requirement (at ${origin})`
863
+ }
864
+ }
865
+
866
+ const callee = functionName(program, calleeID)
867
+ const callSite = formatSite(program, stopSite)
868
+ switch (failure.kind) {
869
+ case 'elementInBounds':
870
+ return `call to ${callee} makes an asserted element read definitely out of bounds (call at ${callSite}; element read at ${origin})`
871
+ case 'nonzeroDivisor':
872
+ return `call to ${callee} violates its nonzero divisor requirement (call at ${callSite}; ${failure.operation} at ${origin})`
873
+ case 'finiteInput': return failure.status === 'refuted'
874
+ ? `call to ${callee} passes a number that is definitely not finite (call at ${callSite}; input declared at ${origin})`
875
+ : `could not verify ${callee}'s number input (call at ${callSite}; input declared at ${origin})`
876
+ case 'declared': return failure.status === 'refuted'
877
+ ? `call to ${callee} makes its declared requirement definitely false (call at ${callSite}; declared at ${origin})`
878
+ : `could not express or prove ${callee}'s declared requirement (call at ${callSite}; declared at ${origin})`
879
+ }
880
+ }
881
+
882
+ function calleeStateText(callee: FunctionAnalysis | undefined): string {
883
+ if (callee == null) return 'which is only partially supported'
884
+ switch (callee.kind) {
885
+ case 'notLowered': return 'which hit unsupported code'
886
+ case 'partial': return 'which is only partially supported'
887
+ // The callee analyzes completely in general but could not be fully analyzed from this call —
888
+ // because of this caller's arguments (e.g. an argument whose expression the requirement
889
+ // language cannot name) or the module state at this point (e.g. a module binding not yet
890
+ // initialized when top-level code makes the call).
891
+ case 'analyzed': return 'which could not be fully analyzed for this specific call'
892
+ }
893
+ }
894
+
895
+ function functionName(program: ProgramIR, callee: number): string {
896
+ const fn = program.functions[callee]
897
+ if (fn == null) throw new Error(`Unknown function ${callee}`)
898
+ return fn.name
899
+ }
900
+
901
+ // The only place reason prose exists; everything else branches on reason.kind. The
902
+ // exhaustiveness check forces a formatting arm for every future variant.
903
+ export function formatUnsupportedReason(reason: UnsupportedReason): string {
904
+ switch (reason.kind) {
905
+ case 'unknownIdentifier': return `unknown identifier ${reason.name}`
906
+ case 'missingSymbol': return 'node without a TypeScript symbol'
907
+ case 'functionWithoutSignature': return 'function without a TypeScript signature'
908
+ case 'functionWithoutBody': return 'function declarations need bodies'
909
+ case 'destructuredParameter': return 'destructured parameters (take a named parameter and destructure it in the body)'
910
+ case 'parameterType': return reason.optionalOrRestTuple
911
+ ? `function parameter with type ${reason.typeText} (a tuple position marked optional or rest makes the runtime length a range, which is outside the analyzed subset; model the value as number[], or as a fixed tuple like [number, number])`
912
+ : `function parameter with type ${reason.typeText}`
913
+ case 'parameterDefaultValue': return `default value for parameter ${reason.name}; supported defaults are literals provably inside the assumed kind (= 5 for a number, = null for a nullable) — otherwise drop the default and pass the argument explicitly`
914
+ case 'missingReturn': return 'function path without a return (add a return on every path)'
915
+ case 'objectPropertyForm': return 'object property form (use plain data properties: name: value or shorthand)'
916
+ case 'computedPropertyName': return 'computed object property name'
917
+ case 'objectSpread': return 'object spread (list every field explicitly, e.g. {gain: config.gain})'
918
+ case 'asyncOrGeneratorFunction': return 'an async or generator function (the runtime result is a Promise or iterator, not the body\'s return value)'
919
+ case 'typePredicate': return 'a type predicate (the checker takes the predicate on faith; return a plain boolean and check properties where they are read)'
920
+ case 'protoProperty': return 'a property named __proto__ (prototype-setting syntax at runtime, not a data property)'
921
+ case 'enumMemberRead': return 'an enum member read (replace the enum with plain module consts, e.g. const directionUp = 1)'
922
+ case 'prototypeMemberRead': return `read of the inherited prototype member ${reason.property} (records carry only their own data properties)`
923
+ case 'binaryOperator': return reason.operator === 'in'
924
+ ? 'the `in` operator (use a distinct string or boolean tag when property presence distinguishes union variants)'
925
+ : `binary operator ${reason.operator} (supported: + - * / %, comparisons, and boolean && || !)`
926
+ case 'call': return reason.callee === 'Object.assign'
927
+ ? 'function call Object.assign (object mutation is outside the subset; rebuilding a plain-data record may be suitable when identity and mutation are not observed)'
928
+ : reason.arrayMethod != null
929
+ ? `function call ${reason.callee} (array methods are outside the subset; a for loop may suit simple dense-array aggregation)`
930
+ : `function call ${reason.callee}`
931
+ case 'callWithFewerArguments': return `call to ${reason.callee} with fewer arguments than parameters (pass every argument explicitly)`
932
+ case 'callWithMoreArguments': return `call to ${reason.callee} with more arguments than its implementation declares`
933
+ case 'nonNumberOperand': return `non-number operand of type ${reason.typeText}`
934
+ case 'nonBooleanCondition': return `condition of type ${reason.typeText} (compare explicitly, e.g. width > 0 or mode !== undefined)`
935
+ case 'valueType': return `value of type ${reason.typeText}`
936
+ case 'kindChangingAssertion': return `a non-null assertion turning ${reason.fromText} into ${reason.toText}`
937
+ case 'propertyReadOnNonObject': return `property read from ${reason.typeText}`
938
+ case 'statementAfterReturn': return 'statements after return'
939
+ case 'assignmentInValuePosition': return 'an assignment used as a value (write it as its own statement)'
940
+ case 'propertyWrite': return 'a write into an object (mutation is outside the subset; rebuilding a plain-data record may be suitable when identity and mutation are not observed)'
941
+ case 'staticAssertionForm': {
942
+ switch (reason.problem) {
943
+ case 'argumentCount': return 'console.assert must have exactly one condition argument'
944
+ case 'position': return 'console.assert must be a standalone statement'
945
+ case 'optionalCall': return 'optional console.assert calls are not supported'
946
+ case 'directCheck': return 'console.assert must contain one direct numeric comparison using ===, !==, <, <=, >, or >=, or a supported Number check'
947
+ case 'bindValueFirst': return 'calculate or read the value before console.assert, then check the variable'
948
+ case 'functionCall': return 'console.assert cannot call a function inside its condition except Number.isInteger, Number.isFinite, or Number.isNaN'
949
+ case 'callerRequirement': return 'a leading console.assert describes what callers must provide. It can compare one parameter with a fixed finite number, require one parameter to be an integer, or require a parameter or fixed-record property to be finite'
950
+ }
951
+ }
952
+ case 'varDeclaration': return 'var declarations (use let or const)'
953
+ case 'evalInFile': return 'eval appears in this file; an eval string can rewrite any binding, so no function in the file is analyzed'
954
+ case 'typeCheckSuppressed': return 'a @ts-ignore, @ts-expect-error, or @ts-nocheck comment turns off type checking in this file, so declared types cannot be trusted and no function is analyzed'
955
+ case 'forLoopWithoutCondition': return 'for loop without a condition'
956
+ case 'variableDeclarationShape': return 'variables without identifier names and initializers'
957
+ case 'expressionForm': return `expression (${reason.syntax})`
958
+ case 'statementForm': return `statement (${reason.syntax})`
959
+ case 'switchFallthrough': return 'switch case that falls through to the next case (end every case body with break or return)'
960
+ case 'switchDefaultNotLast': return 'switch with a default clause before other cases (write default as the last clause)'
961
+ case 'switchSubject': return `switch on a value of type ${reason.typeText} (only numbers and strings dispatch)`
962
+ case 'switchLabel': return `switch case label of type ${reason.typeText} (labels must be literals matching the subject's kind)`
963
+ }
964
+ }
965
+
966
+ function declaredReturn(value: AbstractValue, lowering: FunctionIR): AbstractValue {
967
+ if (lowering.returnPropertyNames == null) return value
968
+ const declared = new Set(lowering.returnPropertyNames)
969
+ if (value.kind === 'record') {
970
+ return {kind: 'record', properties: value.properties.filter(property => declared.has(property.name))}
971
+ }
972
+ if (value.kind === 'maybeNullish' && value.inner.kind === 'record') {
973
+ return {
974
+ ...value,
975
+ inner: {kind: 'record', properties: value.inner.properties.filter(property => declared.has(property.name))},
976
+ }
977
+ }
978
+ return value
979
+ }
980
+
981
+ function returnSummaries(path: string, value: AbstractValue, program: ProgramIR): string[] {
982
+ switch (value.kind) {
983
+ case 'number': return [numberSummary(path, value, program)]
984
+ case 'boolean': return [`${path} is ${value.canBeFalse ? (value.canBeTrue ? 'boolean' : 'false') : 'true'}`]
985
+ case 'record': {
986
+ const summaries: string[] = []
987
+ for (const property of value.properties) {
988
+ summaries.push(...returnSummaries(`${path}.${property.name}`, property.value, program))
989
+ }
990
+ return summaries
991
+ }
992
+ case 'void': return []
993
+ // No numeric claims exist about an opaque value; saying nothing is the honest line.
994
+ case 'opaque': return []
995
+ case 'nullish': return [`${path} is ${sentinelsText(value.sentinels)}`]
996
+ case 'tuple': {
997
+ const lines: string[] = [`${path}.length is exactly ${value.elements.length}`]
998
+ for (let index = 0; index < value.elements.length; index++) {
999
+ lines.push(...returnSummaries(`${path}[${index}]`, value.elements[index]!, program))
1000
+ }
1001
+ return lines
1002
+ }
1003
+ case 'array': {
1004
+ const lines = [numberSummary(`${path}.length`, value.length, program)]
1005
+ if (value.element != null) {
1006
+ lines.push(...returnSummaries(`${path}[each]`, value.element, program).map(line =>
1007
+ line.startsWith(`${path}[each] is `)
1008
+ ? `every ${path} element is ${line.slice(`${path}[each] is `.length)}`
1009
+ : line))
1010
+ }
1011
+ return lines
1012
+ }
1013
+ case 'taggedUnion': {
1014
+ // One line naming the possible tags, then each variant's facts qualified by its
1015
+ // tag — e.g. `return.width is a finite number (when return.type is 'sidebar')`.
1016
+ const uniqueTags: Array<string | boolean> = []
1017
+ for (const variant of value.variants) {
1018
+ if (!uniqueTags.includes(variant.tagValue)) uniqueTags.push(variant.tagValue)
1019
+ }
1020
+ const lines = [`${path}.${value.tagProperty} is ${uniqueTags.map(formatTagValue).join(' or ')}`]
1021
+ // Claims group by tag value, because the tag is all a caller can dispatch on: when
1022
+ // several variants share one tag value, or a plain-boolean tag expands into several
1023
+ // shapes, a property's claim must hold across the whole group — values join, and a
1024
+ // property only some shapes carry gets a presence qualifier. A review round caught
1025
+ // the per-variant version publishing two same-tag shapes' exclusive properties as
1026
+ // unconditional: mutually exclusive claims, at least one false on every call. The
1027
+ // tag property itself is skipped — the tags line and the qualifier already say its
1028
+ // value.
1029
+ for (const tagValue of uniqueTags) {
1030
+ const group = value.variants.filter(variant => variant.tagValue === tagValue)
1031
+ const names: string[] = []
1032
+ for (const variant of group) {
1033
+ for (const property of variant.record.properties) {
1034
+ if (property.name === value.tagProperty) continue
1035
+ if (!names.includes(property.name)) names.push(property.name)
1036
+ }
1037
+ }
1038
+ for (const name of names) {
1039
+ const carried = group
1040
+ .map(variant => recordProperty(variant.record, name))
1041
+ .filter(propertyValue => propertyValue != null)
1042
+ let joined: AbstractValue | null = null
1043
+ for (const propertyValue of carried) {
1044
+ joined = joined == null ? propertyValue : tryJoinValues(joined, propertyValue)
1045
+ if (joined == null) break
1046
+ }
1047
+ // Mixed kinds across same-tag shapes: nothing sound to say about the property.
1048
+ if (joined == null) continue
1049
+ const qualifier = value.variants.length === 1
1050
+ ? null
1051
+ : carried.length === group.length
1052
+ ? `when ${path}.${value.tagProperty} is ${formatTagValue(tagValue)}`
1053
+ : `when ${path}.${value.tagProperty} is ${formatTagValue(tagValue)} and ${path}.${name} is present`
1054
+ const summaries = returnSummaries(`${path}.${name}`, joined, program)
1055
+ lines.push(...(qualifier == null ? summaries : summaries.map(line => `${line} (${qualifier})`)))
1056
+ }
1057
+ }
1058
+ return lines
1059
+ }
1060
+ case 'maybeNullish': {
1061
+ // The inner summary describes the present case; one line states the missing case.
1062
+ // E.g. `return is null or a finite number from 0 through 100`.
1063
+ const inner = returnSummaries(path, value.inner, program)
1064
+ if (inner.length === 0) return [`${path} may be ${sentinelsText(value.sentinels)}`]
1065
+ if (inner.length === 1 && inner[0]!.startsWith(`${path} is `)) {
1066
+ return [`${path} is ${sentinelsText(value.sentinels)} or ${inner[0]!.slice(`${path} is `.length)}`]
1067
+ }
1068
+ return [`${path} may be ${sentinelsText(value.sentinels)}; when present:`, ...inner]
1069
+ }
1070
+ }
1071
+ }
1072
+
1073
+ function sentinelsText(sentinels: 'null' | 'undefined' | 'both'): string {
1074
+ return sentinels === 'both' ? 'null or undefined' : sentinels
1075
+ }
1076
+
1077
+ function numberSummary(path: string, value: AbstractNumber, program: ProgramIR): string {
1078
+ const kind = value.integer ? 'integer ' : ''
1079
+ // Three-way: NaN is the scarier possibility and names itself; a value that can only
1080
+ // overflow says non-finite; everything else is finite.
1081
+ const domain = value.mayBeNaN ? 'possibly NaN ' : isFiniteNumber(value) ? 'finite ' : 'possibly non-finite '
1082
+ // The blame suffix names where the degradation was born, so the line points at the
1083
+ // missing input fact instead of just shrugging. A recovered value (clamped back to a
1084
+ // clean range) prints no suffix even when the annotation lingers.
1085
+ const blameSite = value.mayBeNaN ? value.nanSite : value.nonFiniteSite
1086
+ const blame = blameSite == null || (isFiniteNumber(value) && !value.mayBeNaN)
1087
+ ? ''
1088
+ : value.mayBeNaN
1089
+ ? ` (NaN possible from the operation at ${formatSite(program, blameSite)})`
1090
+ : ` (can overflow at ${formatSite(program, blameSite)})`
1091
+ const subject = `${path} is a ${domain}${kind}number`
1092
+ // A point interval is an exact value (`return 0.1 + 0.2` is exactly
1093
+ // 0.30000000000000004); rewriting either bound into strict phrasing would print an
1094
+ // absurd range around a constant, so the rewrite only applies to genuine ranges.
1095
+ const pointInterval = value.lower === value.upper
1096
+ const strictLower = pointInterval ? null : strictBoundWords(value.lower, 'lower')
1097
+ const strictUpper = pointInterval ? null : strictBoundWords(value.upper, 'upper')
1098
+ if (value.lower === -Number.MAX_VALUE && value.upper === Number.MAX_VALUE) return `${subject}${blame}`
1099
+ if (value.upper === Number.MAX_VALUE) {
1100
+ return `${subject} ${strictLower ?? `at least ${formatNumber(value.lower)}`}${blame}`
1101
+ }
1102
+ if (value.lower === -Number.MAX_VALUE) {
1103
+ return `${subject} ${strictUpper ?? `at most ${formatNumber(value.upper)}`}${blame}`
1104
+ }
1105
+ if (strictLower != null || strictUpper != null) {
1106
+ const low = strictLower ?? `at least ${formatNumber(value.lower)}`
1107
+ const high = strictUpper ?? `at most ${formatNumber(value.upper)}`
1108
+ return `${subject} ${low} and ${high}${blame}`
1109
+ }
1110
+ return `${subject} from ${formatNumber(value.lower)} through ${formatNumber(value.upper)}${blame}`
1111
+ }
1112
+
1113
+ // A strict comparison refines a float bound to the adjacent representable double, which
1114
+ // prints hideously (`if (x > 0)` gives lower bound 5e-324, `if (x < 100)` gives upper
1115
+ // bound 99.99999999999999). When stepping the bound back lands on a visibly simpler
1116
+ // number, the strict phrasing says the same thing readably: 'more than 0', 'less than
1117
+ // 100'. Bounds that already print plainly return null and keep the ordinary phrasing.
1118
+ function strictBoundWords(bound: number, side: 'lower' | 'upper'): string | null {
1119
+ const stepped = side === 'lower' ? nextDown(bound) : nextUp(bound)
1120
+ // The margin is deliberately wide: only rewrite when the stepped form is drastically
1121
+ // shorter (5e-324 -> 0, 99.99999999999999 -> 100), never for a computed bound whose
1122
+ // neighbor happens to print a digit or two shorter.
1123
+ if (formatNumber(stepped).length + 4 <= formatNumber(bound).length) {
1124
+ return `${side === 'lower' ? 'more than' : 'less than'} ${formatNumber(stepped)}`
1125
+ }
1126
+ return null
1127
+ }
1128
+
1129
+ // Infinite bounds are expected here; String renders them as 'Infinity'/'-Infinity'.
1130
+ function formatNumber(value: number): string {
1131
+ return String(value)
1132
+ }