kea-typegen 3.6.8 → 3.7.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 +8 -0
- package/dist/package.json +1 -1
- package/dist/src/__tests__/watch.js +178 -0
- package/dist/src/__tests__/watch.js.map +1 -1
- package/dist/src/__tests__/write.js +44 -0
- package/dist/src/__tests__/write.js.map +1 -1
- package/dist/src/cli/typegen.js +10 -1
- package/dist/src/cli/typegen.js.map +1 -1
- package/dist/src/print/print.js +11 -3
- package/dist/src/print/print.js.map +1 -1
- package/dist/src/typegen.js +58 -10
- package/dist/src/typegen.js.map +1 -1
- package/dist/src/types.d.ts +1 -0
- package/dist/src/visit/visit.js +33 -11
- package/dist/src/visit/visit.js.map +1 -1
- package/dist/src/watch.d.ts +22 -0
- package/dist/src/watch.js +183 -0
- package/dist/src/watch.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/watch.ts +259 -32
- package/src/__tests__/write.ts +56 -0
- package/src/cli/typegen.ts +10 -1
- package/src/print/print.ts +14 -5
- package/src/test-support/watch-mode-smoke.js +39 -4
- package/src/test-support/write-mode-smoke.js +6 -1
- package/src/typegen.ts +78 -15
- package/src/types.ts +2 -0
- package/src/visit/visit.ts +54 -13
- package/src/watch.ts +261 -0
package/src/typegen.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { AppOptions } from './types'
|
|
|
6
6
|
import { Program } from 'typescript'
|
|
7
7
|
import { version } from '../package.json'
|
|
8
8
|
import { restoreCachedTypes } from './cache'
|
|
9
|
+
import { createWatchChangeTracker, planWatchTypegenPass, WatchFileChange, WatchTypegenPlan } from './watch'
|
|
9
10
|
|
|
10
11
|
// The undocumented defaultMaximumTruncationLength setting determines at what point printed types are truncated in versions less than 5.
|
|
11
12
|
// In kea-typegen output, we NEVER want the types truncated, as that results in a syntax error –
|
|
@@ -25,14 +26,18 @@ export async function runTypeGen(appOptions: AppOptions) {
|
|
|
25
26
|
|
|
26
27
|
if (appOptions.sourceFilePath) {
|
|
27
28
|
log(`❇️ Loading file: ${appOptions.sourceFilePath}`)
|
|
29
|
+
const compilerOptions = appOptions.tsConfigPath
|
|
30
|
+
? loadTsConfig(appOptions.tsConfigPath, log).options
|
|
31
|
+
: {
|
|
32
|
+
target: ts.ScriptTarget.ES5,
|
|
33
|
+
module: ts.ModuleKind.CommonJS,
|
|
34
|
+
noEmit: true,
|
|
35
|
+
noErrorTruncation: true,
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
resetProgram = () => {
|
|
29
|
-
program = replaceProgram(
|
|
30
|
-
ts.createProgram([appOptions.sourceFilePath],
|
|
31
|
-
target: ts.ScriptTarget.ES5,
|
|
32
|
-
module: ts.ModuleKind.CommonJS,
|
|
33
|
-
noEmit: true,
|
|
34
|
-
noErrorTruncation: true,
|
|
35
|
-
}),
|
|
39
|
+
program = replaceProgram(
|
|
40
|
+
() => ts.createProgram([appOptions.sourceFilePath], compilerOptions),
|
|
36
41
|
(nextProgram) => {
|
|
37
42
|
program = nextProgram
|
|
38
43
|
},
|
|
@@ -40,22 +45,19 @@ export async function runTypeGen(appOptions: AppOptions) {
|
|
|
40
45
|
}
|
|
41
46
|
resetProgram()
|
|
42
47
|
} else if (appOptions.tsConfigPath) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const configFile = ts.readJsonConfigFile(appOptions.tsConfigPath, ts.sys.readFile)
|
|
46
|
-
const rootFolder = path.dirname(appOptions.tsConfigPath)
|
|
47
|
-
const compilerOptions = ts.parseJsonSourceFileConfigFileContent(configFile, ts.sys, rootFolder)
|
|
48
|
+
const compilerOptions = loadTsConfig(appOptions.tsConfigPath, log)
|
|
48
49
|
|
|
49
50
|
if (appOptions.watch) {
|
|
50
51
|
// We don't emit JavaScript files in typegen watch mode, so the semantic-only
|
|
51
52
|
// builder is enough and avoids extra emit-related work on every rebuild.
|
|
52
53
|
const createProgram = ts.createSemanticDiagnosticsBuilderProgram
|
|
54
|
+
const watchChangeTracker = createWatchChangeTracker(ts.sys)
|
|
53
55
|
|
|
54
56
|
const host = ts.createWatchCompilerHost(
|
|
55
57
|
appOptions.tsConfigPath,
|
|
56
58
|
compilerOptions.options,
|
|
57
59
|
{
|
|
58
|
-
...
|
|
60
|
+
...watchChangeTracker.system,
|
|
59
61
|
writeFile(_path: string, _data: string, _writeByteOrderMark?: boolean) {
|
|
60
62
|
// skip emit
|
|
61
63
|
// https://github.com/microsoft/TypeScript/issues/32385
|
|
@@ -92,6 +94,7 @@ export async function runTypeGen(appOptions: AppOptions) {
|
|
|
92
94
|
|
|
93
95
|
const origPostProgramCreate = host.afterProgramCreate
|
|
94
96
|
let scheduledProgram: Program | undefined
|
|
97
|
+
let scheduledChanges: WatchFileChange[] = []
|
|
95
98
|
let runningTypegen = false
|
|
96
99
|
|
|
97
100
|
const runScheduledTypegen = async () => {
|
|
@@ -104,10 +107,16 @@ export async function runTypeGen(appOptions: AppOptions) {
|
|
|
104
107
|
try {
|
|
105
108
|
while (scheduledProgram) {
|
|
106
109
|
const nextProgram = scheduledProgram
|
|
110
|
+
const nextChanges = scheduledChanges
|
|
107
111
|
scheduledProgram = undefined
|
|
112
|
+
scheduledChanges = []
|
|
108
113
|
|
|
109
114
|
try {
|
|
110
|
-
await
|
|
115
|
+
await goThroughWatchTypegenPlan(
|
|
116
|
+
nextProgram,
|
|
117
|
+
appOptions,
|
|
118
|
+
planWatchTypegenPass(nextProgram, nextChanges),
|
|
119
|
+
)
|
|
111
120
|
} catch (error) {
|
|
112
121
|
console.error('⛔ Error running kea-typegen in watch mode')
|
|
113
122
|
console.error(error)
|
|
@@ -124,8 +133,10 @@ export async function runTypeGen(appOptions: AppOptions) {
|
|
|
124
133
|
|
|
125
134
|
host.afterProgramCreate = (prog) => {
|
|
126
135
|
program = prog.getProgram()
|
|
136
|
+
const changes = watchChangeTracker.consume()
|
|
127
137
|
origPostProgramCreate?.(prog)
|
|
128
138
|
scheduledProgram = program
|
|
139
|
+
scheduledChanges.push(...changes)
|
|
129
140
|
void runScheduledTypegen()
|
|
130
141
|
}
|
|
131
142
|
|
|
@@ -173,7 +184,52 @@ export async function runTypeGen(appOptions: AppOptions) {
|
|
|
173
184
|
return response
|
|
174
185
|
}
|
|
175
186
|
|
|
176
|
-
|
|
187
|
+
async function goThroughWatchTypegenPlan(
|
|
188
|
+
program,
|
|
189
|
+
appOptions,
|
|
190
|
+
plan: WatchTypegenPlan,
|
|
191
|
+
): Promise<{ filesToWrite: number; writtenFiles: number; filesToModify: number }> {
|
|
192
|
+
if (plan.kind === 'skip') {
|
|
193
|
+
if (appOptions.verbose) {
|
|
194
|
+
appOptions.log(`⏭️ Skipping typegen watch pass: ${plan.reason}`)
|
|
195
|
+
}
|
|
196
|
+
return { filesToWrite: 0, writtenFiles: 0, filesToModify: 0 }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (plan.kind === 'full') {
|
|
200
|
+
if (appOptions.verbose) {
|
|
201
|
+
appOptions.log(`🔎 Running full typegen watch pass: ${plan.reason}`)
|
|
202
|
+
}
|
|
203
|
+
return await goThroughAllTheFiles(program, appOptions)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (appOptions.verbose) {
|
|
207
|
+
appOptions.log(`🎯 Running incremental typegen watch pass: ${plan.reason}`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const response = { filesToWrite: 0, writtenFiles: 0, filesToModify: 0 }
|
|
211
|
+
|
|
212
|
+
for (const sourceFilePath of plan.sourceFilePaths) {
|
|
213
|
+
const fileResponse = await goThroughAllTheFiles(program, {
|
|
214
|
+
...appOptions,
|
|
215
|
+
delete: false,
|
|
216
|
+
sourceFilePath,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
response.filesToWrite += fileResponse.filesToWrite
|
|
220
|
+
response.writtenFiles += fileResponse.writtenFiles
|
|
221
|
+
response.filesToModify += fileResponse.filesToModify
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return response
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (program && !appOptions.watch && appOptions.sourceFilePath) {
|
|
228
|
+
await goThroughAllTheFiles(program, appOptions)
|
|
229
|
+
if (appOptions.write) {
|
|
230
|
+
log(`👋 Finished writing files! Exiting.`)
|
|
231
|
+
}
|
|
232
|
+
} else if (program && !appOptions.watch) {
|
|
177
233
|
if (appOptions.write) {
|
|
178
234
|
if (restoreCachedTypes(program, appOptions, log)) {
|
|
179
235
|
resetProgram()
|
|
@@ -202,6 +258,13 @@ export async function runTypeGen(appOptions: AppOptions) {
|
|
|
202
258
|
}
|
|
203
259
|
}
|
|
204
260
|
|
|
261
|
+
function loadTsConfig(tsConfigPath: string, log: AppOptions['log']): ts.ParsedCommandLine {
|
|
262
|
+
log(`🥚 TypeScript Config: ${tsConfigPath}`)
|
|
263
|
+
const configFile = ts.readJsonConfigFile(tsConfigPath, ts.sys.readFile)
|
|
264
|
+
const rootFolder = path.dirname(tsConfigPath)
|
|
265
|
+
return ts.parseJsonSourceFileConfigFileContent(configFile, ts.sys, rootFolder)
|
|
266
|
+
}
|
|
267
|
+
|
|
205
268
|
export function replaceProgram(createProgram: () => Program, setProgram: (program?: Program) => void): Program {
|
|
206
269
|
setProgram(undefined)
|
|
207
270
|
const nextProgram = createProgram()
|
package/src/types.ts
CHANGED
|
@@ -82,6 +82,8 @@ export interface AppOptions {
|
|
|
82
82
|
showTsErrors?: boolean
|
|
83
83
|
/** Cache generated logic files into .typegen, use them if generating a logic type for the first time */
|
|
84
84
|
useCache?: boolean
|
|
85
|
+
/** Skip Prettier formatting while generating logic type files */
|
|
86
|
+
prettier?: boolean
|
|
85
87
|
|
|
86
88
|
log: (message: string) => void
|
|
87
89
|
}
|
package/src/visit/visit.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { visitEvents } from './visitEvents'
|
|
|
25
25
|
import { visitDefaults } from './visitDefaults'
|
|
26
26
|
import { visitSharedListeners } from './visitSharedListeners'
|
|
27
27
|
import { cloneNode } from 'ts-clone-node'
|
|
28
|
-
import {NodeBuilderFlags} from
|
|
28
|
+
import { NodeBuilderFlags } from 'typescript'
|
|
29
29
|
|
|
30
30
|
const visitFunctions = {
|
|
31
31
|
actions: visitActions,
|
|
@@ -44,20 +44,52 @@ const visitFunctions = {
|
|
|
44
44
|
windowValues: visitWindowValues,
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Cheap text prefilters so we only do expensive AST + type-checker work on files
|
|
48
|
+
// that can actually contain the thing we're looking for. On large projects most
|
|
49
|
+
// source files contain no Kea logic at all, and walking every node (and resolving
|
|
50
|
+
// a symbol for every identifier via the checker) for them dominates runtime.
|
|
51
|
+
//
|
|
52
|
+
// `kea` is never aliased on import in practice, so a `kea(` / `kea<` text match is a
|
|
53
|
+
// safe-enough gate for the Kea-call pass. If a project does alias `kea`, set
|
|
54
|
+
// KEA_TYPEGEN_NO_PREFILTER=1 to fall back to scanning every file.
|
|
55
|
+
const KEA_CALL_REGEX = /\bkea\s*[<(]/
|
|
56
|
+
const prefilterDisabled = process.env.KEA_TYPEGEN_NO_PREFILTER === '1'
|
|
57
|
+
|
|
58
|
+
function fileMightContainKeaCall(sourceFile: ts.SourceFile): boolean {
|
|
59
|
+
return prefilterDisabled || KEA_CALL_REGEX.test(sourceFile.text)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fileMightContainResetContext(sourceFile: ts.SourceFile): boolean {
|
|
63
|
+
return prefilterDisabled || sourceFile.text.includes('resetContext')
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
export function visitProgram(program: ts.Program, appOptions?: AppOptions): ParsedLogic[] {
|
|
48
67
|
const checker = program.getTypeChecker()
|
|
49
68
|
const parsedLogics: ParsedLogic[] = []
|
|
50
69
|
const pluginModules: PluginModule[] = []
|
|
51
70
|
const typeBuilderModules: TypeBuilderModule[] = []
|
|
71
|
+
const sourceFilePath = appOptions?.sourceFilePath ? path.resolve(appOptions.sourceFilePath) : undefined
|
|
52
72
|
|
|
53
73
|
for (const sourceFile of program.getSourceFiles()) {
|
|
54
|
-
if (
|
|
74
|
+
if (
|
|
75
|
+
!sourceFile.isDeclarationFile &&
|
|
76
|
+
!sourceFile.fileName.endsWith('Type.ts') &&
|
|
77
|
+
fileMightContainResetContext(sourceFile)
|
|
78
|
+
) {
|
|
55
79
|
ts.forEachChild(sourceFile, visitResetContext(checker, pluginModules))
|
|
56
80
|
}
|
|
57
81
|
}
|
|
58
82
|
|
|
59
83
|
for (const sourceFile of program.getSourceFiles()) {
|
|
60
|
-
if (
|
|
84
|
+
if (sourceFilePath && path.resolve(sourceFile.fileName) !== sourceFilePath) {
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
!sourceFile.isDeclarationFile &&
|
|
90
|
+
!sourceFile.fileName.endsWith('Type.ts') &&
|
|
91
|
+
fileMightContainKeaCall(sourceFile)
|
|
92
|
+
) {
|
|
61
93
|
if (appOptions?.verbose) {
|
|
62
94
|
appOptions.log(`👀 Visiting: ${path.relative(process.cwd(), sourceFile.fileName)}`)
|
|
63
95
|
}
|
|
@@ -245,7 +277,6 @@ export function visitKeaCalls(
|
|
|
245
277
|
const calls: {
|
|
246
278
|
name: string
|
|
247
279
|
type: ts.Type
|
|
248
|
-
typeNode: ts.TypeNode | null
|
|
249
280
|
expression: ts.Expression
|
|
250
281
|
typeBuilders: PluginModule[]
|
|
251
282
|
}[] = []
|
|
@@ -261,8 +292,7 @@ export function visitKeaCalls(
|
|
|
261
292
|
}
|
|
262
293
|
const name = symbol.getName()
|
|
263
294
|
const type = checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration)
|
|
264
|
-
|
|
265
|
-
calls.push({ name, type, typeNode, expression: inputProperty.initializer, typeBuilders: [] })
|
|
295
|
+
calls.push({ name, type, expression: inputProperty.initializer, typeBuilders: [] })
|
|
266
296
|
}
|
|
267
297
|
} else if (ts.isArrayLiteralExpression(input)) {
|
|
268
298
|
for (const callExpression of input.elements) {
|
|
@@ -273,7 +303,6 @@ export function visitKeaCalls(
|
|
|
273
303
|
const builderName = callExpression.expression.getText()
|
|
274
304
|
const argument = callExpression.arguments[0]
|
|
275
305
|
const type = checker.getTypeAtLocation(argument)
|
|
276
|
-
const typeNode = type ? checker.typeToTypeNode(type, undefined, NodeBuilderFlags.NoTruncation) : null
|
|
277
306
|
|
|
278
307
|
const identifier = callExpression.expression
|
|
279
308
|
const symbol = checker.getSymbolAtLocation(identifier)
|
|
@@ -348,14 +377,13 @@ export function visitKeaCalls(
|
|
|
348
377
|
calls.push({
|
|
349
378
|
name: builderName,
|
|
350
379
|
type,
|
|
351
|
-
typeNode,
|
|
352
380
|
expression: callExpression.arguments[0],
|
|
353
381
|
typeBuilders: typeBuilders,
|
|
354
382
|
})
|
|
355
383
|
}
|
|
356
384
|
}
|
|
357
385
|
|
|
358
|
-
for (let { name, type,
|
|
386
|
+
for (let { name, type, expression, typeBuilders } of calls) {
|
|
359
387
|
if (name === 'path' || name === 'logicPath') {
|
|
360
388
|
parsedLogic.hasPathInLogic = true
|
|
361
389
|
}
|
|
@@ -363,18 +391,31 @@ export function visitKeaCalls(
|
|
|
363
391
|
parsedLogic.hasKeyInLogic = true
|
|
364
392
|
}
|
|
365
393
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
394
|
+
// Equivalent to the previous `ts.isFunctionTypeNode(typeNode)` check, but without
|
|
395
|
+
// eagerly building a typeNode: a pure function type has call signatures and no
|
|
396
|
+
// members, so we unwrap it to its return type before visiting/printing.
|
|
397
|
+
const callSignatures = type.getCallSignatures()
|
|
398
|
+
if (callSignatures.length > 0 && type.getProperties().length === 0) {
|
|
399
|
+
type = callSignatures[0].getReturnType()
|
|
369
400
|
}
|
|
370
401
|
|
|
371
402
|
visitFunctions[name]?.(parsedLogic, type, expression)
|
|
372
403
|
|
|
404
|
+
let typeNode: ts.TypeNode | null | undefined = undefined
|
|
405
|
+
const getTypeNode = (): ts.TypeNode | null => {
|
|
406
|
+
if (typeNode === undefined) {
|
|
407
|
+
typeNode = type ? checker.typeToTypeNode(type, undefined, NodeBuilderFlags.NoTruncation) : null
|
|
408
|
+
}
|
|
409
|
+
return typeNode
|
|
410
|
+
}
|
|
411
|
+
|
|
373
412
|
const visitKeaPropertyArguments: VisitKeaPropertyArguments = {
|
|
374
413
|
name,
|
|
375
414
|
appOptions,
|
|
376
415
|
type,
|
|
377
|
-
typeNode
|
|
416
|
+
get typeNode() {
|
|
417
|
+
return getTypeNode()
|
|
418
|
+
},
|
|
378
419
|
parsedLogic,
|
|
379
420
|
node: expression,
|
|
380
421
|
checker,
|
package/src/watch.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
import * as ts from 'typescript'
|
|
4
|
+
import { getFilenameForImportDeclaration } from './utils'
|
|
5
|
+
|
|
6
|
+
const KEA_CALL_REGEX = /\bkea\s*[<(]/
|
|
7
|
+
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mjsx']
|
|
8
|
+
const TYPEGEN_FILE_REGEX = /(^|\.|\/)typegen\.[tj]s$/
|
|
9
|
+
const MAX_INCREMENTAL_CHANGED_FILES = 50
|
|
10
|
+
|
|
11
|
+
export interface WatchFileChange {
|
|
12
|
+
fileName: string
|
|
13
|
+
eventKind: ts.FileWatcherEventKind
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type WatchTypegenPlan =
|
|
17
|
+
| { kind: 'full'; reason: string }
|
|
18
|
+
| { kind: 'files'; sourceFilePaths: string[]; reason: string }
|
|
19
|
+
| { kind: 'skip'; reason: string }
|
|
20
|
+
|
|
21
|
+
export interface WatchChangeTracker {
|
|
22
|
+
system: ts.System
|
|
23
|
+
consume(): WatchFileChange[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createWatchChangeTracker(baseSystem: ts.System): WatchChangeTracker {
|
|
27
|
+
const changes = new Map<string, WatchFileChange>()
|
|
28
|
+
|
|
29
|
+
const track = (fileName: string, eventKind: ts.FileWatcherEventKind): void => {
|
|
30
|
+
changes.set(path.resolve(fileName), { fileName: path.resolve(fileName), eventKind })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const system: ts.System = {
|
|
34
|
+
...baseSystem,
|
|
35
|
+
watchFile(fileName, callback, pollingInterval, options) {
|
|
36
|
+
return baseSystem.watchFile!(
|
|
37
|
+
fileName,
|
|
38
|
+
(changedFileName, eventKind, modifiedTime) => {
|
|
39
|
+
track(changedFileName, eventKind)
|
|
40
|
+
callback(changedFileName, eventKind, modifiedTime)
|
|
41
|
+
},
|
|
42
|
+
pollingInterval,
|
|
43
|
+
options,
|
|
44
|
+
)
|
|
45
|
+
},
|
|
46
|
+
watchDirectory(fileName, callback, recursive, options) {
|
|
47
|
+
return baseSystem.watchDirectory!(
|
|
48
|
+
fileName,
|
|
49
|
+
(changedFileName) => {
|
|
50
|
+
track(changedFileName, ts.FileWatcherEventKind.Created)
|
|
51
|
+
callback(changedFileName)
|
|
52
|
+
},
|
|
53
|
+
recursive,
|
|
54
|
+
options,
|
|
55
|
+
)
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
system,
|
|
61
|
+
consume() {
|
|
62
|
+
const currentChanges = [...changes.values()]
|
|
63
|
+
changes.clear()
|
|
64
|
+
return currentChanges
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function planWatchTypegenPass(program: ts.Program, changes: WatchFileChange[]): WatchTypegenPlan {
|
|
70
|
+
if (changes.length === 0) {
|
|
71
|
+
return { kind: 'full', reason: 'initial watch pass' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const normalizedChanges = dedupeChanges(changes)
|
|
75
|
+
|
|
76
|
+
if (normalizedChanges.length > MAX_INCREMENTAL_CHANGED_FILES) {
|
|
77
|
+
return { kind: 'full', reason: `${normalizedChanges.length} files changed` }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (normalizedChanges.some((change) => change.eventKind === ts.FileWatcherEventKind.Deleted)) {
|
|
81
|
+
return { kind: 'full', reason: 'deleted file detected' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sourceFilesByPath = getSourceFilesByPath(program)
|
|
85
|
+
const reverseDependencies = buildReverseDependencyMap(program)
|
|
86
|
+
const queue: string[] = []
|
|
87
|
+
|
|
88
|
+
for (const change of normalizedChanges) {
|
|
89
|
+
const changedPath = path.resolve(change.fileName)
|
|
90
|
+
|
|
91
|
+
if (isTypegenModuleFile(changedPath)) {
|
|
92
|
+
return { kind: 'full', reason: `typegen module changed: ${path.relative(process.cwd(), changedPath)}` }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sourceFile = sourceFilesByPath.get(changedPath)
|
|
96
|
+
|
|
97
|
+
if (sourceFile?.text.includes('resetContext')) {
|
|
98
|
+
return { kind: 'full', reason: `resetContext changed: ${path.relative(process.cwd(), changedPath)}` }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (isGeneratedTypeFile(changedPath)) {
|
|
102
|
+
queue.push(...sourcePathsForTypeFile(changedPath, sourceFilesByPath))
|
|
103
|
+
queue.push(changedPath)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (sourceFile) {
|
|
108
|
+
if (sourceFile.isDeclarationFile) {
|
|
109
|
+
return {
|
|
110
|
+
kind: 'full',
|
|
111
|
+
reason: `declaration file changed: ${path.relative(process.cwd(), changedPath)}`,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
queue.push(changedPath)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (isSourceLikeFile(changedPath) && fs.existsSync(changedPath)) {
|
|
120
|
+
return {
|
|
121
|
+
kind: 'full',
|
|
122
|
+
reason: `new source file outside program: ${path.relative(process.cwd(), changedPath)}`,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const affectedLogicFiles = collectAffectedLogicFiles(queue, sourceFilesByPath, reverseDependencies)
|
|
128
|
+
|
|
129
|
+
if (affectedLogicFiles.length === 0) {
|
|
130
|
+
return { kind: 'skip', reason: 'no affected Kea logic files' }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
kind: 'files',
|
|
135
|
+
sourceFilePaths: affectedLogicFiles,
|
|
136
|
+
reason: `${affectedLogicFiles.length} affected Kea logic file${affectedLogicFiles.length === 1 ? '' : 's'}`,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function dedupeChanges(changes: WatchFileChange[]): WatchFileChange[] {
|
|
141
|
+
const deduped = new Map<string, WatchFileChange>()
|
|
142
|
+
|
|
143
|
+
for (const change of changes) {
|
|
144
|
+
deduped.set(path.resolve(change.fileName), { ...change, fileName: path.resolve(change.fileName) })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return [...deduped.values()]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getSourceFilesByPath(program: ts.Program): Map<string, ts.SourceFile> {
|
|
151
|
+
const sourceFilesByPath = new Map<string, ts.SourceFile>()
|
|
152
|
+
|
|
153
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
154
|
+
sourceFilesByPath.set(path.resolve(sourceFile.fileName), sourceFile)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return sourceFilesByPath
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function buildReverseDependencyMap(program: ts.Program): Map<string, Set<string>> {
|
|
161
|
+
const checker = program.getTypeChecker()
|
|
162
|
+
const reverseDependencies = new Map<string, Set<string>>()
|
|
163
|
+
|
|
164
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
165
|
+
if (sourceFile.isDeclarationFile) {
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const importerPath = path.resolve(sourceFile.fileName)
|
|
170
|
+
|
|
171
|
+
ts.forEachChild(sourceFile, function visit(node) {
|
|
172
|
+
if (ts.isImportDeclaration(node)) {
|
|
173
|
+
const importedFileName = getFilenameForImportDeclaration(checker, node)
|
|
174
|
+
|
|
175
|
+
if (importedFileName) {
|
|
176
|
+
const importedPath = path.resolve(importedFileName)
|
|
177
|
+
let importers = reverseDependencies.get(importedPath)
|
|
178
|
+
|
|
179
|
+
if (!importers) {
|
|
180
|
+
importers = new Set()
|
|
181
|
+
reverseDependencies.set(importedPath, importers)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
importers.add(importerPath)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
ts.forEachChild(node, visit)
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return reverseDependencies
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function collectAffectedLogicFiles(
|
|
196
|
+
startPaths: string[],
|
|
197
|
+
sourceFilesByPath: Map<string, ts.SourceFile>,
|
|
198
|
+
reverseDependencies: Map<string, Set<string>>,
|
|
199
|
+
): string[] {
|
|
200
|
+
const visited = new Set<string>()
|
|
201
|
+
const queue = startPaths.map((filePath) => path.resolve(filePath))
|
|
202
|
+
const affectedLogicFiles = new Set<string>()
|
|
203
|
+
|
|
204
|
+
while (queue.length > 0) {
|
|
205
|
+
const currentPath = queue.shift()!
|
|
206
|
+
|
|
207
|
+
if (visited.has(currentPath)) {
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
visited.add(currentPath)
|
|
212
|
+
|
|
213
|
+
const sourceFile = sourceFilesByPath.get(currentPath)
|
|
214
|
+
if (
|
|
215
|
+
sourceFile &&
|
|
216
|
+
!sourceFile.isDeclarationFile &&
|
|
217
|
+
!isGeneratedTypeFile(currentPath) &&
|
|
218
|
+
sourceFileMightContainKeaCall(sourceFile)
|
|
219
|
+
) {
|
|
220
|
+
affectedLogicFiles.add(currentPath)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (isGeneratedTypeFile(currentPath)) {
|
|
224
|
+
queue.push(...sourcePathsForTypeFile(currentPath, sourceFilesByPath))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const importerPath of reverseDependencies.get(currentPath) ?? []) {
|
|
228
|
+
queue.push(importerPath)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return [...affectedLogicFiles].sort()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function sourcePathsForTypeFile(typeFilePath: string, sourceFilesByPath: Map<string, ts.SourceFile>): string[] {
|
|
236
|
+
if (!isGeneratedTypeFile(typeFilePath)) {
|
|
237
|
+
return []
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const sourceBasePath = typeFilePath.slice(0, -'Type.ts'.length)
|
|
241
|
+
|
|
242
|
+
return SOURCE_EXTENSIONS.map((extension) => `${sourceBasePath}${extension}`).filter((sourcePath) =>
|
|
243
|
+
sourceFilesByPath.has(path.resolve(sourcePath)),
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isGeneratedTypeFile(filePath: string): boolean {
|
|
248
|
+
return filePath.endsWith('Type.ts')
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isSourceLikeFile(filePath: string): boolean {
|
|
252
|
+
return SOURCE_EXTENSIONS.some((extension) => filePath.endsWith(extension))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isTypegenModuleFile(filePath: string): boolean {
|
|
256
|
+
return TYPEGEN_FILE_REGEX.test(filePath.split(path.sep).join('/'))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function sourceFileMightContainKeaCall(sourceFile: ts.SourceFile): boolean {
|
|
260
|
+
return KEA_CALL_REGEX.test(sourceFile.text)
|
|
261
|
+
}
|