kea-typegen 3.7.0 → 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/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 –
@@ -50,12 +51,13 @@ export async function runTypeGen(appOptions: AppOptions) {
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
- ...ts.sys,
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 goThroughAllTheFiles(nextProgram, appOptions)
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,6 +184,46 @@ export async function runTypeGen(appOptions: AppOptions) {
173
184
  return response
174
185
  }
175
186
 
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
+
176
227
  if (program && !appOptions.watch && appOptions.sourceFilePath) {
177
228
  await goThroughAllTheFiles(program, appOptions)
178
229
  if (appOptions.write) {
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
+ }