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