@assistant-ui/x-generative-compiler 0.0.2 → 0.0.4

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/compile.ts CHANGED
@@ -1,8 +1,13 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import * as nodePath from "node:path";
1
4
  import { parse } from "@babel/parser";
2
5
  import _traverse, { type NodePath } from "@babel/traverse";
3
6
  import _generate from "@babel/generator";
4
7
  import * as t from "@babel/types";
8
+ import { satisfies } from "semver";
5
9
  import { DIRECTIVE, type Target } from "./constants";
10
+ import pkgJson from "../package.json" with { type: "json" };
6
11
 
7
12
  // @babel/traverse and @babel/generator are CJS; their default export is the
8
13
  // function itself under some interop and `{ default }` under others.
@@ -13,7 +18,39 @@ const generate = (
13
18
  typeof _generate === "function" ? _generate : (_generate as any).default
14
19
  ) as typeof _generate;
15
20
 
16
- export type ToolType = "frontend" | "backend" | "human";
21
+ export type ToolType = "frontend" | "backend" | "human" | "provider";
22
+
23
+ /** The required wrapper around a toolkit's tools (stripped at build time). */
24
+ const TOOLKIT_WRAPPER = "defineToolkit";
25
+ /** The helper that produces MCP-only toolkit fragments. */
26
+ const MCP_TOOLKIT_WRAPPER = "defineMcpToolkit";
27
+ /** The required wrapper around a generative-UI library (stripped at build time). */
28
+ const COMPONENTS_WRAPPER = "defineGenerativeComponents";
29
+ /** The core package whose metadata declares supported compiler versions. */
30
+ const CORE_PACKAGE = "@assistant-ui/core";
31
+ /** This package, checked against core's compatibility range. */
32
+ const COMPILER_PACKAGE = "@assistant-ui/x-generative-compiler";
33
+ /** Packages that re-export core's generative markers. */
34
+ const DISTRIBUTION_PACKAGES = [
35
+ CORE_PACKAGE,
36
+ "@assistant-ui/react",
37
+ "@assistant-ui/react-native",
38
+ "@assistant-ui/react-ink",
39
+ ] as const;
40
+ /**
41
+ * The class whose instances expose split-by-condition tools (`present()`,
42
+ * `promptUser()`). A toolkit entry that calls a method on one of these passes
43
+ * through untouched — the library, not this compiler, routes its halves.
44
+ */
45
+ const GENERATIVE_FACTORY = "JSONGenerativeUI";
46
+
47
+ /** Mutable per-build outcomes the toolkit pass reports back for directive/guard injection. */
48
+ interface TargetFlags {
49
+ /** A `render` survived on a client build (→ emit `"use client"`). */
50
+ keptRender: boolean;
51
+ /** A backend `execute` survived on a server build (→ emit `import "server-only"`). */
52
+ keptBackendExecute: boolean;
53
+ }
17
54
 
18
55
  export interface CompileOptions {
19
56
  /** Which build target to emit. */
@@ -93,52 +130,64 @@ export function compileGenerative(
93
130
  );
94
131
  }
95
132
 
96
- const object = findDefaultExportObject(ast, filename);
133
+ ensureCompilerCompatibleWithCore(ast, filename);
97
134
 
98
- let keptRender = false;
99
- let keptBackendExecute = false;
135
+ // A module may hold several `defineToolkit(...)` / `defineGenerativeComponents(...)`
136
+ // calls anywhere (e.g. a library built inside `new JSONGenerativeUI(...)` plus
137
+ // the toolkit that exposes it). Tools may reference a `JSONGenerativeUI`
138
+ // instance's `present()`/`promptUser()`, which the library — not this compiler —
139
+ // splits across builds via export conditions; collect those instance names so
140
+ // such entries pass through.
141
+ ensureDefaultExport(ast, filename);
142
+ const generativeInstances = collectGenerativeInstances(ast);
143
+ const safeToolkitSpreads = collectSafeToolkitSpreads(ast, filename);
100
144
 
101
- for (const entry of object.properties) {
102
- const value = entryValue(entry);
103
- if (!value) {
104
- // A non-inline tool (spread, method, or a call like `makeTool()`) can't be
105
- // analyzed, so its `execute` would pass through to the client unstripped.
106
- throw new GenerativeCompileError(
107
- "each tool must be an inline object literal (`name: { ... }`) so its " +
108
- "`execute` can be routed",
109
- filename,
110
- );
111
- }
145
+ const flags: TargetFlags = { keptRender: false, keptBackendExecute: false };
112
146
 
113
- // Nature is inferred from `execute` (see inferToolType), not an authored
114
- // `type`. The resolved type is written back below so the runtime keeps it.
115
- const type = inferToolType(value, filename);
116
- const hasRender = !!findMember(value, "render");
117
- const execute = findMember(value, "execute");
147
+ traverse(ast, {
148
+ CallExpression(path: NodePath<t.CallExpression>) {
149
+ const callee = path.node.callee;
150
+ const object = t.isObjectExpression(path.node.arguments[0])
151
+ ? path.node.arguments[0]
152
+ : null;
118
153
 
119
- if ((type === "frontend" || type === "human") && !hasRender) {
120
- throw new GenerativeCompileError(
121
- `a ${type} tool must declare a \`render\` (it has no server execute to show otherwise)`,
122
- filename,
123
- );
124
- }
154
+ if (t.isIdentifier(callee, { name: COMPONENTS_WRAPPER })) {
155
+ if (!object) {
156
+ throw new GenerativeCompileError(
157
+ `${COMPONENTS_WRAPPER}() takes an inline object literal of components`,
158
+ filename,
159
+ );
160
+ }
161
+ compileComponents(object, target, flags, filename);
162
+ // Unwrap the authoring helper to the bare library object so its import
163
+ // can be pruned.
164
+ path.replaceWith(object);
165
+ path.skip();
166
+ return;
167
+ }
125
168
 
126
- if (target === "client") {
127
- // A frontend execute stays (its `"use client"` marker is no longer needed
128
- // once the module is client); a backend execute and a human `hitl()`
129
- // sentinel are both dropped.
130
- if (execute && type === "frontend") stripUseClient(execute);
131
- else if (execute) removeMember(value, "execute");
132
- if (hasRender) keptRender = true;
133
- } else {
134
- // server: render is never needed; only a backend execute survives.
135
- if (hasRender) removeMember(value, "render");
136
- if (execute && type !== "backend") removeMember(value, "execute");
137
- if (execute && type === "backend") keptBackendExecute = true;
138
- }
169
+ if (t.isIdentifier(callee, { name: TOOLKIT_WRAPPER })) {
170
+ if (!object) {
171
+ throw new GenerativeCompileError(
172
+ `${TOOLKIT_WRAPPER}() takes an inline object literal of tools`,
173
+ filename,
174
+ );
175
+ }
176
+ compileToolkit(
177
+ object,
178
+ target,
179
+ generativeInstances,
180
+ safeToolkitSpreads,
181
+ flags,
182
+ filename,
183
+ );
184
+ path.replaceWith(object);
185
+ path.skip();
186
+ }
187
+ },
188
+ });
139
189
 
140
- setToolType(value, type);
141
- }
190
+ const { keptRender, keptBackendExecute } = flags;
142
191
 
143
192
  pruneUnused(ast);
144
193
 
@@ -174,61 +223,822 @@ export function compileGenerative(
174
223
  return { code: result.code, map: result.map };
175
224
  }
176
225
 
177
- function findDefaultExportObject(
226
+ interface PackageJson {
227
+ name?: string;
228
+ version?: string;
229
+ optionalDevDependencies?: Record<string, string>;
230
+ }
231
+
232
+ const checkedCorePackageJsonPaths = new Set<string>();
233
+
234
+ // This compiler's own version, inlined from package.json at build time. Read via
235
+ // an import (not by walking the filesystem at runtime) so the literal survives
236
+ // being bundled into a host package like `@assistant-ui/metro`, where no
237
+ // standalone `@assistant-ui/x-generative-compiler` sits on disk to walk up to.
238
+ const COMPILER_VERSION = pkgJson.version;
239
+
240
+ function ensureCompilerCompatibleWithCore(
178
241
  ast: t.File,
179
242
  filename: string | undefined,
180
- ): t.ObjectExpression {
181
- let object: t.ObjectExpression | null = null;
182
- let sawDefault = false;
243
+ ): void {
244
+ const corePackageJsonPath = resolveCorePackageJson(ast, filename);
245
+ if (
246
+ !corePackageJsonPath ||
247
+ checkedCorePackageJsonPaths.has(corePackageJsonPath)
248
+ ) {
249
+ return;
250
+ }
183
251
 
184
- for (const stmt of ast.program.body) {
185
- if (!t.isExportDefaultDeclaration(stmt)) continue;
186
- sawDefault = true;
187
- object = unwrapDefineToolkit(stmt.declaration);
188
- // Emit the bare object literal, dropping the `defineToolkit(...)` wrapper
189
- // (and the import it pulled).
190
- if (object) stmt.declaration = object;
252
+ const corePackageJson = readPackageJson(corePackageJsonPath);
253
+ const range = corePackageJson?.optionalDevDependencies?.[COMPILER_PACKAGE];
254
+ if (!range) {
255
+ checkedCorePackageJsonPaths.add(corePackageJsonPath);
256
+ return;
257
+ }
258
+
259
+ let compatible = false;
260
+ try {
261
+ compatible = satisfies(COMPILER_VERSION, range, {
262
+ includePrerelease: true,
263
+ });
264
+ } catch {
265
+ throw new GenerativeCompileError(
266
+ `${CORE_PACKAGE}@${corePackageJson.version ?? "unknown"} declares an ` +
267
+ `invalid optionalDevDependencies range for ${COMPILER_PACKAGE}: ` +
268
+ JSON.stringify(range),
269
+ filename,
270
+ );
271
+ }
272
+
273
+ if (!compatible) {
274
+ throw new GenerativeCompileError(
275
+ `${CORE_PACKAGE}@${corePackageJson.version ?? "unknown"} requires ` +
276
+ `${COMPILER_PACKAGE} ${range}, but the current compiler is ` +
277
+ `${COMPILER_VERSION}. Update @assistant-ui/next, @assistant-ui/vite, ` +
278
+ "or @assistant-ui/metro so their compiler satisfies the core package's " +
279
+ "optionalDevDependencies range.",
280
+ filename,
281
+ );
282
+ }
283
+
284
+ checkedCorePackageJsonPaths.add(corePackageJsonPath);
285
+ }
286
+
287
+ function resolveCorePackageJson(
288
+ ast: t.File,
289
+ filename: string | undefined,
290
+ ): string | null {
291
+ for (const packageName of collectImportedDistributionPackages(ast)) {
292
+ const packageJsonPath = resolvePackageJson(packageName, filename);
293
+ if (!packageJsonPath) continue;
294
+ if (packageName === CORE_PACKAGE) return packageJsonPath;
295
+
296
+ const corePackageJsonPath = resolvePackageJson(
297
+ CORE_PACKAGE,
298
+ packageJsonPath,
299
+ );
300
+ if (corePackageJsonPath) return corePackageJsonPath;
191
301
  }
192
302
 
193
- if (!sawDefault) {
303
+ return null;
304
+ }
305
+
306
+ function collectImportedDistributionPackages(ast: t.File): Set<string> {
307
+ const packages = new Set<string>();
308
+
309
+ for (const statement of ast.program.body) {
310
+ const source =
311
+ (t.isImportDeclaration(statement) ||
312
+ t.isExportNamedDeclaration(statement) ||
313
+ t.isExportAllDeclaration(statement)) &&
314
+ statement.source
315
+ ? statement.source.value
316
+ : null;
317
+ if (!source) continue;
318
+
319
+ const packageName = packageNameFromSpecifier(source);
320
+ if (packageName) packages.add(packageName);
321
+ }
322
+
323
+ return packages;
324
+ }
325
+
326
+ function packageNameFromSpecifier(specifier: string): string | null {
327
+ for (const packageName of DISTRIBUTION_PACKAGES) {
328
+ if (specifier === packageName || specifier.startsWith(`${packageName}/`)) {
329
+ return packageName;
330
+ }
331
+ }
332
+
333
+ return null;
334
+ }
335
+
336
+ function resolvePackageJson(
337
+ packageName: string,
338
+ filename: string | undefined,
339
+ ): string | null {
340
+ const requirePath = normalizeRequirePath(filename);
341
+ const require = createRequire(requirePath);
342
+
343
+ try {
344
+ return findPackageJson(require.resolve(packageName), packageName);
345
+ } catch {
346
+ return findPackageJsonFromNodeModules(packageName, requirePath);
347
+ }
348
+ }
349
+
350
+ function normalizeRequirePath(filename: string | undefined): string {
351
+ return cleanAbsoluteFilename(filename) ?? import.meta.url;
352
+ }
353
+
354
+ /** Strips a `?query`/`#hash` suffix and returns the path only if it is absolute. */
355
+ function cleanAbsoluteFilename(filename: string | undefined): string | null {
356
+ if (!filename) return null;
357
+ const clean = filename.split(/[?#]/, 1)[0]!;
358
+ return nodePath.isAbsolute(clean) ? clean : null;
359
+ }
360
+
361
+ function findPackageJson(
362
+ fromPathOrUrl: string,
363
+ packageName: string,
364
+ ): string | null {
365
+ let current = nodePath.dirname(
366
+ fromPathOrUrl.startsWith("file:")
367
+ ? new URL(fromPathOrUrl).pathname
368
+ : nodePath.resolve(fromPathOrUrl),
369
+ );
370
+
371
+ for (;;) {
372
+ const packageJsonPath = nodePath.join(current, "package.json");
373
+ const packageJson = readPackageJson(packageJsonPath);
374
+ if (packageJson?.name === packageName) return packageJsonPath;
375
+
376
+ const parent = nodePath.dirname(current);
377
+ if (parent === current) return null;
378
+ current = parent;
379
+ }
380
+ }
381
+
382
+ function findPackageJsonFromNodeModules(
383
+ packageName: string,
384
+ fromPathOrUrl: string,
385
+ ): string | null {
386
+ const parts = packageName.split("/");
387
+ let current = nodePath.dirname(
388
+ fromPathOrUrl.startsWith("file:")
389
+ ? new URL(fromPathOrUrl).pathname
390
+ : nodePath.resolve(fromPathOrUrl),
391
+ );
392
+
393
+ for (;;) {
394
+ const packageJsonPath = nodePath.join(
395
+ current,
396
+ "node_modules",
397
+ ...parts,
398
+ "package.json",
399
+ );
400
+ const packageJson = readPackageJson(packageJsonPath);
401
+ if (packageJson?.name === packageName) return packageJsonPath;
402
+
403
+ const parent = nodePath.dirname(current);
404
+ if (parent === current) return null;
405
+ current = parent;
406
+ }
407
+ }
408
+
409
+ function readPackageJson(packageJsonPath: string): PackageJson | null {
410
+ if (!existsSync(packageJsonPath)) return null;
411
+ try {
412
+ return JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackageJson;
413
+ } catch (error) {
414
+ throw new GenerativeCompileError(
415
+ `could not parse package metadata at ${packageJsonPath}: ${
416
+ error instanceof Error ? error.message : String(error)
417
+ }`,
418
+ );
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Errors unless the module's default export is the toolkit — a `defineToolkit(...)`
424
+ * call (through `satisfies`/`as`/parens). This is the security boundary: the
425
+ * default export is what the runtime registers, so it must be wrapped (and thus
426
+ * split). A bare `export default { ... }` would ship a backend `execute` to the
427
+ * client even if some *other* `defineToolkit(...)` exists elsewhere in the file.
428
+ */
429
+ function ensureDefaultExport(ast: t.File, filename: string | undefined): void {
430
+ const def = ast.program.body.find(
431
+ (stmt): stmt is t.ExportDefaultDeclaration =>
432
+ t.isExportDefaultDeclaration(stmt),
433
+ );
434
+ if (!def) {
194
435
  throw new GenerativeCompileError("missing a default export", filename);
195
436
  }
196
- if (!object) {
437
+ if (!unwrapToToolkitCall(def.declaration)) {
197
438
  throw new GenerativeCompileError(
198
- "the default export must be `defineToolkit({ ... })` (imported from " +
199
- '"@assistant-ui/react"); wrapping is required so a backend `execute` ' +
200
- "can't be authored in a way that reaches the client",
439
+ `the default export must be ${TOOLKIT_WRAPPER}({ ... }) or ` +
440
+ `${MCP_TOOLKIT_WRAPPER}({ ... }) (imported from "@assistant-ui/react"); ` +
441
+ "wrapping is required so a backend `execute` can't be authored in a way " +
442
+ "that reaches the client",
201
443
  filename,
202
444
  );
203
445
  }
204
- return object;
205
446
  }
206
447
 
207
448
  /**
208
- * Unwraps the required `defineToolkit({ ... })` wrapper (through `satisfies`/`as`
209
- * and parens) to the underlying object literal. Anything else — a bare object, a
210
- * `satisfies Toolkit` without the wrapper, some other call — yields `null` so the
211
- * caller errors.
449
+ * Unwraps a node through `satisfies`/`as`/parens to a call of the named function,
450
+ * or returns `null`.
212
451
  */
213
- function unwrapDefineToolkit(node: t.Node): t.ObjectExpression | null {
452
+ function unwrapToCall(node: t.Node, name: string): t.CallExpression | null {
214
453
  if (t.isTSSatisfiesExpression(node) || t.isTSAsExpression(node)) {
215
- return unwrapDefineToolkit(node.expression);
454
+ return unwrapToCall(node.expression, name);
216
455
  }
217
456
  if (t.isParenthesizedExpression(node)) {
218
- return unwrapDefineToolkit(node.expression);
457
+ return unwrapToCall(node.expression, name);
458
+ }
459
+ if (t.isCallExpression(node) && t.isIdentifier(node.callee, { name })) {
460
+ return node;
219
461
  }
462
+ return null;
463
+ }
464
+
465
+ /**
466
+ * Collects the names bound to `new JSONGenerativeUI(...)` (e.g.
467
+ * `const generative = new JSONGenerativeUI({ library })`). A toolkit entry that
468
+ * calls a method on one of these is a generative tool whose halves the library
469
+ * routes by export condition, so it passes through the toolkit pass untouched.
470
+ */
471
+ function collectGenerativeInstances(ast: t.File): Set<string> {
472
+ const names = new Set<string>();
473
+ for (const statement of ast.program.body) {
474
+ if (!t.isVariableDeclaration(statement)) continue;
475
+ for (const declaration of statement.declarations) {
476
+ const { id, init } = declaration;
477
+ if (
478
+ t.isIdentifier(id) &&
479
+ t.isNewExpression(init) &&
480
+ t.isIdentifier(init.callee, { name: GENERATIVE_FACTORY })
481
+ ) {
482
+ names.add(id.name);
483
+ }
484
+ }
485
+ }
486
+ return names;
487
+ }
488
+
489
+ /**
490
+ * Toolkit identifiers that are safe to spread into a `defineToolkit({ ... })`.
491
+ *
492
+ * Two kinds qualify:
493
+ *
494
+ * - A local variable whose initializer is visible to this compiler pass: a
495
+ * `defineToolkit(...)` binding is compiled in-place before a later spread
496
+ * reads it, and `defineMcpToolkit(...)` entries can't contain executable code.
497
+ * - The default import of another `"use generative"` module: that module is
498
+ * split per-target by its own compiler pass, so spreading its default-exported
499
+ * toolkit can't leak a backend `execute` to the client. Only the default
500
+ * export crosses the generative-module boundary, so named imports don't
501
+ * qualify — they would be `undefined` once that module is build-split.
502
+ */
503
+ function collectSafeToolkitSpreads(
504
+ ast: t.File,
505
+ filename: string | undefined,
506
+ ): Set<string> {
507
+ const names = new Set<string>();
508
+ const generativeBySource = new Map<string, boolean>();
509
+
510
+ for (const statement of ast.program.body) {
511
+ if (t.isVariableDeclaration(statement)) {
512
+ for (const declaration of statement.declarations) {
513
+ const { id, init } = declaration;
514
+ if (t.isIdentifier(id) && init && unwrapToToolkitCall(init)) {
515
+ names.add(id.name);
516
+ }
517
+ }
518
+ continue;
519
+ }
520
+
521
+ if (t.isImportDeclaration(statement)) {
522
+ const defaultSpecifier = statement.specifiers.find(
523
+ (specifier): specifier is t.ImportDefaultSpecifier =>
524
+ t.isImportDefaultSpecifier(specifier),
525
+ );
526
+ if (!defaultSpecifier) continue;
527
+
528
+ const source = statement.source.value;
529
+ let isGenerative = generativeBySource.get(source);
530
+ if (isGenerative === undefined) {
531
+ isGenerative = isGenerativeImport(source, filename);
532
+ generativeBySource.set(source, isGenerative);
533
+ }
534
+ if (isGenerative) names.add(defaultSpecifier.local.name);
535
+ }
536
+ }
537
+
538
+ return names;
539
+ }
540
+
541
+ const MODULE_EXTENSIONS = [
542
+ ".ts",
543
+ ".tsx",
544
+ ".mts",
545
+ ".cts",
546
+ ".js",
547
+ ".jsx",
548
+ ".mjs",
549
+ ".cjs",
550
+ ] as const;
551
+
552
+ /** Extensions a specifier may carry that actually map to a TS source file. */
553
+ const REWRITABLE_JS_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".cjs"]);
554
+
555
+ /**
556
+ * Whether an import specifier resolves on disk to a `"use generative"` module.
557
+ * Relative specifiers and `tsconfig` path aliases (e.g. `@/tools`) are resolved;
558
+ * anything else (a bare package, an unresolvable alias) is treated as
559
+ * non-generative, and thus an unsafe spread.
560
+ */
561
+ function isGenerativeImport(
562
+ source: string,
563
+ filename: string | undefined,
564
+ ): boolean {
565
+ const cleanFilename = cleanAbsoluteFilename(filename);
566
+ if (!cleanFilename) return false;
567
+
568
+ const resolved = resolveImportedModuleFile(source, cleanFilename);
569
+ if (!resolved) return false;
570
+
571
+ try {
572
+ return isGenerativeModule(readFileSync(resolved, "utf8"));
573
+ } catch {
574
+ return false;
575
+ }
576
+ }
577
+
578
+ /** Resolves an import specifier (relative or `tsconfig`-aliased) to a file on disk. */
579
+ function resolveImportedModuleFile(
580
+ source: string,
581
+ fromFilename: string,
582
+ ): string | null {
583
+ if (source.startsWith(".")) {
584
+ const base = nodePath.resolve(nodePath.dirname(fromFilename), source);
585
+ return resolveModuleFileAtPath(base);
586
+ }
587
+ return resolveAliasImport(source, fromFilename);
588
+ }
589
+
590
+ /**
591
+ * Resolves a candidate module path, trying TS/JS extensions then index files. A
592
+ * specifier may carry a `.js`-family extension that maps to a `.ts`/`.tsx`
593
+ * source (TypeScript's `bundler`/`nodenext` resolution), so the extension is
594
+ * dropped before probing.
595
+ */
596
+ function resolveModuleFileAtPath(base: string): string | null {
597
+ const ext = nodePath.extname(base);
598
+ if (ext && existsSync(base)) return base;
599
+
600
+ const stem = REWRITABLE_JS_EXTENSIONS.has(ext)
601
+ ? base.slice(0, -ext.length)
602
+ : base;
603
+
604
+ for (const candidateExt of MODULE_EXTENSIONS) {
605
+ const candidate = `${stem}${candidateExt}`;
606
+ if (existsSync(candidate)) return candidate;
607
+ }
608
+ for (const candidateExt of MODULE_EXTENSIONS) {
609
+ const candidate = nodePath.join(base, `index${candidateExt}`);
610
+ if (existsSync(candidate)) return candidate;
611
+ }
612
+ return null;
613
+ }
614
+
615
+ interface TsconfigAliases {
616
+ /** Absolute directory that `paths` targets resolve against. */
617
+ baseDir: string;
618
+ paths: Record<string, string[]>;
619
+ }
620
+
621
+ /** Resolves a `tsconfig` path alias (e.g. `@/tools/x`) to a file on disk. */
622
+ function resolveAliasImport(
623
+ source: string,
624
+ fromFilename: string,
625
+ ): string | null {
626
+ const tsconfig = loadTsconfigAliases(nodePath.dirname(fromFilename));
627
+ if (!tsconfig) return null;
628
+
629
+ // TypeScript matches the most specific key first: an exact (wildcard-free) key
630
+ // beats a wildcard one, and a longer static prefix beats a shorter one.
631
+ const patterns = Object.entries(tsconfig.paths).sort(
632
+ ([a], [b]) => aliasSpecificity(b) - aliasSpecificity(a),
633
+ );
634
+
635
+ for (const [pattern, targets] of patterns) {
636
+ const matched = matchAliasPattern(pattern, source);
637
+ if (matched === null) continue;
638
+
639
+ for (const target of targets) {
640
+ const specifier = substituteAliasWildcard(target, matched);
641
+ const file = resolveModuleFileAtPath(
642
+ nodePath.resolve(tsconfig.baseDir, specifier),
643
+ );
644
+ if (file) return file;
645
+ }
646
+ }
647
+ return null;
648
+ }
649
+
650
+ /** Ranks a `paths` key: an exact key outranks any wildcard; longer prefixes win. */
651
+ function aliasSpecificity(pattern: string): number {
652
+ const star = pattern.indexOf("*");
653
+ return star === -1 ? pattern.length + 1 : star;
654
+ }
655
+
656
+ /** Substitutes the single `*` in a `paths` target with the matched text. */
657
+ function substituteAliasWildcard(target: string, matched: string): string {
658
+ const star = target.indexOf("*");
659
+ if (star === -1) return target;
660
+ return target.slice(0, star) + matched + target.slice(star + 1);
661
+ }
662
+
663
+ /**
664
+ * Matches an import specifier against a `tsconfig` `paths` key. Returns the text
665
+ * captured by the key's `*` (or `""` for an exact, wildcard-free key), or `null`
666
+ * when the specifier doesn't match.
667
+ */
668
+ function matchAliasPattern(pattern: string, source: string): string | null {
669
+ const star = pattern.indexOf("*");
670
+ if (star === -1) return pattern === source ? "" : null;
671
+
672
+ const prefix = pattern.slice(0, star);
673
+ const suffix = pattern.slice(star + 1);
220
674
  if (
221
- t.isCallExpression(node) &&
222
- t.isIdentifier(node.callee, { name: "defineToolkit" }) &&
223
- t.isObjectExpression(node.arguments[0])
675
+ source.length >= prefix.length + suffix.length &&
676
+ source.startsWith(prefix) &&
677
+ source.endsWith(suffix)
224
678
  ) {
225
- return node.arguments[0];
679
+ return source.slice(prefix.length, source.length - suffix.length);
226
680
  }
227
681
  return null;
228
682
  }
229
683
 
684
+ /**
685
+ * Memoizes resolved aliases per start directory. The compiler runs once per
686
+ * file across a build, so without this every aliased spread re-walks and
687
+ * re-parses the same `tsconfig.json`. Process-lifetime, like
688
+ * `checkedCorePackageJsonPaths`.
689
+ */
690
+ const tsconfigAliasesByDir = new Map<string, TsconfigAliases | null>();
691
+
692
+ /** Walks up from a directory to the nearest `tsconfig.json` that declares `paths`. */
693
+ function loadTsconfigAliases(fromDir: string): TsconfigAliases | null {
694
+ const cached = tsconfigAliasesByDir.get(fromDir);
695
+ if (cached !== undefined) return cached;
696
+
697
+ let aliases: TsconfigAliases | null = null;
698
+ let dir = fromDir;
699
+ for (;;) {
700
+ const tsconfigPath = nodePath.join(dir, "tsconfig.json");
701
+ if (existsSync(tsconfigPath)) {
702
+ aliases = readTsconfigAliases(tsconfigPath, new Set());
703
+ if (aliases) break;
704
+ }
705
+ const parent = nodePath.dirname(dir);
706
+ if (parent === dir) break;
707
+ dir = parent;
708
+ }
709
+
710
+ tsconfigAliasesByDir.set(fromDir, aliases);
711
+ return aliases;
712
+ }
713
+
714
+ /** Reads `baseUrl`/`paths` from a tsconfig, following a single `extends` chain. */
715
+ function readTsconfigAliases(
716
+ tsconfigPath: string,
717
+ seen: Set<string>,
718
+ ): TsconfigAliases | null {
719
+ if (seen.has(tsconfigPath)) return null;
720
+ seen.add(tsconfigPath);
721
+
722
+ let config: {
723
+ extends?: string | string[];
724
+ compilerOptions?: { baseUrl?: string; paths?: Record<string, string[]> };
725
+ } | null;
726
+ try {
727
+ config = parseJsonc(readFileSync(tsconfigPath, "utf8"));
728
+ } catch {
729
+ return null;
730
+ }
731
+ if (!config) return null;
732
+
733
+ const configDir = nodePath.dirname(tsconfigPath);
734
+ const { baseUrl, paths } = config.compilerOptions ?? {};
735
+
736
+ if (paths) {
737
+ return {
738
+ baseDir: baseUrl ? nodePath.resolve(configDir, baseUrl) : configDir,
739
+ paths,
740
+ };
741
+ }
742
+
743
+ // `extends` may be a string or (TS 5.0+) a string array; later entries take
744
+ // precedence, so try them last-first. Parsed from untrusted JSONC, so the
745
+ // array case is a real runtime shape, not just a type.
746
+ const extendsList = Array.isArray(config.extends)
747
+ ? config.extends
748
+ : config.extends
749
+ ? [config.extends]
750
+ : [];
751
+ for (let i = extendsList.length - 1; i >= 0; i--) {
752
+ const entry = extendsList[i];
753
+ if (typeof entry !== "string") continue;
754
+ const extended = resolveExtendedTsconfig(entry, configDir);
755
+ if (extended) {
756
+ const aliases = readTsconfigAliases(extended, seen);
757
+ if (aliases) return aliases;
758
+ }
759
+ }
760
+ return null;
761
+ }
762
+
763
+ /** Resolves a tsconfig `extends` value (relative path or installed package). */
764
+ function resolveExtendedTsconfig(
765
+ extendsValue: string,
766
+ configDir: string,
767
+ ): string | null {
768
+ if (extendsValue.startsWith(".")) {
769
+ const base = nodePath.resolve(configDir, extendsValue);
770
+ const candidates =
771
+ nodePath.extname(base) === ".json" ? [base] : [`${base}.json`, base];
772
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
773
+ }
774
+ try {
775
+ return createRequire(nodePath.join(configDir, "package.json")).resolve(
776
+ extendsValue.endsWith(".json") ? extendsValue : `${extendsValue}.json`,
777
+ );
778
+ } catch {
779
+ return null;
780
+ }
781
+ }
782
+
783
+ /** Parses JSON with `//` and block comments and trailing commas stripped (tsconfig is JSONC). */
784
+ function parseJsonc(text: string): any {
785
+ const withoutComments = text.replace(
786
+ /"(?:[^"\\]|\\.)*"|\/\/[^\n\r]*|\/\*[\s\S]*?\*\//g,
787
+ (match) => (match.startsWith('"') ? match : ""),
788
+ );
789
+ const withoutTrailingCommas = withoutComments.replace(/,(\s*[}\]])/g, "$1");
790
+ return JSON.parse(withoutTrailingCommas);
791
+ }
792
+
793
+ function unwrapToToolkitCall(node: t.Node): t.CallExpression | null {
794
+ return (
795
+ unwrapToCall(node, TOOLKIT_WRAPPER) ??
796
+ unwrapToCall(node, MCP_TOOLKIT_WRAPPER)
797
+ );
798
+ }
799
+
800
+ function isSafeToolkitSpread(
801
+ entry: t.SpreadElement,
802
+ safeToolkitSpreads: Set<string>,
803
+ ): boolean {
804
+ if (t.isIdentifier(entry.argument)) {
805
+ return safeToolkitSpreads.has(entry.argument.name);
806
+ }
807
+
808
+ const directMcpToolkit = unwrapToCall(entry.argument, MCP_TOOLKIT_WRAPPER);
809
+ return (
810
+ !!directMcpToolkit && t.isObjectExpression(directMcpToolkit.arguments[0])
811
+ );
812
+ }
813
+
814
+ /** The `JSONGenerativeUI` methods that produce a split-by-condition tool. */
815
+ const GENERATIVE_TOOL_METHODS = new Set(["present", "promptUser"]);
816
+
817
+ /**
818
+ * Whether a toolkit entry's value is a call to a tool-producing method on a
819
+ * collected `JSONGenerativeUI` instance (`generative.present()`), which passes
820
+ * through. The method name is checked too, so a typo like `generative.presnt()`
821
+ * is a compile error here rather than a pass-through that fails at runtime.
822
+ */
823
+ function isGenerativeToolEntry(value: t.Node, instances: Set<string>): boolean {
824
+ return (
825
+ t.isCallExpression(value) &&
826
+ t.isMemberExpression(value.callee) &&
827
+ !value.callee.computed &&
828
+ t.isIdentifier(value.callee.object) &&
829
+ instances.has(value.callee.object.name) &&
830
+ t.isIdentifier(value.callee.property) &&
831
+ GENERATIVE_TOOL_METHODS.has(value.callee.property.name)
832
+ );
833
+ }
834
+
835
+ /**
836
+ * Splits a `defineGenerativeComponents({ ... })` library for a build target:
837
+ * a component's `render` (and the client imports it alone uses) is dropped on
838
+ * the server; `properties`/`description` stay on both, since they drive the tool
839
+ * schema either way. Mutates the object in place.
840
+ */
841
+ function compileComponents(
842
+ object: t.ObjectExpression,
843
+ target: Target,
844
+ flags: TargetFlags,
845
+ filename: string | undefined,
846
+ ): void {
847
+ for (const entry of object.properties) {
848
+ const value = entryValue(entry);
849
+ if (!value) {
850
+ throw new GenerativeCompileError(
851
+ `each component in ${COMPONENTS_WRAPPER}() must be an inline object ` +
852
+ "literal (`name: { ... }`) so its `render` can be routed",
853
+ filename,
854
+ );
855
+ }
856
+ if (!findMember(value, "render")) continue;
857
+ // The client keeps `render` (and so needs the module marked `"use client"`);
858
+ // the server drops it, since only the schema reaches the model there.
859
+ if (target === "client") flags.keptRender = true;
860
+ else removeMember(value, "render");
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Splits a `defineToolkit({ ... })` for a build target. Each inline tool is
866
+ * routed by inferred type (see the per-entry logic); a generative entry like
867
+ * `generative.present()` passes through, the library having already split it.
868
+ * Mutates the object in place and records outcomes in {@link TargetFlags}.
869
+ */
870
+ function compileToolkit(
871
+ object: t.ObjectExpression,
872
+ target: Target,
873
+ instances: Set<string>,
874
+ safeToolkitSpreads: Set<string>,
875
+ flags: TargetFlags,
876
+ filename: string | undefined,
877
+ ): void {
878
+ const nextProperties: t.ObjectExpression["properties"] = [];
879
+
880
+ for (const entry of object.properties) {
881
+ const value = entryValue(entry);
882
+ if (!value) {
883
+ if (
884
+ t.isSpreadElement(entry) &&
885
+ isSafeToolkitSpread(entry, safeToolkitSpreads)
886
+ ) {
887
+ nextProperties.push(entry);
888
+ continue;
889
+ }
890
+ // A generative tool (`generative.present()`) is split by the library's
891
+ // export conditions, so it is safe to keep verbatim. Anything else — a
892
+ // method or opaque call like `makeTool()` — can't be analyzed, so its
893
+ // `execute` could reach the client unstripped.
894
+ const raw = entryRawValue(entry);
895
+ if (raw && isGenerativeToolEntry(raw, instances)) {
896
+ nextProperties.push(entry);
897
+ continue;
898
+ }
899
+ throw new GenerativeCompileError(
900
+ "each tool must be an inline object literal (`name: { ... }`) or a " +
901
+ "compiler-visible toolkit spread / generative tool (e.g. " +
902
+ "`...defineMcpToolkit(...)`, `...baseToolkit`, or " +
903
+ "`generative.present()`) so its `execute` can be routed",
904
+ filename,
905
+ );
906
+ }
907
+
908
+ // Nature is inferred from `execute` (see inferToolType), not an authored
909
+ // `type`. The resolved type is written back below so the runtime keeps it.
910
+ const execute = findMember(value, "execute");
911
+ const isStub = execute ? executeIsStubTool(execute) : false;
912
+ const isExternal = execute ? executeIsExternalTool(execute) : false;
913
+ const type = inferToolType(value, filename);
914
+ const hasRender = !!findMember(value, "render");
915
+ const hasRenderText = !!findMember(value, "renderText");
916
+
917
+ if (type === "frontend" && !hasRender && !hasRenderText) {
918
+ throw new GenerativeCompileError(
919
+ "a frontend tool must declare a `render` or `renderText` " +
920
+ "(it has no server execute to show otherwise)",
921
+ filename,
922
+ );
923
+ }
924
+
925
+ if (type === "human" && !hasRender) {
926
+ throw new GenerativeCompileError(
927
+ "a human tool must declare a `render` so it can collect input",
928
+ filename,
929
+ );
930
+ }
931
+
932
+ if (type === "provider" && execute) {
933
+ applyProviderToolConfig(value, execute, filename);
934
+ }
935
+
936
+ if (isExternal) {
937
+ if (!hasRender && !hasRenderText) {
938
+ throw new GenerativeCompileError(
939
+ "an external tool must declare a `render` or `renderText` " +
940
+ "(assistant-ui only renders calls for tools defined elsewhere)",
941
+ filename,
942
+ );
943
+ }
944
+ if (target === "server") continue;
945
+ stripExternalToolMetadata(value);
946
+ }
947
+
948
+ if (target === "client") {
949
+ // A non-stub frontend execute stays (its `"use client"` marker is no longer needed
950
+ // once the module is client); backend, sentinel, and stub executes are dropped.
951
+ if (execute && type === "frontend" && !isStub) stripUseClient(execute);
952
+ else if (execute) removeMember(value, "execute");
953
+ if (hasRender || hasRenderText) flags.keptRender = true;
954
+ } else {
955
+ // server: render is never needed; only a non-external backend execute survives.
956
+ if (hasRender) removeMember(value, "render");
957
+ if (hasRenderText) removeMember(value, "renderText");
958
+ if (execute) {
959
+ if (type === "backend" && !isExternal) flags.keptBackendExecute = true;
960
+ else removeMember(value, "execute");
961
+ }
962
+ }
963
+
964
+ setToolType(value, type);
965
+ setBackendDefault(value, target, type);
966
+ nextProperties.push(entry);
967
+ }
968
+
969
+ object.properties = nextProperties;
970
+ }
971
+
972
+ function applyProviderToolConfig(
973
+ object: t.ObjectExpression,
974
+ execute: t.ObjectProperty | t.ObjectMethod,
975
+ filename: string | undefined,
976
+ ): void {
977
+ if (
978
+ !t.isObjectProperty(execute) ||
979
+ !t.isCallExpression(execute.value) ||
980
+ execute.value.arguments.length !== 1 ||
981
+ !t.isObjectExpression(execute.value.arguments[0])
982
+ ) {
983
+ throw new GenerativeCompileError(
984
+ "`providerTool(...)` must receive an inline object literal",
985
+ filename,
986
+ );
987
+ }
988
+
989
+ const existingNames = new Set(
990
+ object.properties.flatMap((prop) => {
991
+ if (!t.isObjectProperty(prop) && !t.isObjectMethod(prop)) return [];
992
+ const name = memberName(prop.key, prop.computed);
993
+ return name ? [name] : [];
994
+ }),
995
+ );
996
+ const configNames = new Set<string>();
997
+
998
+ for (const prop of execute.value.arguments[0].properties) {
999
+ if (!t.isObjectProperty(prop)) {
1000
+ throw new GenerativeCompileError(
1001
+ "`providerTool(...)` config can only contain object properties",
1002
+ filename,
1003
+ );
1004
+ }
1005
+ const name = memberName(prop.key, prop.computed);
1006
+ if (!name) {
1007
+ throw new GenerativeCompileError(
1008
+ "`providerTool(...)` config can only contain static property names",
1009
+ filename,
1010
+ );
1011
+ }
1012
+ if (
1013
+ t.isFunctionExpression(prop.value) ||
1014
+ t.isArrowFunctionExpression(prop.value)
1015
+ ) {
1016
+ throw new GenerativeCompileError(
1017
+ "`providerTool(...)` config cannot contain function-valued properties",
1018
+ filename,
1019
+ );
1020
+ }
1021
+ if (existingNames.has(name) || configNames.has(name)) {
1022
+ throw new GenerativeCompileError(
1023
+ "`providerTool(...)` config cannot duplicate tool properties",
1024
+ filename,
1025
+ );
1026
+ }
1027
+ configNames.add(name);
1028
+ object.properties.push(prop);
1029
+ }
1030
+ }
1031
+
230
1032
  type Entry = t.ObjectExpression["properties"][number];
231
1033
 
1034
+ /** The raw AST value of an entry (any expression), or null for spreads/methods. */
1035
+ function entryRawValue(entry: Entry): t.Expression | null {
1036
+ if (t.isObjectProperty(entry) && t.isExpression(entry.value)) {
1037
+ return entry.value;
1038
+ }
1039
+ return null;
1040
+ }
1041
+
232
1042
  function entryValue(entry: Entry): t.ObjectExpression | null {
233
1043
  if (t.isObjectProperty(entry) && t.isObjectExpression(entry.value)) {
234
1044
  return entry.value;
@@ -280,15 +1090,47 @@ function executeIsClient(member: t.ObjectProperty | t.ObjectMethod): boolean {
280
1090
  );
281
1091
  }
282
1092
 
283
- /** Whether an `execute` is the `hitl()` human-in-the-loop sentinel. */
284
- function executeIsHitl(member: t.ObjectProperty | t.ObjectMethod): boolean {
1093
+ function executeIsSentinel(
1094
+ member: t.ObjectProperty | t.ObjectMethod,
1095
+ name: string,
1096
+ ): boolean {
285
1097
  return (
286
1098
  t.isObjectProperty(member) &&
287
1099
  t.isCallExpression(member.value) &&
288
- t.isIdentifier(member.value.callee, { name: "hitl" })
1100
+ t.isIdentifier(member.value.callee, { name })
289
1101
  );
290
1102
  }
291
1103
 
1104
+ /** Whether an `execute` is the human-in-the-loop sentinel. */
1105
+ function executeIsHumanTool(
1106
+ member: t.ObjectProperty | t.ObjectMethod,
1107
+ ): boolean {
1108
+ return (
1109
+ executeIsSentinel(member, "humanTool") ||
1110
+ executeIsSentinel(member, "hitlTool") ||
1111
+ executeIsSentinel(member, "hitl")
1112
+ );
1113
+ }
1114
+
1115
+ /** Whether an `execute` is the provider-tool sentinel. */
1116
+ function executeIsProviderTool(
1117
+ member: t.ObjectProperty | t.ObjectMethod,
1118
+ ): boolean {
1119
+ return executeIsSentinel(member, "providerTool");
1120
+ }
1121
+
1122
+ /** Whether an `execute` is the local override sentinel. */
1123
+ function executeIsStubTool(member: t.ObjectProperty | t.ObjectMethod): boolean {
1124
+ return executeIsSentinel(member, "stubTool");
1125
+ }
1126
+
1127
+ /** Whether an `execute` is the externally-defined backend tool sentinel. */
1128
+ function executeIsExternalTool(
1129
+ member: t.ObjectProperty | t.ObjectMethod,
1130
+ ): boolean {
1131
+ return executeIsSentinel(member, "externalTool");
1132
+ }
1133
+
292
1134
  /** Drops the `"use client"` directive from an `execute` body (kept frontend). */
293
1135
  function stripUseClient(member: t.ObjectProperty | t.ObjectMethod): void {
294
1136
  const body = executeBody(member);
@@ -301,9 +1143,11 @@ function stripUseClient(member: t.ObjectProperty | t.ObjectMethod): void {
301
1143
 
302
1144
  /**
303
1145
  * The tool's nature, inferred from its (mandatory) `execute` rather than an
304
- * authored `type`: `hitl()` → `human`; `"use client"` → `frontend`; otherwise
305
- * `backend`. The loader writes the result back as a `type` field (see
306
- * {@link setToolType}) so the runtime keeps it.
1146
+ * authored `type`: `humanTool()` → `human`; `providerTool(...)` → `provider`;
1147
+ * `stubTool()` → `frontend`; `externalTool()` → `backend`; `"use client"` →
1148
+ * `frontend`; otherwise `backend`.
1149
+ * The loader writes the result back as a `type` field (see {@link setToolType})
1150
+ * so the runtime keeps it.
307
1151
  */
308
1152
  function inferToolType(
309
1153
  object: t.ObjectExpression,
@@ -312,15 +1156,31 @@ function inferToolType(
312
1156
  const execute = findMember(object, "execute");
313
1157
  if (!execute) {
314
1158
  throw new GenerativeCompileError(
315
- "every tool must declare an `execute`; use `hitl()` for a " +
1159
+ "every tool must declare an `execute`; use `humanTool()` for a " +
316
1160
  "human-in-the-loop tool",
317
1161
  filename,
318
1162
  );
319
1163
  }
320
- if (executeIsHitl(execute)) return "human";
1164
+ if (executeIsHumanTool(execute)) return "human";
1165
+ if (executeIsProviderTool(execute)) return "provider";
1166
+ if (executeIsStubTool(execute)) return "frontend";
1167
+ if (executeIsExternalTool(execute)) return "backend";
321
1168
  return executeIsClient(execute) ? "frontend" : "backend";
322
1169
  }
323
1170
 
1171
+ function stripExternalToolMetadata(object: t.ObjectExpression): void {
1172
+ // Mirror BackendTool's forbidden metadata fields: execute is stripped by the
1173
+ // main routing loop, while streamCall is also removed because there is no
1174
+ // assistant-ui executor to stream from.
1175
+ removeMember(object, "description");
1176
+ removeMember(object, "parameters");
1177
+ removeMember(object, "disabled");
1178
+ removeMember(object, "toModelOutput");
1179
+ removeMember(object, "experimental_onSchemaValidationError");
1180
+ removeMember(object, "providerOptions");
1181
+ removeMember(object, "streamCall");
1182
+ }
1183
+
324
1184
  /** Writes the resolved `type` back onto the tool object (replacing any author's). */
325
1185
  function setToolType(object: t.ObjectExpression, type: ToolType): void {
326
1186
  removeMember(object, "type");
@@ -330,6 +1190,26 @@ function setToolType(object: t.ObjectExpression, type: ToolType): void {
330
1190
  );
331
1191
  }
332
1192
 
1193
+ function setBackendDefault(
1194
+ object: t.ObjectExpression,
1195
+ target: Target,
1196
+ type: ToolType,
1197
+ ): void {
1198
+ // Always strip any hand-authored marker first; only re-add it for client
1199
+ // frontend/human tools whose schema is already known by the backend.
1200
+ removeMember(object, "unstable_backendDefault");
1201
+ if (target !== "client" || (type !== "frontend" && type !== "human")) return;
1202
+
1203
+ object.properties.push(
1204
+ t.objectProperty(
1205
+ t.identifier("unstable_backendDefault"),
1206
+ t.objectExpression([
1207
+ t.objectProperty(t.identifier("parameters"), t.booleanLiteral(true)),
1208
+ ]),
1209
+ ),
1210
+ );
1211
+ }
1212
+
333
1213
  function memberName(
334
1214
  key: t.Node,
335
1215
  computed: boolean | undefined,