ttsc 0.12.4 → 0.13.0

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 (52) hide show
  1. package/cmd/ttsc/api_compile.go +15 -2
  2. package/cmd/ttsc/api_transform.go +15 -2
  3. package/cmd/ttsc/build.go +34 -4
  4. package/cmd/ttsc/filter.go +70 -0
  5. package/cmd/ttsc/filter_strips_unknown_forwarded_flag_test.go +61 -0
  6. package/cmd/ttsc/flags_gen.go +30 -0
  7. package/driver/program.go +105 -4
  8. package/driver/rewrite.go +39 -4
  9. package/lib/compiler/internal/project/resolveProjectConfig.js +18 -0
  10. package/lib/compiler/internal/project/resolveProjectConfig.js.map +1 -1
  11. package/lib/compiler/internal/runBuild.d.ts +5 -4
  12. package/lib/compiler/internal/runBuild.js +243 -47
  13. package/lib/compiler/internal/runBuild.js.map +1 -1
  14. package/lib/flags/parser.d.ts +63 -0
  15. package/lib/flags/parser.js +254 -0
  16. package/lib/flags/parser.js.map +1 -0
  17. package/lib/flags/schema.d.ts +147 -0
  18. package/lib/flags/schema.js +405 -0
  19. package/lib/flags/schema.js.map +1 -0
  20. package/lib/launcher/internal/prepareExecution.js +3 -0
  21. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  22. package/lib/launcher/internal/runTtsc.js +71 -147
  23. package/lib/launcher/internal/runTtsc.js.map +1 -1
  24. package/lib/launcher/internal/runTtsx.js +104 -72
  25. package/lib/launcher/internal/runTtsx.js.map +1 -1
  26. package/lib/plugin/internal/loadProjectPlugins.js +58 -23
  27. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  28. package/lib/structures/ITtscPlugin.d.ts +24 -0
  29. package/lib/structures/ITtscPluginCapabilities.d.ts +29 -0
  30. package/lib/structures/ITtscPluginCapabilities.js +3 -0
  31. package/lib/structures/ITtscPluginCapabilities.js.map +1 -0
  32. package/lib/structures/index.d.ts +1 -0
  33. package/lib/structures/index.js +1 -0
  34. package/lib/structures/index.js.map +1 -1
  35. package/lib/structures/internal/ITtscLoadedNativePlugin.d.ts +5 -0
  36. package/lib/structures/internal/TtscCommonOptions.d.ts +19 -0
  37. package/package.json +9 -9
  38. package/src/compiler/internal/project/resolveProjectConfig.ts +20 -0
  39. package/src/compiler/internal/runBuild.ts +288 -57
  40. package/src/flags/parser.ts +367 -0
  41. package/src/flags/schema.ts +570 -0
  42. package/src/launcher/internal/prepareExecution.ts +3 -0
  43. package/src/launcher/internal/runTtsc.ts +78 -136
  44. package/src/launcher/internal/runTtsx.ts +116 -74
  45. package/src/plugin/internal/loadProjectPlugins.ts +74 -27
  46. package/src/structures/ITtscPlugin.ts +26 -0
  47. package/src/structures/ITtscPluginCapabilities.ts +29 -0
  48. package/src/structures/index.ts +1 -0
  49. package/src/structures/internal/ITtscLoadedNativePlugin.ts +5 -0
  50. package/src/structures/internal/TtscCommonOptions.ts +19 -0
  51. package/utility/flags_gen.go +30 -0
  52. package/utility/host.go +43 -33
@@ -8,6 +8,12 @@ import { resolveProjectConfig } from "../../compiler/internal/project/resolvePro
8
8
  import { resolveBinary } from "../../compiler/internal/resolveBinary";
9
9
  import { runBuild } from "../../compiler/internal/runBuild";
10
10
  import { runSingleFileEmit } from "../../compiler/internal/runSingleFileEmit";
11
+ import {
12
+ getBoolean,
13
+ getNumber,
14
+ getString,
15
+ parseFlags,
16
+ } from "../../flags/parser";
11
17
  import { defaultPluginCacheCleanTargets } from "../../plugin/internal/buildSourcePlugin";
12
18
  import type { TtscBuildOptions } from "../../structures/internal/TtscBuildOptions";
13
19
  import { getCompilerVersionText } from "./getCompilerVersionText";
@@ -282,144 +288,80 @@ function ensureExecutable(binary: string): void {
282
288
  }
283
289
 
284
290
  function parseProjectArgs(argv: readonly string[]) {
285
- let cacheDir: string | undefined;
286
- let cwd: string | undefined;
287
- let tsconfig: string | undefined;
288
-
289
- const rest = [...argv];
290
- while (rest.length !== 0) {
291
- const current = rest.shift()!;
292
- switch (current) {
293
- case "--cwd":
294
- cwd = takeValue(current, rest);
295
- break;
296
- case "--cache-dir":
297
- cacheDir = takeValue(current, rest);
298
- break;
299
- case "-p":
300
- case "--tsconfig":
301
- case "--project":
302
- tsconfig = takeValue(current, rest);
303
- break;
304
- default:
305
- if (current.startsWith("--cwd=")) {
306
- cwd = current.slice("--cwd=".length);
307
- } else if (current.startsWith("--cache-dir=")) {
308
- cacheDir = current.slice("--cache-dir=".length);
309
- } else if (current.startsWith("--tsconfig=")) {
310
- tsconfig = current.slice("--tsconfig=".length);
311
- } else if (current.startsWith("--project=")) {
312
- tsconfig = current.slice("--project=".length);
313
- } else {
314
- throw new Error(`ttsc: unknown option ${current}`);
315
- }
316
- break;
317
- }
318
- }
319
- return { cacheDir, cwd, tsconfig };
291
+ // `prepare` and `clean` are project-shaped commands. They share the same
292
+ // schema as the build lane; the engine forwards unknown flags as well as
293
+ // build-only flags (e.g. `--strict`) to the launcher's passthrough list
294
+ // so the legacy "unknown option" behaviour is no longer a separate trap
295
+ // (RC-3 + RC-4 prevention; see issue #125 §5 in the RCA).
296
+ const result = parseFlags({
297
+ argv,
298
+ errorPrefix: "ttsc:",
299
+ subcommand: "prepare",
300
+ });
301
+ return {
302
+ cacheDir: getString(result, "--cache-dir"),
303
+ cwd: getString(result, "--cwd"),
304
+ tsconfig: getString(result, "--tsconfig"),
305
+ };
320
306
  }
321
307
 
322
308
  function parseBuildArgs(argv: readonly string[], checkOnly: boolean) {
323
- let binary: string | undefined;
324
- let cacheDir: string | undefined;
325
- let cwd: string | undefined;
326
- let emit: boolean | undefined = checkOnly ? false : undefined;
327
- const files: string[] = [];
328
- let fix = false;
329
- let format = false;
330
- let outDir: string | undefined;
331
- let preserveWatchOutput = false;
332
- let quiet = true;
333
- let tsconfig: string | undefined;
334
- let watch = false;
335
-
336
- const rest = [...argv];
337
- while (rest.length !== 0) {
338
- const current = rest.shift()!;
339
- switch (current) {
340
- case "--emit":
341
- emit = true;
342
- break;
343
- case "--noEmit":
344
- emit = false;
345
- break;
346
- case "--quiet":
347
- quiet = true;
348
- break;
349
- case "--verbose":
350
- quiet = false;
351
- break;
352
- case "-w":
353
- case "--watch":
354
- watch = true;
355
- break;
356
- case "--preserveWatchOutput":
357
- preserveWatchOutput = true;
358
- break;
359
- case "--cwd":
360
- cwd = takeValue(current, rest);
361
- break;
362
- case "--outDir":
363
- outDir = takeValue(current, rest);
364
- break;
365
- case "-p":
366
- case "--tsconfig":
367
- case "--project":
368
- tsconfig = takeValue(current, rest);
369
- break;
370
- case "--binary":
371
- binary = takeValue(current, rest);
372
- break;
373
- case "--cache-dir":
374
- cacheDir = takeValue(current, rest);
375
- break;
376
- default:
377
- if (current.startsWith("--cwd=")) {
378
- cwd = current.slice("--cwd=".length);
379
- } else if (current.startsWith("--outDir=")) {
380
- outDir = current.slice("--outDir=".length);
381
- } else if (current === "-w") {
382
- // Unreachable: `-w` is handled by the switch case above. Kept here
383
- // as a guard in case the switch is refactored in the future.
384
- watch = true;
385
- } else if (current.startsWith("--tsconfig=")) {
386
- tsconfig = current.slice("--tsconfig=".length);
387
- } else if (current.startsWith("--project=")) {
388
- tsconfig = current.slice("--project=".length);
389
- } else if (current.startsWith("--preserveWatchOutput=")) {
390
- preserveWatchOutput =
391
- current.slice("--preserveWatchOutput=".length) !== "false";
392
- } else if (current.startsWith("--binary=")) {
393
- binary = current.slice("--binary=".length);
394
- } else if (current.startsWith("--cache-dir=")) {
395
- cacheDir = current.slice("--cache-dir=".length);
396
- } else if (current === "--verbose") {
397
- // Unreachable: `--verbose` is handled by the switch case above.
398
- quiet = false;
399
- } else if (current.startsWith("-")) {
400
- throw new Error(`ttsc: unknown option ${current}`);
401
- } else {
402
- files.push(current);
403
- }
404
- break;
405
- }
406
- }
309
+ const result = parseFlags({
310
+ argv,
311
+ errorPrefix: "ttsc:",
312
+ subcommand: "build",
313
+ });
314
+ // Defaults: pinned by the previous hand-parser. `quiet` defaults true,
315
+ // `--verbose` flips it to false; `emit` defaults `undefined` in build
316
+ // mode (let tsconfig decide) and `false` in check/fix/format mode.
317
+ const verbose = getBoolean(result, "--verbose");
318
+ const quietFlag = getBoolean(result, "--quiet");
319
+ const quiet = verbose === true ? false : (quietFlag ?? true);
320
+ const explicitEmit = getBoolean(result, "--emit");
321
+ const explicitNoEmit = getBoolean(result, "--noEmit");
322
+ let emit: boolean | undefined;
323
+ if (explicitEmit === true) emit = true;
324
+ else if (explicitNoEmit === true) emit = false;
325
+ if (emit === undefined && checkOnly) emit = false;
326
+
327
+ const files = result.positional.filter(looksLikeInputFile);
328
+ // Bare non-file positionals are flag values (e.g. the `es2020` in
329
+ // `--target es2020`). The engine left them in `positional` because the
330
+ // forwarded flag is unknown to the schema; route them back into
331
+ // passthrough so the user's pair reaches tsgo intact.
332
+ const trailingValues = result.positional.filter(
333
+ (token) => !looksLikeInputFile(token),
334
+ );
335
+ const passthrough = [...result.passthrough, ...trailingValues];
336
+
407
337
  return {
408
- binary,
409
- cacheDir,
410
- cwd,
338
+ binary: getString(result, "--binary"),
339
+ cacheDir: getString(result, "--cache-dir"),
340
+ checkers: getNumber(result, "--checkers"),
341
+ cwd: getString(result, "--cwd"),
411
342
  emit,
412
343
  files,
413
- fix,
414
- format,
415
- outDir,
416
- preserveWatchOutput,
344
+ fix: false,
345
+ format: false,
346
+ outDir: getString(result, "--outDir"),
347
+ passthrough,
348
+ preserveWatchOutput: getBoolean(result, "--preserveWatchOutput") === true,
417
349
  quiet,
418
- tsconfig,
419
- watch,
350
+ singleThreaded: getBoolean(result, "--singleThreaded") === true,
351
+ tsconfig: getString(result, "--tsconfig"),
352
+ watch: getBoolean(result, "--watch") === true,
420
353
  };
421
354
  }
422
355
 
356
+ /**
357
+ * Report whether a bare CLI token is a TypeScript source file ttsc should
358
+ * compile in single-file mode. Anything without a TypeScript source extension
359
+ * is treated as a forwarded flag value rather than an input file.
360
+ */
361
+ function looksLikeInputFile(token: string): boolean {
362
+ return [".ts", ".tsx", ".mts", ".cts"].some((ext) => token.endsWith(ext));
363
+ }
364
+
423
365
  function printHelp(): void {
424
366
  process.stdout.write(
425
367
  [
@@ -450,6 +392,11 @@ function printHelp(): void {
450
392
  " --verbose Print the build summary and emitted files",
451
393
  " --binary <path> Use an explicit tsgo binary",
452
394
  " --cache-dir <dir> Use this cache root for source-plugin builds",
395
+ " --singleThreaded Run TypeScript-Go single-threaded (one checker)",
396
+ " --checkers <n> Type-checker pool size (default: TypeScript-Go's)",
397
+ "",
398
+ " Any other flag is forwarded to tsgo as-is, so tsgo compiler options",
399
+ " such as --strict or --target work directly (e.g. ttsc --strict file.ts).",
453
400
  "",
454
401
  "Plugin contract:",
455
402
  " ttsc reads compilerOptions.plugins from tsconfig.json.",
@@ -484,9 +431,12 @@ function runSingleFile(options: ReturnType<typeof parseBuildArgs>): number {
484
431
  });
485
432
  const text = runSingleFileEmit({
486
433
  binary: options.binary,
434
+ checkers: options.checkers,
487
435
  cwd,
488
436
  file,
489
437
  out,
438
+ passthrough: options.passthrough,
439
+ singleThreaded: options.singleThreaded,
490
440
  tsconfig: options.tsconfig,
491
441
  });
492
442
  if (!fs.existsSync(out)) {
@@ -694,14 +644,6 @@ function collectWatchDirectories(root: string): string[] {
694
644
  return out;
695
645
  }
696
646
 
697
- function takeValue(flag: string, rest: string[]): string {
698
- const value = rest.shift();
699
- if (!value) {
700
- throw new Error(`ttsc: ${flag} requires a value`);
701
- }
702
- return value;
703
- }
704
-
705
647
  function formatError(error: unknown): string {
706
648
  if (error instanceof Error) {
707
649
  return error.message;
@@ -2,6 +2,12 @@ import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
+ import {
6
+ getBoolean,
7
+ getNumber,
8
+ getString,
9
+ parseFlags,
10
+ } from "../../flags/parser";
5
11
  import { getCompilerVersionText } from "./getCompilerVersionText";
6
12
  import { prepareExecution } from "./prepareExecution";
7
13
  import { resolveCacheDir } from "./resolveCacheDir";
@@ -48,8 +54,20 @@ function run(argv: readonly string[]): number {
48
54
  const prepared = prepareExecution(entry, {
49
55
  binary: parsed.binary,
50
56
  cacheDir: resolveCacheDir(cwd, parsed.cacheDir),
57
+ checkers: parsed.checkers,
51
58
  cwd,
59
+ passthrough: parsed.tsgoFlags,
60
+ // `--no-plugins` builds the entry's owning project with plugin
61
+ // discovery and loading disabled. ttsc's own config loaders use it
62
+ // when they evaluate a `*.config.ts` through ttsx: that build only
63
+ // needs to type-check and run the config file, so loading the host
64
+ // project's transform/check plugins (`@nestia/core`, `typia`, …)
65
+ // would be both wasteful and wrong — those plugins impose project
66
+ // requirements (e.g. `strict` mode) the ephemeral config-loader
67
+ // tsconfig deliberately does not satisfy.
68
+ plugins: parsed.noPlugins ? false : undefined,
52
69
  project: parsed.project,
70
+ singleThreaded: parsed.singleThreaded,
53
71
  });
54
72
  return runPreparedEntry(parsed, prepared, cwd);
55
73
  }
@@ -62,82 +80,108 @@ function formatError(error: unknown): string {
62
80
  }
63
81
 
64
82
  function parseCLI(argv: readonly string[]) {
65
- const preload: string[] = [];
66
- const passthroughIndex = argv.indexOf("--");
67
- const head =
68
- passthroughIndex === -1 ? [...argv] : [...argv.slice(0, passthroughIndex)];
69
- const passthrough =
70
- passthroughIndex === -1 ? [] : [...argv.slice(passthroughIndex + 1)];
71
-
72
- let binary: string | undefined;
73
- let cacheDir: string | undefined;
74
- let cwd: string | undefined;
75
- let entry: string | undefined;
76
- let project: string | undefined;
77
-
78
- while (head.length !== 0) {
79
- const current = head.shift()!;
80
- if (entry) {
81
- passthrough.push(current, ...head);
82
- break;
83
- }
84
- switch (current) {
85
- case "-h":
86
- case "--help":
87
- return "help";
88
- case "-v":
89
- case "--version":
90
- return "version";
91
- case "-P":
92
- case "--project":
93
- project = takeValue(current, head);
94
- break;
95
- case "--cwd":
96
- cwd = takeValue(current, head);
97
- break;
98
- case "--cache-dir":
99
- cacheDir = takeValue(current, head);
100
- break;
101
- case "-r":
102
- case "--require":
103
- preload.push(takeValue(current, head));
104
- break;
105
- case "--binary":
106
- binary = takeValue(current, head);
107
- break;
108
- default:
109
- if (current.startsWith("--project=")) {
110
- project = current.slice("--project=".length);
111
- } else if (current.startsWith("--cwd=")) {
112
- cwd = current.slice("--cwd=".length);
113
- } else if (current.startsWith("--cache-dir=")) {
114
- cacheDir = current.slice("--cache-dir=".length);
115
- } else if (current.startsWith("--binary=")) {
116
- binary = current.slice("--binary=".length);
117
- } else if (current.startsWith("-")) {
118
- throw new Error(`ttsx: unknown option ${current}`);
119
- } else {
120
- entry = current;
121
- }
122
- break;
123
- }
124
- }
83
+ // ttsx accepts ttsc-style flags plus its own `--no-plugins` / `--require`.
84
+ // The shared schema engine recognises both; the engine returns positional
85
+ // tokens (entry file + flag values that aren't `.ts`) and a passthrough
86
+ // list mirroring the pre-schema behaviour.
87
+ //
88
+ // Order pin: ttsx accepts `-P` as an alias for `--project`. The schema
89
+ // declares `-p` for ttsc; ttsx's lowercase shape would collide on `-p`
90
+ // → `--tsconfig`, so the legacy `-P` (uppercase) is treated as `--project`
91
+ // via a manual rewrite before the engine sees argv. We preserve the
92
+ // historical behaviour and emit a structural error otherwise.
93
+ const rewritten = argv.map((token) =>
94
+ token === "-P"
95
+ ? "--project"
96
+ : token.startsWith("-P=")
97
+ ? `--project=${token.slice("-P=".length)}`
98
+ : token,
99
+ );
100
+ // Terminal flags (--help / --version) short-circuit before parsing so
101
+ // ttsx prints help text even when the entry file is missing.
102
+ for (const token of rewritten) {
103
+ if (token === "-h" || token === "--help") return "help" as const;
104
+ if (token === "-v" || token === "--version") return "version" as const;
105
+ }
106
+ const result = parseFlags({
107
+ argv: rewritten,
108
+ errorPrefix: "ttsx:",
109
+ forwardAfterFirstPositional: true,
110
+ honorDoubleDashSeparator: true,
111
+ subcommand: "ttsx",
112
+ });
125
113
 
126
- if (!entry) {
114
+ const entry = result.positional.find(looksLikeEntryFile);
115
+ if (entry === undefined) {
127
116
  throw new Error("ttsx: entry file is required");
128
117
  }
118
+ // With `forwardAfterFirstPositional: true` the parser reports
119
+ // `result.positional` as just the entry, `result.passthrough` as flags
120
+ // arriving BEFORE the entry (tsgo-forwarded), and `result.tail` as every
121
+ // token AFTER the entry — those are the user program's argv (e.g. the
122
+ // `generate --input src/input` tail of `ttsx typia.ts generate
123
+ // --input src/input`) and MUST NOT reach tsgo. Anything in positional
124
+ // that is not the entry is a pre-entry flag value (e.g. `--target es2020`)
125
+ // that the parser stored positionally; forward those to tsgo with the
126
+ // rest of `passthrough`.
127
+ const preEntryValues: string[] = result.positional.filter(
128
+ (token) => token !== entry && !looksLikeEntryFile(token),
129
+ );
130
+ const postEntryArgs: string[] = [...result.tail];
131
+
132
+ const preload: string[] = [];
133
+ // `--require` accepts repeated values; the schema engine writes the
134
+ // LAST one into `values`, so reconstruct the full list by scanning the
135
+ // raw argv. Mirrors the legacy parser's `preload.push(takeValue(...))`
136
+ // behaviour.
137
+ //
138
+ // Stop the rescue scan at the first token that begins tail mode —
139
+ // either the entry file or the `--` separator. Without this guard,
140
+ // `ttsx entry.ts -r preload.cjs` would BOTH preload `preload.cjs` AND
141
+ // forward `-r preload.cjs` to the entry's argv, double-effecting the
142
+ // module load. The schema engine already routes post-entry tokens to
143
+ // `result.tail`; the rescue scan must respect the same boundary.
144
+ const scanEnd = rewritten.findIndex(
145
+ (token) => looksLikeEntryFile(token) || token === "--",
146
+ );
147
+ const scanLimit = scanEnd === -1 ? rewritten.length : scanEnd;
148
+ for (let i = 0; i < scanLimit; i += 1) {
149
+ const token = rewritten[i]!;
150
+ if (token === "-r" || token === "--require") {
151
+ const value = rewritten[i + 1];
152
+ if (value !== undefined && !value.startsWith("-")) {
153
+ preload.push(value);
154
+ i += 1;
155
+ }
156
+ } else if (token.startsWith("--require=")) {
157
+ preload.push(token.slice("--require=".length));
158
+ }
159
+ }
129
160
 
130
161
  return {
131
- binary,
132
- cacheDir,
133
- cwd,
162
+ binary: getString(result, "--binary"),
163
+ cacheDir: getString(result, "--cache-dir"),
164
+ checkers: getNumber(result, "--checkers"),
165
+ cwd: getString(result, "--cwd"),
134
166
  entry,
135
- passthrough,
167
+ noPlugins: getBoolean(result, "--no-plugins") === true,
168
+ passthrough: postEntryArgs,
136
169
  preload,
137
- project,
170
+ project: getString(result, "--tsconfig"),
171
+ singleThreaded: getBoolean(result, "--singleThreaded") === true,
172
+ tsgoFlags: [...result.passthrough, ...preEntryValues],
138
173
  };
139
174
  }
140
175
 
176
+ /**
177
+ * Report whether a bare CLI token is the TypeScript entry file rather than a
178
+ * forwarded flag's value. ttsx runs a TypeScript entrypoint, so only a token
179
+ * with a TypeScript source extension is treated as the entry.
180
+ */
181
+ function looksLikeEntryFile(token: string): boolean {
182
+ return [".ts", ".tsx", ".mts", ".cts"].some((ext) => token.endsWith(ext));
183
+ }
184
+
141
185
  function printHelp(): void {
142
186
  process.stdout.write(
143
187
  [
@@ -151,10 +195,16 @@ function printHelp(): void {
151
195
  " --cwd <dir> Resolve entry/project relative to this directory",
152
196
  " --cache-dir <dir> Override the runner and source-plugin cache root",
153
197
  " --binary <path> Use an explicit tsgo binary",
198
+ " --no-plugins Build the project without ttsc plugins",
154
199
  " -r, --require <module> Preload a module before the entrypoint",
200
+ " --singleThreaded Run TypeScript-Go single-threaded (one checker)",
201
+ " --checkers <n> Type-checker pool size (default: TypeScript-Go's)",
155
202
  " -h, --help Show this help",
156
203
  " -v, --version Print the runner version",
157
204
  "",
205
+ " Any other flag before the entry is forwarded to tsgo, so options like",
206
+ " --strict apply to the type-check (e.g. ttsx --strict src/index.ts).",
207
+ "",
158
208
  "Examples:",
159
209
  " ttsx src/index.ts",
160
210
  " ttsx --project tsconfig.json src/index.ts -- --port 3000",
@@ -181,14 +231,6 @@ function isRelativeSpecifier(specifier: string): boolean {
181
231
  );
182
232
  }
183
233
 
184
- function takeValue(flag: string, rest: string[]): string {
185
- const value = rest.shift();
186
- if (!value) {
187
- throw new Error(`ttsx: ${flag} requires a value`);
188
- }
189
- return value;
190
- }
191
-
192
234
  function runPreparedEntry(
193
235
  parsed: Exclude<ReturnType<typeof parseCLI>, "help" | "version">,
194
236
  execution: ReturnType<typeof prepareExecution>,
@@ -120,12 +120,15 @@ export function loadProjectPlugins(options: {
120
120
  ? `linked_${String(index).padStart(6, "0")}`
121
121
  : undefined;
122
122
  return {
123
+ capabilities: plugin.capabilities,
123
124
  contributors,
124
125
  config: entries[index]!.config,
125
126
  kind,
126
127
  label: pluginLabel(plugin, entries[index]!.config, index),
127
128
  linkedContributorName,
128
129
  name: plugin.name,
130
+ reportsTypeScriptDiagnostics:
131
+ plugin.reportsTypeScriptDiagnostics === true,
129
132
  source,
130
133
  stage,
131
134
  };
@@ -196,10 +199,12 @@ export function loadProjectPlugins(options: {
196
199
  }
197
200
  return {
198
201
  binary,
202
+ capabilities: record.capabilities,
199
203
  config: record.config,
200
204
  contributors: record.contributors,
201
205
  kind: record.kind,
202
206
  name: record.name,
207
+ reportsTypeScriptDiagnostics: record.reportsTypeScriptDiagnostics,
203
208
  source: record.source,
204
209
  stage: record.stage,
205
210
  };
@@ -288,6 +293,14 @@ function composePluginSources(
288
293
  ...plugin,
289
294
  source: aggregate.plugin.source,
290
295
  contributors: aggregate.plugin.contributors,
296
+ // The composed plugin's runtime BINARY is the aggregate's binary,
297
+ // so the CLI surface (which flags the sidecar parses) is the
298
+ // aggregate's. Inherit `capabilities` from the aggregate so a
299
+ // capability the aggregate declares — e.g. threadingArgs — does
300
+ // not get silently dropped just because the composed entry's own
301
+ // descriptor omitted it. If the aggregate did not set capabilities
302
+ // we keep the composed plugin's own as a fallback.
303
+ capabilities: aggregate.plugin.capabilities ?? plugin.capabilities,
291
304
  };
292
305
  });
293
306
  }
@@ -545,38 +558,72 @@ function loadPluginEntry(
545
558
  context: ITtscPluginFactoryContext,
546
559
  baseDir: string,
547
560
  ): ITtscPlugin {
548
- const specifier = entry.transform;
549
- if (typeof specifier !== "string" || specifier.length === 0) {
550
- throw new Error(`ttsc: plugin entry is missing a string "transform" field`);
551
- }
552
-
553
- const request = resolvePluginRequest(specifier, baseDir);
554
- const mod = require(request) as {
555
- createTtscPlugin?: TtscPluginFactory;
556
- default?: ITtscPlugin | TtscPluginFactory;
557
- } & Partial<Record<"plugin", ITtscPlugin | TtscPluginFactory>>;
558
- const candidate =
559
- mod.createTtscPlugin ??
560
- mod.default ??
561
- mod.plugin ??
562
- (mod as unknown as ITtscPlugin | TtscPluginFactory);
563
- if (typeof candidate === "function") {
564
- const plugin = candidate(context);
565
- if (!isTtscPlugin(plugin)) {
561
+ return withPluginLoaderEnv(() => {
562
+ const specifier = entry.transform;
563
+ if (typeof specifier !== "string" || specifier.length === 0) {
566
564
  throw new Error(
567
- `ttsc: plugin "${specifier}" does not export a valid ttsc plugin`,
565
+ `ttsc: plugin entry is missing a string "transform" field`,
568
566
  );
569
567
  }
570
- rejectJsTransformFunctions(specifier, plugin);
571
- return plugin;
568
+
569
+ const request = resolvePluginRequest(specifier, baseDir);
570
+ const mod = require(request) as {
571
+ createTtscPlugin?: TtscPluginFactory;
572
+ default?: ITtscPlugin | TtscPluginFactory;
573
+ } & Partial<Record<"plugin", ITtscPlugin | TtscPluginFactory>>;
574
+ const candidate =
575
+ mod.createTtscPlugin ??
576
+ mod.default ??
577
+ mod.plugin ??
578
+ (mod as unknown as ITtscPlugin | TtscPluginFactory);
579
+ if (typeof candidate === "function") {
580
+ const plugin = candidate(context);
581
+ if (!isTtscPlugin(plugin)) {
582
+ throw new Error(
583
+ `ttsc: plugin "${specifier}" does not export a valid ttsc plugin`,
584
+ );
585
+ }
586
+ rejectJsTransformFunctions(specifier, plugin);
587
+ return plugin;
588
+ }
589
+ if (isTtscPlugin(candidate)) {
590
+ rejectJsTransformFunctions(specifier, candidate);
591
+ return candidate;
592
+ }
593
+ throw new Error(
594
+ `ttsc: plugin "${specifier}" does not export a valid ttsc plugin`,
595
+ );
596
+ });
597
+ }
598
+
599
+ function withPluginLoaderEnv<T>(run: () => T): T {
600
+ const previousNode = process.env.TTSC_NODE_BINARY;
601
+ const previousTtsx = process.env.TTSC_TTSX_BINARY;
602
+ process.env.TTSC_NODE_BINARY ??= process.execPath;
603
+ process.env.TTSC_TTSX_BINARY ??= path.join(
604
+ __dirname,
605
+ "..",
606
+ "..",
607
+ "launcher",
608
+ "ttsx.js",
609
+ );
610
+ try {
611
+ return run();
612
+ } finally {
613
+ restoreEnv("TTSC_NODE_BINARY", previousNode);
614
+ restoreEnv("TTSC_TTSX_BINARY", previousTtsx);
572
615
  }
573
- if (isTtscPlugin(candidate)) {
574
- rejectJsTransformFunctions(specifier, candidate);
575
- return candidate;
616
+ }
617
+
618
+ function restoreEnv(
619
+ key: "TTSC_NODE_BINARY" | "TTSC_TTSX_BINARY",
620
+ value: string | undefined,
621
+ ): void {
622
+ if (value === undefined) {
623
+ delete process.env[key];
624
+ } else {
625
+ process.env[key] = value;
576
626
  }
577
- throw new Error(
578
- `ttsc: plugin "${specifier}" does not export a valid ttsc plugin`,
579
- );
580
627
  }
581
628
 
582
629
  function isTtscPlugin(value: unknown): value is ITtscPlugin {