@chenglou/freerange 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,334 @@
1
+ import type {SiteID, ValueID} from '../ir/ids.ts'
2
+ import type {InstructionIR} from '../ir/instructions.ts'
3
+ import type {FunctionIR} from '../ir/program.ts'
4
+ import type {InferredPrecondition, NumericExpression} from './model.ts'
5
+
6
+ export type ExpressionContext = {
7
+ parameterExpressions: Array<NumericExpression | null>
8
+ // Calls pass the caller's value keys directly, so duplicate arguments and facts created
9
+ // in a callee refer to the same identity as the caller. Local keys use a nested namespace
10
+ // to avoid colliding with the caller's ValueIDs.
11
+ parameterIdentityKeys: string[]
12
+ identityNamespace: string
13
+ parameterIndexByValue: Array<number | undefined>
14
+ instructionByValue: Array<InstructionIR | undefined>
15
+ instructionCount: number
16
+ }
17
+
18
+ export function createExpressionContext(
19
+ fn: FunctionIR,
20
+ parameterExpressions: Array<NumericExpression | null>,
21
+ parameterIdentityKeys?: string[],
22
+ identityNamespace = `${fn.name}/`,
23
+ ): ExpressionContext {
24
+ const identityKeys = parameterIdentityKeys ?? fn.parameters.map((_, index) => `p${index}`)
25
+ if (identityKeys.length !== fn.parameters.length) {
26
+ throw new Error(`Expected ${fn.parameters.length} parameter identity keys for ${fn.name}`)
27
+ }
28
+ const context: ExpressionContext = {
29
+ parameterExpressions,
30
+ parameterIdentityKeys: identityKeys,
31
+ identityNamespace,
32
+ parameterIndexByValue: [],
33
+ instructionByValue: [],
34
+ instructionCount: 0,
35
+ }
36
+ for (let index = 0; index < fn.parameters.length; index++) {
37
+ context.parameterIndexByValue[fn.parameters[index]!.value] = index
38
+ }
39
+ for (const block of fn.blocks) {
40
+ for (const instruction of block.instructions) {
41
+ context.instructionByValue[instruction.result] = instruction
42
+ context.instructionCount += 1
43
+ }
44
+ }
45
+ return context
46
+ }
47
+
48
+ // Follow assignments and reads through records built in this function. The returned IR
49
+ // value is the value that was actually stored, so ordinary analysis, assertion proofs,
50
+ // and requirement expressions all use the same definition of identity.
51
+ export function resolveStoredValue(value: ValueID, context: ExpressionContext): ValueID {
52
+ const producer = context.instructionByValue[value]
53
+ if (producer?.kind === 'moduleWrite') return resolveStoredValue(producer.value, context)
54
+ if (producer?.kind === 'property') {
55
+ const object = resolveStoredValue(producer.object, context)
56
+ const objectProducer = context.instructionByValue[object]
57
+ if (objectProducer?.kind === 'object') {
58
+ const property = objectProducer.properties.find(candidate => candidate.name === producer.property)
59
+ if (property != null) return resolveStoredValue(property.value, context)
60
+ }
61
+ }
62
+ return value
63
+ }
64
+
65
+ // The producer walk expands a value's defining DAG into an expression tree, and a value
66
+ // used twice appears twice — chained squaring (`const b = a * a; const c = b * b`) doubles
67
+ // per level, so tree size is exponential in the worst case while the DAG stays linear.
68
+ // The budget is by construction, not a magic number: each visit charges against the
69
+ // function's own instruction count, so a requirement can never be more complex than the
70
+ // function that produced it. Exhaustion returns null, which surfaces as the nonzero-divisor
71
+ // assumes line (or the element-in-bounds assumes line for element reads) — analysis keeps
72
+ // going either way.
73
+ export function numericExpression(value: ValueID, context: ExpressionContext): NumericExpression | null {
74
+ let remainingVisits = context.instructionCount
75
+ const walk = (current: ValueID): NumericExpression | null => {
76
+ const stored = resolveStoredValue(current, context)
77
+ if (stored !== current) return walk(stored)
78
+ const parameterIndex = context.parameterIndexByValue[current]
79
+ if (parameterIndex != null) return context.parameterExpressions[parameterIndex] ?? null
80
+ const instruction = context.instructionByValue[current]
81
+ if (instruction == null) return null
82
+ // Only an instruction expansion is charged — re-expanding the same instruction is
83
+ // exactly what the duplication blowup repeats, while parameter and constant leaves are
84
+ // bounded by the expansions' own fan-in.
85
+ if (remainingVisits <= 0) return null
86
+ remainingVisits -= 1
87
+ switch (instruction.kind) {
88
+ case 'constant': return {kind: 'constant', value: instruction.value}
89
+ case 'binary': {
90
+ const left = walk(instruction.left)
91
+ const right = walk(instruction.right)
92
+ return left == null || right == null
93
+ ? null
94
+ : {kind: 'binary', operator: instruction.operator, left, right}
95
+ }
96
+ case 'floor': {
97
+ const operand = walk(instruction.value)
98
+ return operand == null ? null : {kind: 'floor', operand}
99
+ }
100
+ // A module write's result is the assigned value, so the written expression carries over.
101
+ case 'moduleWrite': return walk(instruction.value)
102
+ // Requirement expressions name only the function's own parameters; a module binding is
103
+ // not caller-visible, so a requirement cannot name it.
104
+ case 'moduleRead':
105
+ case 'moduleHavoc':
106
+ case 'platformValue':
107
+ case 'booleanConstant':
108
+ case 'not':
109
+ case 'absolute':
110
+ case 'call':
111
+ case 'compare':
112
+ case 'maximum':
113
+ case 'minimum':
114
+ case 'object':
115
+ case 'nullishConstant':
116
+ case 'opaqueConstant':
117
+ case 'unknownBoolean':
118
+ case 'mathUnary':
119
+ case 'stringLength':
120
+ case 'parsedNumber':
121
+ case 'numberCheck':
122
+ case 'staticRequire':
123
+ case 'staticAssert':
124
+ case 'tagCheck':
125
+ case 'nullishCheck':
126
+ case 'arrayLiteral':
127
+ case 'arrayIndex': return null
128
+ // An array's length is fixed at construction (no push in the subset), so a length
129
+ // read over a nameable array could join the expression language later; not yet.
130
+ case 'arrayLength': return null
131
+ case 'property': {
132
+ const base = walk(instruction.object)
133
+ return base == null ? null : {kind: 'property', base, name: instruction.property}
134
+ }
135
+ }
136
+ }
137
+ return walk(value)
138
+ }
139
+
140
+ export function staticRequirement(
141
+ instruction: InstructionIR | undefined,
142
+ site: SiteID,
143
+ context: ExpressionContext,
144
+ purpose?: 'finiteInput',
145
+ ): Extract<InferredPrecondition, {kind: 'declaredComparison' | 'declaredNumberCheck'}> | null {
146
+ if (instruction?.kind === 'compare') {
147
+ const left = numericExpression(instruction.left, context)
148
+ const right = numericExpression(instruction.right, context)
149
+ return left == null || right == null
150
+ ? null
151
+ : {kind: 'declaredComparison', operator: instruction.operator, left, right, site}
152
+ }
153
+ if (instruction?.kind === 'numberCheck') {
154
+ const expression = numericExpression(instruction.value, context)
155
+ return expression == null
156
+ ? null
157
+ : {kind: 'declaredNumberCheck', predicate: instruction.predicate, expression, site, ...(purpose == null ? {} : {purpose})}
158
+ }
159
+ return null
160
+ }
161
+
162
+ // A stable name for the runtime value an IR value holds. Forward value facts and exact
163
+ // same-value operations share this rule instead of maintaining separate notions of
164
+ // identity. Property and array reads are stable under the accepted subset's immutability
165
+ // rules; module and platform reads stay value-keyed because they may change between reads.
166
+ export function canonicalValueKey(value: ValueID, context: ExpressionContext): string {
167
+ const stored = resolveStoredValue(value, context)
168
+ if (stored !== value) return canonicalValueKey(stored, context)
169
+ const parameterIndex = context.parameterIndexByValue[value]
170
+ if (parameterIndex != null) return context.parameterIdentityKeys[parameterIndex] ?? `p${parameterIndex}`
171
+ const producer = context.instructionByValue[value]
172
+ if (producer?.kind === 'property') {
173
+ return `${canonicalValueKey(producer.object, context)}.${JSON.stringify(producer.property)}`
174
+ }
175
+ if (producer?.kind === 'arrayLength') return `${canonicalValueKey(producer.array, context)}.length`
176
+ if (producer?.kind === 'stringLength') return `${canonicalValueKey(producer.value, context)}.length`
177
+ if (producer?.kind === 'arrayIndex') {
178
+ return `${canonicalValueKey(producer.array, context)}[${canonicalValueKey(producer.index, context)}]`
179
+ }
180
+ return `v:${context.identityNamespace}${value}`
181
+ }
182
+
183
+ export function sameRuntimeValue(left: ValueID, right: ValueID, context: ExpressionContext): boolean {
184
+ return left === right || canonicalValueKey(left, context) === canonicalValueKey(right, context)
185
+ }
186
+
187
+ export function addPrecondition(preconditions: InferredPrecondition[], candidate: InferredPrecondition): void {
188
+ if (candidate.kind === 'declaredNumberCheck') {
189
+ if (candidate.predicate === 'finite' && preconditions.some(precondition =>
190
+ precondition.kind === 'declaredNumberCheck'
191
+ && (precondition.predicate === 'integer' || precondition.predicate === 'finite')
192
+ && sameExpression(precondition.expression, candidate.expression))) return
193
+ if (candidate.predicate === 'integer') {
194
+ const redundantFinite = preconditions.findIndex(precondition =>
195
+ precondition.kind === 'declaredNumberCheck'
196
+ && precondition.predicate === 'finite'
197
+ && sameExpression(precondition.expression, candidate.expression))
198
+ if (redundantFinite >= 0) preconditions.splice(redundantFinite, 1)
199
+ }
200
+ }
201
+ if (!preconditions.some(precondition => samePrecondition(precondition, candidate))) preconditions.push(candidate)
202
+ }
203
+
204
+ export function numericParameterPath(
205
+ expression: NumericExpression,
206
+ ): {parameter: number; properties: string[]} | null {
207
+ if (expression.kind === 'parameter') return {parameter: expression.index, properties: []}
208
+ if (expression.kind !== 'property') return null
209
+ const base = numericParameterPath(expression.base)
210
+ return base == null ? null : {...base, properties: [...base.properties, expression.name]}
211
+ }
212
+
213
+ export function constantRequirementStatus(
214
+ requirement: Extract<InferredPrecondition, {kind: 'declaredComparison' | 'declaredNumberCheck'}>,
215
+ ): boolean | null {
216
+ if (requirement.kind === 'declaredNumberCheck') {
217
+ const value = constantNumericExpression(requirement.expression)
218
+ if (value == null) return null
219
+ switch (requirement.predicate) {
220
+ case 'finite': return Number.isFinite(value)
221
+ case 'integer': return Number.isInteger(value)
222
+ case 'nan': return Number.isNaN(value)
223
+ }
224
+ }
225
+ const left = constantNumericExpression(requirement.left)
226
+ const right = constantNumericExpression(requirement.right)
227
+ if (left == null || right == null) return null
228
+ switch (requirement.operator) {
229
+ case 'lessThan': return left < right
230
+ case 'lessThanOrEqual': return left <= right
231
+ case 'greaterThan': return left > right
232
+ case 'greaterThanOrEqual': return left >= right
233
+ case 'equal': return left === right
234
+ case 'notEqual': return left !== right
235
+ }
236
+ }
237
+
238
+ function constantNumericExpression(expression: NumericExpression): number | null {
239
+ switch (expression.kind) {
240
+ case 'constant': return expression.value
241
+ case 'parameter':
242
+ case 'property': return null
243
+ case 'floor': {
244
+ const operand = constantNumericExpression(expression.operand)
245
+ return operand == null ? null : Math.floor(operand)
246
+ }
247
+ case 'binary': {
248
+ const left = constantNumericExpression(expression.left)
249
+ const right = constantNumericExpression(expression.right)
250
+ if (left == null || right == null) return null
251
+ switch (expression.operator) {
252
+ case 'add': return left + right
253
+ case 'subtract': return left - right
254
+ case 'multiply': return left * right
255
+ case 'divide': return left / right
256
+ case 'remainder': return left % right
257
+ }
258
+ }
259
+ }
260
+ }
261
+
262
+ // Rewrites a nonzero obligation into the simplest condition the caller can read, peeling
263
+ // only float-EXACT layers so the biconditional survives:
264
+ // - X - c is nonzero <=> X is not c (IEEE subtraction is zero only on exact equality)
265
+ // - X + c is nonzero <=> X is not -c (same argument)
266
+ // - c * X is nonzero <=> X is nonzero, when |c| >= 1 and finite (|c * x| >= |x| can
267
+ // never underflow to zero; small constants CAN — 1e-200 * 1e-200 is 0 — so those stay)
268
+ // - X / c never peels: a tiny dividend over a huge divisor underflows to zero.
269
+ // The multiply case recurses (still a nonzero form); a peel against a constant ends the
270
+ // chain (width is not 4 is an endpoint — further peeling through rounding would lie).
271
+ // Termination is structural: every step shrinks the expression.
272
+ export function peelNonzero(expression: NumericExpression, site: SiteID, operation: 'division' | 'remainder'): InferredPrecondition {
273
+ if (expression.kind === 'binary') {
274
+ const {operator, left, right} = expression
275
+ const constantSide = right.kind === 'constant' ? right : left.kind === 'constant' ? left : null
276
+ const otherSide = right.kind === 'constant' ? left : right
277
+ if (constantSide != null && Number.isFinite(constantSide.value)) {
278
+ if (operator === 'subtract') {
279
+ // c - X and X - c both peel to X is not c.
280
+ return {kind: 'notEqualConstant', expression: otherSide, value: constantSide.value, operation, site}
281
+ }
282
+ if (operator === 'add') {
283
+ return {kind: 'notEqualConstant', expression: otherSide, value: -constantSide.value, operation, site}
284
+ }
285
+ if (operator === 'multiply' && Math.abs(constantSide.value) >= 1) {
286
+ return peelNonzero(otherSide, site, operation)
287
+ }
288
+ }
289
+ }
290
+ return {kind: 'nonzero', expression, operation, site}
291
+ }
292
+
293
+ // Keep one condition per originating operation. Propagated requirements retain the
294
+ // operation's site, so repeated calls with the same substituted expression collapse while
295
+ // separate operations that need the same condition remain separate findings.
296
+ function samePrecondition(left: InferredPrecondition, right: InferredPrecondition): boolean {
297
+ if (left.site !== right.site) return false
298
+ if (left.kind !== right.kind) return false
299
+ if (left.kind === 'inBounds' && right.kind === 'inBounds') {
300
+ return sameExpression(left.index, right.index) && sameExpression(left.sequence, right.sequence)
301
+ }
302
+ if (left.kind === 'inBounds' || right.kind === 'inBounds') return false
303
+ if (left.kind === 'declaredComparison' && right.kind === 'declaredComparison') {
304
+ return left.operator === right.operator
305
+ && sameExpression(left.left, right.left)
306
+ && sameExpression(left.right, right.right)
307
+ }
308
+ if (left.kind === 'declaredComparison' || right.kind === 'declaredComparison') return false
309
+ if (left.kind === 'declaredNumberCheck' && right.kind === 'declaredNumberCheck') {
310
+ return left.predicate === right.predicate && sameExpression(left.expression, right.expression)
311
+ }
312
+ if (left.kind === 'declaredNumberCheck' || right.kind === 'declaredNumberCheck') return false
313
+ if (left.kind === 'notEqualConstant' && right.kind === 'notEqualConstant' && left.value !== right.value) return false
314
+ return sameExpression(left.expression, right.expression)
315
+ }
316
+
317
+ function sameExpression(left: NumericExpression, right: NumericExpression): boolean {
318
+ if (left.kind !== right.kind) return false
319
+ switch (left.kind) {
320
+ case 'parameter': return left.index === (right as Extract<NumericExpression, {kind: 'parameter'}>).index
321
+ case 'constant': return left.value === (right as Extract<NumericExpression, {kind: 'constant'}>).value
322
+ case 'binary': {
323
+ const other = right as Extract<NumericExpression, {kind: 'binary'}>
324
+ return left.operator === other.operator
325
+ && sameExpression(left.left, other.left)
326
+ && sameExpression(left.right, other.right)
327
+ }
328
+ case 'floor': return sameExpression(left.operand, (right as Extract<NumericExpression, {kind: 'floor'}>).operand)
329
+ case 'property': {
330
+ const other = right as Extract<NumericExpression, {kind: 'property'}>
331
+ return left.name === other.name && sameExpression(left.base, other.base)
332
+ }
333
+ }
334
+ }
@@ -0,0 +1,77 @@
1
+ import type {SiteID} from '../ir/ids.ts'
2
+ import type {ArithmeticOperator, ComparisonOperator} from '../ir/instructions.ts'
3
+
4
+ export type NumericExpression =
5
+ | {kind: 'parameter'; index: number}
6
+ | {kind: 'constant'; value: number}
7
+ | {kind: 'binary'; operator: ArithmeticOperator; left: NumericExpression; right: NumericExpression}
8
+ // Math.floor over a nameable expression. Lets a division by a floored value mint a
9
+ // requirement instead of stopping — and a floored divisor is an integer, so under the
10
+ // nonzero requirement its magnitude is at least 1 and the quotient stays finite.
11
+ | {kind: 'floor'; operand: NumericExpression}
12
+ // A property read off a nameable record, e.g. grid.columnCount. Sound to name because
13
+ // values are immutable after construction: the property cannot change between function
14
+ // entry and the operation that needs the requirement.
15
+ | {kind: 'property'; base: NumericExpression; name: string}
16
+
17
+ // An element read the engine could not prove in bounds: arr[i]! asserts presence, and
18
+ // when the index interval does not sit inside the length interval, the entry's guarantees
19
+ // rest on the read actually being in bounds. The peer of InferredPrecondition, minus the
20
+ // expression language (an assumption line needs only its site).
21
+ export type BoundsAssumption = {
22
+ site: SiteID
23
+ // What is accepted without proof at the site: an asserted element read is in bounds, or a
24
+ // divisor the requirement language cannot express over the caller's arguments (a join,
25
+ // a module read, an element read, a call result — or an exhausted expression walk) is
26
+ // nonzero. The divisor case is the fallback for what used to be the divisorUnknown
27
+ // stop: one honest assumes line instead of losing everything downstream of the division.
28
+ kind: 'elementInBounds' | 'nonzeroDivisor'
29
+ }
30
+
31
+ export type InferredPrecondition =
32
+ | {
33
+ kind: 'nonzero'
34
+ expression: NumericExpression
35
+ // Which operation needs the divisor nonzero — division or remainder — for the prose.
36
+ operation: 'division' | 'remainder'
37
+ // Propagated records keep the callee's site, so a caller's report points at the
38
+ // actual operation even when the requirement surfaces two calls up.
39
+ site: SiteID
40
+ }
41
+ // An asserted element read (data[i]!) whose bounds the engine could not prove, with
42
+ // both the index and the sequence nameable over the caller's arguments. The caller-
43
+ // actionable upgrade of BoundsAssumption below: the condition is
44
+ // Number.isInteger(index) && 0 <= index < sequence.length.
45
+ | {
46
+ kind: 'inBounds'
47
+ index: NumericExpression
48
+ sequence: NumericExpression
49
+ site: SiteID
50
+ }
51
+ // The peeled form of a nonzero obligation: dividing by `width - 4` requires width to
52
+ // not be 4. Produced only by float-exact peeling (see peelNonzero), so the biconditional
53
+ // holds: the printed condition is neither weaker nor stronger than the divisor being
54
+ // nonzero.
55
+ | {
56
+ kind: 'notEqualConstant'
57
+ expression: NumericExpression
58
+ value: number
59
+ operation: 'division' | 'remainder'
60
+ site: SiteID
61
+ }
62
+ // A leading console.assert requirement after substituting the current caller's
63
+ // expressions. Structured operands keep propagation independent of source text.
64
+ | {
65
+ kind: 'declaredComparison'
66
+ operator: ComparisonOperator
67
+ left: NumericExpression
68
+ right: NumericExpression
69
+ site: SiteID
70
+ }
71
+ | {
72
+ kind: 'declaredNumberCheck'
73
+ predicate: 'integer' | 'finite' | 'nan'
74
+ expression: NumericExpression
75
+ site: SiteID
76
+ purpose?: 'finiteInput'
77
+ }
@@ -0,0 +1,56 @@
1
+ import {resolve} from 'node:path'
2
+ import * as ts from 'typescript'
3
+ import {TypeScriptDiagnosticsError} from './diagnostics.ts'
4
+
5
+ export type CheckedSource = {
6
+ sourceFile: ts.SourceFile
7
+ checker: ts.TypeChecker
8
+ }
9
+
10
+ // When no tsconfig is in scope, single-file analysis gets the recommended authoring
11
+ // checks. Project file mode uses the project's existing Program instead of this helper.
12
+ const fallbackOptions: ts.CompilerOptions = {
13
+ target: ts.ScriptTarget.ESNext,
14
+ module: ts.ModuleKind.ESNext,
15
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
16
+ moduleDetection: ts.ModuleDetectionKind.Force,
17
+ strict: true,
18
+ noUncheckedIndexedAccess: true,
19
+ exactOptionalPropertyTypes: true,
20
+ noEmit: true,
21
+ skipLibCheck: true,
22
+ types: [],
23
+ }
24
+
25
+ export function checkFile(file: string): CheckedSource {
26
+ const absoluteFile = resolve(file)
27
+ const program = ts.createProgram([absoluteFile], fallbackOptions)
28
+ return checkedSource(program, absoluteFile, fallbackOptions)
29
+ }
30
+
31
+ export function checkSource(file: string, source: string): CheckedSource {
32
+ const absoluteFile = resolve(file)
33
+ const sourceFile = ts.createSourceFile(absoluteFile, source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS)
34
+ const defaultHost = ts.createCompilerHost(fallbackOptions)
35
+ const host: ts.CompilerHost = {
36
+ ...defaultHost,
37
+ getSourceFile: (requestedFile, languageVersion, onError, shouldCreateNewSourceFile) => {
38
+ if (resolve(requestedFile) === absoluteFile) return sourceFile
39
+ return defaultHost.getSourceFile(requestedFile, languageVersion, onError, shouldCreateNewSourceFile)
40
+ },
41
+ fileExists: requestedFile => resolve(requestedFile) === absoluteFile || defaultHost.fileExists(requestedFile),
42
+ readFile: requestedFile => resolve(requestedFile) === absoluteFile ? source : defaultHost.readFile(requestedFile),
43
+ }
44
+ const program = ts.createProgram([absoluteFile], fallbackOptions, host)
45
+ return checkedSource(program, absoluteFile, fallbackOptions)
46
+ }
47
+
48
+ function checkedSource(program: ts.Program, file: string, options: ts.CompilerOptions): CheckedSource {
49
+ const diagnostics = ts.getPreEmitDiagnostics(program)
50
+ if (diagnostics.length > 0) {
51
+ throw new TypeScriptDiagnosticsError(diagnostics, options, process.cwd())
52
+ }
53
+ const sourceFile = program.getSourceFile(file)
54
+ if (sourceFile == null) throw new Error(`TypeScript did not load ${file}`)
55
+ return {sourceFile, checker: program.getTypeChecker()}
56
+ }
@@ -0,0 +1,69 @@
1
+ import * as ts from 'typescript'
2
+
3
+ export type DiagnosticLevel = 'error' | 'warning' | 'suggestion'
4
+
5
+ type DiagnosticLocation = {
6
+ file: string
7
+ line: number
8
+ column: number
9
+ }
10
+
11
+ export class TypeScriptDiagnosticsError extends Error {
12
+ constructor(
13
+ readonly diagnostics: readonly ts.Diagnostic[],
14
+ readonly options: ts.CompilerOptions,
15
+ readonly currentDirectory: string,
16
+ ) {
17
+ super(formatTypeScriptDiagnostics(diagnostics, {...options, pretty: false}, currentDirectory))
18
+ this.name = 'TypeScriptDiagnosticsError'
19
+ }
20
+ }
21
+
22
+ export function formatTypeScriptDiagnostics(
23
+ diagnostics: readonly ts.Diagnostic[],
24
+ options: ts.CompilerOptions,
25
+ currentDirectory: string,
26
+ ): string {
27
+ const host: ts.FormatDiagnosticsHost = {
28
+ getCurrentDirectory: () => currentDirectory,
29
+ getCanonicalFileName: ts.sys.useCaseSensitiveFileNames ? file => file : file => file.toLowerCase(),
30
+ getNewLine: () => ts.sys.newLine,
31
+ }
32
+ return usePrettyOutput(options['pretty'])
33
+ ? ts.formatDiagnosticsWithColorAndContext(diagnostics, host)
34
+ : ts.formatDiagnostics(diagnostics, host)
35
+ }
36
+
37
+ export function usePrettyOutput(configured?: unknown): boolean {
38
+ if (typeof configured === 'boolean') return configured
39
+ const noColor = process.env['NO_COLOR']
40
+ if (noColor != null && noColor !== '') return false
41
+ const forceColor = process.env['FORCE_COLOR']
42
+ if (forceColor != null && forceColor !== '') return true
43
+ return ts.sys.writeOutputIsTTY?.() === true
44
+ }
45
+
46
+ export function color(code: number, text: string | number): string {
47
+ return `\u001B[${code}m${text}\u001B[0m`
48
+ }
49
+
50
+ export function formatDiagnosticPrefix(
51
+ location: DiagnosticLocation,
52
+ level: DiagnosticLevel,
53
+ rule: string,
54
+ pretty: boolean,
55
+ ): string {
56
+ const formattedLocation = formatDiagnosticLocation(location, pretty)
57
+ const separator = pretty ? ' - ' : ': '
58
+ const levelColor = level === 'error' ? 91 : level === 'warning' ? 93 : 96
59
+ const formattedLevel = pretty ? color(levelColor, level) : level
60
+ const ruleLabel = ` [${rule}]: `
61
+ return `${formattedLocation}${separator}${formattedLevel}${pretty ? color(90, ruleLabel) : ruleLabel}`
62
+ }
63
+
64
+ export function formatDiagnosticLocation(location: DiagnosticLocation, pretty: boolean): string {
65
+ const {file, line, column} = location
66
+ return pretty
67
+ ? `${color(96, file)}:${color(93, line)}:${color(93, column)}`
68
+ : `${file}(${line},${column})`
69
+ }
@@ -0,0 +1,101 @@
1
+ import {dirname, isAbsolute, relative, resolve, sep} from 'node:path'
2
+ import * as ts from 'typescript'
3
+ import {TypeScriptDiagnosticsError} from './diagnostics.ts'
4
+
5
+ export type LoadedTypeScriptProject = {
6
+ rootDirectory: string
7
+ parsed: ts.ParsedCommandLine
8
+ program: ts.Program
9
+ }
10
+
11
+ export type ProjectSource = {
12
+ project: LoadedTypeScriptProject
13
+ sourceFile: ts.SourceFile
14
+ }
15
+
16
+ export function findTypeScriptConfig(searchFrom: string): string | null {
17
+ return ts.findConfigFile(resolve(searchFrom), file => ts.sys.fileExists(file), 'tsconfig.json') ?? null
18
+ }
19
+
20
+ export function loadTypeScriptProjectGraph(configPath: string): LoadedTypeScriptProject[] {
21
+ const loaded: LoadedTypeScriptProject[] = []
22
+ const byConfigPath = new Map<string, LoadedTypeScriptProject | null>()
23
+
24
+ const load = (requestedConfigPath: string): LoadedTypeScriptProject => {
25
+ const absoluteConfigPath = resolve(requestedConfigPath)
26
+ const existing = byConfigPath.get(absoluteConfigPath)
27
+ if (existing === null) {
28
+ throw new Error(`Circular TypeScript project reference involving ${absoluteConfigPath}`)
29
+ }
30
+ if (existing !== undefined) return existing
31
+ byConfigPath.set(absoluteConfigPath, null)
32
+ const parsed = parseConfig(absoluteConfigPath)
33
+ requireStrictNullChecks(parsed.options, absoluteConfigPath)
34
+ for (const reference of parsed.projectReferences ?? []) load(ts.resolveProjectReferencePath(reference))
35
+ const program = ts.createProgram({
36
+ rootNames: parsed.fileNames,
37
+ options: parsed.options,
38
+ configFileParsingDiagnostics: parsed.errors,
39
+ ...(parsed.projectReferences == null ? {} : {projectReferences: parsed.projectReferences}),
40
+ })
41
+ const project = {
42
+ rootDirectory: dirname(absoluteConfigPath),
43
+ parsed,
44
+ program,
45
+ }
46
+ byConfigPath.set(absoluteConfigPath, project)
47
+ loaded.push(project)
48
+ return project
49
+ }
50
+
51
+ load(configPath)
52
+ return loaded
53
+ }
54
+
55
+ export function projectSources(projects: LoadedTypeScriptProject[]): ProjectSource[] {
56
+ const sources = new Map<string, ProjectSource>()
57
+ for (const project of projects) {
58
+ for (const sourceFile of project.program.getSourceFiles()) {
59
+ if (sourceFile.isDeclarationFile || sourceFile.fileName.includes(`${sep}node_modules${sep}`)) continue
60
+ const absoluteFile = resolve(sourceFile.fileName)
61
+ const existing = sources.get(absoluteFile)
62
+ const candidate = {project, sourceFile}
63
+ if (existing == null
64
+ || ownershipScore(project, absoluteFile) > ownershipScore(existing.project, absoluteFile)) {
65
+ sources.set(absoluteFile, candidate)
66
+ }
67
+ }
68
+ }
69
+ return [...sources.values()]
70
+ .sort((left, right) => left.sourceFile.fileName.localeCompare(right.sourceFile.fileName))
71
+ }
72
+
73
+ function parseConfig(configPath: string): ts.ParsedCommandLine {
74
+ const parsed = ts.getParsedCommandLineOfConfigFile(configPath, undefined, {
75
+ ...ts.sys,
76
+ onUnRecoverableConfigFileDiagnostic: diagnostic => {
77
+ throw new TypeScriptDiagnosticsError([diagnostic], {}, dirname(configPath))
78
+ },
79
+ })
80
+ if (parsed == null) throw new Error(`TypeScript could not parse ${configPath}`)
81
+ if (parsed.errors.length > 0) {
82
+ throw new TypeScriptDiagnosticsError(parsed.errors, parsed.options, dirname(configPath))
83
+ }
84
+ return parsed
85
+ }
86
+
87
+ function requireStrictNullChecks(options: ts.CompilerOptions, configPath: string): void {
88
+ // TypeScript 6 defaults strict mode on. An explicit strictNullChecks setting wins;
89
+ // otherwise strict:false is the only way the effective option is disabled.
90
+ const enabled = options.strictNullChecks ?? options.strict !== false
91
+ if (enabled) return
92
+ throw new Error(
93
+ `freerange requires strictNullChecks. Enable "strict": true or "strictNullChecks": true in ${configPath}.`,
94
+ )
95
+ }
96
+
97
+ function ownershipScore(project: LoadedTypeScriptProject, file: string): number {
98
+ const path = relative(project.rootDirectory, file)
99
+ const inside = path === '' || (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${sep}`))
100
+ return inside ? project.rootDirectory.length : -1
101
+ }