ttsc 0.12.4 → 0.13.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 (54) hide show
  1. package/README.md +3 -0
  2. package/cmd/ttsc/api_compile.go +15 -2
  3. package/cmd/ttsc/api_transform.go +15 -2
  4. package/cmd/ttsc/build.go +34 -4
  5. package/cmd/ttsc/filter.go +70 -0
  6. package/cmd/ttsc/filter_strips_unknown_forwarded_flag_test.go +61 -0
  7. package/cmd/ttsc/flags_gen.go +30 -0
  8. package/driver/program.go +105 -4
  9. package/driver/rewrite.go +39 -4
  10. package/lib/compiler/internal/project/resolveProjectConfig.js +18 -0
  11. package/lib/compiler/internal/project/resolveProjectConfig.js.map +1 -1
  12. package/lib/compiler/internal/runBuild.d.ts +5 -4
  13. package/lib/compiler/internal/runBuild.js +358 -51
  14. package/lib/compiler/internal/runBuild.js.map +1 -1
  15. package/lib/flags/parser.d.ts +63 -0
  16. package/lib/flags/parser.js +254 -0
  17. package/lib/flags/parser.js.map +1 -0
  18. package/lib/flags/schema.d.ts +146 -0
  19. package/lib/flags/schema.js +423 -0
  20. package/lib/flags/schema.js.map +1 -0
  21. package/lib/launcher/internal/prepareExecution.js +3 -0
  22. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  23. package/lib/launcher/internal/runTtsc.js +71 -147
  24. package/lib/launcher/internal/runTtsc.js.map +1 -1
  25. package/lib/launcher/internal/runTtsx.js +104 -72
  26. package/lib/launcher/internal/runTtsx.js.map +1 -1
  27. package/lib/plugin/internal/loadProjectPlugins.js +58 -23
  28. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  29. package/lib/structures/ITtscPlugin.d.ts +24 -0
  30. package/lib/structures/ITtscPluginCapabilities.d.ts +40 -0
  31. package/lib/structures/ITtscPluginCapabilities.js +3 -0
  32. package/lib/structures/ITtscPluginCapabilities.js.map +1 -0
  33. package/lib/structures/index.d.ts +1 -0
  34. package/lib/structures/index.js +1 -0
  35. package/lib/structures/index.js.map +1 -1
  36. package/lib/structures/internal/ITtscLoadedNativePlugin.d.ts +5 -0
  37. package/lib/structures/internal/TtscCommonOptions.d.ts +19 -0
  38. package/package.json +9 -9
  39. package/shim/ast/lint.go +6 -0
  40. package/src/compiler/internal/project/resolveProjectConfig.ts +20 -0
  41. package/src/compiler/internal/runBuild.ts +463 -56
  42. package/src/flags/parser.ts +367 -0
  43. package/src/flags/schema.ts +589 -0
  44. package/src/launcher/internal/prepareExecution.ts +3 -0
  45. package/src/launcher/internal/runTtsc.ts +78 -136
  46. package/src/launcher/internal/runTtsx.ts +116 -74
  47. package/src/plugin/internal/loadProjectPlugins.ts +74 -27
  48. package/src/structures/ITtscPlugin.ts +26 -0
  49. package/src/structures/ITtscPluginCapabilities.ts +41 -0
  50. package/src/structures/index.ts +1 -0
  51. package/src/structures/internal/ITtscLoadedNativePlugin.ts +5 -0
  52. package/src/structures/internal/TtscCommonOptions.ts +19 -0
  53. package/utility/flags_gen.go +30 -0
  54. package/utility/host.go +43 -33
@@ -1,12 +1,16 @@
1
1
  import path from "node:path";
2
2
 
3
- import { loadProjectPlugins } from "../../plugin/internal/loadProjectPlugins";
3
+ import { INTERNAL_SHADOW_FLAGS, TERMINAL_FLAGS } from "../../flags/schema";
4
+ import {
5
+ hasProjectPluginEntries,
6
+ loadProjectPlugins,
7
+ } from "../../plugin/internal/loadProjectPlugins";
4
8
  import type { ITtscCompilerDiagnostic } from "../../structures/ITtscCompilerDiagnostic";
5
9
  import type { ITtscLoadedNativePlugin } from "../../structures/internal/ITtscLoadedNativePlugin";
6
10
  import type { TtscBuildOptions } from "../../structures/internal/TtscBuildOptions";
7
11
  import type { TtscBuildResult } from "../../structures/internal/TtscBuildResult";
8
12
  import type { TtscCommonOptions } from "../../structures/internal/TtscCommonOptions";
9
- import { resolveProjectConfig } from "./project/resolveProjectConfig";
13
+ import { readProjectConfig } from "./project/readProjectConfig";
10
14
  import { resolveBinary } from "./resolveBinary";
11
15
  import { resolveTsgo } from "./resolveTsgo";
12
16
  import {
@@ -16,6 +20,17 @@ import {
16
20
  } from "./sharedHostHelpers";
17
21
  import { outputText, spawnNative } from "./spawnNative";
18
22
 
23
+ type RunBuildOptions = TtscBuildOptions & {
24
+ skipDiagnosticsCheck?: boolean;
25
+ forceListEmittedFiles?: boolean;
26
+ };
27
+
28
+ type BuildTiming = {
29
+ enabled: boolean;
30
+ lines: string[];
31
+ startedAt: bigint;
32
+ };
33
+
19
34
  /**
20
35
  * Merge extra environment variables over `process.env`, always injecting
21
36
  * `TTSC_NODE_BINARY` so child processes can re-invoke the same Node.js binary
@@ -30,6 +45,81 @@ function mergeEnv(extra?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
30
45
  return { ...base, ...extra };
31
46
  }
32
47
 
48
+ function createBuildTiming(options: TtscCommonOptions): BuildTiming {
49
+ return {
50
+ enabled: hasDiagnosticsFlag(options),
51
+ lines: [],
52
+ startedAt: process.hrtime.bigint(),
53
+ };
54
+ }
55
+
56
+ function hasDiagnosticsFlag(options: TtscCommonOptions): boolean {
57
+ return (
58
+ hasEnabledPassthroughFlag(options, "--diagnostics") ||
59
+ hasEnabledPassthroughFlag(options, "--extendedDiagnostics")
60
+ );
61
+ }
62
+
63
+ function hasEnabledPassthroughFlag(
64
+ options: TtscCommonOptions,
65
+ flag: string,
66
+ ): boolean {
67
+ const passthrough = options.passthrough ?? [];
68
+ for (let i = 0; i < passthrough.length; i++) {
69
+ const token = passthrough[i]!;
70
+ if (token.startsWith(`${flag}=`)) {
71
+ return token.slice(flag.length + 1).toLowerCase() !== "false";
72
+ }
73
+ if (token === flag) {
74
+ if (
75
+ i + 1 < passthrough.length &&
76
+ isBooleanLiteral(passthrough[i + 1]!)
77
+ ) {
78
+ return passthrough[i + 1]!.toLowerCase() !== "false";
79
+ }
80
+ return true;
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+
86
+ function recordTiming(
87
+ timing: BuildTiming,
88
+ label: string,
89
+ startedAt: bigint,
90
+ ): void {
91
+ if (!timing.enabled) return;
92
+ timing.lines.push(`${label}: ${formatTimingSeconds(hrtimeMs(startedAt))}`);
93
+ }
94
+
95
+ function appendTimingOutput(
96
+ result: TtscBuildResult,
97
+ timing: BuildTiming,
98
+ ): TtscBuildResult {
99
+ if (!timing.enabled) return result;
100
+ const lines = [
101
+ ...timing.lines,
102
+ `ttsc total time: ${formatTimingSeconds(hrtimeMs(timing.startedAt))}`,
103
+ ];
104
+ return {
105
+ ...result,
106
+ stdout: appendStdout(result.stdout, lines.join("\n") + "\n"),
107
+ };
108
+ }
109
+
110
+ function appendStdout(stdout: string, text: string): string {
111
+ if (stdout.length === 0 || stdout.endsWith("\n")) return stdout + text;
112
+ return `${stdout}\n${text}`;
113
+ }
114
+
115
+ function hrtimeMs(startedAt: bigint): number {
116
+ return Number(process.hrtime.bigint() - startedAt) / 1e6;
117
+ }
118
+
119
+ function formatTimingSeconds(ms: number): string {
120
+ return `${(ms / 1000).toFixed(3)}s`;
121
+ }
122
+
33
123
  /**
34
124
  * Build the environment for a native plugin spawn. Injects `TTSC_TSGO_BINARY`
35
125
  * and `TTSC_TTSX_BINARY` alongside the base env from `mergeEnv`. For
@@ -61,24 +151,33 @@ function nativePluginEnv(
61
151
  * Run `ttsc` against a tsconfig. Returns once the binary exits so the CLI can
62
152
  * decide how to surface diagnostics. Does not throw on non-zero exit.
63
153
  */
64
- export function runBuild(
65
- options: TtscBuildOptions & {
66
- skipDiagnosticsCheck?: boolean;
67
- forceListEmittedFiles?: boolean;
68
- } = {},
154
+ export function runBuild(options: RunBuildOptions = {}): TtscBuildResult {
155
+ const timing = createBuildTiming(options);
156
+ const result = runBuildTimed(options, timing);
157
+ return appendTimingOutput(result, timing);
158
+ }
159
+
160
+ function runBuildTimed(
161
+ options: RunBuildOptions,
162
+ timing: BuildTiming,
69
163
  ): TtscBuildResult {
164
+ const setupStartedAt = process.hrtime.bigint();
70
165
  const execution = resolveExecutionContext(options);
166
+ if (execution.nativePlugins.length > 0) {
167
+ recordTiming(timing, "ttsc plugin setup time", setupStartedAt);
168
+ }
169
+ const buildOptions = applyProjectNoEmit(options, execution);
71
170
  if (execution.nativePlugins.length > 0) {
72
171
  const compilers = execution.nativePlugins.filter(
73
172
  (plugin) => plugin.stage === "transform",
74
173
  );
75
- const checked = runNativeCheckPlugins(options, execution);
174
+ const checked = runNativeCheckPlugins(buildOptions, execution, timing);
76
175
  if (checked.status !== 0) {
77
176
  return checked;
78
177
  }
79
178
 
80
- if (options.emit === false) {
81
- if (options.format === true) {
179
+ if (buildOptions.emit === false) {
180
+ if (buildOptions.format === true) {
82
181
  // Format mode is write-only by contract: the lint sidecar
83
182
  // already rewrote source files and reported nothing. Running
84
183
  // tsgo --noEmit OR a transform compiler afterwards would either
@@ -97,12 +196,20 @@ export function runBuild(
97
196
  assertSharedHostCompatibility(compilers, "emit");
98
197
  return appendBuildOutput(
99
198
  checked,
100
- buildWithNativeCompilerPlugins(options, execution, compilers),
199
+ buildWithNativeCompilerPlugins(
200
+ buildOptions,
201
+ execution,
202
+ compilers,
203
+ timing,
204
+ ),
101
205
  );
102
206
  }
207
+ if (checkPluginsReportTypeScriptDiagnostics(execution.nativePlugins)) {
208
+ return checked;
209
+ }
103
210
  return appendBuildOutput(
104
211
  checked,
105
- runTsgo(execution, ["--noEmit"], options),
212
+ runTsgo(execution, ["--noEmit"], buildOptions),
106
213
  );
107
214
  }
108
215
 
@@ -111,26 +218,35 @@ export function runBuild(
111
218
  assertSharedHostCompatibility(compilers, "emit");
112
219
  result = appendBuildOutput(
113
220
  checked,
114
- buildWithNativeCompilerPlugins(options, execution, compilers),
221
+ buildWithNativeCompilerPlugins(
222
+ buildOptions,
223
+ execution,
224
+ compilers,
225
+ timing,
226
+ ),
115
227
  );
116
228
  } else {
117
- if (options.skipDiagnosticsCheck !== true) {
118
- const tsgoChecked = runTsgo(execution, ["--noEmit"], options);
229
+ if (
230
+ buildOptions.skipDiagnosticsCheck !== true &&
231
+ !checkPluginsReportTypeScriptDiagnostics(execution.nativePlugins) &&
232
+ !forwardsTerminalTsgoFlag(buildOptions)
233
+ ) {
234
+ const tsgoChecked = runTsgo(execution, ["--noEmit"], buildOptions);
119
235
  if (tsgoChecked.status !== 0) {
120
236
  return appendBuildOutput(checked, tsgoChecked);
121
237
  }
122
238
  }
123
- const args = createTsgoBuildArgs(execution, options, {
124
- listEmittedFiles: options.forceListEmittedFiles === true,
239
+ const args = createTsgoBuildArgs(execution, buildOptions, {
240
+ listEmittedFiles: buildOptions.forceListEmittedFiles === true,
125
241
  });
126
- const emitted = runTsgoBuild(execution, options, args);
242
+ const emitted = runTsgoBuild(execution, buildOptions, args);
127
243
  result = appendBuildOutput(checked, emitted);
128
244
  }
129
245
 
130
246
  return result;
131
247
  }
132
248
 
133
- if (options.format === true) {
249
+ if (buildOptions.format === true) {
134
250
  // Format mode is write-only by contract — see the matching
135
251
  // short-circuit in the with-native-plugins branch above. When no
136
252
  // native plugin is loaded there is nothing for `ttsc format` to
@@ -145,18 +261,100 @@ export function runBuild(
145
261
  };
146
262
  }
147
263
 
148
- if (options.emit !== false && options.skipDiagnosticsCheck !== true) {
149
- const checked = runTsgo(execution, ["--noEmit"], options);
150
- if (checked.status !== 0) {
151
- return checked;
152
- }
153
- }
154
-
155
- const args = createTsgoBuildArgs(execution, options, {
264
+ const args = createTsgoBuildArgs(execution, buildOptions, {
156
265
  listEmittedFiles:
157
- options.emit !== false && options.forceListEmittedFiles === true,
266
+ buildOptions.emit !== false &&
267
+ buildOptions.forceListEmittedFiles === true,
268
+ noEmitOnError:
269
+ buildOptions.emit !== false &&
270
+ buildOptions.skipDiagnosticsCheck !== true &&
271
+ !forwardsTerminalTsgoFlag(buildOptions),
158
272
  });
159
- return runTsgoBuild(execution, options, args);
273
+ return runTsgoBuild(execution, buildOptions, args);
274
+ }
275
+
276
+ /**
277
+ * A tsconfig-level `noEmit: true` is an analysis-only build unless the user
278
+ * explicitly asks `ttsc --emit` to override it. Treat it like CLI `--noEmit`
279
+ * before composing tsgo/native-host arguments so ttsc does not add emit-only
280
+ * guards around projects that cannot emit.
281
+ */
282
+ function applyProjectNoEmit(
283
+ options: RunBuildOptions,
284
+ execution: ReturnType<typeof resolveExecutionContext>,
285
+ ): RunBuildOptions {
286
+ if (options.emit !== undefined || execution.projectNoEmit !== true) {
287
+ return options;
288
+ }
289
+ return { ...options, emit: false };
290
+ }
291
+
292
+ function checkPluginsReportTypeScriptDiagnostics(
293
+ plugins: readonly ITtscLoadedNativePlugin[],
294
+ ): boolean {
295
+ return plugins.some(
296
+ (plugin) =>
297
+ plugin.stage === "check" && plugin.reportsTypeScriptDiagnostics === true,
298
+ );
299
+ }
300
+
301
+ /**
302
+ * Tsgo CLI flags that make `tsgo` print something and exit instead of building
303
+ * (`--showConfig`, `--listFilesOnly`, `--help`, …). ttsc must not add
304
+ * build-only guard flags around these because the forwarded flag asks tsgo to
305
+ * print something and exit instead of compiling the project.
306
+ *
307
+ * Schema-derived: the set is computed from `FLAG_SCHEMA[*].terminal === true`,
308
+ * not hand-maintained next to runBuild. Adding a new terminal flag now means
309
+ * editing `schema.ts` and re-running `pnpm format` — every layer learns about
310
+ * it automatically.
311
+ */
312
+ const TERMINAL_TSGO_FLAGS: ReadonlySet<string> = (() => {
313
+ // Mirror the legacy hand-list: schema's terminal flags ∪ the `-?` alias
314
+ // tsgo accepts (kept here because it is a tsgo synonym, not a ttsc flag).
315
+ const out = new Set<string>(TERMINAL_FLAGS);
316
+ out.add("-?");
317
+ return out;
318
+ })();
319
+
320
+ /**
321
+ * Report whether the caller forwarded a print-and-exit tsgo flag, so ttsc can
322
+ * avoid adding compile-only flags to a command that is not going to compile.
323
+ */
324
+ function forwardsTerminalTsgoFlag(options: TtscCommonOptions): boolean {
325
+ return (
326
+ options.passthrough?.some((flag) => TERMINAL_TSGO_FLAGS.has(flag)) ?? false
327
+ );
328
+ }
329
+
330
+ /**
331
+ * Report whether the caller forwarded a flag ttsc adds to tsgo internally —
332
+ * e.g. `--listEmittedFiles` (ttsc adds it to learn emitted paths) or `--noEmit`
333
+ * (ttsc adds it for the pre-emit type-check). When the user also forwards the
334
+ * same flag, post-processing must keep the user-visible effect intact instead
335
+ * of stripping it as ttsc-internal noise.
336
+ *
337
+ * Schema-derived: `FLAG_SCHEMA[*].internalShadow === true`. RC-2 from the RCA
338
+ * (RCA section 3, `--listEmittedFiles` / `--showConfig` swallowed): the
339
+ * per-flag `passthrough.includes("…")` check is now one structural lookup
340
+ * against the schema, not one bespoke `if` per shadow flag.
341
+ */
342
+ function forwardsInternalShadowFlag(
343
+ options: TtscCommonOptions,
344
+ flag: string,
345
+ ): boolean {
346
+ if (!INTERNAL_SHADOW_FLAGS.has(flag)) return false;
347
+ const passthrough = options.passthrough;
348
+ if (passthrough === undefined) return false;
349
+ // Match both the bare form (`--pretty`) and the inline-value form
350
+ // (`--pretty=true`). The launcher's parser preserves these shapes
351
+ // verbatim, so a tolerant prefix compare is what we want — `passthrough`
352
+ // never holds a stray substring that happens to start with the flag
353
+ // because parser positional/passthrough sinks split on whitespace.
354
+ const inlinePrefix = `${flag}=`;
355
+ return passthrough.some(
356
+ (token) => token === flag || token.startsWith(inlinePrefix),
357
+ );
160
358
  }
161
359
 
162
360
  /**
@@ -168,6 +366,7 @@ function buildWithNativeCompilerPlugins(
168
366
  options: TtscBuildOptions,
169
367
  execution: ReturnType<typeof resolveExecutionContext>,
170
368
  plugins: readonly ITtscLoadedNativePlugin[],
369
+ timing: BuildTiming,
171
370
  ): TtscBuildResult {
172
371
  const host = selectSharedHostPlugin(plugins);
173
372
  return runNativePluginCommand(
@@ -176,6 +375,8 @@ function buildWithNativeCompilerPlugins(
176
375
  options,
177
376
  execution,
178
377
  "ttsc.build",
378
+ timing,
379
+ transformHostTimingLabel(plugins),
179
380
  );
180
381
  }
181
382
 
@@ -186,7 +387,7 @@ function buildWithNativeCompilerPlugins(
186
387
  function runTsgo(
187
388
  execution: ReturnType<typeof resolveExecutionContext>,
188
389
  extraArgs: readonly string[],
189
- options: NonNullable<Parameters<typeof runBuild>[0]>,
390
+ options: RunBuildOptions,
190
391
  ): TtscBuildResult {
191
392
  const res = spawnNative(
192
393
  execution.tsgo.binary,
@@ -195,6 +396,8 @@ function runTsgo(
195
396
  execution.tsconfig,
196
397
  ...extraArgs,
197
398
  ...createTsgoDiagnosticArgs(options),
399
+ ...createTsgoThreadingArgs(options),
400
+ ...(options.passthrough ?? []),
198
401
  ],
199
402
  {
200
403
  cwd: execution.projectRoot,
@@ -227,7 +430,7 @@ function runTsgo(
227
430
  */
228
431
  function runTsgoBuild(
229
432
  execution: ReturnType<typeof resolveExecutionContext>,
230
- options: NonNullable<Parameters<typeof runBuild>[0]>,
433
+ options: RunBuildOptions,
231
434
  args: readonly string[],
232
435
  ): TtscBuildResult {
233
436
  const res = spawnNative(execution.tsgo.binary, args, {
@@ -249,7 +452,18 @@ function runTsgoBuild(
249
452
  stderr: outputText(res.stderr),
250
453
  };
251
454
  const emittedFiles = parseEmittedFiles(result.stdout);
252
- if (emittedFiles.length !== 0) {
455
+ // The `TSFILE:` lines are tsgo's `--listEmittedFiles` output. ttsc adds that
456
+ // flag internally to learn the emitted paths and strips the lines back out
457
+ // as noise — but when the user themselves forwarded `--listEmittedFiles`,
458
+ // the listing is what they asked for, so it must survive to stdout.
459
+ // The lookup is schema-driven (FLAG_SCHEMA marks `--listEmittedFiles` with
460
+ // `internalShadow: true`); see `forwardsInternalShadowFlag` for the RC-2
461
+ // background.
462
+ const userListedEmitted = forwardsInternalShadowFlag(
463
+ options,
464
+ "--listEmittedFiles",
465
+ );
466
+ if (emittedFiles.length !== 0 && !userListedEmitted) {
253
467
  result.stdout = stripEmittedFileLines(result.stdout);
254
468
  }
255
469
  return normalizeBuildOutput(
@@ -261,12 +475,15 @@ function runTsgoBuild(
261
475
  /** Build the argument list for a direct `tsgo` build invocation. */
262
476
  function createTsgoBuildArgs(
263
477
  execution: ReturnType<typeof resolveExecutionContext>,
264
- options: NonNullable<Parameters<typeof runBuild>[0]>,
265
- flags: { listEmittedFiles: boolean },
478
+ options: RunBuildOptions,
479
+ flags: { listEmittedFiles: boolean; noEmitOnError?: boolean },
266
480
  ): string[] {
267
481
  const args = ["-p", execution.tsconfig];
268
482
  if (options.emit === true) {
269
483
  args.push("--noEmit", "false", "--emitDeclarationOnly", "false");
484
+ if (execution.rewriteRelativeImportExtensionsForEmit) {
485
+ args.push("--rewriteRelativeImportExtensions");
486
+ }
270
487
  } else if (options.emit === false) {
271
488
  args.push("--noEmit");
272
489
  }
@@ -277,15 +494,49 @@ function createTsgoBuildArgs(
277
494
  args.push("--listEmittedFiles");
278
495
  }
279
496
  args.push(...createTsgoDiagnosticArgs(options));
497
+ args.push(...createTsgoThreadingArgs(options));
498
+ args.push(...(options.passthrough ?? []));
499
+ if (flags.noEmitOnError === true) {
500
+ args.push("--noEmitOnError");
501
+ }
280
502
  return args;
281
503
  }
282
504
 
283
505
  /**
284
506
  * Return `["--pretty", "false"]` when structured diagnostics are requested so
285
507
  * that the output can be parsed line-by-line, or an empty array otherwise.
508
+ *
509
+ * When the user explicitly forwarded `--pretty` (any value), the internal
510
+ * `--pretty false` shadow is dropped so the user wins on the surface. ttsc's
511
+ * own diagnostic parser will then see pretty-formatted output and fall back to
512
+ * surfacing it verbatim — the RC-2 contract that `--pretty`'s `internalShadow:
513
+ * true` flag in `FLAG_SCHEMA` declares. Without this guard the order in
514
+ * `runTsgo` (internal flags first, passthrough last) would still let the user's
515
+ * `--pretty true` override at the tsgo level, but ttsc would have already
516
+ * committed to a structured-diagnostics post-process that no longer matches the
517
+ * actual output.
286
518
  */
287
519
  function createTsgoDiagnosticArgs(options: TtscCommonOptions): string[] {
288
- return options.structuredDiagnostics === true ? ["--pretty", "false"] : [];
520
+ if (options.structuredDiagnostics !== true) return [];
521
+ if (forwardsInternalShadowFlag(options, "--pretty")) return [];
522
+ return ["--pretty", "false"];
523
+ }
524
+
525
+ /**
526
+ * Forward the `--singleThreaded` / `--checkers` knobs to a `tsgo` invocation.
527
+ * tsgo accepts both flags natively, so the no-plugin build lane only has to
528
+ * pass them through; the type-check and emit passes share this so the checker
529
+ * pool size stays consistent across both.
530
+ */
531
+ function createTsgoThreadingArgs(options: TtscCommonOptions): string[] {
532
+ const args: string[] = [];
533
+ if (options.singleThreaded === true) {
534
+ args.push("--singleThreaded");
535
+ }
536
+ if (options.checkers !== undefined) {
537
+ args.push("--checkers", String(options.checkers));
538
+ }
539
+ return args;
289
540
  }
290
541
 
291
542
  /** Build the argument list for a native plugin `build`/`check` invocation. */
@@ -302,17 +553,21 @@ function createNativeBuildArgs(
302
553
  ];
303
554
  if (options.emit === true) {
304
555
  args.push("--emit");
305
- } else if (options.emit === false) {
306
- args.push("--noEmit");
307
556
  }
308
557
  if (options.outDir) {
309
558
  args.push("--outDir=" + path.resolve(execution.cwd, options.outDir));
310
559
  }
311
- if (options.quiet === false) {
312
- args.push("--verbose");
313
- } else if (options.quiet === true) {
314
- args.push("--quiet");
560
+ // Third-party transform hosts already treat the `check` subcommand as a
561
+ // quiet no-emit pass. Keep ttsc-owned build modifiers off that lane so older
562
+ // strict hosts do not reject unknown optional flags before analysis starts.
563
+ if (options.emit !== false) {
564
+ if (options.quiet === false) {
565
+ args.push("--verbose");
566
+ } else if (options.quiet === true) {
567
+ args.push("--quiet");
568
+ }
315
569
  }
570
+ args.push(...createNativeTsgoArgs(options));
316
571
  return args;
317
572
  }
318
573
 
@@ -320,6 +575,7 @@ function createNativeBuildArgs(
320
575
  function createNativeCheckArgs(
321
576
  execution: ReturnType<typeof resolveExecutionContext>,
322
577
  options: TtscBuildOptions,
578
+ plugin: ITtscLoadedNativePlugin,
323
579
  ): string[] {
324
580
  const args = [
325
581
  nativeCheckSubcommand(options),
@@ -335,9 +591,147 @@ function createNativeCheckArgs(
335
591
  } else if (options.quiet === true) {
336
592
  args.push("--quiet");
337
593
  }
594
+ args.push(...createNativeCheckThreadingArgs(options, plugin));
595
+ args.push(...createNativeCheckDiagnosticsArgs(options, plugin));
596
+ args.push(...createNativeTsgoArgs(options));
597
+ return args;
598
+ }
599
+
600
+ // `--singleThreaded` / `--checkers` are forwarded to native check-stage hosts
601
+ // only when the host is one ttsc itself owns (currently `@ttsc/lint`). #113
602
+ // forwarded both flags as bare CLI tokens to every native sidecar, then
603
+ // commit ad3443a reverted that across the board because a third-party host
604
+ // built before #113 has no `singleThreaded` / `checkers` flag in its
605
+ // `flag.FlagSet` and would exit 2 on the unknown flag — so
606
+ // `ttsc --singleThreaded` failed deterministically on every typia/nestia
607
+ // transform-plugin project.
608
+ //
609
+ // The performance ceiling that caused, though, is real: format/check passes
610
+ // through the lint sidecar are dominated by parallel parse + parallel rule
611
+ // walk, and with the threading knob silently dropped, MT and ST runs of
612
+ // `ttsc format` produced identical wall-clock numbers — the benchmark cell
613
+ // became a non-measurement. The lint sidecar is built and shipped from this
614
+ // repo, accepts both flags via `parseSubcommandFlags`, and threads them down
615
+ // to `loadProgram` (parse phase) and `engine.SetSerial` (rule walk). The host
616
+ // opts in through `capabilities.threadingArgs`, so a third-party check-stage
617
+ // plugin keeps the strict-host behavior from ad3443a unless it declares the
618
+ // same contract. Transform-stage hosts are never reached by this path (they go
619
+ // through `createNativeBuildArgs`), so the typia/nestia regression remains
620
+ // pinned by `test_plugin_corpus_single_threaded_flag_does_not_break_a_native_plugin_build`.
621
+ function createNativeCheckThreadingArgs(
622
+ options: TtscCommonOptions,
623
+ plugin: ITtscLoadedNativePlugin,
624
+ ): string[] {
625
+ if (!nativeHostAcceptsThreadingArgs(plugin)) return [];
626
+ const args: string[] = [];
627
+ if (options.singleThreaded === true) {
628
+ args.push("--singleThreaded");
629
+ }
630
+ if (options.checkers !== undefined) {
631
+ args.push("--checkers=" + String(options.checkers));
632
+ }
338
633
  return args;
339
634
  }
340
635
 
636
+ /**
637
+ * Return true when the loaded native check-stage host has declared
638
+ * `capabilities.threadingArgs` in its plugin descriptor.
639
+ *
640
+ * The lint sidecar (`packages/lint/src/index.ts::createTtscPlugin`) opts in
641
+ * because its `parseSubcommandFlags` handler accepts `--singleThreaded` and
642
+ * `--checkers` directly and threads them into `loadProgram` (parse phase) and
643
+ * `engine.SetSerial` (rule walk). Any other check-stage host that has not
644
+ * declared the capability is treated as a third-party binary whose flag set is
645
+ * unknown, matching the conservative default from commit ad3443a.
646
+ *
647
+ * The capability flag replaces the prior `plugin.name === "@ttsc/lint"` string
648
+ * check: routing on a descriptor field instead of the plugin name lets the next
649
+ * first-party check-stage plugin opt in without ttsc needing to learn its name.
650
+ * See `ITtscPluginCapabilities` and issue #125 for the broader CLI-parser
651
+ * cleanup this is the quick-win step of.
652
+ */
653
+ function nativeHostAcceptsThreadingArgs(
654
+ plugin: ITtscLoadedNativePlugin,
655
+ ): boolean {
656
+ return plugin.capabilities?.threadingArgs === true;
657
+ }
658
+
659
+ function createNativeCheckDiagnosticsArgs(
660
+ options: TtscCommonOptions,
661
+ plugin: ITtscLoadedNativePlugin,
662
+ ): string[] {
663
+ if (!nativeHostAcceptsDiagnosticsTiming(plugin)) return [];
664
+ if (!hasDiagnosticsFlag(options)) return [];
665
+ return ["--diagnostics"];
666
+ }
667
+
668
+ function nativeHostAcceptsDiagnosticsTiming(
669
+ plugin: ITtscLoadedNativePlugin,
670
+ ): boolean {
671
+ return plugin.capabilities?.diagnosticsTiming === true;
672
+ }
673
+
674
+ function transformHostTimingLabel(
675
+ plugins: readonly ITtscLoadedNativePlugin[],
676
+ ): string {
677
+ return (
678
+ `ttsc transform host [` +
679
+ `${plugins.map((plugin) => plugin.name).join(", ")}] time`
680
+ );
681
+ }
682
+
683
+ /**
684
+ * Forward the tsgo flags ttsc did not recognize to a native sidecar as one
685
+ * JSON-encoded `--tsgo-args` flag. The sidecar replays them through tsgo's own
686
+ * option parser onto `CompilerOptions`, so a flag like `ttsc --strict` reaches
687
+ * a plugin build the same way it reaches the plain tsgo lane. Encoded as a
688
+ * single token so the sidecars' unknown-flag filters keep it intact.
689
+ */
690
+ function createNativeTsgoArgs(options: TtscCommonOptions): string[] {
691
+ const passthrough = nativeTsgoPassthroughArgs(options);
692
+ if (passthrough === undefined || passthrough.length === 0) {
693
+ return [];
694
+ }
695
+ return ["--tsgo-args=" + JSON.stringify(passthrough)];
696
+ }
697
+
698
+ function nativeTsgoPassthroughArgs(
699
+ options: TtscCommonOptions,
700
+ ): readonly string[] | undefined {
701
+ const passthrough = options.passthrough;
702
+ if (passthrough === undefined) return undefined;
703
+ const out: string[] = [];
704
+ for (let i = 0; i < passthrough.length; i++) {
705
+ const token = passthrough[i]!;
706
+ if (isDiagnosticsPassthroughFlag(token)) {
707
+ if (
708
+ !token.includes("=") &&
709
+ i + 1 < passthrough.length &&
710
+ isBooleanLiteral(passthrough[i + 1]!)
711
+ ) {
712
+ i++;
713
+ }
714
+ continue;
715
+ }
716
+ out.push(token);
717
+ }
718
+ return out;
719
+ }
720
+
721
+ function isDiagnosticsPassthroughFlag(token: string): boolean {
722
+ return (
723
+ token === "--diagnostics" ||
724
+ token.startsWith("--diagnostics=") ||
725
+ token === "--extendedDiagnostics" ||
726
+ token.startsWith("--extendedDiagnostics=")
727
+ );
728
+ }
729
+
730
+ function isBooleanLiteral(token: string): boolean {
731
+ const normalized = token.toLowerCase();
732
+ return normalized === "true" || normalized === "false";
733
+ }
734
+
341
735
  /**
342
736
  * Decide which native plugin subcommand the lint sidecar should run for the
343
737
  * current `runBuild` invocation. The launcher selects exactly one of `fix` /
@@ -373,6 +767,7 @@ function serializeNativePlugins(
373
767
  function runNativeCheckPlugins(
374
768
  options: TtscBuildOptions,
375
769
  execution: ReturnType<typeof resolveExecutionContext>,
770
+ timing: BuildTiming,
376
771
  ): TtscBuildResult {
377
772
  let out: TtscBuildResult = {
378
773
  diagnostics: [],
@@ -385,10 +780,12 @@ function runNativeCheckPlugins(
385
780
  )) {
386
781
  const result = runNativePluginCommand(
387
782
  plugin,
388
- createNativeCheckArgs(execution, options),
783
+ createNativeCheckArgs(execution, options, plugin),
389
784
  options,
390
785
  execution,
391
786
  "ttsc.check",
787
+ timing,
788
+ `ttsc check plugin ${plugin.name} time`,
392
789
  );
393
790
  out = appendBuildOutput(out, result);
394
791
  if (result.status !== 0) {
@@ -409,12 +806,16 @@ function runNativePluginCommand(
409
806
  options: TtscBuildOptions,
410
807
  execution: ReturnType<typeof resolveExecutionContext>,
411
808
  label: string,
809
+ timing: BuildTiming,
810
+ timingLabel: string,
412
811
  ): TtscBuildResult {
812
+ const startedAt = process.hrtime.bigint();
413
813
  const res = spawnNative(plugin.binary, args, {
414
814
  cwd: execution.projectRoot,
415
815
  env: nativePluginEnv(options.env, execution, plugin),
416
816
  encoding: "utf8",
417
817
  });
818
+ recordTiming(timing, timingLabel, startedAt);
418
819
  if (res.error) {
419
820
  throw new Error(
420
821
  `${label}: failed to spawn ${plugin.binary}: ${res.error.message}`,
@@ -458,30 +859,36 @@ export function appendBuildOutput(
458
859
  * here so every code path in `runBuild` shares the same resolution logic.
459
860
  */
460
861
  function resolveExecutionContext(
461
- options: TtscCommonOptions & { tsconfig?: string },
862
+ options: TtscCommonOptions & { emit?: boolean; tsconfig?: string },
462
863
  ) {
463
864
  const cwd = path.resolve(options.cwd ?? process.cwd());
464
- const tsconfig = resolveProjectConfig({
865
+ const project = readProjectConfig({
465
866
  cwd,
867
+ projectRoot: options.projectRoot,
466
868
  tsconfig: options.tsconfig,
467
869
  });
468
- const projectRoot = options.projectRoot
469
- ? path.resolve(cwd, options.projectRoot)
470
- : path.dirname(tsconfig);
870
+ const tsconfig = project.path;
871
+ const projectRoot = project.root;
471
872
  const tsgo = resolveTsgo({ ...options, cwd: projectRoot });
472
- const fallbackBinary = resolveBinary(options);
473
- const loaded = loadProjectPlugins({
474
- binary: fallbackBinary ?? "",
475
- cacheDir: options.cacheDir ?? options.env?.TTSC_CACHE_DIR,
476
- cwd,
477
- entries: options.plugins,
478
- projectRoot,
479
- tsconfig,
480
- });
873
+ const hasPlugins = hasProjectPluginEntries(project, options.plugins);
874
+ const loaded = hasPlugins
875
+ ? loadProjectPlugins({
876
+ binary: resolveBinary(options) ?? "",
877
+ cacheDir: options.cacheDir ?? options.env?.TTSC_CACHE_DIR,
878
+ cwd,
879
+ entries: options.plugins,
880
+ projectRoot,
881
+ tsconfig,
882
+ })
883
+ : { nativePlugins: [] };
481
884
  return {
482
885
  cwd,
483
886
  nativePlugins: loaded.nativePlugins,
887
+ projectNoEmit: project.compilerOptions.noEmit === true,
484
888
  projectRoot,
889
+ rewriteRelativeImportExtensionsForEmit:
890
+ options.emit === true &&
891
+ project.compilerOptions.allowImportingTsExtensions === true,
485
892
  tsgo,
486
893
  tsconfig,
487
894
  };