ttsc 0.4.2 → 0.4.3-dev.20260426-2

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/api.ts CHANGED
@@ -2,26 +2,26 @@
2
2
  * Programmatic API for ttsc.
3
3
  *
4
4
  * This module is the TypeScript surface bundler adapters (unplugin, vite,
5
- * webpack, rollup, esbuild, rspack, farm, next/swc, bun) consume. Everything
6
- * here is a thin wrapper around the Go binary resolved by `platform.ts` —
7
- * adapters never have to spawn the process themselves.
5
+ * webpack, rollup, esbuild, rspack, farm, next/swc, bun) consume. Project
6
+ * builds delegate to the consuming project's `@typescript/native-preview`
7
+ * `tsgo` binary, while plugin-selected native sidecars remain opt-in.
8
8
  *
9
9
  * Contract:
10
10
  *
11
11
  * - `transform()` emits one file's rewritten JS, returning the text.
12
12
  * - `build()` runs the whole project (tsgo + ttsc rewrite + --emit).
13
13
  * - `check()` runs the analysis pass without emitting (CI gate use).
14
- * - `version()` returns the binary's version banner for user-agent logs.
14
+ * - `version()` returns the wrapper and resolved `tsgo` version banner.
15
15
  *
16
16
  * All helpers accept a `binary` override so tests can point at a specific
17
- * executable without touching PATH or node_modules.
17
+ * `tsgo` executable without touching PATH or node_modules.
18
18
  */
19
19
  import { spawnSync } from "node:child_process";
20
20
  import * as fs from "node:fs";
21
21
  import * as os from "node:os";
22
22
  import * as path from "node:path";
23
23
 
24
- import { type ResolveOptions, installHint, resolveBinary } from "./platform";
24
+ import { type ResolveOptions, resolveBinary } from "./platform";
25
25
  import {
26
26
  type TtscPlugin,
27
27
  applyPluginTransforms,
@@ -32,6 +32,7 @@ import {
32
32
  resolveProjectConfig,
33
33
  resolveProjectRoot,
34
34
  } from "./project";
35
+ import { type ResolvedTsgo, resolveTsgo } from "./tsgo";
35
36
 
36
37
  /**
37
38
  * Options shared by every API call. `binary` takes precedence over platform
@@ -39,7 +40,7 @@ import {
39
40
  * current process env.
40
41
  */
41
42
  export interface CommonOptions extends ResolveOptions {
42
- /** Absolute path to an already-resolved ttsc binary. Skips resolution. */
43
+ /** Absolute path to an already-resolved tsgo binary. Skips package resolution. */
43
44
  binary?: string;
44
45
  /** Working directory passed to the child process. */
45
46
  cwd?: string;
@@ -85,30 +86,13 @@ export interface BuildOptions extends CommonOptions {
85
86
  outDir?: string;
86
87
  /** Suppress the per-call summary banner. Default: `true`. */
87
88
  quiet?: boolean;
89
+ /** @internal Caller already ran diagnostics and accepts responsibility. */
90
+ skipDiagnosticsCheck?: boolean;
88
91
  }
89
92
 
90
93
  /** Options for `check()`. */
91
94
  export type CheckOptions = Omit<BuildOptions, "emit">;
92
95
 
93
- /**
94
- * Resolve the binary or throw a user-friendly error. Extracted so every API
95
- * helper shares the same failure mode.
96
- */
97
- function resolveOrThrow(opts: CommonOptions): string {
98
- if (
99
- opts.binary &&
100
- path.isAbsolute(opts.binary) &&
101
- fs.existsSync(opts.binary)
102
- ) {
103
- return opts.binary;
104
- }
105
- const bin = resolveBinary(opts);
106
- if (!bin) {
107
- throw new Error(installHint(opts));
108
- }
109
- return bin;
110
- }
111
-
112
96
  /** Merge spawn env without clobbering unrelated vars. */
113
97
  function mergeEnv(extra?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
114
98
  if (!extra) return process.env;
@@ -184,6 +168,62 @@ export function transform(options: TransformOptions): string {
184
168
  ? options.file
185
169
  : path.resolve(options.cwd ?? process.cwd(), options.file),
186
170
  );
171
+ if (execution.nativeBinary) {
172
+ return transformWithNativeBinary(options, execution, sourceFile);
173
+ }
174
+ if (execution.nativeMode !== "none") {
175
+ throw new Error(
176
+ `ttsc.transform: native rewrite mode "${execution.nativeMode}" requires a plugin-provided version-matched binary`,
177
+ );
178
+ }
179
+
180
+ const tempOutDir = fs.mkdtempSync(path.join(os.tmpdir(), "ttsc-transform-"));
181
+ try {
182
+ const result = build({
183
+ ...options,
184
+ emit: true,
185
+ outDir: tempOutDir,
186
+ plugins: false,
187
+ tsconfig: execution.tsconfig,
188
+ });
189
+ if (result.status !== 0) {
190
+ throw new Error(
191
+ "ttsc.transform exited " +
192
+ result.status +
193
+ "\n" +
194
+ (result.stderr || result.stdout),
195
+ );
196
+ }
197
+ const emitted = findEmittedFile(tempOutDir, execution.projectRoot, sourceFile);
198
+ if (!emitted) {
199
+ throw new Error(`ttsc.transform: no output produced for ${sourceFile}`);
200
+ }
201
+ const transformed = finalizeTransformText(
202
+ execution.plugins,
203
+ {
204
+ command: "transform",
205
+ cwd: execution.cwd,
206
+ projectRoot: execution.projectRoot,
207
+ sourceFile,
208
+ tsconfig: execution.tsconfig,
209
+ },
210
+ fs.readFileSync(emitted, "utf8"),
211
+ );
212
+ if (options.out) {
213
+ fs.mkdirSync(path.dirname(options.out), { recursive: true });
214
+ fs.writeFileSync(options.out, transformed, "utf8");
215
+ }
216
+ return transformed;
217
+ } finally {
218
+ fs.rmSync(tempOutDir, { recursive: true, force: true });
219
+ }
220
+ }
221
+
222
+ function transformWithNativeBinary(
223
+ options: TransformOptions,
224
+ execution: ExecutionContext,
225
+ sourceFile: string,
226
+ ): string {
187
227
  const args = [
188
228
  "transform",
189
229
  "--file=" + sourceFile,
@@ -191,7 +231,7 @@ export function transform(options: TransformOptions): string {
191
231
  "--rewrite-mode=" + execution.nativeMode,
192
232
  ];
193
233
 
194
- const res = spawnBinary(execution.bin, args, {
234
+ const res = spawnBinary(execution.nativeBinary!, args, {
195
235
  cwd: options.cwd,
196
236
  env: mergeEnv(options.env),
197
237
  encoding: "utf8",
@@ -199,7 +239,7 @@ export function transform(options: TransformOptions): string {
199
239
  if (res.error) {
200
240
  throw new Error(
201
241
  "ttsc.transform: failed to spawn " +
202
- execution.bin +
242
+ execution.nativeBinary +
203
243
  ": " +
204
244
  res.error.message,
205
245
  );
@@ -249,45 +289,80 @@ export interface BuildResult {
249
289
  */
250
290
  export function build(options: BuildOptions = {}): BuildResult {
251
291
  const execution = resolveExecutionContext(options);
252
- const args = [
253
- "build",
254
- "--tsconfig=" + execution.tsconfig,
255
- "--rewrite-mode=" + execution.nativeMode,
256
- ];
257
- if (options.emit === true) args.push("--emit");
258
- else if (options.emit === false) args.push("--noEmit");
259
- if (options.outDir) args.push("--outDir=" + options.outDir);
260
- if (options.quiet === false) args.push("--verbose");
261
- else if (options.quiet === true) args.push("--quiet");
262
- const needsManifest = options.emit !== false && execution.plugins.length > 0;
263
- const manifest = needsManifest ? createTempManifestPath() : null;
264
- if (manifest) args.push("--manifest=" + manifest);
265
-
266
- const res = spawnBinary(execution.bin, args, {
267
- cwd: options.cwd,
292
+ if (execution.nativeBinary) {
293
+ return buildWithNativeBinary(options, execution);
294
+ }
295
+ if (execution.nativeMode !== "none") {
296
+ return {
297
+ status: 2,
298
+ stdout: "",
299
+ stderr:
300
+ `ttsc.build: native rewrite mode "${execution.nativeMode}" requires ` +
301
+ "a version-matched sidecar build; project builds use the consuming project's @typescript/native-preview by default\n",
302
+ };
303
+ }
304
+
305
+ if (options.emit !== false && options.skipDiagnosticsCheck !== true) {
306
+ const checked = runTsgo(execution, ["--noEmit"], options);
307
+ if (checked.status !== 0) {
308
+ return checked;
309
+ }
310
+ }
311
+
312
+ const args = createTsgoBuildArgs(execution, options, {
313
+ listEmittedFiles: options.emit !== false && execution.plugins.length > 0,
314
+ });
315
+ const res = spawnBinary(execution.tsgo.binary, args, {
316
+ cwd: execution.projectRoot,
268
317
  env: mergeEnv(options.env),
269
318
  encoding: "utf8",
270
319
  });
271
320
  if (res.error) {
272
321
  throw new Error(
273
- "ttsc.build: failed to spawn " + execution.bin + ": " + res.error.message,
322
+ "ttsc.build: failed to spawn " +
323
+ execution.tsgo.binary +
324
+ ": " +
325
+ res.error.message,
274
326
  );
275
327
  }
276
- if ((res.status ?? 1) === 0 && manifest) {
277
- try {
278
- const emittedFiles = JSON.parse(
279
- fs.readFileSync(manifest, "utf8"),
280
- ) as string[];
281
- applyBuildPlugins(execution.plugins, execution, emittedFiles);
282
- } finally {
283
- fs.rmSync(path.dirname(manifest), { recursive: true, force: true });
284
- }
285
- }
286
- return {
328
+ const result = {
287
329
  status: res.status ?? 1,
288
330
  stdout: outputText(res.stdout),
289
331
  stderr: outputText(res.stderr),
290
332
  };
333
+ const emittedFiles = parseEmittedFiles(result.stdout);
334
+ if (emittedFiles.length !== 0) {
335
+ result.stdout = stripEmittedFileLines(result.stdout);
336
+ }
337
+ if (result.status === 0 && options.emit !== false && execution.plugins.length > 0) {
338
+ applyBuildPlugins(execution.plugins, execution, emittedFiles);
339
+ }
340
+ return normalizeFailedDiagnostics(result);
341
+ }
342
+
343
+ function buildWithNativeBinary(
344
+ options: BuildOptions,
345
+ execution: ExecutionContext,
346
+ ): BuildResult {
347
+ const args = createNativeBuildArgs(execution, options);
348
+ const res = spawnBinary(execution.nativeBinary!, args, {
349
+ cwd: execution.projectRoot,
350
+ env: mergeEnv(options.env),
351
+ encoding: "utf8",
352
+ });
353
+ if (res.error) {
354
+ throw new Error(
355
+ "ttsc.build: failed to spawn " +
356
+ execution.nativeBinary +
357
+ ": " +
358
+ res.error.message,
359
+ );
360
+ }
361
+ return normalizeFailedDiagnostics({
362
+ status: res.status ?? 1,
363
+ stdout: outputText(res.stdout),
364
+ stderr: outputText(res.stderr),
365
+ });
291
366
  }
292
367
 
293
368
  /**
@@ -298,10 +373,82 @@ export function check(options: CheckOptions = {}): BuildResult {
298
373
  return build({ ...options, emit: false });
299
374
  }
300
375
 
376
+ function runTsgo(
377
+ execution: ExecutionContext,
378
+ extraArgs: readonly string[],
379
+ options: BuildOptions,
380
+ ): BuildResult {
381
+ const res = spawnBinary(
382
+ execution.tsgo.binary,
383
+ ["-p", execution.tsconfig, ...extraArgs],
384
+ {
385
+ cwd: execution.projectRoot,
386
+ env: mergeEnv(options.env),
387
+ encoding: "utf8",
388
+ },
389
+ );
390
+ if (res.error) {
391
+ throw new Error(
392
+ "ttsc: failed to spawn " + execution.tsgo.binary + ": " + res.error.message,
393
+ );
394
+ }
395
+ return normalizeFailedDiagnostics({
396
+ status: res.status ?? 1,
397
+ stdout: outputText(res.stdout),
398
+ stderr: outputText(res.stderr),
399
+ });
400
+ }
401
+
402
+ function createTsgoBuildArgs(
403
+ execution: ExecutionContext,
404
+ options: BuildOptions,
405
+ flags: { listEmittedFiles: boolean },
406
+ ): string[] {
407
+ const args = ["-p", execution.tsconfig];
408
+ if (options.emit === true) {
409
+ args.push("--noEmit", "false", "--emitDeclarationOnly", "false");
410
+ } else if (options.emit === false) {
411
+ args.push("--noEmit");
412
+ }
413
+ if (options.outDir) {
414
+ args.push("--outDir", path.resolve(execution.cwd, options.outDir));
415
+ }
416
+ if (flags.listEmittedFiles) {
417
+ args.push("--listEmittedFiles");
418
+ }
419
+ return args;
420
+ }
421
+
422
+ function createNativeBuildArgs(
423
+ execution: ExecutionContext,
424
+ options: BuildOptions,
425
+ ): string[] {
426
+ const args = [
427
+ options.emit === false ? "check" : "build",
428
+ "--tsconfig=" + execution.tsconfig,
429
+ "--rewrite-mode=" + execution.nativeMode,
430
+ "--cwd=" + execution.projectRoot,
431
+ ];
432
+ if (options.emit === true) {
433
+ args.push("--emit");
434
+ } else if (options.emit === false) {
435
+ args.push("--noEmit");
436
+ }
437
+ if (options.outDir) {
438
+ args.push("--outDir=" + path.resolve(execution.cwd, options.outDir));
439
+ }
440
+ if (options.quiet === false) {
441
+ args.push("--verbose");
442
+ } else if (options.quiet === true) {
443
+ args.push("--quiet");
444
+ }
445
+ return args;
446
+ }
447
+
301
448
  /** Ask the binary for its version banner. Handy for user-agent strings. */
302
449
  export function version(options: CommonOptions = {}): string {
303
- const bin = resolveOrThrow(options);
304
- const res = spawnBinary(bin, ["version"], {
450
+ const tsgo = resolveTsgo(options);
451
+ const res = spawnBinary(tsgo.binary, ["--version"], {
305
452
  encoding: "utf8",
306
453
  });
307
454
  if (res.error || res.status !== 0) {
@@ -309,7 +456,7 @@ export function version(options: CommonOptions = {}): string {
309
456
  "ttsc.version: failed: " + (outputText(res.stderr) || res.error?.message),
310
457
  );
311
458
  }
312
- return outputText(res.stdout).trim();
459
+ return `ttsc ${readOwnPackageVersion()} (${outputText(res.stdout).trim()})`;
313
460
  }
314
461
 
315
462
  /**
@@ -322,11 +469,12 @@ export function transformAsync(options: TransformOptions): Promise<string> {
322
469
  }
323
470
 
324
471
  interface ExecutionContext {
325
- bin: string;
326
472
  cwd: string;
473
+ nativeBinary: string | null;
327
474
  nativeMode: string;
328
475
  plugins: readonly TtscPlugin[];
329
476
  projectRoot: string;
477
+ tsgo: ResolvedTsgo;
330
478
  tsconfig: string;
331
479
  }
332
480
 
@@ -334,33 +482,26 @@ function resolveExecutionContext(
334
482
  options: CommonOptions & { tsconfig?: string },
335
483
  ): ExecutionContext {
336
484
  const cwd = path.resolve(options.cwd ?? process.cwd());
337
- const fallbackBinary =
338
- options.binary &&
339
- path.isAbsolute(options.binary) &&
340
- fs.existsSync(options.binary)
341
- ? options.binary
342
- : resolveBinary(options);
343
485
  const tsconfig = resolveProjectConfig({
344
486
  cwd,
345
487
  tsconfig: options.tsconfig,
346
488
  });
347
489
  const projectRoot = resolveProjectRoot({ cwd, tsconfig });
490
+ const tsgo = resolveTsgo({ ...options, cwd: projectRoot });
491
+ const fallbackBinary = resolveBinary(options);
348
492
  const loaded = loadProjectPlugins({
349
493
  binary: fallbackBinary ?? "",
350
494
  cwd,
351
495
  entries: options.plugins,
352
496
  tsconfig,
353
497
  });
354
- const bin = options.binary ?? loaded.nativeBinary ?? fallbackBinary;
355
- if (!bin) {
356
- throw new Error(installHint(options));
357
- }
358
498
  return {
359
- bin,
360
499
  cwd,
500
+ nativeBinary: loaded.nativeBinary ?? null,
361
501
  nativeMode: options.rewriteMode ?? loaded.nativeMode,
362
502
  plugins: loaded.plugins.filter((plugin) => plugin.transformOutput),
363
503
  projectRoot,
504
+ tsgo,
364
505
  tsconfig,
365
506
  };
366
507
  }
@@ -410,7 +551,106 @@ function isJavaScriptOutput(file: string): boolean {
410
551
  return /\.(?:[cm]?js)$/i.test(file);
411
552
  }
412
553
 
413
- function createTempManifestPath(): string {
414
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ttsc-build-"));
415
- return path.join(dir, "manifest.json");
554
+ function parseEmittedFiles(stdout: string): string[] {
555
+ const out: string[] = [];
556
+ for (const line of stdout.split(/\r?\n/)) {
557
+ const match = line.match(/^TSFILE:\s*(.+)$/);
558
+ if (match?.[1]) {
559
+ out.push(path.resolve(match[1].trim()));
560
+ }
561
+ }
562
+ return out;
563
+ }
564
+
565
+ function stripEmittedFileLines(stdout: string): string {
566
+ return stdout
567
+ .split(/\r?\n/)
568
+ .filter((line) => !/^TSFILE:\s*/.test(line))
569
+ .join("\n")
570
+ .replace(/\n+$/, "");
571
+ }
572
+
573
+ function normalizeFailedDiagnostics(result: BuildResult): BuildResult {
574
+ if (result.status === 0 || result.stderr.trim().length !== 0) {
575
+ return result;
576
+ }
577
+ if (result.stdout.trim().length === 0) {
578
+ return result;
579
+ }
580
+ return {
581
+ status: result.status,
582
+ stdout: "",
583
+ stderr: result.stdout,
584
+ };
585
+ }
586
+
587
+ function findEmittedFile(
588
+ outDir: string,
589
+ projectRoot: string,
590
+ sourceFile: string,
591
+ ): string | null {
592
+ let best: string | null = null;
593
+ let bestScore = 0;
594
+ for (const file of listJavaScriptFiles(outDir)) {
595
+ const score = sharedSourceStemSegments(file, sourceFile);
596
+ if (score > bestScore) {
597
+ best = file;
598
+ bestScore = score;
599
+ }
600
+ }
601
+ if (best) {
602
+ return best;
603
+ }
604
+ const relative = path.relative(projectRoot, sourceFile);
605
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
606
+ const exact = path.resolve(outDir, relative).replace(/\.[cm]?tsx?$/i, ".js");
607
+ if (fs.existsSync(exact)) {
608
+ return exact;
609
+ }
610
+ }
611
+ return null;
612
+ }
613
+
614
+ function listJavaScriptFiles(root: string): string[] {
615
+ const out: string[] = [];
616
+ const stack = [root];
617
+ while (stack.length !== 0) {
618
+ const current = stack.pop()!;
619
+ if (!fs.existsSync(current)) continue;
620
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
621
+ const next = path.join(current, entry.name);
622
+ if (entry.isDirectory()) {
623
+ stack.push(next);
624
+ } else if (entry.isFile() && isJavaScriptOutput(next)) {
625
+ out.push(next);
626
+ }
627
+ }
628
+ }
629
+ return out;
630
+ }
631
+
632
+ function sharedSourceStemSegments(outPath: string, srcPath: string): number {
633
+ const trim = (value: string): string[] => {
634
+ const normalized = value.replace(/\\/g, "/");
635
+ return normalized.replace(/\.[^.]+$/, "").split("/");
636
+ };
637
+ const a = trim(outPath);
638
+ const b = trim(srcPath);
639
+ const n = Math.min(a.length, b.length);
640
+ let shared = 0;
641
+ for (let i = 1; i <= n; i += 1) {
642
+ if (a[a.length - i] !== b[b.length - i]) break;
643
+ shared += 1;
644
+ }
645
+ return shared;
646
+ }
647
+
648
+ function readOwnPackageVersion(): string {
649
+ try {
650
+ const file = path.resolve(__dirname, "..", "package.json");
651
+ const pkg = JSON.parse(fs.readFileSync(file, "utf8")) as { version?: string };
652
+ return pkg.version ?? "0.0.0";
653
+ } catch {
654
+ return "0.0.0";
655
+ }
416
656
  }
package/src/cli.ts CHANGED
@@ -311,11 +311,11 @@ function printHelp(): void {
311
311
  " -w, --watch Rebuild when project files change",
312
312
  " --preserveWatchOutput Do not clear the screen between watch rebuilds",
313
313
  " --outDir <dir> Override compilerOptions.outDir for this invocation",
314
- " --rewrite-mode <mode> Native rewrite backend id",
315
- " --quiet Keep native build output quiet (default)",
316
- " --verbose Print the native build summary and emitted files",
314
+ " --rewrite-mode <mode> Plugin-selected native rewrite backend id",
315
+ " --quiet Keep build output quiet (default)",
316
+ " --verbose Print the build summary and emitted files",
317
317
  " --out <path> Write transform output to a file instead of stdout",
318
- " --binary <path> Use an explicit native backend binary",
318
+ " --binary <path> Use an explicit tsgo binary",
319
319
  "",
320
320
  "Plugin contract:",
321
321
  " ttsc reads compilerOptions.plugins from tsconfig.json.",
package/src/index.ts CHANGED
@@ -2,18 +2,19 @@
2
2
  * ttsc — public TypeScript entry.
3
3
  *
4
4
  * Exports:
5
- * - platform helpers (`resolveBinary`, `installHint`, …) — used by the
6
- * public launcher and any external tool that wants to know where
7
- * the native binary lives without spawning it.
5
+ * - tsgo helpers (`resolveTsgo`, …) — resolve the consuming project's
6
+ * `@typescript/native-preview` compiler binary.
7
+ * - platform helpers (`resolveBinary`, `installHint`, …) — legacy helpers
8
+ * for plugin-selected native sidecars.
8
9
  * - programmatic API (`transform`, `build`, `check`, `version`) — a thin
9
- * TS wrapper around the Go binary that bundler adapters (unplugin, vite,
10
- * webpack, rollup, esbuild, rspack, farm) delegate to. Adapters never
11
- * have to shell out themselves; they call these helpers and get back
12
- * a string or a result record.
10
+ * TS wrapper around the consumer `tsgo` binary plus JS output-plugin
11
+ * hooks. Adapters never have to shell out themselves; they call these
12
+ * helpers and get back a string or a result record.
13
13
  */
14
14
 
15
15
  export * from "./platform";
16
16
  export * from "./api";
17
17
  export * from "./project";
18
18
  export * from "./plugin";
19
+ export * from "./tsgo";
19
20
  export * from "./runner/register";
package/src/runner/cli.ts CHANGED
@@ -133,7 +133,7 @@ function parseCLI(argv: readonly string[]): ParsedCLI | "help" | "version" {
133
133
  binary = current.slice("--binary=".length);
134
134
  } else if (current.startsWith("-")) {
135
135
  throw new Error(`ttsx: unknown option ${current}`);
136
- } else {
136
+ } else {
137
137
  entry = current;
138
138
  }
139
139
  break;
@@ -167,7 +167,7 @@ function printHelp(): void {
167
167
  " -P, --project <file> Use an explicit tsconfig.json",
168
168
  " --cwd <dir> Resolve entry/project relative to this directory",
169
169
  " --cache-dir <dir> Override the compiled JS cache directory",
170
- " --binary <path> Use an explicit ttsc native binary",
170
+ " --binary <path> Use an explicit tsgo binary",
171
171
  " -r, --require <file> Preload a module before the entrypoint",
172
172
  " -h, --help Show this help",
173
173
  " -v, --version Print the runner version",