@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/src/project.ts ADDED
@@ -0,0 +1,500 @@
1
+ // Both commands resolve the tsconfig from the current directory — searching upward like
2
+ // `tsc` — and load that project and its declared references once. `fr [file]` prints lint
3
+ // findings — the CI gate — and `fr --audit [file]` prints the deep layer: every function's
4
+ // contracts plus refactoring suggestions. Each command's file version is the project
5
+ // version narrowed to that file: same configuration, same content kinds, same line
6
+ // formats, one file's slice.
7
+
8
+ import {existsSync, realpathSync} from 'node:fs'
9
+ import {resolve} from 'node:path'
10
+ import * as ts from 'typescript'
11
+ import {analyzeCheckedSource, type DetailedAnalysis} from './analyze.ts'
12
+ import {createFileAudit, formatFileAuditUnit} from './audit.ts'
13
+ import type {AssertionVerdict, FunctionAnalysis, RequirementFailure} from './engine/outcome.ts'
14
+ import type {SiteID} from './ir/ids.ts'
15
+ import {reportPath, siteLocation} from './ir/program.ts'
16
+ import {formatUnsupportedReason} from './report/index.ts'
17
+ import {checkFile} from './typescript/check.ts'
18
+ import {formatDiagnosticLocation, formatDiagnosticPrefix, formatTypeScriptDiagnostics, TypeScriptDiagnosticsError, usePrettyOutput} from './typescript/diagnostics.ts'
19
+ import {
20
+ findTypeScriptConfig,
21
+ loadTypeScriptProjectGraph,
22
+ projectSources,
23
+ type ProjectSource,
24
+ } from './typescript/project.ts'
25
+
26
+ type SimpleLintFinding = {
27
+ kind: 'simple'
28
+ file: string
29
+ line: number
30
+ column: number
31
+ functionName: string
32
+ stop: 'outOfBoundsRead' | 'nonExitingLoop'
33
+ }
34
+
35
+ type ErrorLintFinding = {
36
+ kind: 'error'
37
+ file: string
38
+ line: number
39
+ column: number
40
+ rule: 'console-assert' | 'declared-requirement' | 'inferred-requirement'
41
+ message: string
42
+ related?: {label: string; line: number; column: number}
43
+ }
44
+
45
+ type LintFinding =
46
+ | SimpleLintFinding
47
+ | ErrorLintFinding
48
+
49
+ export type ProjectCoverage = {
50
+ functions: number
51
+ analyzed: number
52
+ partial: number
53
+ unsupported: number
54
+ }
55
+
56
+ type ProjectScan = {
57
+ files: DetailedAnalysis[]
58
+ coverage: ProjectCoverage
59
+ pretty: boolean
60
+ }
61
+
62
+ // `fr`: every file's lint findings plus project coverage. TypeScript errors throw before
63
+ // analysis; the returned failure covers Freerange's error-level findings.
64
+ export function runProjectFindings(searchFrom: string): boolean {
65
+ const scan = analyzeProject(searchFrom)
66
+ const findings = scan.files.flatMap(collectLintFindings)
67
+ .sort((left, right) =>
68
+ left.file.localeCompare(right.file) || left.line - right.line || left.column - right.column)
69
+ console.log(formatFindings(findings, scan.coverage, scan.pretty))
70
+ return findings.some(finding => lintLevel(finding) === 'error')
71
+ }
72
+
73
+ // `fr <file>`: the project findings narrowed to one file — the same finding lines a
74
+ // project run prints for the file, with the file's own coverage counts.
75
+ export function runFileFindings(file: string): boolean {
76
+ const target = analyzeTargetFile(file)
77
+ const findings = collectLintFindings(target.detailed)
78
+ .sort((left, right) => left.line - right.line || left.column - right.column)
79
+ console.log(formatFindings(findings, fileCoverage(target.detailed), target.pretty))
80
+ return findings.some(finding => lintLevel(finding) === 'error')
81
+ }
82
+
83
+ // `fr --audit`: the deep layer at project scope. One unit per file — contracts, then
84
+ // refactoring suggestions — with the explanatory prose once at the top and project
85
+ // coverage once at the end. The units all come from the one shared project analysis;
86
+ // nothing here creates a per-file TypeScript program. Audit output is informational and
87
+ // returns success; TypeScript errors throw before audit output.
88
+ export function runProjectAudit(searchFrom: string): boolean {
89
+ const scan = analyzeProject(searchFrom)
90
+ const audits = scan.files.map(createFileAudit)
91
+ .sort((left, right) => left.file.localeCompare(right.file))
92
+ console.log([
93
+ ...audits.map(audit => formatFileAuditUnit(audit, scan.pretty)),
94
+ formatCoverage(scan.coverage),
95
+ ].join('\n\n'))
96
+ return false
97
+ }
98
+
99
+ // `fr --audit <file>`: exactly one file's unit under the same preamble — a literal slice
100
+ // of the project audit.
101
+ export function runFileAudit(file: string): boolean {
102
+ const target = analyzeTargetFile(file)
103
+ console.log(formatFileAuditUnit(createFileAudit(target.detailed), target.pretty))
104
+ return false
105
+ }
106
+
107
+ function analyzeProject(searchFrom: string): ProjectScan {
108
+ const configPath = findTypeScriptConfig(searchFrom)
109
+ if (configPath == null) {
110
+ throw new Error(`No tsconfig.json found from ${resolve(searchFrom)} or any parent directory.`)
111
+ }
112
+ const projects = loadTypeScriptProjectGraph(configPath)
113
+ const rootProject = projects.at(-1)!
114
+ const sources = projectSources(projects)
115
+ const diagnostics = uniqueDiagnostics(projects.flatMap(project => ts.getPreEmitDiagnostics(project.program)))
116
+ requireNoTypeScriptErrors(diagnostics, rootProject.parsed.options)
117
+
118
+ const files: DetailedAnalysis[] = []
119
+ let analyzed = 0
120
+ let partial = 0
121
+ let unsupported = 0
122
+
123
+ for (const source of sources) {
124
+ const detailed = analyzeProjectSource(source, process.cwd())
125
+ files.push(detailed)
126
+ const perFile = fileCoverage(detailed)
127
+ analyzed += perFile.analyzed
128
+ partial += perFile.partial
129
+ unsupported += perFile.unsupported
130
+ }
131
+
132
+ return {
133
+ files,
134
+ coverage: {
135
+ functions: analyzed + partial + unsupported,
136
+ analyzed,
137
+ partial,
138
+ unsupported,
139
+ },
140
+ pretty: usePrettyOutput(rootProject.parsed.options['pretty']),
141
+ }
142
+ }
143
+
144
+ function collectLintFindings({program, analysis}: DetailedAnalysis): LintFinding[] {
145
+ const file = reportPath(program)
146
+ const findings: LintFinding[] = []
147
+ const addError = (
148
+ site: SiteID,
149
+ rule: ErrorLintFinding['rule'],
150
+ message: string,
151
+ related?: ErrorLintFinding['related'],
152
+ ): void => {
153
+ const location = siteLocation(program, site)
154
+ findings.push({kind: 'error', file, ...location, rule, message, ...(related == null ? {} : {related})})
155
+ }
156
+
157
+ const addRequirementFailure = (
158
+ failure: RequirementFailure,
159
+ stopSite: SiteID,
160
+ functionName: string,
161
+ calleeName: string | null,
162
+ ): void => {
163
+ if (failure.kind === 'elementInBounds') {
164
+ if (calleeName == null) {
165
+ const location = siteLocation(program, stopSite)
166
+ findings.push({kind: 'simple', file, ...location, functionName, stop: 'outOfBoundsRead'})
167
+ } else {
168
+ const origin = siteLocation(program, failure.site)
169
+ addError(
170
+ stopSite,
171
+ 'inferred-requirement',
172
+ `call to ${calleeName} makes an asserted element read definitely out of bounds`,
173
+ {label: 'element read at', ...origin},
174
+ )
175
+ }
176
+ return
177
+ }
178
+
179
+ if (failure.kind === 'nonzeroDivisor') {
180
+ if (calleeName == null) {
181
+ addError(
182
+ stopSite,
183
+ 'inferred-requirement',
184
+ `${failure.operation} has a divisor that is definitely zero in ${functionName}`,
185
+ )
186
+ } else {
187
+ const origin = siteLocation(program, failure.site)
188
+ addError(
189
+ stopSite,
190
+ 'inferred-requirement',
191
+ `call to ${calleeName} violates its nonzero divisor requirement`,
192
+ {label: `${failure.operation} at`, ...origin},
193
+ )
194
+ }
195
+ return
196
+ }
197
+
198
+ if (failure.kind === 'finiteInput') {
199
+ const origin = siteLocation(program, failure.site)
200
+ addError(
201
+ stopSite,
202
+ 'inferred-requirement',
203
+ calleeName == null
204
+ ? failure.status === 'refuted'
205
+ ? `number input is definitely not finite in ${functionName}`
206
+ : `could not verify the number input in ${functionName}`
207
+ : failure.status === 'refuted'
208
+ ? `call to ${calleeName} passes a number that is definitely not finite`
209
+ : `could not verify ${calleeName}'s number input at this call`,
210
+ {label: 'input declared at', ...origin},
211
+ )
212
+ return
213
+ }
214
+
215
+ if (calleeName == null) {
216
+ addError(
217
+ stopSite,
218
+ 'declared-requirement',
219
+ failure.status === 'refuted'
220
+ ? `declared console.assert requirement is false in ${functionName}`
221
+ : `could not express or prove the declared console.assert requirement in ${functionName}`,
222
+ )
223
+ } else {
224
+ const origin = siteLocation(program, failure.site)
225
+ addError(
226
+ stopSite,
227
+ 'declared-requirement',
228
+ failure.status === 'refuted'
229
+ ? `call to ${calleeName} makes its declared requirement definitely false`
230
+ : `could not express or prove ${calleeName}'s declared requirement at this call`,
231
+ {label: 'declared at', ...origin},
232
+ )
233
+ }
234
+ }
235
+
236
+ const collectStops = (fn: FunctionAnalysis): void => {
237
+ if (fn.kind !== 'partial') return
238
+ for (const stop of fn.stops) {
239
+ const reason = stop.reason
240
+ switch (reason.kind) {
241
+ case 'nonExitingLoop': {
242
+ const location = siteLocation(program, stop.site)
243
+ findings.push({
244
+ kind: 'simple',
245
+ file,
246
+ line: location.line,
247
+ column: location.column,
248
+ functionName: fn.lowering.name,
249
+ stop: reason.kind,
250
+ })
251
+ break
252
+ }
253
+ case 'requirementFailure': {
254
+ const callee = reason.callee == null ? null : program.functions[reason.callee]
255
+ if (reason.callee != null && callee == null) throw new Error(`Unknown function ${reason.callee}`)
256
+ addRequirementFailure(reason.failure, stop.site, fn.lowering.name, callee?.name ?? null)
257
+ break
258
+ }
259
+ case 'recursion':
260
+ case 'calleeStopped':
261
+ case 'loopLimit':
262
+ case 'unsupportedCode':
263
+ case 'moduleRead':
264
+ case 'kindMismatch':
265
+ case 'possiblyMissingElement': break
266
+ }
267
+ }
268
+ }
269
+
270
+ const collectAssertions = (fn: FunctionAnalysis): void => {
271
+ if (fn.kind === 'notLowered') return
272
+ for (const assertion of fn.assertions) {
273
+ const message = assertionErrorMessage(fn.lowering.name, assertion)
274
+ if (message != null) addError(assertion.site, 'console-assert', message)
275
+ }
276
+ // Leading calls are requirements rather than interior assertion records. A function
277
+ // containing only requirements must still satisfy the same complete-function gate.
278
+ if (fn.assertions.length > 0) return
279
+ const requirementSite = firstStaticRequirementSite(fn.lowering)
280
+ if (requirementSite == null) return
281
+ const incomplete = fn.kind === 'partial' || fn.boundsAssumptions.length > 0
282
+ if (!incomplete) return
283
+ const ownRequirementFailure = fn.kind === 'partial' && fn.stops.some(stop =>
284
+ stop.reason.kind === 'requirementFailure'
285
+ && stop.reason.callee == null
286
+ && stop.reason.failure.kind === 'declared')
287
+ if (!ownRequirementFailure) {
288
+ addError(
289
+ requirementSite,
290
+ 'console-assert',
291
+ `console.assert requirements in ${fn.lowering.name} were not checked because the function did not finish analysis without site-specific assumptions`,
292
+ )
293
+ }
294
+ }
295
+
296
+ // The module initializer is analyzed through the same engine but stored separately
297
+ // because no function can call it. Its failures are still project lint findings.
298
+ collectStops(analysis.initializer)
299
+ collectAssertions(analysis.initializer)
300
+ for (const issue of program.staticAnnotationIssues) {
301
+ addError(
302
+ issue.site,
303
+ 'console-assert',
304
+ 'console.assert is only supported inside a named top-level function declaration',
305
+ )
306
+ }
307
+ for (const fn of analysis.functions) {
308
+ collectStops(fn)
309
+ collectAssertions(fn)
310
+ if (fn.kind === 'notLowered') {
311
+ if (fn.lowering.hasStaticAnnotations) {
312
+ const reason = formatUnsupportedReason(fn.lowering.reason)
313
+ addError(
314
+ fn.lowering.site,
315
+ 'console-assert',
316
+ fn.lowering.reason.kind === 'staticAssertionForm'
317
+ ? `${reason} in ${fn.lowering.name}`
318
+ : `console.assert in ${fn.lowering.name} was not checked because ${reason}`,
319
+ )
320
+ }
321
+ }
322
+ }
323
+ return findings
324
+ }
325
+
326
+ function firstStaticRequirementSite(fn: Exclude<FunctionAnalysis, {kind: 'notLowered'}>['lowering']): SiteID | null {
327
+ for (const block of fn.blocks) {
328
+ for (const instruction of block.instructions) {
329
+ if (instruction.kind === 'staticRequire' && instruction.purpose !== 'finiteInput') return instruction.site
330
+ }
331
+ }
332
+ return null
333
+ }
334
+
335
+ function assertionErrorMessage(functionName: string, assertion: AssertionVerdict): string | null {
336
+ switch (assertion.verdict) {
337
+ case 'proven': return null
338
+ case 'refuted': return `console.assert condition can be false in ${functionName}: ${assertion.text}`
339
+ case 'unproven': return `could not prove console.assert condition in ${functionName}: ${assertion.text}`
340
+ case 'dead': return `console.assert is unreachable in ${functionName}: ${assertion.text}`
341
+ case 'blocked': return `could not check console.assert condition in ${functionName}; the function did not finish analysis without site-specific assumptions: ${assertion.text}`
342
+ }
343
+ }
344
+
345
+ // Project and file findings share this format: with a file argument, the output is the
346
+ // project output narrowed to the file, so only the coverage counts differ.
347
+ function formatFindings(findings: LintFinding[], coverage: ProjectCoverage, pretty: boolean): string {
348
+ const lines: string[] = []
349
+ for (const finding of findings) lines.push(formatLintFinding(finding, pretty))
350
+
351
+ if (findings.length === 0) lines.push('No lint findings.')
352
+ const errors = findings.filter(finding => lintLevel(finding) === 'error').length
353
+ const warnings = findings.filter(finding => lintLevel(finding) === 'warning').length
354
+ lines.push(
355
+ '',
356
+ `${findings.length} finding${findings.length === 1 ? '' : 's'} (${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}).`,
357
+ formatCoverage(coverage),
358
+ 'Run `fr --audit [file]` for every function\'s contracts and refactoring suggestions.',
359
+ )
360
+ return lines.join('\n')
361
+ }
362
+
363
+ // The findings-mode coverage counts for one file, in the same shape project coverage
364
+ // uses; a file that reaches this point has no TypeScript errors, so nothing was skipped.
365
+ function fileCoverage(detailed: DetailedAnalysis): ProjectCoverage {
366
+ const coverage = {
367
+ functions: detailed.analysis.functions.length,
368
+ analyzed: 0,
369
+ partial: 0,
370
+ unsupported: 0,
371
+ }
372
+ for (const fn of detailed.analysis.functions) {
373
+ switch (fn.kind) {
374
+ case 'analyzed': coverage.analyzed++; break
375
+ case 'partial': coverage.partial++; break
376
+ case 'notLowered': coverage.unsupported++; break
377
+ }
378
+ }
379
+ return coverage
380
+ }
381
+
382
+ function formatLintFinding(finding: LintFinding, pretty: boolean): string {
383
+ switch (finding.kind) {
384
+ case 'simple': return finding.stop === 'outOfBoundsRead'
385
+ ? `${formatLintPrefix(finding, 'out-of-bounds-read', pretty)}asserted element read (arr[i]!) is provably out of bounds in ${finding.functionName}`
386
+ : `${formatLintPrefix(finding, 'non-exiting-loop', pretty)}loop in ${finding.functionName} has no analyzable exit; it may never terminate`
387
+ case 'error': {
388
+ const related = finding.related == null
389
+ ? ''
390
+ : ` (${finding.related.label} ${formatDiagnosticLocation({
391
+ file: finding.file,
392
+ line: finding.related.line,
393
+ column: finding.related.column,
394
+ }, pretty)})`
395
+ return `${formatLintPrefix(finding, finding.rule, pretty)}${finding.message}${related}`
396
+ }
397
+ }
398
+ }
399
+
400
+ function lintLevel(finding: LintFinding): 'error' | 'warning' {
401
+ switch (finding.kind) {
402
+ case 'simple': return finding.stop === 'outOfBoundsRead' ? 'error' : 'warning'
403
+ case 'error': return 'error'
404
+ }
405
+ }
406
+
407
+ function formatLintPrefix(finding: LintFinding, rule: string, pretty: boolean): string {
408
+ return formatDiagnosticPrefix(finding, lintLevel(finding), rule, pretty)
409
+ }
410
+
411
+ function formatCoverage(coverage: ProjectCoverage): string {
412
+ return `coverage: ${coverage.analyzed}/${coverage.functions} named top-level function declarations fully analyzed; ${coverage.partial} partially supported; ${coverage.unsupported} unsupported.`
413
+ }
414
+
415
+ // A target file analyzed on its own, with the output styling its project configures.
416
+ type TargetFile = {detailed: DetailedAnalysis; pretty: boolean}
417
+
418
+ // The configuration rule: like a bare `fr`, the tsconfig is resolved from the current
419
+ // directory, never from the file's own directory. The file argument narrows the output,
420
+ // not the configuration, so a nested tsconfig near the file cannot make `fr sub/file.ts`
421
+ // disagree with what `fr` reports for that same file. When a project exists, the file
422
+ // must belong to it; otherwise there is no project result for file mode to be a subset of.
423
+ function analyzeTargetFile(file: string): TargetFile {
424
+ const absoluteFile = resolve(file)
425
+ if (!existsSync(absoluteFile)) throw new Error(`File not found: ${absoluteFile}`)
426
+ const configPath = findTypeScriptConfig(process.cwd())
427
+ if (configPath == null) return analyzeFileAlone(absoluteFile)
428
+
429
+ const projects = loadTypeScriptProjectGraph(configPath)
430
+ const rootProject = projects.at(-1)!
431
+ const targetPath = canonicalFilePath(absoluteFile)
432
+ const source = projectSources(projects).find(candidate =>
433
+ canonicalFilePath(candidate.sourceFile.fileName) === targetPath)
434
+ if (source == null) {
435
+ throw new Error(`File is not part of the project resolved from ${configPath}: ${absoluteFile}`)
436
+ }
437
+ const diagnostics = ts.getPreEmitDiagnostics(source.project.program, source.sourceFile)
438
+ requireNoTypeScriptErrors(diagnostics, rootProject.parsed.options)
439
+ return {
440
+ detailed: analyzeProjectSource(source, process.cwd()),
441
+ pretty: usePrettyOutput(rootProject.parsed.options['pretty']),
442
+ }
443
+ }
444
+
445
+ function canonicalFilePath(file: string): string {
446
+ const real = realpathSync.native(file)
447
+ return ts.sys.useCaseSensitiveFileNames ? real : real.toLowerCase()
448
+ }
449
+
450
+ // A single-file program when no tsconfig resolves from the current directory.
451
+ function analyzeFileAlone(absoluteFile: string): TargetFile {
452
+ return {
453
+ detailed: analyzeCheckedSource(checkFile(absoluteFile), process.cwd()),
454
+ pretty: usePrettyOutput(undefined),
455
+ }
456
+ }
457
+
458
+ function analyzeProjectSource(
459
+ source: ProjectSource,
460
+ reportBaseDirectory: string,
461
+ ): DetailedAnalysis {
462
+ return analyzeCheckedSource({
463
+ sourceFile: source.sourceFile,
464
+ checker: source.project.program.getTypeChecker(),
465
+ }, reportBaseDirectory)
466
+ }
467
+
468
+ function uniqueDiagnostics(diagnostics: readonly ts.Diagnostic[]): ts.Diagnostic[] {
469
+ const seen = new Set<string>()
470
+ return diagnostics.filter(diagnostic => {
471
+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')
472
+ const key = `${diagnostic.file?.fileName ?? ''}:${diagnostic.start ?? ''}:${diagnostic.length ?? ''}:${diagnostic.code}:${message}`
473
+ if (seen.has(key)) return false
474
+ seen.add(key)
475
+ return true
476
+ })
477
+ }
478
+
479
+ function printTypeScriptDiagnostics(
480
+ diagnostics: readonly ts.Diagnostic[],
481
+ options: ts.CompilerOptions,
482
+ currentDirectory: string,
483
+ ): void {
484
+ if (diagnostics.length === 0) return
485
+ console.error(formatTypeScriptDiagnostics(diagnostics, options, currentDirectory).trimEnd())
486
+ }
487
+
488
+ function requireNoTypeScriptErrors(
489
+ diagnostics: readonly ts.Diagnostic[],
490
+ options: ts.CompilerOptions,
491
+ ): void {
492
+ if (hasErrorDiagnostics(diagnostics)) {
493
+ throw new TypeScriptDiagnosticsError(diagnostics, options, process.cwd())
494
+ }
495
+ printTypeScriptDiagnostics(diagnostics, options, process.cwd())
496
+ }
497
+
498
+ function hasErrorDiagnostics(diagnostics: readonly ts.Diagnostic[]): boolean {
499
+ return diagnostics.some(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error)
500
+ }
@@ -0,0 +1,110 @@
1
+ import type {ArithmeticOperator, ComparisonOperator} from '../ir/instructions.ts'
2
+ import {formatSite, type ParameterIR, type ProgramIR} from '../ir/program.ts'
3
+ import {numericParameterPath} from '../requirements/infer.ts'
4
+ import type {InferredPrecondition, NumericExpression} from '../requirements/model.ts'
5
+
6
+ export type PreconditionOperation = 'division' | 'remainder' | 'element read' | 'declared requirement'
7
+
8
+ export function formatPrecondition(precondition: InferredPrecondition, parameters: ParameterIR[], program: ProgramIR): string {
9
+ const description = describePrecondition(precondition, parameters)
10
+ const source = description.operation === 'declared requirement'
11
+ ? 'declared at'
12
+ : `${description.operation} at`
13
+ return `${description.condition} (${source} ${formatSite(program, precondition.site)})`
14
+ }
15
+
16
+ // Project reports group propagated requirements at the operation that created them, so
17
+ // they need the condition and operation as separate fields instead of parsing prose.
18
+ export function describePrecondition(
19
+ precondition: InferredPrecondition,
20
+ parameters: ParameterIR[],
21
+ ): {condition: string; operation: PreconditionOperation} {
22
+ return {
23
+ condition: conditionWords(precondition, parameters),
24
+ operation: precondition.kind === 'inBounds'
25
+ ? 'element read'
26
+ : precondition.kind === 'declaredComparison' || precondition.kind === 'declaredNumberCheck'
27
+ ? 'declared requirement'
28
+ : precondition.operation,
29
+ }
30
+ }
31
+
32
+ // The evidence wording for a requirement inferred before a stop — deliberately a different
33
+ // sentence shape from the requires line above, and it names the guarantee it enables.
34
+ export function formatObservedNeed(precondition: InferredPrecondition, parameters: ParameterIR[], program: ProgramIR): string {
35
+ if (precondition.kind === 'declaredComparison' || precondition.kind === 'declaredNumberCheck') {
36
+ return `the requirement declared at ${formatSite(program, precondition.site)} is ${conditionWords(precondition, parameters)}`
37
+ }
38
+ if (precondition.kind === 'inBounds') {
39
+ return `the element read at ${formatSite(program, precondition.site)} hits an element only when ${conditionWords(precondition, parameters)}`
40
+ }
41
+ return `the ${precondition.operation} at ${formatSite(program, precondition.site)} gives a finite result only when ${conditionWords(precondition, parameters)}`
42
+ }
43
+
44
+ function conditionWords(precondition: InferredPrecondition, parameters: ParameterIR[]): string {
45
+ switch (precondition.kind) {
46
+ case 'nonzero':
47
+ return `${formatExpression(precondition.expression, parameters)} is nonzero`
48
+ // E.g. `width is not 4`: dividing by width - 4 is exactly a division by zero when
49
+ // width is 4.
50
+ case 'notEqualConstant':
51
+ return `${formatExpression(precondition.expression, parameters)} is not ${precondition.value}`
52
+ // E.g. `slot is a valid sizes index`: an integer from 0 through sizes.length - 1.
53
+ case 'inBounds':
54
+ return `${formatExpression(precondition.index, parameters)} is a valid ${formatExpression(precondition.sequence, parameters)} index`
55
+ case 'declaredComparison':
56
+ return `${formatExpression(precondition.left, parameters)} ${comparisonOperatorText(precondition.operator)} ${formatExpression(precondition.right, parameters)}`
57
+ case 'declaredNumberCheck': {
58
+ const predicate = precondition.predicate === 'integer'
59
+ ? 'isInteger'
60
+ : precondition.predicate === 'finite' ? 'isFinite' : 'isNaN'
61
+ return `Number.${predicate}(${formatExpression(precondition.expression, parameters)})`
62
+ }
63
+ }
64
+ }
65
+
66
+ function formatExpression(expression: NumericExpression, parameters: ParameterIR[]): string {
67
+ const path = numericParameterPath(expression)
68
+ if (path != null) return formatParameterPath(parameters, path.parameter, path.properties)
69
+ switch (expression.kind) {
70
+ case 'parameter': throw new Error(`Missing parameter ${expression.index}`)
71
+ case 'constant': return String(expression.value)
72
+ case 'floor': return `Math.floor(${formatExpression(expression.operand, parameters)})`
73
+ case 'property': return `${formatExpression(expression.base, parameters)}.${expression.name}`
74
+ case 'binary': {
75
+ return `(${formatExpression(expression.left, parameters)} ${operatorText(expression.operator)} ${formatExpression(expression.right, parameters)})`
76
+ }
77
+ }
78
+ }
79
+
80
+ function formatParameterPath(parameters: ParameterIR[], index: number, properties: string[]): string {
81
+ const parameter = parameters[index]
82
+ if (parameter == null) throw new Error(`Missing parameter ${index}`)
83
+ const [first, ...rest] = properties
84
+ if (first == null) return parameter.name
85
+ const binding = parameter.bindings?.find(candidate => candidate.property === first)
86
+ return binding == null
87
+ ? `${parameter.name}.${properties.join('.')}`
88
+ : [binding.local, ...rest].join('.')
89
+ }
90
+
91
+ function operatorText(operator: ArithmeticOperator): string {
92
+ switch (operator) {
93
+ case 'add': return '+'
94
+ case 'subtract': return '-'
95
+ case 'multiply': return '*'
96
+ case 'divide': return '/'
97
+ case 'remainder': return '%'
98
+ }
99
+ }
100
+
101
+ function comparisonOperatorText(operator: ComparisonOperator): string {
102
+ switch (operator) {
103
+ case 'lessThan': return '<'
104
+ case 'lessThanOrEqual': return '<='
105
+ case 'greaterThan': return '>'
106
+ case 'greaterThanOrEqual': return '>='
107
+ case 'equal': return '==='
108
+ case 'notEqual': return '!=='
109
+ }
110
+ }