@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/audit.ts ADDED
@@ -0,0 +1,468 @@
1
+ import type {AssertionVerdict, ProgramAnalysis, Stop, StopReason} from './engine/outcome.ts'
2
+ import type {SiteID} from './ir/ids.ts'
3
+ import {
4
+ reportPath,
5
+ siteLocation,
6
+ type ProgramIR,
7
+ type UnsupportedReason,
8
+ } from './ir/program.ts'
9
+ import type {
10
+ BoundsAssumption,
11
+ InferredPrecondition,
12
+ NumericExpression,
13
+ } from './requirements/model.ts'
14
+ import {createReport, formatReport, type AnalysisReport} from './report/index.ts'
15
+ import {color, formatDiagnosticPrefix} from './typescript/diagnostics.ts'
16
+
17
+ type RefactorGuideShape = {
18
+ id: string
19
+ title: string
20
+ summary: string
21
+ caveat: string
22
+ before: string
23
+ after: string
24
+ }
25
+
26
+ export const refactorGuides = [
27
+ {
28
+ id: 'guard-derived-value',
29
+ title: 'Check the exact divisor',
30
+ summary: 'Give the divisor expression a name, then handle zero before dividing.',
31
+ caveat: 'The function owns the zero case. If zero is invalid input, keep the caller requirement instead.',
32
+ before: `export function remap(value: number, oldMin: number, oldMax: number, newMin: number, newMax: number): number {
33
+ if (oldMin === oldMax) return (newMin + newMax) / 2
34
+ return (value - oldMin) / (oldMax - oldMin) * (newMax - newMin) + newMin
35
+ }`,
36
+ after: `export function remap(value: number, oldMin: number, oldMax: number, newMin: number, newMax: number): number {
37
+ const oldSpan = oldMax - oldMin
38
+ if (oldMin === oldMax) return (newMin + newMax) / 2
39
+ if (oldSpan === 0) return (newMin + newMax) / 2
40
+ return (value - oldMin) / oldSpan * (newMax - newMin) + newMin
41
+ }`,
42
+ },
43
+ {
44
+ id: 'encode-input-rule',
45
+ title: 'Encode a real input rule where the calculation begins',
46
+ summary: 'Turn a domain rule such as "column count is a positive integer" into code once, before downstream calculations use it.',
47
+ caveat: 'A positive integer is the real API rule. This changes fractional and nonpositive values, and external NaN still needs validation.',
48
+ before: `export function perColumn(total: number, columnCount: number): number {
49
+ if (columnCount === 0) return 0
50
+ return total / columnCount
51
+ }`,
52
+ after: `export function perColumn(total: number, columnCount: number): number {
53
+ const columns = Math.max(1, Math.floor(columnCount))
54
+ return total / columns
55
+ }`,
56
+ },
57
+ {
58
+ id: 'use-direct-operands',
59
+ title: 'Use guarded dimensions directly instead of dividing by a ratio',
60
+ summary: 'A positive ratio can still round down to zero. Guard the original dimensions and divide by one of those values directly.',
61
+ caveat: 'The positive minimum is a real product rule and small rounding differences are acceptable. Nonpositive values change, NaN still needs validation, and multiplication can still overflow.',
62
+ before: `export function fittedHeight(frameWidth: number, imageWidth: number, imageHeight: number): number {
63
+ const aspectRatio = imageWidth / imageHeight
64
+ return frameWidth / aspectRatio
65
+ }`,
66
+ after: `export function fittedHeight(frameWidth: number, imageWidth: number, imageHeight: number): number {
67
+ const width = Math.max(1, imageWidth)
68
+ const height = Math.max(1, imageHeight)
69
+ return (frameWidth * height) / width
70
+ }`,
71
+ },
72
+ {
73
+ id: 'write-explicit-condition',
74
+ title: 'Write the numeric case explicitly',
75
+ summary: 'Replace number truthiness with the exact comparison that expresses the intended case.',
76
+ caveat: 'The code means a specific condition such as zero. Choose the comparison that states that condition; NaN can behave differently from truthiness.',
77
+ before: `export function safeWidth(width: number): number {
78
+ return width || 1
79
+ }`,
80
+ after: `export function safeWidth(width: number): number {
81
+ return width === 0 ? 1 : width
82
+ }`,
83
+ },
84
+ {
85
+ id: 'use-loop-for-aggregation',
86
+ title: 'Use an explicit loop for dense-array aggregation',
87
+ summary: 'For a simple aggregation, a for loop exposes the accumulator and each numeric step.',
88
+ caveat: 'The array is dense, the reduction has an initial value, and callback arguments or effects do not matter. Indexed loops differ for sparse arrays.',
89
+ before: `export function total(values: number[]): number {
90
+ return values.reduce((sum, value) => sum + value, 0)
91
+ }`,
92
+ after: `export function total(values: number[]): number {
93
+ let sum = 0
94
+ for (let index = 0; index < values.length; index++) {
95
+ sum += values[index]!
96
+ }
97
+ return sum
98
+ }`,
99
+ },
100
+ {
101
+ id: 'handle-missing-element',
102
+ title: 'Handle a possibly missing array element',
103
+ summary: 'A bare array read may be undefined even when the project\'s TypeScript settings show a plain element type. Handle the missing case before using the value.',
104
+ caveat: 'A fallback is real application behavior. Otherwise validate or throw; a bounds check alone does not detect a sparse-array hole.',
105
+ before: `export function incrementAt(values: number[], index: number): number {
106
+ return values[index] + 1
107
+ }`,
108
+ after: `export function incrementAt(values: number[], index: number): number {
109
+ const value = values[index] ?? 0
110
+ return value + 1
111
+ }`,
112
+ },
113
+ {
114
+ id: 'guard-array-index',
115
+ title: 'Check an asserted array index',
116
+ summary: 'Before using values[index]!, prove that the index is an integer inside the array bounds.',
117
+ caveat: 'The function owns invalid-index behavior. Otherwise keep the caller requirement; any fallback changes an invalid read.',
118
+ before: `export function valueAt(values: number[], index: number): number {
119
+ return values[index]!
120
+ }`,
121
+ after: `export function valueAt(values: number[], index: number): number {
122
+ if (!Number.isInteger(index) || index < 0 || index >= values.length) return 0
123
+ return values[index]!
124
+ }`,
125
+ },
126
+ ] as const satisfies readonly RefactorGuideShape[]
127
+
128
+ export type RefactorGuide = (typeof refactorGuides)[number]
129
+ export type RefactorGuideID = RefactorGuide['id']
130
+
131
+ export type AuditCoverage = {
132
+ functions: number
133
+ analyzed: number
134
+ partial: number
135
+ unsupported: number
136
+ initializer: 'analyzed' | 'partial'
137
+ initializerSkips: number
138
+ }
139
+
140
+ export type AuditReason =
141
+ | {kind: 'requires'; precondition: InferredPrecondition}
142
+ | {kind: 'assumes'; assumption: BoundsAssumption}
143
+ | {kind: 'assertion'; assertion: AssertionVerdict}
144
+ | {kind: 'staticAnnotationIssue'; issue: ProgramIR['staticAnnotationIssues'][number]}
145
+ | {kind: 'unsupported'; reason: UnsupportedReason}
146
+ | {kind: 'partialSupport'; reason: StopReason}
147
+ | {kind: 'skipped'; reason: UnsupportedReason}
148
+
149
+ export type AuditReference = {
150
+ functionName: string
151
+ line: number
152
+ column: number
153
+ span: {start: number; end: number}
154
+ reason: AuditReason
155
+ guideIDs: RefactorGuideID[]
156
+ }
157
+
158
+ export type FileAudit = {
159
+ file: string
160
+ coverage: AuditCoverage
161
+ contracts: AnalysisReport
162
+ references: AuditReference[]
163
+ guideIDs: RefactorGuideID[]
164
+ }
165
+
166
+ export function createFileAudit({program, analysis}: {program: ProgramIR; analysis: ProgramAnalysis}): FileAudit {
167
+ const contracts = createReport(program, analysis)
168
+ let analyzed = 0
169
+ let partial = 0
170
+ let unsupported = 0
171
+ const references: AuditReference[] = []
172
+
173
+ const addReference = (
174
+ functionName: string,
175
+ site: SiteID,
176
+ reason: AuditReason,
177
+ ): void => {
178
+ const span = program.sites[site]
179
+ if (span == null) throw new Error(`Unknown site ${site}`)
180
+ references.push({
181
+ functionName,
182
+ ...siteLocation(program, site),
183
+ span: {...span},
184
+ reason,
185
+ guideIDs: guidesForReason(reason),
186
+ })
187
+ }
188
+
189
+ const addPartialReason = (functionName: string, stop: Stop): void => {
190
+ addReference(functionName, stop.site, {kind: 'partialSupport', reason: stop.reason})
191
+ }
192
+
193
+ const addPrecondition = (functionName: string, precondition: InferredPrecondition): void => {
194
+ addReference(functionName, precondition.site, {kind: 'requires', precondition})
195
+ }
196
+
197
+ const addAssumption = (functionName: string, assumption: BoundsAssumption): void => {
198
+ addReference(functionName, assumption.site, {kind: 'assumes', assumption})
199
+ }
200
+
201
+ const addAssertion = (functionName: string, assertion: AssertionVerdict): void => {
202
+ addReference(functionName, assertion.site, {kind: 'assertion', assertion})
203
+ }
204
+
205
+ for (const fn of analysis.functions) {
206
+ switch (fn.kind) {
207
+ case 'analyzed': {
208
+ analyzed++
209
+ for (const precondition of fn.preconditions) addPrecondition(fn.lowering.name, precondition)
210
+ for (const assumption of fn.boundsAssumptions) addAssumption(fn.lowering.name, assumption)
211
+ for (const assertion of fn.assertions) addAssertion(fn.lowering.name, assertion)
212
+ break
213
+ }
214
+ case 'partial': {
215
+ partial++
216
+ for (const precondition of fn.observedNeeds) addPrecondition(fn.lowering.name, precondition)
217
+ for (const assumption of fn.observedBoundsAssumptions) addAssumption(fn.lowering.name, assumption)
218
+ for (const assertion of fn.assertions) addAssertion(fn.lowering.name, assertion)
219
+ for (const stop of fn.stops) addPartialReason(fn.lowering.name, stop)
220
+ break
221
+ }
222
+ case 'notLowered': {
223
+ unsupported++
224
+ addReference(
225
+ fn.lowering.name,
226
+ fn.lowering.site,
227
+ {kind: 'unsupported', reason: fn.lowering.reason},
228
+ )
229
+ break
230
+ }
231
+ }
232
+ }
233
+
234
+ if (analysis.initializer.kind === 'partial') {
235
+ for (const precondition of analysis.initializer.observedNeeds) {
236
+ addPrecondition(program.initializer.name, precondition)
237
+ }
238
+ for (const assumption of analysis.initializer.observedBoundsAssumptions) {
239
+ addAssumption(program.initializer.name, assumption)
240
+ }
241
+ for (const stop of analysis.initializer.stops) addPartialReason(program.initializer.name, stop)
242
+ }
243
+ for (const assertion of analysis.initializer.assertions) {
244
+ addAssertion(program.initializer.name, assertion)
245
+ }
246
+ for (const issue of program.staticAnnotationIssues) {
247
+ addReference(
248
+ program.initializer.name,
249
+ issue.site,
250
+ {kind: 'staticAnnotationIssue', issue},
251
+ )
252
+ }
253
+ for (const skip of program.initializerSkips) {
254
+ addReference(
255
+ program.initializer.name,
256
+ skip.site,
257
+ {kind: 'skipped', reason: skip.reason},
258
+ )
259
+ }
260
+
261
+ const initializer = analysis.initializer.kind
262
+ const functions = analysis.functions.length
263
+ const initializerSkips = program.initializerSkips.length
264
+ references.sort((left, right) => left.span.start - right.span.start || left.span.end - right.span.end)
265
+ const guideIDs: RefactorGuideID[] = []
266
+ for (const reference of references) {
267
+ for (const guideID of reference.guideIDs) {
268
+ if (!guideIDs.includes(guideID)) guideIDs.push(guideID)
269
+ }
270
+ }
271
+ return {
272
+ file: reportPath(program),
273
+ coverage: {
274
+ functions,
275
+ analyzed,
276
+ partial,
277
+ unsupported,
278
+ initializer,
279
+ initializerSkips,
280
+ },
281
+ contracts,
282
+ references,
283
+ guideIDs,
284
+ }
285
+ }
286
+
287
+ function formatAuditCoverage(coverage: AuditCoverage): string {
288
+ const parts = coverage.functions === 0
289
+ ? ['no named function declarations']
290
+ : [`${coverage.analyzed}/${coverage.functions} functions fully analyzed`]
291
+ if (coverage.partial > 0) parts.push(`${coverage.partial} partially supported`)
292
+ if (coverage.unsupported > 0) parts.push(`${coverage.unsupported} unsupported`)
293
+ if (coverage.initializer !== 'analyzed') parts.push('module setup partially supported')
294
+ if (coverage.initializerSkips > 0) {
295
+ parts.push(`${coverage.initializerSkips} module statement${coverage.initializerSkips === 1 ? '' : 's'} skipped`)
296
+ }
297
+ return parts.join('; ')
298
+ }
299
+
300
+ // One file's audit unit: a header carrying the file's coverage counts, its contract
301
+ // entries, then its refactoring suggestions — always in that order, under these exact
302
+ // section labels. `fr --audit <file>` prints one unit; bare `fr --audit` prints one unit
303
+ // per project file, so a file's unit stays identical between the two outputs.
304
+ export function formatFileAuditUnit(audit: FileAudit, pretty = false): string {
305
+ const {coverage} = audit
306
+ const file = pretty ? color(96, audit.file) : audit.file
307
+ const lines = [`# ${file} (${formatAuditCoverage(coverage)})`]
308
+ if (audit.contracts.functions.length > 0) {
309
+ lines.push(
310
+ '',
311
+ '## Contracts',
312
+ '',
313
+ colorAuditLocations(formatReport(audit.contracts), audit.file, pretty),
314
+ )
315
+ }
316
+
317
+ if (audit.guideIDs.length > 0) {
318
+ const printedSuggestions = new Set<string>()
319
+ lines.push(
320
+ '',
321
+ '## Refactoring suggestions',
322
+ )
323
+ for (const reference of audit.references) {
324
+ const guideIDs = reference.guideIDs.filter(guideID =>
325
+ !printedSuggestions.has(`${reference.span.start}:${guideID}`))
326
+ if (guideIDs.length === 0) continue
327
+ lines.push('')
328
+ for (const guideID of guideIDs) {
329
+ printedSuggestions.add(`${reference.span.start}:${guideID}`)
330
+ const guide = refactorGuide(guideID)
331
+ const prefix = formatDiagnosticPrefix(
332
+ {file: audit.file, line: reference.line, column: reference.column},
333
+ 'suggestion',
334
+ guide.id,
335
+ pretty,
336
+ )
337
+ lines.push(`${prefix}${guide.title}. ${guide.summary}`)
338
+ }
339
+ }
340
+ }
341
+ return lines.join('\n')
342
+ }
343
+
344
+ function colorAuditLocations(output: string, file: string, pretty: boolean): string {
345
+ if (!pretty) return output
346
+ const escapedFile = file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
347
+ return output.replace(
348
+ new RegExp(`${escapedFile}:(\\d+):(\\d+)`, 'g'),
349
+ (_location, line: string, column: string) =>
350
+ `${color(96, file)}:${color(93, line)}:${color(93, column)}`,
351
+ )
352
+ }
353
+
354
+ export function refactorGuide(id: RefactorGuideID): RefactorGuide {
355
+ const guide = refactorGuides.find(candidate => candidate.id === id)
356
+ if (guide == null) throw new Error(`Missing refactor guide ${id}`)
357
+ return guide
358
+ }
359
+
360
+ function guidesForReason(reason: AuditReason): RefactorGuideID[] {
361
+ switch (reason.kind) {
362
+ case 'requires': return guidesForPrecondition(reason.precondition)
363
+ case 'assumes': return reason.assumption.kind === 'nonzeroDivisor'
364
+ ? ['guard-derived-value']
365
+ : ['guard-array-index']
366
+ case 'assertion':
367
+ case 'staticAnnotationIssue': return []
368
+ case 'unsupported': return guidesForUnsupportedReason(reason.reason)
369
+ case 'partialSupport': return guidesForStop(reason.reason)
370
+ case 'skipped': return guidesForUnsupportedReason(reason.reason)
371
+ }
372
+ }
373
+
374
+ function guidesForPrecondition(precondition: InferredPrecondition): RefactorGuideID[] {
375
+ switch (precondition.kind) {
376
+ case 'inBounds': return ['guard-array-index']
377
+ case 'declaredComparison':
378
+ case 'declaredNumberCheck': return []
379
+ case 'nonzero':
380
+ case 'notEqualConstant': break
381
+ }
382
+ const directRatio = precondition.kind === 'nonzero'
383
+ && precondition.operation === 'division'
384
+ && precondition.expression.kind === 'binary'
385
+ && precondition.expression.operator === 'divide'
386
+ const guides: RefactorGuideID[] = directRatio
387
+ ? ['use-direct-operands', 'guard-derived-value']
388
+ : ['guard-derived-value']
389
+ if (precondition.kind === 'nonzero') {
390
+ if (isCallerInput(precondition.expression)) guides.push('encode-input-rule')
391
+ }
392
+ return guides
393
+ }
394
+
395
+ function guidesForUnsupportedReason(reason: UnsupportedReason): RefactorGuideID[] {
396
+ // A rejection selects a guide only when its structured fields identify the relevant
397
+ // rewrite shape. Generic calls, mutation, and display text are not enough evidence.
398
+ switch (reason.kind) {
399
+ case 'call': return reason.arrayMethod === 'reduce'
400
+ ? ['use-loop-for-aggregation']
401
+ : []
402
+ case 'nonBooleanCondition': return reason.conditionKind === 'number'
403
+ ? ['write-explicit-condition']
404
+ : []
405
+ case 'unknownIdentifier':
406
+ case 'missingSymbol':
407
+ case 'functionWithoutSignature':
408
+ case 'functionWithoutBody':
409
+ case 'destructuredParameter':
410
+ case 'parameterType':
411
+ case 'parameterDefaultValue':
412
+ case 'missingReturn':
413
+ case 'objectPropertyForm':
414
+ case 'computedPropertyName':
415
+ case 'objectSpread':
416
+ case 'asyncOrGeneratorFunction':
417
+ case 'typePredicate':
418
+ case 'protoProperty':
419
+ case 'enumMemberRead':
420
+ case 'prototypeMemberRead':
421
+ case 'binaryOperator':
422
+ case 'callWithFewerArguments':
423
+ case 'callWithMoreArguments':
424
+ case 'nonNumberOperand':
425
+ case 'valueType':
426
+ case 'kindChangingAssertion':
427
+ case 'propertyReadOnNonObject':
428
+ case 'statementAfterReturn':
429
+ case 'assignmentInValuePosition':
430
+ case 'propertyWrite':
431
+ case 'staticAssertionForm':
432
+ case 'varDeclaration':
433
+ case 'evalInFile':
434
+ case 'typeCheckSuppressed':
435
+ case 'forLoopWithoutCondition':
436
+ case 'variableDeclarationShape':
437
+ case 'expressionForm':
438
+ case 'statementForm':
439
+ case 'switchFallthrough':
440
+ case 'switchDefaultNotLast':
441
+ case 'switchSubject':
442
+ case 'switchLabel': return []
443
+ }
444
+ }
445
+
446
+ function guidesForStop(reason: StopReason): RefactorGuideID[] {
447
+ switch (reason.kind) {
448
+ case 'unsupportedCode': return guidesForUnsupportedReason(reason.reason)
449
+ case 'possiblyMissingElement': return ['handle-missing-element']
450
+ case 'requirementFailure':
451
+ case 'moduleRead':
452
+ case 'recursion':
453
+ case 'calleeStopped':
454
+ case 'loopLimit':
455
+ case 'nonExitingLoop':
456
+ case 'kindMismatch': return []
457
+ }
458
+ }
459
+
460
+ function isCallerInput(expression: NumericExpression): boolean {
461
+ switch (expression.kind) {
462
+ case 'parameter': return true
463
+ case 'property': return isCallerInput(expression.base)
464
+ case 'floor': return isCallerInput(expression.operand)
465
+ case 'constant':
466
+ case 'binary': return false
467
+ }
468
+ }