ttsc 0.18.4 → 0.19.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 (137) hide show
  1. package/README.md +5 -6
  2. package/cmd/graphbench/main.go +1 -1
  3. package/cmd/platform/main.go +1 -48
  4. package/cmd/ttsc/build_branches_test.go +5 -1
  5. package/cmd/ttsc/main.go +0 -50
  6. package/cmd/ttsc/main_alias_branches_test.go +6 -12
  7. package/cmd/ttscgraph/dump.go +13 -3
  8. package/cmd/ttscserver/main.go +5 -3
  9. package/driver/lsp.go +13 -1
  10. package/driver/plugins.go +35 -0
  11. package/driver/program.go +7 -8
  12. package/driver/rewrite.go +381 -21
  13. package/go.mod +1 -0
  14. package/internal/graph/build.go +297 -3
  15. package/internal/graph/build_records_a_node_per_top_level_declaration_test.go +16 -2
  16. package/internal/graph/diagnostics.go +5 -4
  17. package/internal/graph/dump.go +60 -4
  18. package/internal/graph/dump_is_a_function_of_the_source_test.go +71 -0
  19. package/internal/graph/dump_serializes_the_full_graph_test.go +16 -4
  20. package/internal/graph/edges.go +122 -11
  21. package/internal/graph/exports.go +74 -17
  22. package/internal/graph/graph.go +33 -1
  23. package/internal/graph/node_identity_is_position_invariant_test.go +4 -2
  24. package/internal/graph/resolve.go +24 -0
  25. package/internal/graphsymbols/provider.go +50 -14
  26. package/internal/lspserver/lsp_native_plugin_source.go +77 -23
  27. package/internal/lspserver/lsp_plugin.go +19 -2
  28. package/internal/lspserver/lsp_proxy.go +244 -52
  29. package/internal/lspserver/lsp_server.go +35 -27
  30. package/internal/lspserver/lsp_symbols.go +25 -16
  31. package/lib/TtscCompiler.d.ts +10 -0
  32. package/lib/TtscCompiler.js +24 -6
  33. package/lib/TtscCompiler.js.map +1 -1
  34. package/lib/TtscService.js +9 -7
  35. package/lib/TtscService.js.map +1 -1
  36. package/lib/compiler/internal/compileProjectInMemory.js +1 -0
  37. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  38. package/lib/compiler/internal/project/createNativeProjectContextArgs.d.ts +3 -0
  39. package/lib/compiler/internal/project/createNativeProjectContextArgs.js +14 -0
  40. package/lib/compiler/internal/project/createNativeProjectContextArgs.js.map +1 -0
  41. package/lib/compiler/internal/project/readProjectConfig.js +53 -4
  42. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  43. package/lib/compiler/internal/project/resolveProjectConfig.d.ts +9 -2
  44. package/lib/compiler/internal/project/resolveProjectConfig.js +45 -16
  45. package/lib/compiler/internal/project/resolveProjectConfig.js.map +1 -1
  46. package/lib/compiler/internal/residentTransformProcess.d.ts +16 -5
  47. package/lib/compiler/internal/residentTransformProcess.js +68 -20
  48. package/lib/compiler/internal/residentTransformProcess.js.map +1 -1
  49. package/lib/compiler/internal/runBuild.d.ts +11 -0
  50. package/lib/compiler/internal/runBuild.js +44 -8
  51. package/lib/compiler/internal/runBuild.js.map +1 -1
  52. package/lib/compiler/internal/runSingleFileEmit.js +7 -4
  53. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  54. package/lib/compiler/internal/sharedHostHelpers.d.ts +16 -0
  55. package/lib/compiler/internal/sharedHostHelpers.js +23 -0
  56. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  57. package/lib/compiler/internal/startResidentTransform.js +18 -1
  58. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  59. package/lib/compiler/internal/transformProjectInMemory.js +34 -8
  60. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  61. package/lib/flags/parser.d.ts +17 -0
  62. package/lib/flags/parser.js +11 -1
  63. package/lib/flags/parser.js.map +1 -1
  64. package/lib/flags/schema.js +7 -0
  65. package/lib/flags/schema.js.map +1 -1
  66. package/lib/launcher/internal/prepareExecution.js +21 -2
  67. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  68. package/lib/launcher/internal/runTtsc.js +13 -53
  69. package/lib/launcher/internal/runTtsc.js.map +1 -1
  70. package/lib/launcher/internal/runTtscserver.js +11 -0
  71. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  72. package/lib/launcher/internal/runTtsx.js +27 -11
  73. package/lib/launcher/internal/runTtsx.js.map +1 -1
  74. package/lib/launcher/internal/runtimeHooks.d.ts +81 -0
  75. package/lib/launcher/internal/runtimeHooks.js +632 -43
  76. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  77. package/lib/launcher/internal/servedSourceMap.d.ts +25 -0
  78. package/lib/launcher/internal/servedSourceMap.js +164 -0
  79. package/lib/launcher/internal/servedSourceMap.js.map +1 -0
  80. package/lib/plugin/internal/buildSourcePlugin.d.ts +146 -2
  81. package/lib/plugin/internal/buildSourcePlugin.js +651 -113
  82. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  83. package/lib/plugin/internal/loadProjectPlugins.d.ts +7 -0
  84. package/lib/plugin/internal/loadProjectPlugins.js +33 -10
  85. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  86. package/lib/structures/ITtscCompilerContext.d.ts +16 -2
  87. package/lib/structures/ITtscPluginCapabilities.d.ts +8 -0
  88. package/lib/structures/ITtscPluginFactoryContext.d.ts +14 -0
  89. package/lib/structures/internal/ITtscParsedProjectConfig.d.ts +3 -0
  90. package/lib/structures/internal/ITtscProjectIdentity.d.ts +17 -0
  91. package/lib/structures/internal/ITtscProjectIdentity.js +3 -0
  92. package/lib/structures/internal/ITtscProjectIdentity.js.map +1 -0
  93. package/lib/structures/internal/TtscCommonOptions.d.ts +8 -0
  94. package/package.json +11 -8
  95. package/shim/ast/lint.go +10 -0
  96. package/shim/ast/shim.go +44 -0
  97. package/shim/checker/enums_gen.go +19 -0
  98. package/shim/checker/shim.go +130 -0
  99. package/shim/diagnosticwriter/lint.go +30 -3
  100. package/shim/scanner/regexp.go +27 -0
  101. package/src/TtscCompiler.ts +25 -6
  102. package/src/TtscService.ts +15 -7
  103. package/src/compiler/internal/compileProjectInMemory.ts +1 -0
  104. package/src/compiler/internal/project/createNativeProjectContextArgs.ts +15 -0
  105. package/src/compiler/internal/project/readProjectConfig.ts +61 -5
  106. package/src/compiler/internal/project/resolveProjectConfig.ts +51 -21
  107. package/src/compiler/internal/residentTransformProcess.ts +95 -21
  108. package/src/compiler/internal/runBuild.ts +76 -9
  109. package/src/compiler/internal/runSingleFileEmit.ts +7 -4
  110. package/src/compiler/internal/sharedHostHelpers.ts +24 -0
  111. package/src/compiler/internal/startResidentTransform.ts +21 -1
  112. package/src/compiler/internal/transformProjectInMemory.ts +49 -6
  113. package/src/flags/parser.ts +28 -1
  114. package/src/flags/schema.ts +8 -0
  115. package/src/launcher/internal/prepareExecution.ts +22 -2
  116. package/src/launcher/internal/runTtsc.ts +13 -61
  117. package/src/launcher/internal/runTtscserver.ts +14 -0
  118. package/src/launcher/internal/runTtsx.ts +28 -13
  119. package/src/launcher/internal/runtimeHooks.ts +771 -53
  120. package/src/launcher/internal/servedSourceMap.ts +183 -0
  121. package/src/plugin/internal/buildSourcePlugin.ts +859 -122
  122. package/src/plugin/internal/loadProjectPlugins.ts +36 -6
  123. package/src/structures/ITtscCompilerContext.ts +17 -2
  124. package/src/structures/ITtscPluginCapabilities.ts +9 -0
  125. package/src/structures/ITtscPluginFactoryContext.ts +15 -0
  126. package/src/structures/internal/ITtscParsedProjectConfig.ts +3 -0
  127. package/src/structures/internal/ITtscProjectIdentity.ts +17 -0
  128. package/src/structures/internal/TtscCommonOptions.ts +8 -0
  129. package/tools/shim_audit/baseline.json +16 -7
  130. package/tools/shim_audit/main.go +777 -71
  131. package/tools/shim_audit/main_test.go +28 -1
  132. package/tools/shim_audit/producer_exemptions_require_rationale_test.go +42 -0
  133. package/tools/shim_audit/producer_surface_includes_exposed_methods_test.go +154 -0
  134. package/tools/shim_audit/producer_surface_rejects_unproducible_reference_test.go +142 -0
  135. package/utility/host.go +46 -20
  136. package/utility/serve.go +1 -1
  137. package/shim/checker/signature_api_completeness.go +0 -58
@@ -3,9 +3,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.TTSX_MINIMUM_NODE_VERSION = void 0;
7
+ exports.checkNodeRuntimeSupport = checkNodeRuntimeSupport;
6
8
  exports.installRuntimeHooks = installRuntimeHooks;
7
9
  exports.entryModuleFormat = entryModuleFormat;
8
10
  exports.isWithin = isWithin;
11
+ exports.readDependencyCache = readDependencyCache;
12
+ exports.acquireDependencyBuildLock = acquireDependencyBuildLock;
13
+ exports.releaseDependencyBuildLock = releaseDependencyBuildLock;
14
+ exports.reclaimDependencyBuildLock = reclaimDependencyBuildLock;
15
+ exports.inspectDependencyBuildLock = inspectDependencyBuildLock;
9
16
  const node_crypto_1 = __importDefault(require("node:crypto"));
10
17
  const node_fs_1 = __importDefault(require("node:fs"));
11
18
  const node_module_1 = require("node:module");
@@ -17,6 +24,7 @@ const resolveEmittedJavaScript_1 = require("../../compiler/internal/resolveEmitt
17
24
  const resolveTsgo_1 = require("../../compiler/internal/resolveTsgo");
18
25
  const runBuild_1 = require("../../compiler/internal/runBuild");
19
26
  const spawnNative_1 = require("../../compiler/internal/spawnNative");
27
+ const servedSourceMap_1 = require("./servedSourceMap");
20
28
  /**
21
29
  * Synchronous Node module hooks installed (via `module.registerHooks`) in the
22
30
  * child process `ttsx` spawns to run a TypeScript entry _from source_.
@@ -75,6 +83,72 @@ function manifest() {
75
83
  }
76
84
  return manifestCache;
77
85
  }
86
+ /**
87
+ * Lowest Node.js the ttsx source runtime supports. The synchronous
88
+ * `module.registerHooks` (Node 22.15.0) is the highest floor among the runtime
89
+ * APIs these hooks depend on — `stripTypeScriptTypes` (22.13.0) and the child's
90
+ * `--disable-warning` flag (20.11.0) are both lower — so it sets the effective
91
+ * minimum. Kept in sync with `packages/ttsc/package.json#engines.node` and the
92
+ * documented requirement in `website/src/content/docs/development/index.mdx`.
93
+ */
94
+ exports.TTSX_MINIMUM_NODE_VERSION = "22.15.0";
95
+ const TTSX_MINIMUM_NODE_PARTS = [22, 15, 0];
96
+ /**
97
+ * Report why the running (or a candidate) Node.js version cannot execute the
98
+ * ttsx source runtime, or `null` when it can. Returning an actionable message —
99
+ * rather than letting the child die with an internal `TypeError` on the missing
100
+ * `registerHooks`, or Node 18 rejecting `--disable-warning` with exit 9 — is
101
+ * what turns an opaque internal failure into a clear version diagnostic.
102
+ *
103
+ * Exported for direct exercise by the ttsx e2e suite: the built launcher can
104
+ * only be spawned under the Node version running the tests, so the boundary
105
+ * around the floor cannot otherwise be pinned on CI.
106
+ */
107
+ function checkNodeRuntimeSupport(version) {
108
+ const parts = parseNodeVersion(version);
109
+ if (parts === null) {
110
+ // An unrecognizable version string is not proof of an unsupported runtime;
111
+ // let execution proceed rather than block on a parsing quirk.
112
+ return null;
113
+ }
114
+ if (compareVersionParts(parts, TTSX_MINIMUM_NODE_PARTS) >= 0) {
115
+ return null;
116
+ }
117
+ return (`ttsx requires Node.js ${exports.TTSX_MINIMUM_NODE_VERSION} or later, but this ` +
118
+ `process is Node.js ${version}. The source runtime installs synchronous ` +
119
+ `module hooks (module.registerHooks, Node 22.15.0) and strips types with ` +
120
+ `module.stripTypeScriptTypes (Node 22.13.0), neither of which exists on ` +
121
+ `earlier releases. Upgrade Node.js to 22.15.0+ (or the current LTS), or ` +
122
+ `compile the project with \`ttsc\` and run the emitted JavaScript directly.`);
123
+ }
124
+ function parseNodeVersion(version) {
125
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
126
+ if (match === null) {
127
+ return null;
128
+ }
129
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
130
+ }
131
+ function compareVersionParts(a, b) {
132
+ for (let index = 0; index < 3; index += 1) {
133
+ if (a[index] !== b[index]) {
134
+ return a[index] < b[index] ? -1 : 1;
135
+ }
136
+ }
137
+ return 0;
138
+ }
139
+ /**
140
+ * Throw an actionable version error when the current Node.js cannot run the
141
+ * ttsx source runtime. Guards the hook-installation boundary directly (a child
142
+ * or grandchild that inherits the runtime preload under an unsupported Node) so
143
+ * the failure is diagnosed here instead of surfacing as a bare `TypeError:
144
+ * registerHooks is not a function`.
145
+ */
146
+ function assertNodeRuntimeSupport() {
147
+ const message = checkNodeRuntimeSupport(process.versions.node);
148
+ if (message !== null) {
149
+ throw new Error(message);
150
+ }
151
+ }
78
152
  let installed = false;
79
153
  /**
80
154
  * Install the source-loading hooks on the current (main) thread. Idempotent:
@@ -93,7 +167,15 @@ function installRuntimeHooks() {
93
167
  if (installed) {
94
168
  return;
95
169
  }
170
+ assertNodeRuntimeSupport();
96
171
  installed = true;
172
+ // Map error stacks through the source maps the serve path now inlines, so a
173
+ // thrown frame reports the true `.ts` line:col out of the box (no
174
+ // `--enable-source-maps` needed). Applied before the entry loads; user code
175
+ // that later toggles it wins, since this is a plain runtime switch.
176
+ if (typeof process.setSourceMapsEnabled === "function") {
177
+ process.setSourceMapsEnabled(true);
178
+ }
97
179
  (0, node_module_1.registerHooks)({ load, resolve });
98
180
  installCommonJsHook();
99
181
  }
@@ -233,11 +315,14 @@ function resolveServedSource(filename, url = (0, node_url_1.pathToFileURL)(filen
233
315
  const real = realPath(filename);
234
316
  const served = serveEntryEmit(real);
235
317
  if (served !== null) {
236
- return { moduleOption: manifest()?.moduleOption, ...served };
318
+ return withInlineSourceMap({
319
+ moduleOption: manifest()?.moduleOption,
320
+ ...served,
321
+ });
237
322
  }
238
323
  const built = serveDependencyEmit(real);
239
324
  if (built !== null) {
240
- return built;
325
+ return withInlineSourceMap(built);
241
326
  }
242
327
  return {
243
328
  moduleOption: undefined,
@@ -245,6 +330,17 @@ function resolveServedSource(filename, url = (0, node_url_1.pathToFileURL)(filen
245
330
  source: transformOrphanSource(filename, url),
246
331
  };
247
332
  }
333
+ /**
334
+ * Inline a served emit's external source map into its text and absolutize the
335
+ * map's `sources`, so the JavaScript executed under the `.ts` source URL stays
336
+ * self-describing after the per-run emit directory is deleted. Applied to both
337
+ * the entry lane (`serveEntryEmit`) and the dependency lane
338
+ * (`serveBuiltDependency`); the orphan type-strip lane carries no emitted map.
339
+ */
340
+ function withInlineSourceMap(served) {
341
+ const source = (0, servedSourceMap_1.inlineServedSourceMap)(served.source, served.emittedFile, served.sourceFile);
342
+ return source === served.source ? served : { ...served, source };
343
+ }
248
344
  /**
249
345
  * Transform a TypeScript source file that no tsconfig owns (a published or
250
346
  * vendored package that ships raw `.ts`/`.cts`/`.mts` straight under
@@ -551,14 +647,152 @@ function collectCommonJsExportNames(emittedFile, seen) {
551
647
  return names;
552
648
  }
553
649
  function collectStaticCommonJsExportNames(source) {
650
+ // Scan executable syntax only. Text that merely resembles an assignment —
651
+ // `exports.x =` inside a comment, string, or template-literal text — must not
652
+ // become an ESM-visible export name, or a named import of it would link to
653
+ // `undefined` for a property the CommonJS module never defines. Masking the
654
+ // inert lexical spans before matching keeps genuine top-level assignments
655
+ // (and executable `${ ... }` template substitutions) while dropping the
656
+ // decoys.
657
+ const scannable = maskCommentsAndStrings(source);
554
658
  const names = new Set();
555
659
  const pattern = /(?:^|[^\w$])(?:exports|module\.exports)\.([A-Za-z_$][\w$]*)\s*=/g;
556
660
  let match;
557
- while ((match = pattern.exec(source)) !== null) {
661
+ while ((match = pattern.exec(scannable)) !== null) {
558
662
  names.add(match[1]);
559
663
  }
560
664
  return names;
561
665
  }
666
+ /**
667
+ * Blank out the interior of line comments, block comments, string literals, and
668
+ * template-literal text in emitted JavaScript, replacing each masked character
669
+ * with a space while preserving newlines, code, and template `${ ... }`
670
+ * substitutions verbatim. Positions and length are preserved so an offset in
671
+ * the masked text maps back to the same offset in the source.
672
+ *
673
+ * The input is tsgo's CommonJS emit (well-formed JavaScript), so a character
674
+ * scanner that tracks the standard comment/string/template states is sufficient
675
+ * to separate executable tokens from inert text. Regular-expression literals
676
+ * are intentionally not masked: distinguishing `/`-division from a regex
677
+ * literal needs full tokenization, and tsgo's CommonJS emit never wraps an
678
+ * `exports.<name> =` assignment inside a regex literal.
679
+ */
680
+ function maskCommentsAndStrings(source) {
681
+ const out = source.split("");
682
+ const n = out.length;
683
+ const blank = (index) => {
684
+ const ch = out[index];
685
+ if (ch !== "\n" && ch !== "\r") {
686
+ out[index] = " ";
687
+ }
688
+ };
689
+ const stack = [{ kind: "code", braceDepth: 0 }];
690
+ let i = 0;
691
+ while (i < n) {
692
+ const top = stack[stack.length - 1];
693
+ const ch = out[i];
694
+ if (top.kind === "template") {
695
+ if (ch === "\\") {
696
+ blank(i);
697
+ blank(i + 1);
698
+ i += 2;
699
+ continue;
700
+ }
701
+ if (ch === "`") {
702
+ blank(i);
703
+ stack.pop();
704
+ i += 1;
705
+ continue;
706
+ }
707
+ if (ch === "$" && out[i + 1] === "{") {
708
+ // Enter a code substitution: `${` and its contents stay executable.
709
+ stack.push({ kind: "code", braceDepth: 0 });
710
+ i += 2;
711
+ continue;
712
+ }
713
+ blank(i);
714
+ i += 1;
715
+ continue;
716
+ }
717
+ // Code context.
718
+ if (ch === "/" && out[i + 1] === "/") {
719
+ blank(i);
720
+ blank(i + 1);
721
+ i += 2;
722
+ while (i < n && out[i] !== "\n") {
723
+ blank(i);
724
+ i += 1;
725
+ }
726
+ continue;
727
+ }
728
+ if (ch === "/" && out[i + 1] === "*") {
729
+ blank(i);
730
+ blank(i + 1);
731
+ i += 2;
732
+ while (i < n && !(out[i] === "*" && out[i + 1] === "/")) {
733
+ blank(i);
734
+ i += 1;
735
+ }
736
+ if (i < n) {
737
+ blank(i);
738
+ blank(i + 1);
739
+ i += 2;
740
+ }
741
+ continue;
742
+ }
743
+ if (ch === '"' || ch === "'") {
744
+ blank(i);
745
+ i += 1;
746
+ while (i < n && out[i] !== ch) {
747
+ if (out[i] === "\\") {
748
+ blank(i);
749
+ blank(i + 1);
750
+ i += 2;
751
+ continue;
752
+ }
753
+ // A bare newline ends an unterminated string; stop masking so the rest
754
+ // of the line is still scanned as code (defensive — tsgo never emits
755
+ // one).
756
+ if (out[i] === "\n") {
757
+ break;
758
+ }
759
+ blank(i);
760
+ i += 1;
761
+ }
762
+ if (i < n && out[i] === ch) {
763
+ blank(i);
764
+ i += 1;
765
+ }
766
+ continue;
767
+ }
768
+ if (ch === "`") {
769
+ blank(i);
770
+ stack.push({ kind: "template", braceDepth: 0 });
771
+ i += 1;
772
+ continue;
773
+ }
774
+ if (ch === "{") {
775
+ top.braceDepth += 1;
776
+ i += 1;
777
+ continue;
778
+ }
779
+ if (ch === "}") {
780
+ if (top.braceDepth === 0 && stack.length > 1) {
781
+ // Close the enclosing template `${ ... }` and resume template text.
782
+ stack.pop();
783
+ i += 1;
784
+ continue;
785
+ }
786
+ if (top.braceDepth > 0) {
787
+ top.braceDepth -= 1;
788
+ }
789
+ i += 1;
790
+ continue;
791
+ }
792
+ i += 1;
793
+ }
794
+ return out.join("");
795
+ }
562
796
  function collectSourceCommonJsExportNames(sourceFile, seen) {
563
797
  const real = realPath(sourceFile);
564
798
  if (seen.has(real)) {
@@ -761,14 +995,14 @@ function ensureProjectBuilt(tsconfig) {
761
995
  if (cached !== undefined) {
762
996
  return cached;
763
997
  }
764
- const { emitDir, lockDir, metaPath, root } = dependencyCachePaths(tsconfig);
765
- const reuse = readDependencyCache(emitDir, metaPath);
998
+ const { cacheDir, lockDir, metaPath, root } = dependencyCachePaths(tsconfig);
999
+ const reuse = readDependencyCache(cacheDir, metaPath);
766
1000
  if (reuse !== null) {
767
1001
  builtProjects.set(tsconfig, reuse);
768
1002
  return reuse;
769
1003
  }
770
1004
  node_fs_1.default.mkdirSync(root, { recursive: true });
771
- const built = withBuildLock(lockDir, metaPath, emitDir, () => buildDependency(tsconfig, emitDir, metaPath));
1005
+ const built = withBuildLock(cacheDir, metaPath, lockDir, () => buildDependency(tsconfig, cacheDir, metaPath));
772
1006
  builtProjects.set(tsconfig, built);
773
1007
  return built;
774
1008
  }
@@ -780,14 +1014,38 @@ function dependencyCachePaths(tsconfig) {
780
1014
  .slice(0, 16);
781
1015
  const root = dependencyCacheRoot();
782
1016
  return {
783
- emitDir: node_path_1.default.join(root, key),
1017
+ cacheDir: node_path_1.default.join(root, key),
784
1018
  lockDir: node_path_1.default.join(root, `${key}.lock`),
785
1019
  metaPath: node_path_1.default.join(root, `${key}.json`),
786
1020
  root,
787
1021
  };
788
1022
  }
789
- /** Reuse a dependency another process (or an earlier import) already built. */
790
- function readDependencyCache(emitDir, metaPath) {
1023
+ /** The immutable emit directory of one build generation under `cacheDir`. */
1024
+ function dependencyGenerationDir(cacheDir, generation) {
1025
+ return node_path_1.default.join(cacheDir, `gen-${generation}`);
1026
+ }
1027
+ /** A fresh 128-bit build-generation identifier. */
1028
+ function newDependencyGeneration() {
1029
+ return node_crypto_1.default.randomBytes(16).toString("hex");
1030
+ }
1031
+ /** True for a well-formed 128-bit hex build generation. */
1032
+ function isDependencyGeneration(value) {
1033
+ return typeof value === "string" && /^[0-9a-f]{32}$/.test(value);
1034
+ }
1035
+ /**
1036
+ * Reuse a dependency another process (or an earlier import) already built.
1037
+ *
1038
+ * The completion marker names the exact emit generation, so this reads metadata
1039
+ * and emit as one unit: it returns a hit only when the marker parses to a valid
1040
+ * generation AND that generation's directory holds emitted JavaScript. A reader
1041
+ * that runs while a replacement build is populating a DIFFERENT generation
1042
+ * directory keeps returning the previous complete generation until the atomic
1043
+ * marker swap points at the new one — never a mix of old metadata and a partial
1044
+ * new emit.
1045
+ *
1046
+ * Exported for the ttsx dependency-cache regressions.
1047
+ */
1048
+ function readDependencyCache(cacheDir, metaPath) {
791
1049
  let meta;
792
1050
  try {
793
1051
  meta = JSON.parse(node_fs_1.default.readFileSync(metaPath, "utf8"));
@@ -795,73 +1053,102 @@ function readDependencyCache(emitDir, metaPath) {
795
1053
  catch {
796
1054
  return null;
797
1055
  }
1056
+ if (!isDependencyGeneration(meta.generation) ||
1057
+ typeof meta.rootDir !== "string") {
1058
+ return null;
1059
+ }
1060
+ const emitDir = dependencyGenerationDir(cacheDir, meta.generation);
798
1061
  if (!emittedAnything(emitDir)) {
799
1062
  return null;
800
1063
  }
801
1064
  return {
802
1065
  emitDir,
803
1066
  emittedFiles: undefined,
804
- moduleOption: meta.moduleOption,
1067
+ moduleOption: typeof meta.moduleOption === "string" ? meta.moduleOption : undefined,
805
1068
  rootDir: meta.rootDir,
806
1069
  };
807
1070
  }
808
1071
  /**
809
- * Run `build` while holding an exclusive lock for this dependency, re-checking
1072
+ * Run `build` while holding the fenced lock for this dependency, re-checking
810
1073
  * the cache once the lock is held (a concurrent builder may have just
811
- * finished). A waiter polls for the winner's meta marker, and steals an
812
- * abandoned lock (a builder that crashed) after a generous timeout so the run
813
- * never wedges.
1074
+ * finished). A loser polls for the winner's completion marker and, only when
1075
+ * the holding generation is provably abandoned (dead owner or the steal budget
1076
+ * elapsed), retires precisely that generation before retrying — never a
1077
+ * successor's.
814
1078
  */
815
- function withBuildLock(lockDir, metaPath, emitDir, build) {
816
- const stealAfterMs = 600_000;
1079
+ function withBuildLock(cacheDir, metaPath, lockDir, build) {
817
1080
  for (;;) {
1081
+ const reuse = readDependencyCache(cacheDir, metaPath);
1082
+ if (reuse !== null) {
1083
+ return reuse;
1084
+ }
1085
+ let lease;
818
1086
  try {
819
- node_fs_1.default.mkdirSync(lockDir);
1087
+ lease = acquireDependencyBuildLock(lockDir);
820
1088
  }
821
1089
  catch {
822
- const waited = waitForDependencyCache(emitDir, metaPath, stealAfterMs);
823
- if (waited !== null) {
824
- return waited;
1090
+ // An unusable coordination directory must not silently skip the build.
1091
+ // Generation-stamped emit and the atomic marker swap still keep every
1092
+ // reader's view of publication consistent without the lock.
1093
+ return build();
1094
+ }
1095
+ if (lease === null) {
1096
+ const waited = waitForDependencyBuild(cacheDir, metaPath, lockDir, DEP_BUILD_LOCK_STEAL_MS);
1097
+ if (waited.outcome === "built") {
1098
+ return waited.built;
1099
+ }
1100
+ if (waited.outcome === "abandoned") {
1101
+ // Retire only the generation this observation named. Losing the rename
1102
+ // race means the holder's own release (or another waiter) already made
1103
+ // progress, so a stale result never removes a live successor.
1104
+ reclaimDependencyBuildLock(lockDir, waited.fence);
825
1105
  }
826
- node_fs_1.default.rmSync(lockDir, { force: true, recursive: true });
1106
+ // "released" needs no repair: the holder freed the lock normally, so
1107
+ // retry the ordinary acquisition.
827
1108
  continue;
828
1109
  }
829
1110
  try {
830
- const reuse = readDependencyCache(emitDir, metaPath);
831
- return reuse ?? build();
1111
+ const reuseUnderLock = readDependencyCache(cacheDir, metaPath);
1112
+ return reuseUnderLock ?? build();
832
1113
  }
833
1114
  finally {
834
- node_fs_1.default.rmSync(lockDir, { force: true, recursive: true });
835
- }
836
- }
837
- }
838
- /** Poll for a concurrent builder's completion, up to `timeoutMs`. */
839
- function waitForDependencyCache(emitDir, metaPath, timeoutMs) {
840
- const startedAt = Date.now();
841
- for (;;) {
842
- const reuse = readDependencyCache(emitDir, metaPath);
843
- if (reuse !== null) {
844
- return reuse;
845
- }
846
- if (Date.now() - startedAt > timeoutMs) {
847
- return null;
1115
+ releaseDependencyBuildLock(lockDir, lease);
848
1116
  }
849
- sleepSync(50);
850
1117
  }
851
1118
  }
852
1119
  /** Block the current (synchronous) thread for `ms` without busy-spinning. */
853
1120
  function sleepSync(ms) {
854
1121
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
855
1122
  }
856
- /** Compile a dependency project to `emitDir` and write its completion marker. */
857
- function buildDependency(tsconfig, emitDir, metaPath) {
1123
+ /**
1124
+ * Compile a dependency project into a fresh generation directory and publish
1125
+ * its completion marker atomically.
1126
+ *
1127
+ * The emit lands in `<cacheDir>/gen-<generation>`, a directory no other
1128
+ * generation or process shares, so it is never mutated in place while a reader
1129
+ * looks at it. Only after the emit is proven non-empty is the marker written by
1130
+ * temp-and-rename, binding metadata to that exact generation. A build that
1131
+ * produced no output drops its partial directory so a failed generation can
1132
+ * never be reused.
1133
+ */
1134
+ function buildDependency(tsconfig, cacheDir, metaPath) {
858
1135
  const project = (0, readProjectConfig_1.readProjectConfig)({ cwd: node_path_1.default.dirname(tsconfig), tsconfig });
1136
+ const generation = newDependencyGeneration();
1137
+ const emitDir = dependencyGenerationDir(cacheDir, generation);
859
1138
  node_fs_1.default.rmSync(emitDir, { force: true, recursive: true });
1139
+ node_fs_1.default.mkdirSync(emitDir, { recursive: true });
860
1140
  const result = (0, runBuild_1.runBuild)({
861
1141
  cwd: project.root,
862
1142
  emit: true,
863
1143
  forceListEmittedFiles: true,
864
1144
  outDir: emitDir,
1145
+ // Emit a source map on the transient dependency emit (it never reaches the
1146
+ // dependency's published `lib/`) so the serve path can inline it under the
1147
+ // source URL, but only when the dependency configures none itself. Routed
1148
+ // as a dedicated build option, not a forwarded tsgo flag, so it never
1149
+ // reaches a native plugin host's argument parser (issue #353).
1150
+ forceRuntimeSourceMap: project.compilerOptions.sourceMap !== true &&
1151
+ project.compilerOptions.inlineSourceMap !== true,
865
1152
  // Honour the dependency's own transform plugins: a source-shipping package
866
1153
  // can itself depend on a transform (e.g. a fixture whose values are built
867
1154
  // with `typia.createRandom`), and its runtime behaviour is wrong without it.
@@ -872,6 +1159,7 @@ function buildDependency(tsconfig, emitDir, metaPath) {
872
1159
  // deadlocks, so every dependency in that graph builds with plugins off.
873
1160
  plugins: process.env.TTSC_PLUGIN_DESCRIPTOR_LOAD === "1" ? false : undefined,
874
1161
  quiet: true,
1162
+ resolvedProject: project,
875
1163
  // Emit only: the entry project's up-front check is the type gate. A
876
1164
  // dependency build pulls its own transitive sources into the program and
877
1165
  // would otherwise fail on type diagnostics that belong to those packages
@@ -887,6 +1175,9 @@ function buildDependency(tsconfig, emitDir, metaPath) {
887
1175
  // even on a clean build. A genuinely empty output directory is the real
888
1176
  // failure; the caller then falls back to type-stripping the one file.
889
1177
  if (!emittedAnything(emitDir)) {
1178
+ // Drop the failed generation so its partial directory can never be mistaken
1179
+ // for a reusable build.
1180
+ node_fs_1.default.rmSync(emitDir, { force: true, recursive: true });
890
1181
  throw new Error([
891
1182
  `ttsx: dependency build produced no output for ${tsconfig}`,
892
1183
  result.stderr || result.stdout,
@@ -900,11 +1191,30 @@ function buildDependency(tsconfig, emitDir, metaPath) {
900
1191
  const moduleOption = typeof project.compilerOptions.module === "string"
901
1192
  ? project.compilerOptions.module
902
1193
  : undefined;
903
- writeDependencyMeta(metaPath, { moduleOption, rootDir });
1194
+ publishDependencyMeta(metaPath, { generation, moduleOption, rootDir });
904
1195
  return { emitDir, emittedFiles: undefined, moduleOption, rootDir };
905
1196
  }
906
- function writeDependencyMeta(metaPath, meta) {
907
- node_fs_1.default.writeFileSync(metaPath, JSON.stringify(meta), "utf8");
1197
+ /**
1198
+ * Publish the completion marker atomically: write it to a private temp name in
1199
+ * the same directory, then rename onto `metaPath`. Node's rename replaces an
1200
+ * existing file atomically on POSIX and Windows alike, so a concurrent reader
1201
+ * sees either the whole previous marker or the whole new one, never a
1202
+ * half-written file. The marker is the LAST artifact a build writes, after its
1203
+ * generation's emit is complete, so observing the new marker guarantees the new
1204
+ * generation is complete.
1205
+ */
1206
+ function publishDependencyMeta(metaPath, meta) {
1207
+ const tmp = `${metaPath}.${process.pid}.${Date.now()}.${node_crypto_1.default
1208
+ .randomBytes(6)
1209
+ .toString("hex")}.tmp`;
1210
+ node_fs_1.default.writeFileSync(tmp, JSON.stringify(meta), "utf8");
1211
+ try {
1212
+ node_fs_1.default.renameSync(tmp, metaPath);
1213
+ }
1214
+ catch (error) {
1215
+ node_fs_1.default.rmSync(tmp, { force: true });
1216
+ throw error;
1217
+ }
908
1218
  }
909
1219
  function dependencyCacheRoot() {
910
1220
  const m = manifest();
@@ -912,6 +1222,285 @@ function dependencyCacheRoot() {
912
1222
  ? m.depCacheDir
913
1223
  : node_path_1.default.join(node_os_1.default.tmpdir(), "ttsx-dep");
914
1224
  }
1225
+ // -----------------------------------------------------------------------------
1226
+ // Fenced dependency-build lock.
1227
+ //
1228
+ // The lock serialises concurrent first-builders of one dependency so the
1229
+ // expensive `runBuild` runs once per key while the rest wait and reuse the
1230
+ // published generation. It is generation-fenced so a stale observer or a former
1231
+ // owner can never release a successor's lock:
1232
+ //
1233
+ // * `<lockDir>/current` is the held generation — a directory carrying a
1234
+ // `generation` id and an `owner.json` (pid + hostname). A contender writes a
1235
+ // private candidate and atomically renames it onto `current`; a directory
1236
+ // rename cannot replace a non-empty `current`, so exactly one contender wins
1237
+ // with no empty-owner publication window.
1238
+ // * Release and reclaim both retire a generation by renaming `current` to its
1239
+ // deterministic tombstone `<lockDir>/retired/<generation>`. The only way to
1240
+ // free `current` is to create that tombstone, so a successor can acquire only
1241
+ // after its predecessor's tombstone exists. A late or duplicate retire of an
1242
+ // already-retired generation therefore finds the tombstone occupied and
1243
+ // fails atomically, and a reclaim that named an old generation can never move
1244
+ // a different successor into that old tombstone.
1245
+ //
1246
+ // This mirrors the source-plugin v2 protocol (`buildSourcePlugin.ts`) proven by
1247
+ // issue #452 / PR #460, minus the legacy-compatibility layer: the ttsx
1248
+ // dependency cache lives under a per-run directory with no shipped on-disk
1249
+ // format to stay compatible with.
1250
+ // -----------------------------------------------------------------------------
1251
+ const DEP_BUILD_LOCK_STEAL_MS = 600_000;
1252
+ const DEP_BUILD_LOCK_POLL_MS = 50;
1253
+ const DEP_BUILD_LOCK_STALE_MS = 30_000;
1254
+ const DEP_BUILD_LOCK_CURRENT_DIR = "current";
1255
+ const DEP_BUILD_LOCK_RETIRED_DIR = "retired";
1256
+ const DEP_BUILD_LOCK_GENERATION_FILE = "generation";
1257
+ const DEP_BUILD_LOCK_OWNER_FILE = "owner.json";
1258
+ /**
1259
+ * Atomically acquire the current generation of a dependency build lock, or
1260
+ * `null` when another process already holds it. Exported for the deterministic
1261
+ * multi-process cache regressions.
1262
+ */
1263
+ function acquireDependencyBuildLock(lockDir) {
1264
+ ensureDependencyLockRoot(lockDir);
1265
+ const generation = newDependencyGeneration();
1266
+ const candidateDir = node_path_1.default.join(lockDir, `candidate-${generation}`);
1267
+ node_fs_1.default.mkdirSync(candidateDir);
1268
+ try {
1269
+ node_fs_1.default.writeFileSync(node_path_1.default.join(candidateDir, DEP_BUILD_LOCK_GENERATION_FILE), `${generation}\n`, { encoding: "utf8", flag: "wx" });
1270
+ writeDependencyLockOwner(candidateDir, generation);
1271
+ const currentDir = node_path_1.default.join(lockDir, DEP_BUILD_LOCK_CURRENT_DIR);
1272
+ try {
1273
+ node_fs_1.default.renameSync(candidateDir, currentDir);
1274
+ }
1275
+ catch (error) {
1276
+ if (isMissingPathError(error) ||
1277
+ isRenameDestinationOccupied(error, currentDir)) {
1278
+ return null;
1279
+ }
1280
+ throw error;
1281
+ }
1282
+ return { generation };
1283
+ }
1284
+ finally {
1285
+ // The candidate name carries this process's random generation and can never
1286
+ // alias `current` or another contender's candidate.
1287
+ node_fs_1.default.rmSync(candidateDir, { force: true, recursive: true });
1288
+ }
1289
+ }
1290
+ /** Retire a held generation during the holder's `finally`. */
1291
+ function releaseDependencyBuildLock(lockDir, lease) {
1292
+ return retireDependencyBuildLock(lockDir, lease.generation);
1293
+ }
1294
+ /**
1295
+ * Retire exactly the generation carried by an abandoned observation. Exported
1296
+ * for the deterministic multi-process cache regressions.
1297
+ */
1298
+ function reclaimDependencyBuildLock(lockDir, fence) {
1299
+ return retireDependencyBuildLock(lockDir, fence.generation);
1300
+ }
1301
+ function retireDependencyBuildLock(lockDir, generation) {
1302
+ if (!isDependencyGeneration(generation)) {
1303
+ return false;
1304
+ }
1305
+ const retiredDir = node_path_1.default.join(lockDir, DEP_BUILD_LOCK_RETIRED_DIR);
1306
+ try {
1307
+ node_fs_1.default.mkdirSync(retiredDir);
1308
+ }
1309
+ catch (error) {
1310
+ if (error.code !== "EEXIST") {
1311
+ if (isMissingPathError(error)) {
1312
+ return false;
1313
+ }
1314
+ throw error;
1315
+ }
1316
+ }
1317
+ const destination = node_path_1.default.join(retiredDir, generation);
1318
+ try {
1319
+ node_fs_1.default.renameSync(node_path_1.default.join(lockDir, DEP_BUILD_LOCK_CURRENT_DIR), destination);
1320
+ return true;
1321
+ }
1322
+ catch (error) {
1323
+ if (isMissingPathError(error) ||
1324
+ isRenameDestinationOccupied(error, destination)) {
1325
+ return false;
1326
+ }
1327
+ throw error;
1328
+ }
1329
+ }
1330
+ /**
1331
+ * Classify the current state of a dependency build lock directory. Exported for
1332
+ * the deterministic multi-process cache regressions.
1333
+ */
1334
+ function inspectDependencyBuildLock(lockDir, now) {
1335
+ const currentDir = node_path_1.default.join(lockDir, DEP_BUILD_LOCK_CURRENT_DIR);
1336
+ const generation = readDependencyLockGeneration(currentDir);
1337
+ if (generation === null) {
1338
+ if (dependencyLockAgeMs(currentDir, now) === null) {
1339
+ return { state: "released" };
1340
+ }
1341
+ // `current` exists without a readable generation id. Acquisition writes the
1342
+ // id into the candidate BEFORE the atomic rename, so this is a transient or
1343
+ // corrupt state; keep waiting rather than steal on ambiguous evidence.
1344
+ return {
1345
+ state: "active",
1346
+ owner: "lock generation without a readable id",
1347
+ fence: { generation: "" },
1348
+ };
1349
+ }
1350
+ const fence = { generation };
1351
+ const owner = readDependencyLockOwner(currentDir);
1352
+ if (owner !== null) {
1353
+ const label = describeDependencyLockOwner(owner);
1354
+ if (isLocalHostName(owner.hostname) && !isProcessAlive(owner.pid)) {
1355
+ return {
1356
+ state: "abandoned",
1357
+ reason: `${label} is no longer running`,
1358
+ fence,
1359
+ };
1360
+ }
1361
+ return { state: "active", owner: label, fence };
1362
+ }
1363
+ const ageMs = dependencyLockAgeMs(currentDir, now);
1364
+ if (ageMs === null) {
1365
+ return { state: "released" };
1366
+ }
1367
+ if (ageMs > DEP_BUILD_LOCK_STALE_MS) {
1368
+ return {
1369
+ state: "abandoned",
1370
+ reason: `lock generation has no ${DEP_BUILD_LOCK_OWNER_FILE} and is ${formatDuration(ageMs)} old`,
1371
+ fence,
1372
+ };
1373
+ }
1374
+ return {
1375
+ state: "active",
1376
+ owner: `lock generation with no ${DEP_BUILD_LOCK_OWNER_FILE}`,
1377
+ fence,
1378
+ };
1379
+ }
1380
+ /** Poll for the locked builder to publish, up to `timeoutMs`. */
1381
+ function waitForDependencyBuild(cacheDir, metaPath, lockDir, timeoutMs) {
1382
+ const startedAt = Date.now();
1383
+ for (;;) {
1384
+ const reuse = readDependencyCache(cacheDir, metaPath);
1385
+ if (reuse !== null) {
1386
+ return { outcome: "built", built: reuse };
1387
+ }
1388
+ const now = Date.now();
1389
+ const lock = inspectDependencyBuildLock(lockDir, now);
1390
+ if (lock.state === "released") {
1391
+ // The holder retired its generation between the cache check above and this
1392
+ // observation: prefer the marker if it landed in that window, otherwise
1393
+ // hand the free lock back to the caller to re-acquire.
1394
+ const built = readDependencyCache(cacheDir, metaPath);
1395
+ return built !== null
1396
+ ? { outcome: "built", built }
1397
+ : { outcome: "released" };
1398
+ }
1399
+ if (lock.state === "abandoned") {
1400
+ return { outcome: "abandoned", reason: lock.reason, fence: lock.fence };
1401
+ }
1402
+ if (now - startedAt > timeoutMs) {
1403
+ return {
1404
+ outcome: "abandoned",
1405
+ reason: `timed out after ${formatDuration(now - startedAt)}`,
1406
+ fence: lock.fence,
1407
+ };
1408
+ }
1409
+ sleepSync(DEP_BUILD_LOCK_POLL_MS);
1410
+ }
1411
+ }
1412
+ function ensureDependencyLockRoot(lockDir) {
1413
+ node_fs_1.default.mkdirSync(node_path_1.default.join(lockDir, DEP_BUILD_LOCK_RETIRED_DIR), {
1414
+ recursive: true,
1415
+ });
1416
+ }
1417
+ function writeDependencyLockOwner(generationDir, generation) {
1418
+ node_fs_1.default.writeFileSync(node_path_1.default.join(generationDir, DEP_BUILD_LOCK_OWNER_FILE), `${JSON.stringify({
1419
+ generation,
1420
+ hostname: node_os_1.default.hostname(),
1421
+ pid: process.pid,
1422
+ startedAt: new Date().toISOString(),
1423
+ })}\n`, "utf8");
1424
+ }
1425
+ function readDependencyLockOwner(generationDir) {
1426
+ try {
1427
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(generationDir, DEP_BUILD_LOCK_OWNER_FILE), "utf8"));
1428
+ if (typeof parsed.hostname !== "string" ||
1429
+ typeof parsed.pid !== "number" ||
1430
+ !Number.isInteger(parsed.pid) ||
1431
+ parsed.pid <= 0) {
1432
+ return null;
1433
+ }
1434
+ return {
1435
+ hostname: parsed.hostname,
1436
+ pid: parsed.pid,
1437
+ startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
1438
+ };
1439
+ }
1440
+ catch {
1441
+ return null;
1442
+ }
1443
+ }
1444
+ function readDependencyLockGeneration(generationDir) {
1445
+ try {
1446
+ const generation = node_fs_1.default
1447
+ .readFileSync(node_path_1.default.join(generationDir, DEP_BUILD_LOCK_GENERATION_FILE), "utf8")
1448
+ .trim();
1449
+ return isDependencyGeneration(generation) ? generation : null;
1450
+ }
1451
+ catch {
1452
+ return null;
1453
+ }
1454
+ }
1455
+ /** Age of an observed lock directory, or `null` when it no longer exists. */
1456
+ function dependencyLockAgeMs(generationDir, now) {
1457
+ try {
1458
+ return Math.max(0, now - node_fs_1.default.statSync(generationDir).mtimeMs);
1459
+ }
1460
+ catch (error) {
1461
+ return isMissingPathError(error) ? null : 0;
1462
+ }
1463
+ }
1464
+ function describeDependencyLockOwner(owner) {
1465
+ const started = owner.startedAt === undefined ? "" : ` started at ${owner.startedAt}`;
1466
+ return `pid ${owner.pid} on ${owner.hostname}${started}`;
1467
+ }
1468
+ function isLocalHostName(hostname) {
1469
+ return hostname.toLowerCase() === node_os_1.default.hostname().toLowerCase();
1470
+ }
1471
+ function isProcessAlive(pid) {
1472
+ try {
1473
+ process.kill(pid, 0);
1474
+ return true;
1475
+ }
1476
+ catch (error) {
1477
+ return error.code === "EPERM";
1478
+ }
1479
+ }
1480
+ function isMissingPathError(error) {
1481
+ const code = error.code;
1482
+ return code === "ENOENT" || code === "ENOTDIR";
1483
+ }
1484
+ function isRenameDestinationOccupied(error, destination) {
1485
+ const code = error.code;
1486
+ if (code === "EEXIST" || code === "ENOTEMPTY") {
1487
+ return true;
1488
+ }
1489
+ return (code === "EACCES" || code === "EPERM") && node_fs_1.default.existsSync(destination);
1490
+ }
1491
+ /** Render a millisecond duration for lock diagnostics (`137ms`, `42s`, `9m 3s`). */
1492
+ function formatDuration(ms) {
1493
+ if (!Number.isFinite(ms)) {
1494
+ return "an unknown time";
1495
+ }
1496
+ if (ms < 1_000) {
1497
+ return `${Math.max(0, Math.round(ms))}ms`;
1498
+ }
1499
+ const seconds = Math.floor(ms / 1_000);
1500
+ const minutes = Math.floor(seconds / 60);
1501
+ const remainder = seconds % 60;
1502
+ return minutes === 0 ? `${seconds}s` : `${minutes}m ${remainder}s`;
1503
+ }
915
1504
  /** Owning-tsconfig cache keyed by directory, mirroring `packageTypeCache`. */
916
1505
  const tsconfigCache = new Map();
917
1506
  const moduleOptionCache = new Map();