@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,690 @@
1
+ // The front door: source text in, source text out.
2
+ //
3
+ // Two paths, and the split is the whole point of Phase 5.
4
+ //
5
+ // `transformFile` is the real one. It asks the compiler what `T` is, reflects the answer
6
+ // into IR, and hands the IR to the emitter. Every type TypeScript can express is
7
+ // therefore either understood exactly or refused by name (REQ-TF-8).
8
+ //
9
+ // `transformCode` is what is left of the old text-based transformer: `validate(tags.X,
10
+ // …)` inlining, which needs no types because the rule is spelled out at the call site.
11
+ // It used to *also* parse type arguments with a 60-line hand-rolled parser, and that is
12
+ // deleted rather than kept as a fallback. `f70186c6` is the reason: the parser read
13
+ // `string[]` as `string` and `number | string` as `number`, so a call was inlined to a
14
+ // check that answers a different question, and a wrong answer that looks right is worse
15
+ // than no answer at all. A call site the checker cannot reach is now left alone.
16
+ //
17
+ // Rewriting is by text offset rather than by AST printing, because `sourceFile.text` is
18
+ // byte-identical to the file on disk (measured) and every other byte of the file — the
19
+ // comments, the formatting, the sourcemap-relevant line breaks — should survive
20
+ // untouched. The price is that the offsets are only valid for the exact text the
21
+ // compiler parsed, so `transformFile` checks that before it trusts one.
22
+
23
+ import type { ToolProvider } from '@zmdb/ai';
24
+ import { type SchemaIR, type ShapeIR, type TypeIR } from '@zmdb/schema/ir';
25
+ import { MAX_REGEX_CACHE_SIZE, validatePatternComplexity } from '@zmdb/validator';
26
+ import { createScanner, LanguageVariant, SyntaxKind } from 'typescript/unstable/ast';
27
+ import {
28
+ isLiteralTypeNode,
29
+ isNumericLiteral,
30
+ isPrefixUnaryExpression,
31
+ isStringLiteral,
32
+ } from 'typescript/unstable/ast/is';
33
+ import type { Type } from 'typescript/unstable/sync';
34
+
35
+ import { Emitter, escapePattern, type EmitOptions } from '../emit/index.js';
36
+ import type { GrpcServiceIR } from '../protobuf/grpc-ir.js';
37
+ import { CALL_OWNERS, findOwnedCallSites, OWNED_CALLEES, type CallSite } from '../reflect/callsites.js';
38
+ import { Reflector, type ReflectOptions } from '../reflect/index.js';
39
+ import type { ReflectSession } from '../reflect/session.js';
40
+
41
+ /**
42
+ * The calls `transformFile` rewrites. Matched by identifier text — see `callsites.ts`.
43
+ *
44
+ * Exported because no-bundler project compilation asks the same question about the same names, and
45
+ * two lists would drift: a callee added here and not there is a call the bundler inlines
46
+ * and the CLI leaves as a runtime walk, which is a silent performance cliff between two
47
+ * paths that are supposed to be equivalent.
48
+ */
49
+ export const CALLEES: ReadonlySet<string> = OWNED_CALLEES;
50
+
51
+ /**
52
+ * What the reflector made of the type argument.
53
+ *
54
+ * Three shapes because there are three questions. The validation callees ask "what does
55
+ * a value of this type look like", which is the structural walk. `toJsonSchema<T>()` asks
56
+ * "which columns does this document describe", which needs per-property optionality and
57
+ * tags and no structure below the first level. `schemaOf<T>()` asks "which table is this",
58
+ * which additionally wants a name, a primary key and the relations — and refuses a type
59
+ * that has no `Table<'name'>` tag, where a document is happy with a `Pick`.
60
+ *
61
+ * Reflecting one and emitting from another would mean re-deriving in the emitter what the
62
+ * checker already knew.
63
+ */
64
+ type Reflected =
65
+ | { readonly kind: 'type'; readonly node: TypeIR }
66
+ | { readonly kind: 'shape'; readonly shape: ShapeIR }
67
+ | { readonly kind: 'schema'; readonly ir: SchemaIR }
68
+ | { readonly kind: 'grpc'; readonly service: GrpcServiceIR }
69
+ | { readonly kind: 'protobuf'; readonly node: TypeIR; readonly name: string };
70
+
71
+ type EmissionDepth =
72
+ | { readonly kind: 'full' }
73
+ | { readonly kind: 'shallow'; readonly value: number }
74
+ | { readonly kind: 'refused'; readonly reason: string };
75
+
76
+ const SHALLOW_CALLEES: ReadonlySet<string> = new Set(['isShallow', 'assertShallow', 'validateShallow']);
77
+ const TOOL_PROVIDERS: ReadonlySet<string> = new Set(['openai', 'openai-strict', 'anthropic', 'gemini', 'json-schema']);
78
+ type ToolProviderTarget = ToolProvider | 'dynamic';
79
+
80
+ /** A call site left alone, and why. Plan D4: the build reports these as errors. */
81
+ export interface TransformDiagnostic {
82
+ readonly fileName: string;
83
+ /** Offset of the call this came from, when it came from one. */
84
+ readonly position?: number;
85
+ readonly callee?: string;
86
+ /** The property chain within the type that reached the refusal. */
87
+ readonly path: string;
88
+ readonly reason: string;
89
+ /** The type as the checker prints it, when that is more use than the path. */
90
+ readonly source?: string;
91
+ }
92
+
93
+ export interface TransformResult {
94
+ readonly code: string;
95
+ readonly changed: boolean;
96
+ readonly diagnostics: readonly TransformDiagnostic[];
97
+ }
98
+
99
+ export interface TransformContext {
100
+ /** One session per build, held open by the caller. See `session.ts`. */
101
+ readonly session: ReflectSession;
102
+ readonly emit?: EmitOptions;
103
+ readonly reflect?: ReflectOptions;
104
+ /** Synthetic test projects can opt into their ambient global call declarations. */
105
+ readonly allowUnboundCallees?: boolean;
106
+ }
107
+
108
+ /**
109
+ * Rewrite every reachable validation call in one file.
110
+ *
111
+ * The file must be part of the session's project and its text must be what the compiler
112
+ * parsed. When either does not hold the offsets are meaningless, so this degrades to
113
+ * `transformCode` and says so, rather than rewriting at a position it guessed.
114
+ */
115
+ export function transformFile(fileName: string, code: string, context: TransformContext): TransformResult {
116
+ const { session } = context;
117
+
118
+ const sourceFile = session.sourceFile(fileName);
119
+ if (!sourceFile) {
120
+ return degrade(fileName, code, 'this file is not part of the TypeScript project the session loaded');
121
+ }
122
+ if (sourceFile.text !== code) {
123
+ // Another plugin got here first, or the watcher is a revision behind. Either way the
124
+ // AST describes text we do not have, and an offset into the wrong string lands in
125
+ // the middle of an identifier.
126
+ return degrade(
127
+ fileName,
128
+ code,
129
+ 'the text handed to the transformer is not the text the compiler parsed, so every offset in it would be a guess',
130
+ );
131
+ }
132
+
133
+ const sites = findOwnedCallSites(sourceFile, session.checker, CALLEES, CALL_OWNERS, {
134
+ allowUnbound: context.allowUnboundCallees === true,
135
+ });
136
+ if (sites.length === 0) {
137
+ const out = transformCode(code);
138
+ return { code: out, changed: out !== code, diagnostics: [] };
139
+ }
140
+
141
+ const emitter = new Emitter(context.emit);
142
+ // One reflector for the file, so two `is<User>(…)` calls agree on `User`'s name and
143
+ // the emitter can then share one hoisted helper between them.
144
+ const reflector = new Reflector(session.checker, sourceFile, context.reflect);
145
+ const diagnostics: TransformDiagnostic[] = [];
146
+ const rewriter = new Rewriter(code);
147
+ let rewritten = 0;
148
+
149
+ // Back to front. `findCallSites` walks pre-order, so reversing puts the innermost and
150
+ // last call first, which is what keeps the offsets of everything before it valid.
151
+ for (const site of sites.toReversed()) {
152
+ const position = site.node.getStart();
153
+ const reflectedAt = reflector.diagnostics.length;
154
+ const emittedAt = emitter.diagnostics.length;
155
+ const depth = emissionDepth(site);
156
+ if (depth.kind === 'refused') {
157
+ diagnostics.push({
158
+ fileName,
159
+ position,
160
+ callee: site.callee,
161
+ path: '',
162
+ reason: depth.reason,
163
+ });
164
+ continue;
165
+ }
166
+
167
+ const type = session.checker.getTypeFromTypeNode(site.typeArgument);
168
+ if (!type) {
169
+ // The checker has no type for a node it parsed, which in practice means the file
170
+ // has an error in it. Rewriting from a type the compiler could not resolve is the
171
+ // one thing this path exists to avoid.
172
+ diagnostics.push({
173
+ fileName,
174
+ position,
175
+ callee: site.callee,
176
+ path: '',
177
+ reason: 'the compiler could not resolve this type argument; the file probably does not compile',
178
+ });
179
+ continue;
180
+ }
181
+ const reflected: Reflected = reflect(reflector, site.callee, type);
182
+
183
+ const provider = site.callee === 'toolFor' ? toolProvider(site) : undefined;
184
+ if (site.callee === 'toolFor' && provider === undefined) {
185
+ diagnostics.push({
186
+ fileName,
187
+ position,
188
+ callee: site.callee,
189
+ path: '',
190
+ reason: '`toolFor<T>` needs a provider argument',
191
+ });
192
+ continue;
193
+ }
194
+ if (site.callee === 'toolFor' && site.node.arguments[1] === undefined) {
195
+ diagnostics.push({
196
+ fileName,
197
+ position,
198
+ callee: site.callee,
199
+ path: '',
200
+ reason: '`toolFor<T>` needs a tool name',
201
+ });
202
+ continue;
203
+ }
204
+
205
+ const refusals = reflector.diagnostics.slice(reflectedAt);
206
+ if (refusals.length > 0) {
207
+ // The type is only partly understood, so nothing is emitted from it. This is the
208
+ // f70186c6 rule applied to the checker-driven path: partial knowledge produces a
209
+ // named build error, never a partial check.
210
+ for (const refusal of refusals) diagnostics.push({ fileName, position, callee: site.callee, ...refusal });
211
+ continue;
212
+ }
213
+
214
+ const replacement = emitFor(
215
+ emitter,
216
+ site,
217
+ reflected,
218
+ rewriter,
219
+ depth.kind === 'shallow' ? depth.value : undefined,
220
+ provider,
221
+ );
222
+ if (replacement === undefined) {
223
+ const emitted = emitter.diagnostics.slice(emittedAt);
224
+ if (emitted.length === 0) {
225
+ diagnostics.push({
226
+ fileName,
227
+ position,
228
+ callee: site.callee,
229
+ path: '',
230
+ reason: `\`${site.callee}<T>()\` needs a value to check`,
231
+ });
232
+ }
233
+ for (const refusal of emitted) diagnostics.push({ fileName, position, callee: site.callee, ...refusal });
234
+ continue;
235
+ }
236
+
237
+ rewriter.replace(position, site.node.end, replacement);
238
+ rewritten += 1;
239
+ }
240
+
241
+ let out = transformCode(rewriter.text);
242
+ // No prelude when nothing was rewritten. A refused site can still have hoisted a helper
243
+ // on its way to the refusal — `random<T>` reserves its integer sampler before it
244
+ // discovers the `pattern` it cannot invert — and emitting that alone would leave dead
245
+ // code in a file the transformer decided not to touch.
246
+ if (rewritten > 0 && emitter.hasPrelude) out = withPrelude(out, emitter.prelude());
247
+ return { code: out, changed: out !== code, diagnostics };
248
+ }
249
+
250
+ function emissionDepth(site: CallSite): EmissionDepth {
251
+ if (!SHALLOW_CALLEES.has(site.callee)) return { kind: 'full' };
252
+
253
+ const argument = site.node.typeArguments?.[1];
254
+ if (argument === undefined) return { kind: 'shallow', value: 1 };
255
+ if (!isLiteralTypeNode(argument)) {
256
+ return { kind: 'refused', reason: '`depth` must be a positive integer literal type' };
257
+ }
258
+
259
+ const literal = argument.literal;
260
+ let value: number | undefined;
261
+ if (isNumericLiteral(literal)) {
262
+ value = Number(literal.text);
263
+ } else if (
264
+ isPrefixUnaryExpression(literal) &&
265
+ (literal.operator === SyntaxKind.PlusToken || literal.operator === SyntaxKind.MinusToken) &&
266
+ isNumericLiteral(literal.operand)
267
+ ) {
268
+ const magnitude = Number(literal.operand.text);
269
+ value = literal.operator === SyntaxKind.MinusToken ? -magnitude : magnitude;
270
+ }
271
+
272
+ if (value === undefined) {
273
+ return { kind: 'refused', reason: '`depth` must be a positive integer literal type' };
274
+ }
275
+ if (!Number.isInteger(value) || value <= 0) {
276
+ return { kind: 'refused', reason: '`depth` must be a positive integer literal' };
277
+ }
278
+ return { kind: 'shallow', value };
279
+ }
280
+
281
+ function degrade(fileName: string, code: string, reason: string): TransformResult {
282
+ const out = transformCode(code);
283
+ return { code: out, changed: out !== code, diagnostics: [{ fileName, path: '', reason }] };
284
+ }
285
+
286
+ function reflect(reflector: Reflector, callee: string, type: Type): Reflected {
287
+ switch (callee) {
288
+ case 'toJsonSchema':
289
+ return { kind: 'shape', shape: reflector.shapeIR(type) };
290
+ case 'schemaOf':
291
+ case 'toolFor':
292
+ return { kind: 'schema', ir: reflector.schemaIR(type) };
293
+ case 'grpcDescriptor':
294
+ case 'loadGrpcService':
295
+ return { kind: 'grpc', service: reflector.grpcServiceIR(type) };
296
+ case 'protoDescriptor':
297
+ case 'protoDecode':
298
+ case 'protoEncode':
299
+ return { kind: 'protobuf', node: reflector.protobufIR(type), name: protobufName(type) };
300
+ default:
301
+ return { kind: 'type', node: reflector.typeIR(type) };
302
+ }
303
+ }
304
+
305
+ function emitFor(
306
+ emitter: Emitter,
307
+ site: CallSite,
308
+ reflected: Reflected,
309
+ rewriter: Rewriter,
310
+ maxDepth?: number,
311
+ provider?: ToolProviderTarget,
312
+ ) {
313
+ if (site.callee === 'toolFor' && reflected.kind === 'schema' && provider !== undefined) {
314
+ const providerArgument = site.node.arguments[0];
315
+ const name = site.node.arguments[1];
316
+ if (providerArgument === undefined || name === undefined) return undefined;
317
+ const providerExpression = rewriter.slice(providerArgument.getStart(), providerArgument.end);
318
+ const nameExpression = rewriter.slice(name.getStart(), name.end);
319
+ const options = site.node.arguments[2];
320
+ const optionsExpression = options === undefined ? undefined : rewriter.slice(options.getStart(), options.end);
321
+ if (provider !== 'dynamic') {
322
+ const parameters = emitter.emitToolSchema(reflected.ir, provider);
323
+ return parameters === undefined ? undefined : toolFrame(provider, nameExpression, parameters, optionsExpression);
324
+ }
325
+ const documents = toolProviders().map(target => {
326
+ const parameters = emitter.emitToolSchema(reflected.ir, target);
327
+ return parameters === undefined ? undefined : { provider: target, parameters };
328
+ });
329
+ if (documents.some(document => document === undefined)) return undefined;
330
+ return toolFrameDynamic(
331
+ providerExpression,
332
+ nameExpression,
333
+ documents.filter(document => document !== undefined),
334
+ optionsExpression,
335
+ );
336
+ }
337
+
338
+ // Both of these are the answer itself, so there is nothing to check and no argument
339
+ // to read.
340
+ if (reflected.kind === 'shape') return emitter.emitJsonSchema(reflected.shape);
341
+ if (reflected.kind === 'schema') return emitter.emitSchemaValue(reflected.ir);
342
+ if (reflected.kind === 'grpc') {
343
+ const names = grpcNames(site, emitter);
344
+ if (names === undefined) return undefined;
345
+ return site.callee === 'grpcDescriptor'
346
+ ? emitter.emitGrpcDescriptor(reflected.service, names.service, names.pkg)
347
+ : emitter.emitGrpcService(reflected.service, names.service, names.pkg);
348
+ }
349
+ if (reflected.kind === 'protobuf' && site.callee === 'protoDescriptor') {
350
+ return emitter.emitProtoDescriptor(reflected.node, reflected.name);
351
+ }
352
+
353
+ const node = reflected.node;
354
+ if (site.callee === 'random') return emitter.emitRandom(node);
355
+
356
+ const argument = site.node.arguments[0];
357
+ if (!argument) return undefined;
358
+ // Read through the rewriter, not the original text: in `assert<A>(is<B>(x))` the inner
359
+ // call has already been replaced, and taking the original text here would carry a live
360
+ // `is<B>(x)` into the output and silently undo it.
361
+ const expression = rewriter.slice(argument.getStart(), argument.end);
362
+
363
+ switch (site.callee) {
364
+ case 'protoDecode':
365
+ return emitter.emitProtoDecode(node, reflected.kind === 'protobuf' ? reflected.name : 'Message', expression);
366
+ case 'protoEncode':
367
+ return emitter.emitProtoEncode(node, reflected.kind === 'protobuf' ? reflected.name : 'Message', expression);
368
+ case 'is':
369
+ return emitter.emitIs(node, expression);
370
+ case 'isShallow':
371
+ return emitter.emitIs(node, expression, maxDepth);
372
+ case 'equals':
373
+ return emitter.emitEquals(node, expression);
374
+ case 'assert':
375
+ return emitter.emitAssert(node, expression, false);
376
+ case 'assertShallow':
377
+ return emitter.emitAssert(node, expression, false, maxDepth);
378
+ case 'assertEquals':
379
+ return emitter.emitAssert(node, expression, true);
380
+ case 'validate':
381
+ return emitter.emitValidate(node, expression);
382
+ case 'validateShallow':
383
+ return emitter.emitValidate(node, expression, false, maxDepth);
384
+ default:
385
+ return undefined;
386
+ }
387
+ }
388
+
389
+ function toolProvider(site: CallSite): ToolProviderTarget | undefined {
390
+ const argument = site.node.arguments[0];
391
+ if (argument === undefined) return undefined;
392
+ if (!isStringLiteral(argument)) return 'dynamic';
393
+ if (!TOOL_PROVIDERS.has(argument.text)) return undefined;
394
+ switch (argument.text) {
395
+ case 'openai':
396
+ case 'openai-strict':
397
+ case 'anthropic':
398
+ case 'gemini':
399
+ case 'json-schema':
400
+ return argument.text;
401
+ }
402
+ }
403
+
404
+ function toolProviders(): readonly ToolProvider[] {
405
+ return ['openai', 'openai-strict', 'anthropic', 'gemini', 'json-schema'];
406
+ }
407
+
408
+ function toolFrame(provider: ToolProvider, name: string, parameters: string, options: string | undefined): string {
409
+ const description = options === undefined ? '' : '...(_o?.description ? { description: _o.description } : {}), ';
410
+ let body: string;
411
+ switch (provider) {
412
+ case 'openai':
413
+ body = `{ type: "function", function: { name: _n, ${description}parameters: ${parameters} } }`;
414
+ break;
415
+ case 'openai-strict':
416
+ body = `{ type: "function", function: { name: _n, ${description}strict: true, parameters: ${parameters} } }`;
417
+ break;
418
+ case 'anthropic':
419
+ body = `{ name: _n, ${description}input_schema: ${parameters} }`;
420
+ break;
421
+ case 'gemini':
422
+ case 'json-schema':
423
+ body = `{ name: _n, ${description}parameters: ${parameters} }`;
424
+ break;
425
+ }
426
+ return options === undefined ? `((_n) => (${body}))(${name})` : `((_n, _o) => (${body}))(${name}, ${options})`;
427
+ }
428
+
429
+ function toolFrameDynamic(
430
+ provider: string,
431
+ name: string,
432
+ documents: readonly { readonly provider: ToolProvider; readonly parameters: string }[],
433
+ options: string | undefined,
434
+ ): string {
435
+ const cases = documents
436
+ .map(document => {
437
+ const framed = toolFrame(document.provider, '_n', document.parameters, options === undefined ? undefined : '_o');
438
+ return `case ${JSON.stringify(document.provider)}: return ${framed};`;
439
+ })
440
+ .join(' ');
441
+ const body = `switch (_p) { ${cases} } throw new Error(\`unsupported tool provider \${String(_p)}\`);`;
442
+ return options === undefined
443
+ ? `((_p, _n) => { ${body} })(${provider}, ${name})`
444
+ : `((_p, _n, _o) => { ${body} })(${provider}, ${name}, ${options})`;
445
+ }
446
+
447
+ function grpcNames(site: CallSite, emitter: Emitter): { readonly service: string; readonly pkg: string } | undefined {
448
+ const service = site.node.arguments[0];
449
+ const pkg = site.node.arguments[1];
450
+ if (service === undefined || pkg === undefined || site.node.arguments.length !== 2) {
451
+ return emitter.refuse('', `\`${site.callee}<S>()\` needs exactly two string literals: service and package`);
452
+ }
453
+ if (!isStringLiteral(service)) {
454
+ return emitter.refuse('service', 'the gRPC service name must be a string literal so the build artifact is stable');
455
+ }
456
+ if (!isStringLiteral(pkg)) {
457
+ return emitter.refuse('package', 'the gRPC package name must be a string literal so the build artifact is stable');
458
+ }
459
+ return { service: service.text, pkg: pkg.text };
460
+ }
461
+
462
+ function protobufName(type: Type): string {
463
+ const alias = type.getAliasSymbol()?.name;
464
+ if (alias !== undefined && alias !== '__type') return alias;
465
+ const symbol = type.getSymbol()?.name;
466
+ return symbol === undefined || symbol === '__type' ? 'Message' : symbol;
467
+ }
468
+
469
+ /** Hoisted helpers go at the top of the module, but after a shebang if there is one. */
470
+ function withPrelude(code: string, prelude: string): string {
471
+ if (!code.startsWith('#!')) return `${prelude}\n${code}`;
472
+ const newline = code.indexOf('\n');
473
+ if (newline === -1) return `${code}\n${prelude}\n`;
474
+ return `${code.slice(0, newline + 1)}${prelude}\n${code.slice(newline + 1)}`;
475
+ }
476
+
477
+ /**
478
+ * Text edits in original coordinates, applied back to front, with one twist: `slice`
479
+ * reads the *current* text, so an outer call sees an inner one already rewritten.
480
+ *
481
+ * The bookkeeping is a list of applied edits and their length deltas. Because AST spans
482
+ * nest and never partially overlap, and because callers work backwards, every applied
483
+ * edit is either inside the span being asked about or entirely after it — which is what
484
+ * makes a single sum the right answer.
485
+ */
486
+ export class Rewriter {
487
+ #text: string;
488
+ /** Applied edits in original coordinates. Nested ones are folded into their parent. */
489
+ #edits: { start: number; end: number; delta: number }[] = [];
490
+
491
+ constructor(text: string) {
492
+ this.#text = text;
493
+ }
494
+
495
+ get text(): string {
496
+ return this.#text;
497
+ }
498
+
499
+ /** A span of the original text, as it reads now. */
500
+ slice(start: number, end: number): string {
501
+ return this.#text.slice(start, this.#current(start, end));
502
+ }
503
+
504
+ /** Replace a span of the original text. Call in descending order of `start`. */
505
+ replace(start: number, end: number, text: string): void {
506
+ const current = this.#current(start, end);
507
+ this.#text = this.#text.slice(0, start) + text + this.#text.slice(current);
508
+ // `text` was built from the current buffer, so it already contains every nested
509
+ // edit. They stop being separate deltas and become part of this one; leaving them
510
+ // in the list would count their lengths twice.
511
+ this.#edits = this.#edits.filter(edit => edit.start < start || edit.end > end);
512
+ this.#edits.push({ start, end, delta: text.length - (end - start) });
513
+ }
514
+
515
+ #current(start: number, end: number): number {
516
+ let shift = 0;
517
+ for (const edit of this.#edits) {
518
+ if (edit.start >= start && edit.end <= end) shift += edit.delta;
519
+ }
520
+ return end + shift;
521
+ }
522
+ }
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // The no-checker path: `validate(tags.X, expr)`
526
+ // ---------------------------------------------------------------------------
527
+
528
+ function splitTopLevelComma(s: string): [string, string] {
529
+ let depth = 0;
530
+ for (let k = 0; k < s.length; k++) {
531
+ const ch = s[k] ?? '';
532
+ if (ch === '(' || ch === '[') depth++;
533
+ else if (ch === ')' || ch === ']') depth--;
534
+ else if (ch === ',' && depth === 0) return [s.slice(0, k), s.slice(k + 1)];
535
+ }
536
+ return [s, ''];
537
+ }
538
+
539
+ function splitArgs(s: string): string[] {
540
+ const parts: string[] = [];
541
+ let depth = 0;
542
+ let cur = '';
543
+ for (const ch of s) {
544
+ if (ch === '(' || ch === '[') depth++;
545
+ else if (ch === ')' || ch === ']') depth--;
546
+ if (ch === ',' && depth === 0) {
547
+ parts.push(cur.trim());
548
+ cur = '';
549
+ } else cur += ch;
550
+ }
551
+ if (cur.trim()) parts.push(cur.trim());
552
+ return parts;
553
+ }
554
+
555
+ function inlineCheck(ruleSrc: string, expr: string, ensureRegexCache?: () => void): string {
556
+ const m = /^tags\.(\w+)\((.*)\)$/s.exec(ruleSrc);
557
+ if (!m) return `validate(${ruleSrc}, ${expr})`;
558
+ const kind = m[1] ?? '';
559
+ const args = (m[2] ?? '').trim();
560
+ switch (kind) {
561
+ case 'Min':
562
+ return `(typeof ${expr} === "number" && ${expr} >= ${args})`;
563
+ case 'Max':
564
+ return `(typeof ${expr} === "number" && ${expr} <= ${args})`;
565
+ case 'MinLength':
566
+ return `(typeof ${expr} === "string" && ${expr}.length >= ${args})`;
567
+ case 'MaxLength':
568
+ return `(typeof ${expr} === "string" && ${expr}.length <= ${args})`;
569
+ case 'Pattern': {
570
+ let raw = args.trim();
571
+ const first = raw[0] ?? '';
572
+ const last = raw.length > 0 ? (raw[raw.length - 1] ?? '') : '';
573
+ const isQuoted =
574
+ raw.length >= 2 &&
575
+ ((first === '"' && last === '"') || (first === "'" && last === "'") || (first === '`' && last === '`'));
576
+
577
+ if (!isQuoted || (first === '`' && raw.includes('${'))) {
578
+ if (ensureRegexCache) {
579
+ ensureRegexCache();
580
+ return `(typeof ${expr} === "string" && _getRegExp(${raw}).test(${expr}))`;
581
+ }
582
+ return `(typeof ${expr} === "string" && new RegExp(${raw}).test(${expr}))`;
583
+ }
584
+
585
+ raw = raw.slice(1, -1);
586
+ const re = escapePattern(raw);
587
+ validatePatternComplexity(re);
588
+ return `(typeof ${expr} === "string" && /${re}/.test(${expr}))`;
589
+ }
590
+ case 'Enum': {
591
+ const values = splitArgs(args);
592
+ return `(${values.map(v => `${expr} === ${v}`).join(' || ')})`;
593
+ }
594
+ default:
595
+ return `validate(${ruleSrc}, ${expr})`;
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Inline `validate(tags.X(…), expr)`, and nothing else.
601
+ *
602
+ * A rule spelled at the call site needs no type information, which is why this survives
603
+ * without a compiler: the scanner is here only to avoid rewriting the inside of a string
604
+ * literal or a comment. `validate<T>(expr)` and every other type-argument form are left
605
+ * for `transformFile`; this function does not look at type arguments at all beyond
606
+ * skipping past them.
607
+ */
608
+ export function transformCode(code: string): string {
609
+ const scanner = createScanner(false, LanguageVariant.Standard);
610
+ scanner.setText(code);
611
+
612
+ let out = '';
613
+ let lastPos = 0;
614
+
615
+ const hoisted: string[] = [];
616
+ let hasRegexCache = false;
617
+
618
+ const ensureRegexCache = () => {
619
+ if (!hasRegexCache) {
620
+ hasRegexCache = true;
621
+ hoisted.push(
622
+ `const _MAX_REGEX_CACHE_SIZE = ${MAX_REGEX_CACHE_SIZE};\nconst _regexCache = new Map();\nfunction _getRegExp(p) { let re = _regexCache.get(p); if (re) { _regexCache.delete(p); _regexCache.set(p, re); return re; } if (_regexCache.size >= _MAX_REGEX_CACHE_SIZE) { const k = _regexCache.keys().next().value; if (k !== undefined) _regexCache.delete(k); } re = new RegExp(p); _regexCache.set(p, re); return re; }`,
623
+ );
624
+ }
625
+ };
626
+
627
+ let token = scanner.scan();
628
+ while (token !== SyntaxKind.EndOfFile) {
629
+ const tokenStart = scanner.getTokenStart();
630
+ const tokenEnd = scanner.getTokenEnd();
631
+
632
+ if (tokenStart < lastPos) {
633
+ token = scanner.scan();
634
+ continue;
635
+ }
636
+
637
+ if (scanner.getTokenText() === 'validate') {
638
+ const prevChar = tokenStart > 0 ? (code[tokenStart - 1] ?? '') : '';
639
+ if (prevChar && /[A-Za-z0-9_$.]/.test(prevChar)) {
640
+ token = scanner.scan();
641
+ continue;
642
+ }
643
+
644
+ let i = tokenEnd;
645
+ while (i < code.length && /\s/.test(code[i] ?? '')) i++;
646
+
647
+ // A type argument means this is `validate<T>(…)`, which belongs to the checker.
648
+ let typed = false;
649
+ if (i < code.length && code[i] === '<') {
650
+ let depth = 1;
651
+ i++;
652
+ while (i < code.length && depth > 0) {
653
+ if (code[i] === '<') depth++;
654
+ else if (code[i] === '>') depth--;
655
+ i++;
656
+ }
657
+ typed = depth === 0;
658
+ }
659
+
660
+ while (i < code.length && /\s/.test(code[i] ?? '')) i++;
661
+
662
+ if (!typed && i < code.length && code[i] === '(') {
663
+ let depth = 1;
664
+ const argStart = i + 1;
665
+ i++;
666
+ while (i < code.length && depth > 0) {
667
+ if (code[i] === '(') depth++;
668
+ else if (code[i] === ')') depth--;
669
+ i++;
670
+ }
671
+ if (depth === 0) {
672
+ const [ruleSrc, exprSrc] = splitTopLevelComma(code.slice(argStart, i - 1));
673
+ if (ruleSrc && exprSrc) {
674
+ out += code.slice(lastPos, tokenStart);
675
+ out += inlineCheck(ruleSrc.trim(), exprSrc.trim(), ensureRegexCache);
676
+ lastPos = i;
677
+ }
678
+ }
679
+ }
680
+ }
681
+
682
+ token = scanner.scan();
683
+ }
684
+
685
+ out += code.slice(lastPos);
686
+ if (hoisted.length > 0) {
687
+ return hoisted.join('\n') + '\n' + out;
688
+ }
689
+ return out;
690
+ }