@chenglou/freerange 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/fr.ts +33 -0
- package/package.json +39 -0
- package/src/analyze.ts +16 -0
- package/src/audit.ts +468 -0
- package/src/domain/number.ts +444 -0
- package/src/domain/value.ts +445 -0
- package/src/engine/analyze.ts +745 -0
- package/src/engine/outcome.ts +137 -0
- package/src/engine/state.ts +155 -0
- package/src/engine/transfer.ts +1723 -0
- package/src/index.ts +23 -0
- package/src/ir/finite-inputs.ts +51 -0
- package/src/ir/function-usage.ts +50 -0
- package/src/ir/ids.ts +10 -0
- package/src/ir/instructions.ts +164 -0
- package/src/ir/program.ts +446 -0
- package/src/lower/accept.ts +119 -0
- package/src/lower/context.ts +250 -0
- package/src/lower/expression.ts +1742 -0
- package/src/lower/literals.ts +84 -0
- package/src/lower/module.ts +748 -0
- package/src/lower/platform.ts +81 -0
- package/src/lower/program.ts +310 -0
- package/src/lower/statements.ts +553 -0
- package/src/lower/static-intrinsics.ts +87 -0
- package/src/project.ts +500 -0
- package/src/report/format-requirement.ts +110 -0
- package/src/report/index.ts +1132 -0
- package/src/requirements/infer.ts +334 -0
- package/src/requirements/model.ts +77 -0
- package/src/typescript/check.ts +56 -0
- package/src/typescript/diagnostics.ts +69 -0
- package/src/typescript/project.ts +101 -0
|
@@ -0,0 +1,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
|
+
}
|