@zmdb/compiler 1.0.0-beta.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.
Files changed (172) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +40 -0
  3. package/dist/codegen/index.d.ts +50 -0
  4. package/dist/codegen/index.d.ts.map +1 -0
  5. package/dist/codegen/index.js +364 -0
  6. package/dist/codegen/index.js.map +1 -0
  7. package/dist/codegen/scan.d.ts +105 -0
  8. package/dist/codegen/scan.d.ts.map +1 -0
  9. package/dist/codegen/scan.js +450 -0
  10. package/dist/codegen/scan.js.map +1 -0
  11. package/dist/codegen/witness.d.ts +76 -0
  12. package/dist/codegen/witness.d.ts.map +1 -0
  13. package/dist/codegen/witness.js +690 -0
  14. package/dist/codegen/witness.js.map +1 -0
  15. package/dist/config/contract.d.ts +41 -0
  16. package/dist/config/contract.d.ts.map +1 -0
  17. package/dist/config/contract.js +5 -0
  18. package/dist/config/contract.js.map +1 -0
  19. package/dist/config/index.d.ts +53 -0
  20. package/dist/config/index.d.ts.map +1 -0
  21. package/dist/config/index.js +277 -0
  22. package/dist/config/index.js.map +1 -0
  23. package/dist/config/index.zmdb.generated.d.ts +12 -0
  24. package/dist/config/index.zmdb.generated.js +28 -0
  25. package/dist/config/index.zmdb.witness.d.ts +3 -0
  26. package/dist/config/index.zmdb.witness.d.ts.map +1 -0
  27. package/dist/config/index.zmdb.witness.js +20 -0
  28. package/dist/config/index.zmdb.witness.js.map +1 -0
  29. package/dist/configured-plugin.d.ts +11 -0
  30. package/dist/configured-plugin.d.ts.map +1 -0
  31. package/dist/configured-plugin.js +27 -0
  32. package/dist/configured-plugin.js.map +1 -0
  33. package/dist/emit/index.d.ts +103 -0
  34. package/dist/emit/index.d.ts.map +1 -0
  35. package/dist/emit/index.js +1356 -0
  36. package/dist/emit/index.js.map +1 -0
  37. package/dist/errors.d.ts +7 -0
  38. package/dist/errors.d.ts.map +1 -0
  39. package/dist/errors.js +2 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/index.d.ts +46 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +254 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lint/ast.d.ts +17 -0
  46. package/dist/lint/ast.d.ts.map +1 -0
  47. package/dist/lint/ast.js +77 -0
  48. package/dist/lint/ast.js.map +1 -0
  49. package/dist/lint/host-types.d.ts +9 -0
  50. package/dist/lint/host-types.d.ts.map +1 -0
  51. package/dist/lint/host-types.js +2 -0
  52. package/dist/lint/host-types.js.map +1 -0
  53. package/dist/lint/index.d.ts +23 -0
  54. package/dist/lint/index.d.ts.map +1 -0
  55. package/dist/lint/index.js +53 -0
  56. package/dist/lint/index.js.map +1 -0
  57. package/dist/lint/rules/no-distributed-nullable-tags.d.ts +3 -0
  58. package/dist/lint/rules/no-distributed-nullable-tags.d.ts.map +1 -0
  59. package/dist/lint/rules/no-distributed-nullable-tags.js +71 -0
  60. package/dist/lint/rules/no-distributed-nullable-tags.js.map +1 -0
  61. package/dist/lint/rules/no-empty-patch.d.ts +3 -0
  62. package/dist/lint/rules/no-empty-patch.d.ts.map +1 -0
  63. package/dist/lint/rules/no-empty-patch.js +26 -0
  64. package/dist/lint/rules/no-empty-patch.js.map +1 -0
  65. package/dist/lint/rules/no-interpolated-sql.d.ts +3 -0
  66. package/dist/lint/rules/no-interpolated-sql.d.ts.map +1 -0
  67. package/dist/lint/rules/no-interpolated-sql.js +35 -0
  68. package/dist/lint/rules/no-interpolated-sql.js.map +1 -0
  69. package/dist/lint/rules/no-unbounded-find.d.ts +3 -0
  70. package/dist/lint/rules/no-unbounded-find.d.ts.map +1 -0
  71. package/dist/lint/rules/no-unbounded-find.js +26 -0
  72. package/dist/lint/rules/no-unbounded-find.js.map +1 -0
  73. package/dist/lint/rules/no-unknown-json-column.d.ts +3 -0
  74. package/dist/lint/rules/no-unknown-json-column.d.ts.map +1 -0
  75. package/dist/lint/rules/no-unknown-json-column.js +35 -0
  76. package/dist/lint/rules/no-unknown-json-column.js.map +1 -0
  77. package/dist/lint/rules/require-sql-on-number.d.ts +3 -0
  78. package/dist/lint/rules/require-sql-on-number.d.ts.map +1 -0
  79. package/dist/lint/rules/require-sql-on-number.js +34 -0
  80. package/dist/lint/rules/require-sql-on-number.js.map +1 -0
  81. package/dist/lint/types.d.ts +12 -0
  82. package/dist/lint/types.d.ts.map +1 -0
  83. package/dist/lint/types.js +2 -0
  84. package/dist/lint/types.js.map +1 -0
  85. package/dist/metro/metro.d.ts +22 -0
  86. package/dist/metro/metro.d.ts.map +1 -0
  87. package/dist/metro/metro.js +242 -0
  88. package/dist/metro/metro.js.map +1 -0
  89. package/dist/protobuf/decode.d.ts +20 -0
  90. package/dist/protobuf/decode.d.ts.map +1 -0
  91. package/dist/protobuf/decode.js +289 -0
  92. package/dist/protobuf/decode.js.map +1 -0
  93. package/dist/protobuf/descriptor.d.ts +16 -0
  94. package/dist/protobuf/descriptor.d.ts.map +1 -0
  95. package/dist/protobuf/descriptor.js +303 -0
  96. package/dist/protobuf/descriptor.js.map +1 -0
  97. package/dist/protobuf/encode.d.ts +20 -0
  98. package/dist/protobuf/encode.d.ts.map +1 -0
  99. package/dist/protobuf/encode.js +203 -0
  100. package/dist/protobuf/encode.js.map +1 -0
  101. package/dist/protobuf/grpc-ir.d.ts +16 -0
  102. package/dist/protobuf/grpc-ir.d.ts.map +1 -0
  103. package/dist/protobuf/grpc-ir.js +2 -0
  104. package/dist/protobuf/grpc-ir.js.map +1 -0
  105. package/dist/protobuf/plan.d.ts +42 -0
  106. package/dist/protobuf/plan.d.ts.map +1 -0
  107. package/dist/protobuf/plan.js +158 -0
  108. package/dist/protobuf/plan.js.map +1 -0
  109. package/dist/reflect/callsites.d.ts +40 -0
  110. package/dist/reflect/callsites.d.ts.map +1 -0
  111. package/dist/reflect/callsites.js +153 -0
  112. package/dist/reflect/callsites.js.map +1 -0
  113. package/dist/reflect/index.d.ts +106 -0
  114. package/dist/reflect/index.d.ts.map +1 -0
  115. package/dist/reflect/index.js +1421 -0
  116. package/dist/reflect/index.js.map +1 -0
  117. package/dist/reflect/session.d.ts +118 -0
  118. package/dist/reflect/session.d.ts.map +1 -0
  119. package/dist/reflect/session.js +228 -0
  120. package/dist/reflect/session.js.map +1 -0
  121. package/dist/testing/index.d.ts +69 -0
  122. package/dist/testing/index.d.ts.map +1 -0
  123. package/dist/testing/index.js +210 -0
  124. package/dist/testing/index.js.map +1 -0
  125. package/dist/transform/index.d.ts +74 -0
  126. package/dist/transform/index.d.ts.map +1 -0
  127. package/dist/transform/index.js +572 -0
  128. package/dist/transform/index.js.map +1 -0
  129. package/dist/unplugin/index.d.ts +53 -0
  130. package/dist/unplugin/index.d.ts.map +1 -0
  131. package/dist/unplugin/index.js +130 -0
  132. package/dist/unplugin/index.js.map +1 -0
  133. package/dist/unplugin/inline-bench.d.ts +29 -0
  134. package/dist/unplugin/inline-bench.d.ts.map +1 -0
  135. package/dist/unplugin/inline-bench.js +82 -0
  136. package/dist/unplugin/inline-bench.js.map +1 -0
  137. package/package.json +110 -0
  138. package/src/codegen/index.ts +451 -0
  139. package/src/codegen/scan.ts +574 -0
  140. package/src/codegen/witness.ts +819 -0
  141. package/src/config/contract.ts +48 -0
  142. package/src/config/index.ts +384 -0
  143. package/src/config/index.zmdb.generated.d.ts +12 -0
  144. package/src/config/index.zmdb.generated.js +28 -0
  145. package/src/config/index.zmdb.witness.ts +22 -0
  146. package/src/configured-plugin.ts +35 -0
  147. package/src/emit/index.ts +1496 -0
  148. package/src/errors.ts +6 -0
  149. package/src/index.ts +336 -0
  150. package/src/lint/ast.ts +96 -0
  151. package/src/lint/host-types.ts +9 -0
  152. package/src/lint/index.ts +80 -0
  153. package/src/lint/rules/no-distributed-nullable-tags.ts +76 -0
  154. package/src/lint/rules/no-empty-patch.ts +26 -0
  155. package/src/lint/rules/no-interpolated-sql.ts +45 -0
  156. package/src/lint/rules/no-unbounded-find.ts +26 -0
  157. package/src/lint/rules/no-unknown-json-column.ts +36 -0
  158. package/src/lint/rules/require-sql-on-number.ts +34 -0
  159. package/src/lint/types.ts +11 -0
  160. package/src/metro/metro.ts +313 -0
  161. package/src/protobuf/decode.ts +375 -0
  162. package/src/protobuf/descriptor.ts +353 -0
  163. package/src/protobuf/encode.ts +277 -0
  164. package/src/protobuf/grpc-ir.ts +17 -0
  165. package/src/protobuf/plan.ts +244 -0
  166. package/src/reflect/callsites.ts +198 -0
  167. package/src/reflect/index.ts +1723 -0
  168. package/src/reflect/session.ts +276 -0
  169. package/src/testing/index.ts +298 -0
  170. package/src/transform/index.ts +690 -0
  171. package/src/unplugin/index.ts +182 -0
  172. package/src/unplugin/inline-bench.ts +92 -0
@@ -0,0 +1,451 @@
1
+ // The no-bundler AOT project compiler.
2
+ //
3
+ // The unplugin gets the type information for free — a bundler hands it a module, it asks the
4
+ // compiler about the type arguments in it, and it hands back rewritten source that only the
5
+ // bundler ever sees. A project built by plain `tsc`, or run straight off `node --strip-types`,
6
+ // has nowhere to put that step, and REQ-AV-3 says the compiled path may not be a reward for
7
+ // choosing a particular bundler. So this writes the rewrite down.
8
+ //
9
+ // Per source file that validates anything, three files (see `./witness.ts` for why three)
10
+ // and one edit to the source: `is<User>(data)` becomes `zmdbIsUser(data)`, imported from a
11
+ // generated module. The result is checked in, so a clone of the repository builds the fast
12
+ // path with no tool at all in the way.
13
+ //
14
+ // ## The order matters, and it is not the obvious one
15
+ //
16
+ // Every witness is written *before* any of them is transformed. The reason is cost: telling
17
+ // the compiler about a new file is a snapshot update, and a snapshot update per file would
18
+ // make a hundred-file project a hundred re-checks. Two updates for the whole run is the
19
+ // difference between this being usable and being a thing people turn off (REQ-TF-11).
20
+ //
21
+ // ## `--check` writes nothing, and still checks everything
22
+ //
23
+ // A check that had to write the witnesses to verify them would be a check that dirties the
24
+ // tree it is auditing. It does not have to: the witness is a pure function of the scan, so a
25
+ // stale one is caught by comparing text. And if every witness matches, then the compiled
26
+ // modules are derivable from files already on disk — the transform runs against those, and
27
+ // its output is compared the same way.
28
+
29
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
30
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
31
+
32
+ import { resolveNaming, type NamingStrategyConfig } from '@zmdb/schema/naming';
33
+
34
+ import { CALL_OWNERS, STRICT_OWNER_CALLEES } from '../reflect/callsites.js';
35
+ import { ReflectSession, type SourceFileHandle } from '../reflect/session.js';
36
+ import { CALLEES, transformFile } from '../transform/index.js';
37
+ import { scan, type Entry, type SiteEntry, type TypeImport } from './scan.js';
38
+ import {
39
+ artifactPaths,
40
+ generatedModules,
41
+ isGeneratedPath,
42
+ quoteStyle,
43
+ rewriteSource,
44
+ witnessSource,
45
+ type ArtifactPaths,
46
+ } from './witness.js';
47
+
48
+ export interface CodegenOptions {
49
+ /** Path to the `tsconfig.json` that defines what to generate for. */
50
+ readonly project: string;
51
+ /** Exact project members to compile. Omit to scan the complete project. */
52
+ readonly files?: readonly string[];
53
+ /** Report what would change and write nothing. */
54
+ readonly check?: boolean | undefined;
55
+ /** Where progress goes. Defaults to nowhere, so a library caller stays quiet. */
56
+ readonly log?: ((line: string) => void) | undefined;
57
+ /**
58
+ * An already-open session, as the plugin takes. The caller keeps ownership, closing
59
+ * included.
60
+ *
61
+ * A caller that already has the project loaded should not pay to load it again — that is
62
+ * REQ-TF-11's whole point, and it applies to a tool driving `codegen` just as much as to
63
+ * the build it is part of. It is also what makes the claim observable: the session records
64
+ * its snapshot updates, so `verify:build-budget` can watch a 64-module run and see that the
65
+ * project is opened once.
66
+ */
67
+ readonly session?: ReflectSession | undefined;
68
+ /** A named or custom build-time strategy, resolved once for this run. */
69
+ readonly naming?: NamingStrategyConfig;
70
+ }
71
+
72
+ export interface CodegenResult {
73
+ /** Absolute paths written, or — under `--check` — that would have been. */
74
+ readonly written: readonly string[];
75
+ /** Absolute paths deleted, because their source stopped validating anything. */
76
+ readonly deleted: readonly string[];
77
+ /** Everything that stopped a file from being generated, formatted for a terminal. */
78
+ readonly problems: readonly string[];
79
+ readonly ok: boolean;
80
+ }
81
+
82
+ /**
83
+ * Cheap pre-filter: does this file import any package that owns a transformed call?
84
+ * Looking for the owner rather than a callee spelling preserves the fast path for aliases.
85
+ */
86
+ const OWNER_SPECIFIERS = [...new Set(Object.values(CALL_OWNERS).flat())];
87
+ const MENTIONS_OWNER = new RegExp(
88
+ `\\bfrom\\s+['"](?:${OWNER_SPECIFIERS.map(specifier => specifier.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})['"]`,
89
+ );
90
+ const MENTIONS_FORWARDED_CALL = new RegExp(
91
+ `\\b(?:${[...CALLEES]
92
+ .filter(callee => !STRICT_OWNER_CALLEES.has(callee))
93
+ .map(callee => callee.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))
94
+ .join('|')})\\s*<`,
95
+ );
96
+
97
+ export function codegen(options: CodegenOptions): CodegenResult {
98
+ const project = resolve(options.project);
99
+ if (options.session) return run(options.session, project, options);
100
+ using session = ReflectSession.open({ project });
101
+ return run(session, project, options);
102
+ }
103
+
104
+ // -----------------------------------------------------------------------------
105
+ // One pass
106
+ // -----------------------------------------------------------------------------
107
+
108
+ interface Candidate {
109
+ readonly fileName: string;
110
+ readonly sourceFile: SourceFileHandle;
111
+ readonly code: string;
112
+ readonly paths: ArtifactPaths;
113
+ readonly hadWitness: boolean;
114
+ /** The quote character this file writes its own imports with. */
115
+ readonly style: string;
116
+ /** Filled in by the scan. Empty means the file validates nothing any more. */
117
+ entries: readonly Entry[];
118
+ sites: readonly SiteEntry[];
119
+ typeImports: readonly TypeImport[];
120
+ calleeSources: ReadonlyMap<string, string>;
121
+ witness: string;
122
+ }
123
+
124
+ function run(session: ReflectSession, project: string, options: CodegenOptions): CodegenResult {
125
+ const root = dirname(project);
126
+ const log = options.log ?? (() => undefined);
127
+ const check = options.check === true;
128
+ const reflect = { naming: resolveNaming(options.naming) } as const;
129
+ const problems: string[] = [];
130
+ const written: string[] = [];
131
+ const deleted: string[] = [];
132
+ const show = (path: string): string => relative(root, path) || path;
133
+ const selected = options.files === undefined ? undefined : new Set(options.files.map(file => pathKey(file)));
134
+
135
+ const tsExtensions = session.compilerOptions()['allowImportingTsExtensions'] === true;
136
+
137
+ // -- Phase 1: what to generate ---------------------------------------------
138
+
139
+ const candidates: Candidate[] = [];
140
+ for (const fileName of session.sourceFileNames()) {
141
+ if (selected !== undefined && !selected.has(pathKey(fileName))) continue;
142
+ if (!isCandidate(fileName, root)) continue;
143
+ const paths = artifactPaths(fileName);
144
+ const hadWitness = existsSync(paths.witness);
145
+ let code: string;
146
+ try {
147
+ code = readFileSync(fileName, 'utf8');
148
+ } catch {
149
+ // In the program but not on disk. A virtual file from another tool's plugin, or a race
150
+ // with a delete; either way there is nothing here to rewrite.
151
+ continue;
152
+ }
153
+ if (!hadWitness && !MENTIONS_OWNER.test(code) && !MENTIONS_FORWARDED_CALL.test(code)) continue;
154
+
155
+ const sourceFile = session.sourceFile(fileName);
156
+ if (!sourceFile) {
157
+ problems.push(`${show(fileName)}: in the project's file list but the compiler has no parse of it`);
158
+ continue;
159
+ }
160
+ if (sourceFile.text !== code) {
161
+ problems.push(`${show(fileName)}: changed on disk since the project loaded; run again`);
162
+ continue;
163
+ }
164
+
165
+ const witnessFile = hadWitness ? session.sourceFile(paths.witness) : undefined;
166
+ if (hadWitness && !witnessFile) {
167
+ problems.push(
168
+ `${show(paths.witness)}: exists but is not part of ${show(project)}. ` +
169
+ 'The witness has to be typechecked, so the project must include it.',
170
+ );
171
+ continue;
172
+ }
173
+
174
+ const scanned = scan({ checker: session.checker, sourceFile, witnessFile, tsExtensions });
175
+ for (const refusal of scanned.refusals) {
176
+ problems.push(`${show(fileName)}: cannot generate for \`${refusal.typeText}\` — ${refusal.reason}`);
177
+ }
178
+ if (scanned.refusals.length > 0) continue;
179
+
180
+ const sourceName = show(fileName);
181
+ const style = quoteStyle(sourceFile);
182
+ candidates.push({
183
+ fileName,
184
+ sourceFile,
185
+ code,
186
+ paths,
187
+ hadWitness,
188
+ style,
189
+ entries: scanned.entries,
190
+ sites: scanned.sites,
191
+ typeImports: scanned.typeImports,
192
+ calleeSources: scanned.calleeSources,
193
+ witness:
194
+ scanned.entries.length === 0
195
+ ? ''
196
+ : witnessSource({
197
+ sourceName,
198
+ entries: scanned.entries,
199
+ typeImports: scanned.typeImports,
200
+ calleeSources: scanned.calleeSources,
201
+ style,
202
+ }),
203
+ });
204
+ }
205
+
206
+ // -- Phase 2: the witnesses, then one snapshot update ---------------------
207
+
208
+ const created: string[] = [];
209
+ const refreshed: string[] = [];
210
+ const stale = new Set<Candidate>();
211
+
212
+ for (const candidate of candidates) {
213
+ if (candidate.witness.length === 0) continue;
214
+ const current = candidate.hadWitness ? readFileSync(candidate.paths.witness, 'utf8') : undefined;
215
+ if (current === candidate.witness) continue;
216
+ if (check) {
217
+ // Without the right witness on disk there is nothing to transform, so this file's
218
+ // compiled modules are not examined further — the answer is already "stale".
219
+ written.push(candidate.paths.witness);
220
+ stale.add(candidate);
221
+ continue;
222
+ }
223
+ mkdirSync(dirname(candidate.paths.witness), { recursive: true });
224
+ writeFileSync(candidate.paths.witness, candidate.witness);
225
+ written.push(candidate.paths.witness);
226
+ (candidate.hadWitness ? refreshed : created).push(candidate.paths.witness);
227
+ }
228
+
229
+ if (created.length > 0) session.created(created);
230
+ if (refreshed.length > 0) session.refresh(refreshed);
231
+
232
+ // -- Phase 3: the compiled modules and the rewrite ------------------------
233
+
234
+ for (const candidate of candidates) {
235
+ if (stale.has(candidate)) continue;
236
+
237
+ if (candidate.witness.length === 0) {
238
+ // The source stopped validating. Its artifacts are removed rather than left behind:
239
+ // a witness with no referent is a validator for code that is gone.
240
+ for (const path of [candidate.paths.witness, candidate.paths.js, candidate.paths.dts]) {
241
+ if (!existsSync(path)) continue;
242
+ deleted.push(path);
243
+ if (!check) rmSync(path);
244
+ }
245
+ const rewritten = rewriteSource({
246
+ sourceFile: candidate.sourceFile,
247
+ code: candidate.code,
248
+ sites: [],
249
+ specifier: candidate.paths.specifier,
250
+ entries: [],
251
+ calleeSources: candidate.calleeSources,
252
+ style: candidate.style,
253
+ });
254
+ emit(candidate.fileName, rewritten);
255
+ continue;
256
+ }
257
+
258
+ const diagnostics = session.diagnostics(candidate.paths.witness);
259
+ if (diagnostics.length > 0) {
260
+ const first = diagnostics[0];
261
+ problems.push(
262
+ `${show(candidate.paths.witness)}: does not typecheck (TS${String(first?.code ?? 0)}: ${first?.text ?? ''}). ` +
263
+ 'A type read out of a file that does not compile is a guess.',
264
+ );
265
+ continue;
266
+ }
267
+
268
+ const errorModule =
269
+ candidate.calleeSources.get('assert') ??
270
+ candidate.calleeSources.get('assertShallow') ??
271
+ candidate.calleeSources.get('assertEquals') ??
272
+ '@zmdb/validator';
273
+ const transformed = transformFile(candidate.paths.witness, candidate.witness, {
274
+ session,
275
+ reflect,
276
+ emit: { errorModule },
277
+ });
278
+ if (transformed.diagnostics.length > 0) {
279
+ for (const diagnostic of transformed.diagnostics) {
280
+ const where = diagnostic.path ? ` at \`${diagnostic.path}\`` : '';
281
+ problems.push(
282
+ `${show(candidate.fileName)}: ${diagnostic.callee ?? 'the transform'} refused${where} — ${diagnostic.reason}`,
283
+ );
284
+ }
285
+ continue;
286
+ }
287
+ if (!transformed.changed) {
288
+ problems.push(
289
+ `${show(candidate.paths.witness)}: the transform left every call in place, so nothing was compiled`,
290
+ );
291
+ continue;
292
+ }
293
+
294
+ const modules = generatedModules({
295
+ sourceName: show(candidate.fileName),
296
+ entries: candidate.entries,
297
+ typeImports: candidate.typeImports,
298
+ calleeSources: candidate.calleeSources,
299
+ style: candidate.style,
300
+ transformed: transformed.code,
301
+ });
302
+ emit(candidate.paths.js, modules.js);
303
+ emit(candidate.paths.dts, modules.dts);
304
+
305
+ emit(
306
+ candidate.fileName,
307
+ rewriteSource({
308
+ sourceFile: candidate.sourceFile,
309
+ code: candidate.code,
310
+ sites: candidate.sites,
311
+ specifier: candidate.paths.specifier,
312
+ entries: candidate.entries,
313
+ calleeSources: candidate.calleeSources,
314
+ style: candidate.style,
315
+ }),
316
+ );
317
+ }
318
+
319
+ function emit(path: string, text: string): void {
320
+ let current: string | undefined;
321
+ try {
322
+ current = readFileSync(path, 'utf8');
323
+ } catch {
324
+ current = undefined;
325
+ }
326
+ if (current === text) return;
327
+ written.push(path);
328
+ if (check) return;
329
+ mkdirSync(dirname(path), { recursive: true });
330
+ writeFileSync(path, text);
331
+ }
332
+
333
+ for (const path of written) log(`${check ? 'stale' : 'wrote'} ${show(path)}`);
334
+ for (const path of deleted) log(`${check ? 'orphan' : 'removed'} ${show(path)}`);
335
+
336
+ return {
337
+ written,
338
+ deleted,
339
+ problems,
340
+ ok: problems.length === 0 && (!check || (written.length === 0 && deleted.length === 0)),
341
+ };
342
+ }
343
+
344
+ function pathKey(path: string): string {
345
+ const value = resolve(path);
346
+ return process.platform === 'win32' ? value.toLowerCase() : value;
347
+ }
348
+
349
+ /** Files this run may rewrite: the project's own TypeScript, minus what it generated. */
350
+ function isCandidate(fileName: string, root: string): boolean {
351
+ if (fileName.includes(`${sep}node_modules${sep}`) || fileName.includes('/node_modules/')) return false;
352
+ if (/\.d\.[cm]?ts$/.test(fileName)) return false;
353
+ if (!/\.[cm]?tsx?$/.test(fileName)) return false;
354
+ if (isGeneratedPath(fileName)) return false;
355
+ // A project can reference files above its own directory; rewriting one would edit a
356
+ // package this run was not pointed at.
357
+ const inside = relative(root, fileName);
358
+ return inside.length > 0 && !inside.startsWith('..') && !isAbsolute(inside);
359
+ }
360
+
361
+ // -----------------------------------------------------------------------------
362
+ // Watch
363
+ // -----------------------------------------------------------------------------
364
+
365
+ export interface WatchOptions extends CodegenOptions {
366
+ /** Quiet period after a change before regenerating. Editors save in bursts. */
367
+ readonly debounceMs?: number | undefined;
368
+ /** Resolves when the watch should stop. Omit to watch until the process ends. */
369
+ readonly until?: Promise<unknown> | undefined;
370
+ }
371
+
372
+ /**
373
+ * Regenerate on every save, on one compiler session.
374
+ *
375
+ * The session is the whole reason this is not "rerun project compilation from a file watcher":
376
+ * reopening the project per keystroke re-reads the config and re-walks the import graph, which
377
+ * is the expensive half of a build. `ReflectSession.refresh` re-checks only what changed.
378
+ */
379
+ export async function watchCodegen(options: WatchOptions): Promise<CodegenResult> {
380
+ const { watch } = await import('node:fs/promises');
381
+ const project = resolve(options.project);
382
+ const root = dirname(project);
383
+ const log = options.log ?? (() => undefined);
384
+ const borrowed = options.session;
385
+ const session = borrowed ?? ReflectSession.open({ project });
386
+ const controller = new AbortController();
387
+ let timer: ReturnType<typeof setTimeout> | undefined;
388
+ let last: CodegenResult;
389
+ let failure: unknown;
390
+ const pending = new Set<string>();
391
+ const stop = (): void => {
392
+ controller.abort();
393
+ };
394
+ void options.until?.then(stop, stop);
395
+
396
+ const flush = (): void => {
397
+ timer = undefined;
398
+ if (controller.signal.aborted) return;
399
+ const changed = [...pending];
400
+ pending.clear();
401
+ if (changed.length === 0) return;
402
+ try {
403
+ const live = changed.filter(path => existsSync(path));
404
+ const created = live.filter(path => session.sourceFile(path) === undefined);
405
+ const refreshed = live.filter(path => session.sourceFile(path) !== undefined);
406
+ const gone = changed.filter(path => !existsSync(path));
407
+ if (created.length > 0) session.created(created);
408
+ if (refreshed.length > 0) session.refresh(refreshed);
409
+ if (gone.length > 0) session.deleted(gone);
410
+ last = run(session, project, options);
411
+ report(last, log);
412
+ } catch (error) {
413
+ failure = error;
414
+ stop();
415
+ }
416
+ };
417
+
418
+ try {
419
+ last = run(session, project, options);
420
+ report(last, log);
421
+ try {
422
+ for await (const event of watch(root, { recursive: true, signal: controller.signal })) {
423
+ const name = typeof event.filename === 'string' ? resolve(root, event.filename) : undefined;
424
+ if (name === undefined || !isCandidate(name, root)) continue;
425
+ pending.add(name);
426
+ if (timer !== undefined) clearTimeout(timer);
427
+ timer = setTimeout(flush, options.debounceMs ?? 60);
428
+ }
429
+ } catch (error) {
430
+ if (!(error instanceof Error) || error.name !== 'AbortError') throw error;
431
+ }
432
+ if (failure !== undefined) throw failure;
433
+ return last;
434
+ } finally {
435
+ stop();
436
+ if (timer !== undefined) clearTimeout(timer);
437
+ pending.clear();
438
+ if (borrowed === undefined) session.close();
439
+ }
440
+ }
441
+
442
+ function report(result: CodegenResult, log: (line: string) => void): void {
443
+ for (const problem of result.problems) log(`error: ${problem}`);
444
+ if (result.problems.length === 0) {
445
+ log(
446
+ result.written.length === 0 && result.deleted.length === 0
447
+ ? 'up to date'
448
+ : `generated ${String(result.written.length)} artifact(s); deleted ${String(result.deleted.length)}`,
449
+ );
450
+ }
451
+ }