ttsc 0.14.0-dev.20260529.2 → 0.14.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.
@@ -10,19 +10,14 @@
10
10
  //
11
11
  // The schema is the spec; behaviour at every layer boundary is determined by
12
12
  // the FlagSpec attributes, not by ad-hoc `if (arg === "--foo")` branches.
13
-
14
- import type {
15
- AnySubcommand,
16
- FlagSpec,
17
- ValueValidator,
18
- } from "./schema";
13
+ import type { AnySubcommand, FlagSpec, ValueValidator } from "./schema";
19
14
  import { FLAG_BY_NAME, flagsForSubcommand } from "./schema";
20
15
 
21
16
  /**
22
- * Per-subcommand parse result. `values` is keyed by the canonical flag
23
- * name (e.g. `"--singleThreaded"`); boolean flags resolve to `true`/`false`,
24
- * value flags to the parsed value type. Callers narrow with the helpers
25
- * below (`getBoolean`, `getString`, `getNumber`).
17
+ * Per-subcommand parse result. `values` is keyed by the canonical flag name
18
+ * (e.g. `"--singleThreaded"`); boolean flags resolve to `true`/`false`, value
19
+ * flags to the parsed value type. Callers narrow with the helpers below
20
+ * (`getBoolean`, `getString`, `getNumber`).
26
21
  */
27
22
  export interface ParseResult {
28
23
  /** Canonical flag name → resolved value. */
@@ -32,17 +27,15 @@ export interface ParseResult {
32
27
  /** Bare non-flag positional arguments, in original order. */
33
28
  readonly positional: readonly string[];
34
29
  /**
35
- * Tokens that arrived after the `forwardAfterFirstPositional` sentinel.
36
- * These are intended for the user's program (e.g. ttsx's entry-file argv);
37
- * they are NOT forwarded to tsgo. Always empty when
38
- * `forwardAfterFirstPositional` is false.
30
+ * Tokens that arrived after the `forwardAfterFirstPositional` sentinel. These
31
+ * are intended for the user's program (e.g. ttsx's entry-file argv); they are
32
+ * NOT forwarded to tsgo. Always empty when `forwardAfterFirstPositional` is
33
+ * false.
39
34
  */
40
35
  readonly tail: readonly string[];
41
36
  }
42
37
 
43
- /**
44
- * Options controlling a single `parseFlags` invocation.
45
- */
38
+ /** Options controlling a single `parseFlags` invocation. */
46
39
  export interface ParseOptions {
47
40
  /** Which subcommand's flag subset to accept. */
48
41
  readonly subcommand: AnySubcommand;
@@ -54,24 +47,24 @@ export interface ParseOptions {
54
47
  */
55
48
  readonly errorPrefix: string;
56
49
  /**
57
- * `true` to treat the FIRST positional token as a sentinel that switches
58
- * the engine to "forward everything after" mode (ttsx's entry-file
59
- * behaviour: tokens after the entry are runtime argv, not tsgo flags).
60
- * The sentinel itself is still recorded as a positional argument.
50
+ * `true` to treat the FIRST positional token as a sentinel that switches the
51
+ * engine to "forward everything after" mode (ttsx's entry-file behaviour:
52
+ * tokens after the entry are runtime argv, not tsgo flags). The sentinel
53
+ * itself is still recorded as a positional argument.
61
54
  */
62
55
  readonly forwardAfterFirstPositional?: boolean;
63
56
  /**
64
- * Optional `"--"` separator handling: when present in argv, every token
65
- * after `--` is appended to `passthrough` as-is (ttsx already does this).
57
+ * Optional `"--"` separator handling: when present in argv, every token after
58
+ * `--` is appended to `passthrough` as-is (ttsx already does this).
66
59
  */
67
60
  readonly honorDoubleDashSeparator?: boolean;
68
61
  }
69
62
 
70
63
  /**
71
64
  * Parse `argv` according to FLAG_SCHEMA filtered by `subcommand`. Returns a
72
- * `ParseResult`. Throws `Error` (with the configured prefix) on invalid
73
- * input — unknown subcommand-only flag, missing required value, value that
74
- * fails its validator.
65
+ * `ParseResult`. Throws `Error` (with the configured prefix) on invalid input —
66
+ * unknown subcommand-only flag, missing required value, value that fails its
67
+ * validator.
75
68
  */
76
69
  export function parseFlags(opts: ParseOptions): ParseResult {
77
70
  const accepted = new Map<string, FlagSpec>();
@@ -103,7 +96,9 @@ export function parseFlags(opts: ParseOptions): ParseResult {
103
96
  }
104
97
  }
105
98
  const head: string[] = [
106
- ...(remainder === null ? opts.argv : opts.argv.slice(0, indexOfSeparator(opts.argv))),
99
+ ...(remainder === null
100
+ ? opts.argv
101
+ : opts.argv.slice(0, indexOfSeparator(opts.argv))),
107
102
  ];
108
103
 
109
104
  let forwardingTail = false;
@@ -160,10 +155,7 @@ export function parseFlags(opts: ParseOptions): ParseResult {
160
155
 
161
156
  // Bare token: positional argument (file / entry / project path).
162
157
  positional.push(current);
163
- if (
164
- opts.forwardAfterFirstPositional === true &&
165
- positional.length === 1
166
- ) {
158
+ if (opts.forwardAfterFirstPositional === true && positional.length === 1) {
167
159
  forwardingTail = true;
168
160
  }
169
161
  }
@@ -179,9 +171,9 @@ export function parseFlags(opts: ParseOptions): ParseResult {
179
171
  }
180
172
 
181
173
  /**
182
- * Pull the value for `flag` from either `inlineValue` (`--flag=value`) or
183
- * the next argv token (`--flag value`), validate it per the schema, and
184
- * write it to the result map.
174
+ * Pull the value for `flag` from either `inlineValue` (`--flag=value`) or the
175
+ * next argv token (`--flag value`), validate it per the schema, and write it to
176
+ * the result map.
185
177
  */
186
178
  function consumeFlag(
187
179
  values: Map<string, string | boolean | number>,
@@ -226,7 +218,9 @@ function consumeFlag(
226
218
  }
227
219
 
228
220
  const raw =
229
- inlineValue !== undefined ? inlineValue : takeValueToken(token, rest, errorPrefix);
221
+ inlineValue !== undefined
222
+ ? inlineValue
223
+ : takeValueToken(token, rest, errorPrefix);
230
224
  if (flag.validator === "positiveInt") {
231
225
  values.set(flag.name, validatePositiveInt(token, raw, errorPrefix));
232
226
  return;
@@ -235,13 +229,13 @@ function consumeFlag(
235
229
  }
236
230
 
237
231
  /**
238
- * Read the value token that follows `flag`. Throws if argv ends here OR if
239
- * the next token looks like another flag (`-` prefix). Without the
240
- * "looks like a flag" guard `ttsc --cwd --strict src/main.ts` would silently
241
- * consume `--strict` as the value of `--cwd`, leaving `--strict` lost and
242
- * `cwd` set to a junk path. Mirrors the symmetric guard already in
243
- * `forwardKnownButUnaccepted` (RC-1 fairness — the two value-resolution
244
- * paths must agree on what counts as "a missing value").
232
+ * Read the value token that follows `flag`. Throws if argv ends here OR if the
233
+ * next token looks like another flag (`-` prefix). Without the "looks like a
234
+ * flag" guard `ttsc --cwd --strict src/main.ts` would silently consume
235
+ * `--strict` as the value of `--cwd`, leaving `--strict` lost and `cwd` set to
236
+ * a junk path. Mirrors the symmetric guard already in
237
+ * `forwardKnownButUnaccepted` (RC-1 fairness — the two value-resolution paths
238
+ * must agree on what counts as "a missing value").
245
239
  */
246
240
  function takeValueToken(
247
241
  flag: string,
@@ -265,9 +259,9 @@ function takeValueToken(
265
259
  }
266
260
 
267
261
  /**
268
- * Parse a CLI boolean literal — `true`/`false` only (case-sensitive to
269
- * match tsgo's parser). Returns `undefined` for any other token so the
270
- * caller knows to throw or treat as a non-value.
262
+ * Parse a CLI boolean literal — `true`/`false` only (case-sensitive to match
263
+ * tsgo's parser). Returns `undefined` for any other token so the caller knows
264
+ * to throw or treat as a non-value.
271
265
  */
272
266
  function parseBooleanLiteral(raw: string): boolean | undefined {
273
267
  if (raw === "true") return true;
@@ -277,8 +271,8 @@ function parseBooleanLiteral(raw: string): boolean | undefined {
277
271
 
278
272
  /**
279
273
  * Validate a `positiveInt` value. Mirrors tsgo's `--checkers minValue:1`
280
- * constraint so a typo fails loudly at the launcher rather than reaching
281
- * tsgo with an invalid argument.
274
+ * constraint so a typo fails loudly at the launcher rather than reaching tsgo
275
+ * with an invalid argument.
282
276
  */
283
277
  function validatePositiveInt(
284
278
  flag: string,
@@ -295,10 +289,10 @@ function validatePositiveInt(
295
289
  }
296
290
 
297
291
  /**
298
- * Forward a flag the schema knows about but the current subcommand does
299
- * not accept. The launcher will hand it to tsgo (or to native sidecars
300
- * via `--tsgo-args`); without this branch the parser would lose the
301
- * value token of a `--flag value` pair.
292
+ * Forward a flag the schema knows about but the current subcommand does not
293
+ * accept. The launcher will hand it to tsgo (or to native sidecars via
294
+ * `--tsgo-args`); without this branch the parser would lose the value token of
295
+ * a `--flag value` pair.
302
296
  */
303
297
  function forwardKnownButUnaccepted(
304
298
  passthrough: string[],
@@ -558,37 +558,50 @@ function runWatch(
558
558
  tsconfig: options.tsconfig,
559
559
  }),
560
560
  );
561
- const directories = collectWatchDirectories(root);
562
- const watchers = directories.map((dir) =>
563
- fs.watch(dir, { persistent: true }, () => trigger()),
564
- );
565
561
  let running = false;
566
562
  let rerun = false;
567
563
  let timer: NodeJS.Timeout | null = null;
564
+ // Tracks the most recent build's exit code so the watch session can exit
565
+ // non-zero when its latest rebuild failed, instead of always reporting 0.
566
+ let lastStatus = 0;
568
567
 
569
568
  const runOnce = () => {
570
569
  running = true;
571
- if (!options.preserveWatchOutput) {
572
- process.stdout.write("\x1bc");
570
+ try {
571
+ if (!options.preserveWatchOutput) {
572
+ process.stdout.write("\x1bc");
573
+ }
574
+ process.stdout.write(
575
+ `[ttsc] rebuilding at ${new Date().toLocaleTimeString()}\n`,
576
+ );
577
+ // The debounced rebuild fires from setTimeout, outside runTtsc's
578
+ // top-level try/catch. runBuild/runSingleFile/loadProjectPlugins/
579
+ // readProjectConfig can throw (plugin go-build failure, tsconfig edited
580
+ // to invalid JSON, single-file invariant). Catch here so a throwing
581
+ // rebuild reports a clear failure and the watcher keeps running instead
582
+ // of crashing the process with an uncaught exception.
583
+ const status =
584
+ invocation.files.length !== 0
585
+ ? runSingleFile(invocation)
586
+ : (() => {
587
+ const result = runBuild(
588
+ checkOnly ? { ...invocation, emit: false } : invocation,
589
+ );
590
+ if (result.stdout) process.stdout.write(result.stdout);
591
+ if (result.stderr) process.stderr.write(result.stderr);
592
+ return result.status;
593
+ })();
594
+ lastStatus = status;
595
+ process.stdout.write(
596
+ `[ttsc] ${status === 0 ? "watch build complete" : "watch build failed"}\n`,
597
+ );
598
+ } catch (error) {
599
+ process.stderr.write(`${formatError(error)}\n`);
600
+ lastStatus = lastStatus === 0 ? 2 : lastStatus;
601
+ process.stdout.write(`[ttsc] watch build failed\n`);
602
+ } finally {
603
+ running = false;
573
604
  }
574
- process.stdout.write(
575
- `[ttsc] rebuilding at ${new Date().toLocaleTimeString()}\n`,
576
- );
577
- const status =
578
- invocation.files.length !== 0
579
- ? runSingleFile(invocation)
580
- : (() => {
581
- const result = runBuild(
582
- checkOnly ? { ...invocation, emit: false } : invocation,
583
- );
584
- if (result.stdout) process.stdout.write(result.stdout);
585
- if (result.stderr) process.stderr.write(result.stderr);
586
- return result.status;
587
- })();
588
- process.stdout.write(
589
- `[ttsc] ${status === 0 ? "watch build complete" : "watch build failed"}\n`,
590
- );
591
- running = false;
592
605
  if (rerun) {
593
606
  rerun = false;
594
607
  trigger();
@@ -603,22 +616,52 @@ function runWatch(
603
616
  timer = setTimeout(runOnce, 60);
604
617
  };
605
618
 
619
+ const directories = collectWatchDirectories(root);
620
+ const watchers = directories.map((dir) => {
621
+ const watcher = fs.watch(dir, { persistent: true }, () => trigger());
622
+ // Without an error handler Node rethrows watcher errors as uncaught
623
+ // exceptions, which would crash the session. inotify limits (ENOSPC) and
624
+ // transient FS errors should be logged while the session stays alive.
625
+ watcher.on("error", (err) => {
626
+ process.stderr.write(
627
+ `[ttsc] watch error on ${path.relative(cwd, dir) || "."}: ${formatError(err)}\n`,
628
+ );
629
+ });
630
+ return watcher;
631
+ });
632
+
606
633
  const close = () => {
607
634
  if (timer) clearTimeout(timer);
608
635
  for (const watcher of watchers) watcher.close();
609
636
  };
610
637
  process.on("SIGINT", () => {
611
638
  close();
612
- process.exit(0);
639
+ process.exit(toExitCode(lastStatus));
613
640
  });
614
641
  process.on("SIGTERM", () => {
615
642
  close();
616
- process.exit(0);
643
+ process.exit(toExitCode(lastStatus));
617
644
  });
618
645
 
619
646
  process.stdout.write(`[ttsc] watching ${path.relative(cwd, root) || "."}\n`);
620
- runOnce();
621
- return 0;
647
+ try {
648
+ runOnce();
649
+ } catch (error) {
650
+ // runOnce already swallows build throws, but guard against any unforeseen
651
+ // throw escaping the first pass: tear down the persistent watchers so the
652
+ // event loop drains and the process exits cleanly with a non-zero code
653
+ // instead of hanging on live fs.watch handles.
654
+ close();
655
+ process.stderr.write(`${formatError(error)}\n`);
656
+ return toExitCode(lastStatus === 0 ? 2 : lastStatus);
657
+ }
658
+ return toExitCode(lastStatus);
659
+ }
660
+
661
+ // Coerces a build status into a valid process exit code: 0 stays 0, any
662
+ // non-zero (or non-finite) status collapses to 1 so the session signals failure.
663
+ function toExitCode(status: number): number {
664
+ return Number.isInteger(status) && status >= 0 && status <= 255 ? status : 1;
622
665
  }
623
666
 
624
667
  function collectWatchDirectories(root: string): string[] {
@@ -1004,8 +1004,51 @@ function isHashableFile(name: string): boolean {
1004
1004
  return !name.endsWith("~");
1005
1005
  }
1006
1006
 
1007
+ // Per-process memo for the Go compiler identity. `computeCacheKey` runs once
1008
+ // per source plugin, so an N-plugin project that points every plugin at the
1009
+ // same toolchain would otherwise pay N `go version` spawns plus N ~150MB
1010
+ // binary hashes for a value that does not change between plugins. The result
1011
+ // is a pure function of the go binary's resolved real path plus its on-disk
1012
+ // content; the memo key therefore mixes the resolved real path with a cheap
1013
+ // content signature (byte size + nanosecond mtime). That signature changes if
1014
+ // a long-lived host rewrites the binary in place between calls, so the memo
1015
+ // re-derives the identity exactly as the un-memoized code would and the
1016
+ // cache-key bytes stay byte-for-byte identical to today. `go version` reads no
1017
+ // cwd/custom env (the spawn passes neither), so cwd is not part of the key.
1018
+ const goCompilerIdentityCache = new Map<string, string>();
1019
+
1007
1020
  function resolveGoCompilerIdentity(goBinary: string): string {
1008
1021
  const resolved = resolveExecutableIdentityPath(goBinary);
1022
+ const memoKey = goCompilerIdentityMemoKey(goBinary, resolved);
1023
+ if (memoKey !== null) {
1024
+ const cached = goCompilerIdentityCache.get(memoKey);
1025
+ if (cached !== undefined) {
1026
+ return cached;
1027
+ }
1028
+ }
1029
+ const identity = computeGoCompilerIdentity(goBinary, resolved);
1030
+ if (memoKey !== null) {
1031
+ goCompilerIdentityCache.set(memoKey, identity);
1032
+ }
1033
+ return identity;
1034
+ }
1035
+
1036
+ // Build a memo key that pins both the resolved binary path and its current
1037
+ // content. Returns null (skip caching, recompute) when the binary cannot be
1038
+ // stat-ed, so the rare unstattable case never serves a stale identity.
1039
+ function goCompilerIdentityMemoKey(
1040
+ goBinary: string,
1041
+ resolved: string,
1042
+ ): string | null {
1043
+ try {
1044
+ const stat = fs.statSync(resolved);
1045
+ return `${goBinary}\0${resolved}\0${stat.size}\0${stat.mtimeMs}`;
1046
+ } catch {
1047
+ return null;
1048
+ }
1049
+ }
1050
+
1051
+ function computeGoCompilerIdentity(goBinary: string, resolved: string): string {
1009
1052
  if (!fs.existsSync(resolved)) {
1010
1053
  return "missing";
1011
1054
  }
@@ -87,8 +87,8 @@ export namespace ITtscCompilerResult {
87
87
  * origin. Treat as `"unknown"` when missing.
88
88
  *
89
89
  * - `"plugin"`: a native plugin sidecar crashed or exited non-zero.
90
- * - `"host"`: the TypeScript-Go host could not start (missing binary,
91
- * cache lock, invalid config).
90
+ * - `"host"`: the TypeScript-Go host could not start (missing binary, cache
91
+ * lock, invalid config).
92
92
  * - `"unknown"`: any other host-level failure.
93
93
  */
94
94
  kind?: "plugin" | "host" | "unknown";
@@ -62,8 +62,8 @@ export namespace ITtscCompilerTransformation {
62
62
  * origin. Treat as `"unknown"` when missing.
63
63
  *
64
64
  * - `"plugin"`: a native plugin sidecar crashed or exited non-zero.
65
- * - `"host"`: the TypeScript-Go host could not start (missing binary,
66
- * cache lock, invalid config).
65
+ * - `"host"`: the TypeScript-Go host could not start (missing binary, cache
66
+ * lock, invalid config).
67
67
  * - `"unknown"`: any other host-level failure.
68
68
  */
69
69
  kind?: "plugin" | "host" | "unknown";