ttsc 0.4.1 → 0.4.3-dev.20260426

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,52 @@ 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.nativeMode !== "none") {
293
+ return {
294
+ status: 2,
295
+ stdout: "",
296
+ stderr:
297
+ `ttsc.build: native rewrite mode "${execution.nativeMode}" requires ` +
298
+ "a version-matched sidecar build; project builds use the consuming project's @typescript/native-preview by default\n",
299
+ };
300
+ }
301
+
302
+ if (options.emit !== false && options.skipDiagnosticsCheck !== true) {
303
+ const checked = runTsgo(execution, ["--noEmit"], options);
304
+ if (checked.status !== 0) {
305
+ return checked;
306
+ }
307
+ }
308
+
309
+ const args = createTsgoBuildArgs(execution, options, {
310
+ listEmittedFiles: options.emit !== false && execution.plugins.length > 0,
311
+ });
312
+ const res = spawnBinary(execution.tsgo.binary, args, {
313
+ cwd: execution.projectRoot,
268
314
  env: mergeEnv(options.env),
269
315
  encoding: "utf8",
270
316
  });
271
317
  if (res.error) {
272
318
  throw new Error(
273
- "ttsc.build: failed to spawn " + execution.bin + ": " + res.error.message,
319
+ "ttsc.build: failed to spawn " +
320
+ execution.tsgo.binary +
321
+ ": " +
322
+ res.error.message,
274
323
  );
275
324
  }
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 {
325
+ const result = {
287
326
  status: res.status ?? 1,
288
327
  stdout: outputText(res.stdout),
289
328
  stderr: outputText(res.stderr),
290
329
  };
330
+ const emittedFiles = parseEmittedFiles(result.stdout);
331
+ if (emittedFiles.length !== 0) {
332
+ result.stdout = stripEmittedFileLines(result.stdout);
333
+ }
334
+ if (result.status === 0 && options.emit !== false && execution.plugins.length > 0) {
335
+ applyBuildPlugins(execution.plugins, execution, emittedFiles);
336
+ }
337
+ return normalizeFailedDiagnostics(result);
291
338
  }
292
339
 
293
340
  /**
@@ -298,10 +345,56 @@ export function check(options: CheckOptions = {}): BuildResult {
298
345
  return build({ ...options, emit: false });
299
346
  }
300
347
 
348
+ function runTsgo(
349
+ execution: ExecutionContext,
350
+ extraArgs: readonly string[],
351
+ options: BuildOptions,
352
+ ): BuildResult {
353
+ const res = spawnBinary(
354
+ execution.tsgo.binary,
355
+ ["-p", execution.tsconfig, ...extraArgs],
356
+ {
357
+ cwd: execution.projectRoot,
358
+ env: mergeEnv(options.env),
359
+ encoding: "utf8",
360
+ },
361
+ );
362
+ if (res.error) {
363
+ throw new Error(
364
+ "ttsc: failed to spawn " + execution.tsgo.binary + ": " + res.error.message,
365
+ );
366
+ }
367
+ return normalizeFailedDiagnostics({
368
+ status: res.status ?? 1,
369
+ stdout: outputText(res.stdout),
370
+ stderr: outputText(res.stderr),
371
+ });
372
+ }
373
+
374
+ function createTsgoBuildArgs(
375
+ execution: ExecutionContext,
376
+ options: BuildOptions,
377
+ flags: { listEmittedFiles: boolean },
378
+ ): string[] {
379
+ const args = ["-p", execution.tsconfig];
380
+ if (options.emit === true) {
381
+ args.push("--noEmit", "false", "--emitDeclarationOnly", "false");
382
+ } else if (options.emit === false) {
383
+ args.push("--noEmit");
384
+ }
385
+ if (options.outDir) {
386
+ args.push("--outDir", path.resolve(execution.cwd, options.outDir));
387
+ }
388
+ if (flags.listEmittedFiles) {
389
+ args.push("--listEmittedFiles");
390
+ }
391
+ return args;
392
+ }
393
+
301
394
  /** Ask the binary for its version banner. Handy for user-agent strings. */
302
395
  export function version(options: CommonOptions = {}): string {
303
- const bin = resolveOrThrow(options);
304
- const res = spawnBinary(bin, ["version"], {
396
+ const tsgo = resolveTsgo(options);
397
+ const res = spawnBinary(tsgo.binary, ["--version"], {
305
398
  encoding: "utf8",
306
399
  });
307
400
  if (res.error || res.status !== 0) {
@@ -309,7 +402,7 @@ export function version(options: CommonOptions = {}): string {
309
402
  "ttsc.version: failed: " + (outputText(res.stderr) || res.error?.message),
310
403
  );
311
404
  }
312
- return outputText(res.stdout).trim();
405
+ return `ttsc ${readOwnPackageVersion()} (${outputText(res.stdout).trim()})`;
313
406
  }
314
407
 
315
408
  /**
@@ -322,11 +415,12 @@ export function transformAsync(options: TransformOptions): Promise<string> {
322
415
  }
323
416
 
324
417
  interface ExecutionContext {
325
- bin: string;
326
418
  cwd: string;
419
+ nativeBinary: string | null;
327
420
  nativeMode: string;
328
421
  plugins: readonly TtscPlugin[];
329
422
  projectRoot: string;
423
+ tsgo: ResolvedTsgo;
330
424
  tsconfig: string;
331
425
  }
332
426
 
@@ -334,33 +428,26 @@ function resolveExecutionContext(
334
428
  options: CommonOptions & { tsconfig?: string },
335
429
  ): ExecutionContext {
336
430
  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
431
  const tsconfig = resolveProjectConfig({
344
432
  cwd,
345
433
  tsconfig: options.tsconfig,
346
434
  });
347
435
  const projectRoot = resolveProjectRoot({ cwd, tsconfig });
436
+ const tsgo = resolveTsgo({ ...options, cwd: projectRoot });
437
+ const fallbackBinary = resolveBinary(options);
348
438
  const loaded = loadProjectPlugins({
349
439
  binary: fallbackBinary ?? "",
350
440
  cwd,
351
441
  entries: options.plugins,
352
442
  tsconfig,
353
443
  });
354
- const bin = options.binary ?? loaded.nativeBinary ?? fallbackBinary;
355
- if (!bin) {
356
- throw new Error(installHint(options));
357
- }
358
444
  return {
359
- bin,
360
445
  cwd,
446
+ nativeBinary: loaded.nativeBinary ?? null,
361
447
  nativeMode: options.rewriteMode ?? loaded.nativeMode,
362
448
  plugins: loaded.plugins.filter((plugin) => plugin.transformOutput),
363
449
  projectRoot,
450
+ tsgo,
364
451
  tsconfig,
365
452
  };
366
453
  }
@@ -410,7 +497,106 @@ function isJavaScriptOutput(file: string): boolean {
410
497
  return /\.(?:[cm]?js)$/i.test(file);
411
498
  }
412
499
 
413
- function createTempManifestPath(): string {
414
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ttsc-build-"));
415
- return path.join(dir, "manifest.json");
500
+ function parseEmittedFiles(stdout: string): string[] {
501
+ const out: string[] = [];
502
+ for (const line of stdout.split(/\r?\n/)) {
503
+ const match = line.match(/^TSFILE:\s*(.+)$/);
504
+ if (match?.[1]) {
505
+ out.push(path.resolve(match[1].trim()));
506
+ }
507
+ }
508
+ return out;
509
+ }
510
+
511
+ function stripEmittedFileLines(stdout: string): string {
512
+ return stdout
513
+ .split(/\r?\n/)
514
+ .filter((line) => !/^TSFILE:\s*/.test(line))
515
+ .join("\n")
516
+ .replace(/\n+$/, "");
517
+ }
518
+
519
+ function normalizeFailedDiagnostics(result: BuildResult): BuildResult {
520
+ if (result.status === 0 || result.stderr.trim().length !== 0) {
521
+ return result;
522
+ }
523
+ if (result.stdout.trim().length === 0) {
524
+ return result;
525
+ }
526
+ return {
527
+ status: result.status,
528
+ stdout: "",
529
+ stderr: result.stdout,
530
+ };
531
+ }
532
+
533
+ function findEmittedFile(
534
+ outDir: string,
535
+ projectRoot: string,
536
+ sourceFile: string,
537
+ ): string | null {
538
+ let best: string | null = null;
539
+ let bestScore = 0;
540
+ for (const file of listJavaScriptFiles(outDir)) {
541
+ const score = sharedSourceStemSegments(file, sourceFile);
542
+ if (score > bestScore) {
543
+ best = file;
544
+ bestScore = score;
545
+ }
546
+ }
547
+ if (best) {
548
+ return best;
549
+ }
550
+ const relative = path.relative(projectRoot, sourceFile);
551
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
552
+ const exact = path.resolve(outDir, relative).replace(/\.[cm]?tsx?$/i, ".js");
553
+ if (fs.existsSync(exact)) {
554
+ return exact;
555
+ }
556
+ }
557
+ return null;
558
+ }
559
+
560
+ function listJavaScriptFiles(root: string): string[] {
561
+ const out: string[] = [];
562
+ const stack = [root];
563
+ while (stack.length !== 0) {
564
+ const current = stack.pop()!;
565
+ if (!fs.existsSync(current)) continue;
566
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
567
+ const next = path.join(current, entry.name);
568
+ if (entry.isDirectory()) {
569
+ stack.push(next);
570
+ } else if (entry.isFile() && isJavaScriptOutput(next)) {
571
+ out.push(next);
572
+ }
573
+ }
574
+ }
575
+ return out;
576
+ }
577
+
578
+ function sharedSourceStemSegments(outPath: string, srcPath: string): number {
579
+ const trim = (value: string): string[] => {
580
+ const normalized = value.replace(/\\/g, "/");
581
+ return normalized.replace(/\.[^.]+$/, "").split("/");
582
+ };
583
+ const a = trim(outPath);
584
+ const b = trim(srcPath);
585
+ const n = Math.min(a.length, b.length);
586
+ let shared = 0;
587
+ for (let i = 1; i <= n; i += 1) {
588
+ if (a[a.length - i] !== b[b.length - i]) break;
589
+ shared += 1;
590
+ }
591
+ return shared;
592
+ }
593
+
594
+ function readOwnPackageVersion(): string {
595
+ try {
596
+ const file = path.resolve(__dirname, "..", "package.json");
597
+ const pkg = JSON.parse(fs.readFileSync(file, "utf8")) as { version?: string };
598
+ return pkg.version ?? "0.0.0";
599
+ } catch {
600
+ return "0.0.0";
601
+ }
416
602
  }
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";
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../cli";
4
+
5
+ const code = main(process.argv.slice(2));
6
+ if (typeof code === "number") {
7
+ process.exitCode = code;
8
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../runner/cli";
4
+
5
+ const code = main(process.argv.slice(2));
6
+ if (typeof code === "number") {
7
+ process.exitCode = code;
8
+ }
package/src/platform.ts CHANGED
@@ -71,6 +71,7 @@ export function platformPackageRequest(opts: ResolveOptions = {}): string {
71
71
  /** The list of platform keys the package currently ships. */
72
72
  export const SUPPORTED_PLATFORMS: readonly string[] = Object.freeze([
73
73
  "linux-x64",
74
+ "linux-arm",
74
75
  "linux-arm64",
75
76
  "darwin-x64",
76
77
  "darwin-arm64",
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",