ttsc 0.18.3 → 0.19.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.
Files changed (173) 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/ttscgraph/main.go +18 -12
  9. package/cmd/ttscgraph/run_help_version_and_bad_flags_exit_codes_test.go +23 -5
  10. package/cmd/ttscgraph/serve.go +863 -0
  11. package/cmd/ttscgraph/serve_protocol_rejects_invalid_request_line_test.go +43 -0
  12. package/cmd/ttscgraph/serve_protocol_retries_session_creation_test.go +83 -0
  13. package/cmd/ttscgraph/serve_protocol_reuses_session_test.go +35 -0
  14. package/cmd/ttscgraph/serve_session_applies_source_edit_incrementally_test.go +55 -0
  15. package/cmd/ttscgraph/serve_session_fails_closed_on_deleted_config_test.go +68 -0
  16. package/cmd/ttscgraph/serve_session_fails_closed_on_invalid_config_test.go +50 -0
  17. package/cmd/ttscgraph/serve_session_keeps_missing_literal_root_unchanged_test.go +46 -0
  18. package/cmd/ttscgraph/serve_session_rebuilds_import_graph_change_test.go +60 -0
  19. package/cmd/ttscgraph/serve_session_reloads_created_literal_root_test.go +44 -0
  20. package/cmd/ttscgraph/serve_session_reloads_extended_config_change_test.go +45 -0
  21. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_conditional_export_test.go +60 -0
  22. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_config_types_test.go +44 -0
  23. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_excluded_import_test.go +62 -0
  24. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_package_export_test.go +46 -0
  25. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_package_import_test.go +46 -0
  26. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_path_alias_test.go +47 -0
  27. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_reference_path_test.go +42 -0
  28. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_root_dirs_target_test.go +46 -0
  29. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_type_reference_test.go +42 -0
  30. package/cmd/ttscgraph/serve_session_reloads_root_file_set_test.go +52 -0
  31. package/cmd/ttscgraph/serve_session_reloads_source_deleted_during_load_test.go +60 -0
  32. package/cmd/ttscgraph/serve_session_reloads_transitive_project_reference_test.go +55 -0
  33. package/cmd/ttscgraph/serve_session_reloads_when_program_plugin_linked_test.go +53 -0
  34. package/cmd/ttscgraph/serve_session_reuses_unchanged_preamble_source_test.go +45 -0
  35. package/cmd/ttscgraph/serve_session_reuses_unchanged_snapshot_test.go +52 -0
  36. package/cmd/ttscgraph/serve_session_revisits_source_edited_during_load_test.go +61 -0
  37. package/cmd/ttscgraph/serve_session_tolerates_os_invalid_resolution_candidate_test.go +56 -0
  38. package/cmd/ttscgraph/serve_session_tracks_all_module_specifier_forms_test.go +41 -0
  39. package/cmd/ttscgraph/serve_session_tracks_diamond_project_references_once_test.go +61 -0
  40. package/cmd/ttscserver/main.go +5 -3
  41. package/driver/lsp.go +13 -1
  42. package/driver/plugins.go +56 -5
  43. package/driver/program.go +25 -10
  44. package/driver/rewrite.go +381 -21
  45. package/driver/session.go +11 -3
  46. package/go.mod +1 -0
  47. package/internal/graph/build.go +297 -3
  48. package/internal/graph/build_records_a_node_per_top_level_declaration_test.go +16 -2
  49. package/internal/graph/diagnostics.go +5 -4
  50. package/internal/graph/dump.go +60 -4
  51. package/internal/graph/dump_is_a_function_of_the_source_test.go +71 -0
  52. package/internal/graph/dump_serializes_the_full_graph_test.go +16 -4
  53. package/internal/graph/edges.go +122 -11
  54. package/internal/graph/exports.go +74 -17
  55. package/internal/graph/graph.go +33 -1
  56. package/internal/graph/node_identity_is_position_invariant_test.go +4 -2
  57. package/internal/graph/resolve.go +24 -0
  58. package/internal/graphsymbols/provider.go +50 -14
  59. package/internal/lspserver/lsp_native_plugin_source.go +77 -23
  60. package/internal/lspserver/lsp_plugin.go +19 -2
  61. package/internal/lspserver/lsp_proxy.go +244 -52
  62. package/internal/lspserver/lsp_server.go +35 -27
  63. package/internal/lspserver/lsp_symbols.go +25 -16
  64. package/lib/TtscCompiler.d.ts +13 -2
  65. package/lib/TtscCompiler.js +30 -20
  66. package/lib/TtscCompiler.js.map +1 -1
  67. package/lib/TtscService.js +9 -7
  68. package/lib/TtscService.js.map +1 -1
  69. package/lib/compiler/internal/compileProjectInMemory.js +15 -1
  70. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  71. package/lib/compiler/internal/project/createNativeProjectContextArgs.d.ts +3 -0
  72. package/lib/compiler/internal/project/createNativeProjectContextArgs.js +14 -0
  73. package/lib/compiler/internal/project/createNativeProjectContextArgs.js.map +1 -0
  74. package/lib/compiler/internal/project/readProjectConfig.js +53 -4
  75. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  76. package/lib/compiler/internal/project/resolveProjectConfig.d.ts +9 -2
  77. package/lib/compiler/internal/project/resolveProjectConfig.js +45 -16
  78. package/lib/compiler/internal/project/resolveProjectConfig.js.map +1 -1
  79. package/lib/compiler/internal/residentTransformProcess.d.ts +16 -5
  80. package/lib/compiler/internal/residentTransformProcess.js +68 -20
  81. package/lib/compiler/internal/residentTransformProcess.js.map +1 -1
  82. package/lib/compiler/internal/runBuild.d.ts +18 -0
  83. package/lib/compiler/internal/runBuild.js +220 -24
  84. package/lib/compiler/internal/runBuild.js.map +1 -1
  85. package/lib/compiler/internal/runSingleFileEmit.js +7 -4
  86. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  87. package/lib/compiler/internal/sharedHostHelpers.d.ts +16 -0
  88. package/lib/compiler/internal/sharedHostHelpers.js +23 -0
  89. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  90. package/lib/compiler/internal/startResidentTransform.js +18 -1
  91. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  92. package/lib/compiler/internal/transformProjectInMemory.js +34 -8
  93. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  94. package/lib/flags/parser.d.ts +17 -0
  95. package/lib/flags/parser.js +11 -1
  96. package/lib/flags/parser.js.map +1 -1
  97. package/lib/flags/schema.js +7 -0
  98. package/lib/flags/schema.js.map +1 -1
  99. package/lib/launcher/internal/prepareExecution.js +21 -2
  100. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  101. package/lib/launcher/internal/runTtsc.js +13 -53
  102. package/lib/launcher/internal/runTtsc.js.map +1 -1
  103. package/lib/launcher/internal/runTtscserver.js +11 -0
  104. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  105. package/lib/launcher/internal/runTtsx.js +27 -11
  106. package/lib/launcher/internal/runTtsx.js.map +1 -1
  107. package/lib/launcher/internal/runtimeHooks.d.ts +81 -0
  108. package/lib/launcher/internal/runtimeHooks.js +632 -43
  109. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  110. package/lib/launcher/internal/servedSourceMap.d.ts +25 -0
  111. package/lib/launcher/internal/servedSourceMap.js +164 -0
  112. package/lib/launcher/internal/servedSourceMap.js.map +1 -0
  113. package/lib/plugin/internal/buildSourcePlugin.d.ts +146 -2
  114. package/lib/plugin/internal/buildSourcePlugin.js +651 -113
  115. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  116. package/lib/plugin/internal/loadProjectPlugins.d.ts +7 -0
  117. package/lib/plugin/internal/loadProjectPlugins.js +33 -10
  118. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  119. package/lib/structures/ITtscCompilerContext.d.ts +16 -2
  120. package/lib/structures/ITtscCompilerResult.d.ts +8 -5
  121. package/lib/structures/ITtscPluginCapabilities.d.ts +8 -0
  122. package/lib/structures/ITtscPluginFactoryContext.d.ts +14 -0
  123. package/lib/structures/internal/ITtscParsedProjectConfig.d.ts +3 -0
  124. package/lib/structures/internal/ITtscProjectIdentity.d.ts +17 -0
  125. package/lib/structures/internal/ITtscProjectIdentity.js +3 -0
  126. package/lib/structures/internal/ITtscProjectIdentity.js.map +1 -0
  127. package/lib/structures/internal/TtscCommonOptions.d.ts +8 -0
  128. package/package.json +11 -8
  129. package/shim/ast/lint.go +10 -0
  130. package/shim/ast/shim.go +44 -0
  131. package/shim/checker/enums_gen.go +19 -0
  132. package/shim/checker/shim.go +130 -0
  133. package/shim/diagnosticwriter/lint.go +30 -3
  134. package/shim/printer/shim.go +22 -4
  135. package/shim/scanner/regexp.go +27 -0
  136. package/src/TtscCompiler.ts +29 -22
  137. package/src/TtscService.ts +15 -7
  138. package/src/compiler/internal/compileProjectInMemory.ts +18 -1
  139. package/src/compiler/internal/project/createNativeProjectContextArgs.ts +15 -0
  140. package/src/compiler/internal/project/readProjectConfig.ts +61 -5
  141. package/src/compiler/internal/project/resolveProjectConfig.ts +51 -21
  142. package/src/compiler/internal/residentTransformProcess.ts +95 -21
  143. package/src/compiler/internal/runBuild.ts +313 -32
  144. package/src/compiler/internal/runSingleFileEmit.ts +7 -4
  145. package/src/compiler/internal/sharedHostHelpers.ts +24 -0
  146. package/src/compiler/internal/startResidentTransform.ts +21 -1
  147. package/src/compiler/internal/transformProjectInMemory.ts +49 -6
  148. package/src/flags/parser.ts +28 -1
  149. package/src/flags/schema.ts +8 -0
  150. package/src/launcher/internal/prepareExecution.ts +22 -2
  151. package/src/launcher/internal/runTtsc.ts +13 -61
  152. package/src/launcher/internal/runTtscserver.ts +14 -0
  153. package/src/launcher/internal/runTtsx.ts +28 -13
  154. package/src/launcher/internal/runtimeHooks.ts +771 -53
  155. package/src/launcher/internal/servedSourceMap.ts +183 -0
  156. package/src/plugin/internal/buildSourcePlugin.ts +859 -122
  157. package/src/plugin/internal/loadProjectPlugins.ts +36 -6
  158. package/src/structures/ITtscCompilerContext.ts +17 -2
  159. package/src/structures/ITtscCompilerResult.ts +8 -5
  160. package/src/structures/ITtscPluginCapabilities.ts +9 -0
  161. package/src/structures/ITtscPluginFactoryContext.ts +15 -0
  162. package/src/structures/internal/ITtscParsedProjectConfig.ts +3 -0
  163. package/src/structures/internal/ITtscProjectIdentity.ts +17 -0
  164. package/src/structures/internal/TtscCommonOptions.ts +8 -0
  165. package/tools/shim_audit/baseline.json +16 -7
  166. package/tools/shim_audit/main.go +777 -71
  167. package/tools/shim_audit/main_test.go +28 -1
  168. package/tools/shim_audit/producer_exemptions_require_rationale_test.go +42 -0
  169. package/tools/shim_audit/producer_surface_includes_exposed_methods_test.go +154 -0
  170. package/tools/shim_audit/producer_surface_rejects_unproducible_reference_test.go +142 -0
  171. package/utility/host.go +46 -20
  172. package/utility/serve.go +1 -1
  173. package/shim/checker/signature_api_completeness.go +0 -58
@@ -10,6 +10,7 @@ import { resolveEmittedJavaScript } from "../../compiler/internal/resolveEmitted
10
10
  import { resolveTsgo } from "../../compiler/internal/resolveTsgo";
11
11
  import { runBuild } from "../../compiler/internal/runBuild";
12
12
  import { outputText, spawnNative } from "../../compiler/internal/spawnNative";
13
+ import { inlineServedSourceMap } from "./servedSourceMap";
13
14
 
14
15
  /**
15
16
  * Synchronous Node module hooks installed (via `module.registerHooks`) in the
@@ -131,6 +132,83 @@ function manifest(): RuntimeManifest | null {
131
132
  return manifestCache;
132
133
  }
133
134
 
135
+ /**
136
+ * Lowest Node.js the ttsx source runtime supports. The synchronous
137
+ * `module.registerHooks` (Node 22.15.0) is the highest floor among the runtime
138
+ * APIs these hooks depend on — `stripTypeScriptTypes` (22.13.0) and the child's
139
+ * `--disable-warning` flag (20.11.0) are both lower — so it sets the effective
140
+ * minimum. Kept in sync with `packages/ttsc/package.json#engines.node` and the
141
+ * documented requirement in `website/src/content/docs/development/index.mdx`.
142
+ */
143
+ export const TTSX_MINIMUM_NODE_VERSION = "22.15.0";
144
+
145
+ const TTSX_MINIMUM_NODE_PARTS: readonly [number, number, number] = [22, 15, 0];
146
+
147
+ /**
148
+ * Report why the running (or a candidate) Node.js version cannot execute the
149
+ * ttsx source runtime, or `null` when it can. Returning an actionable message —
150
+ * rather than letting the child die with an internal `TypeError` on the missing
151
+ * `registerHooks`, or Node 18 rejecting `--disable-warning` with exit 9 — is
152
+ * what turns an opaque internal failure into a clear version diagnostic.
153
+ *
154
+ * Exported for direct exercise by the ttsx e2e suite: the built launcher can
155
+ * only be spawned under the Node version running the tests, so the boundary
156
+ * around the floor cannot otherwise be pinned on CI.
157
+ */
158
+ export function checkNodeRuntimeSupport(version: string): string | null {
159
+ const parts = parseNodeVersion(version);
160
+ if (parts === null) {
161
+ // An unrecognizable version string is not proof of an unsupported runtime;
162
+ // let execution proceed rather than block on a parsing quirk.
163
+ return null;
164
+ }
165
+ if (compareVersionParts(parts, TTSX_MINIMUM_NODE_PARTS) >= 0) {
166
+ return null;
167
+ }
168
+ return (
169
+ `ttsx requires Node.js ${TTSX_MINIMUM_NODE_VERSION} or later, but this ` +
170
+ `process is Node.js ${version}. The source runtime installs synchronous ` +
171
+ `module hooks (module.registerHooks, Node 22.15.0) and strips types with ` +
172
+ `module.stripTypeScriptTypes (Node 22.13.0), neither of which exists on ` +
173
+ `earlier releases. Upgrade Node.js to 22.15.0+ (or the current LTS), or ` +
174
+ `compile the project with \`ttsc\` and run the emitted JavaScript directly.`
175
+ );
176
+ }
177
+
178
+ function parseNodeVersion(version: string): [number, number, number] | null {
179
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
180
+ if (match === null) {
181
+ return null;
182
+ }
183
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
184
+ }
185
+
186
+ function compareVersionParts(
187
+ a: readonly [number, number, number],
188
+ b: readonly [number, number, number],
189
+ ): number {
190
+ for (let index = 0; index < 3; index += 1) {
191
+ if (a[index]! !== b[index]!) {
192
+ return a[index]! < b[index]! ? -1 : 1;
193
+ }
194
+ }
195
+ return 0;
196
+ }
197
+
198
+ /**
199
+ * Throw an actionable version error when the current Node.js cannot run the
200
+ * ttsx source runtime. Guards the hook-installation boundary directly (a child
201
+ * or grandchild that inherits the runtime preload under an unsupported Node) so
202
+ * the failure is diagnosed here instead of surfacing as a bare `TypeError:
203
+ * registerHooks is not a function`.
204
+ */
205
+ function assertNodeRuntimeSupport(): void {
206
+ const message = checkNodeRuntimeSupport(process.versions.node);
207
+ if (message !== null) {
208
+ throw new Error(message);
209
+ }
210
+ }
211
+
134
212
  let installed = false;
135
213
 
136
214
  /**
@@ -150,7 +228,15 @@ export function installRuntimeHooks(): void {
150
228
  if (installed) {
151
229
  return;
152
230
  }
231
+ assertNodeRuntimeSupport();
153
232
  installed = true;
233
+ // Map error stacks through the source maps the serve path now inlines, so a
234
+ // thrown frame reports the true `.ts` line:col out of the box (no
235
+ // `--enable-source-maps` needed). Applied before the entry loads; user code
236
+ // that later toggles it wins, since this is a plain runtime switch.
237
+ if (typeof process.setSourceMapsEnabled === "function") {
238
+ process.setSourceMapsEnabled(true);
239
+ }
154
240
  registerHooks({ load, resolve });
155
241
  installCommonJsHook();
156
242
  }
@@ -356,11 +442,14 @@ function resolveServedSource(
356
442
  const real = realPath(filename);
357
443
  const served = serveEntryEmit(real);
358
444
  if (served !== null) {
359
- return { moduleOption: manifest()?.moduleOption, ...served };
445
+ return withInlineSourceMap({
446
+ moduleOption: manifest()?.moduleOption,
447
+ ...served,
448
+ });
360
449
  }
361
450
  const built = serveDependencyEmit(real);
362
451
  if (built !== null) {
363
- return built;
452
+ return withInlineSourceMap(built);
364
453
  }
365
454
  return {
366
455
  moduleOption: undefined,
@@ -369,6 +458,22 @@ function resolveServedSource(
369
458
  };
370
459
  }
371
460
 
461
+ /**
462
+ * Inline a served emit's external source map into its text and absolutize the
463
+ * map's `sources`, so the JavaScript executed under the `.ts` source URL stays
464
+ * self-describing after the per-run emit directory is deleted. Applied to both
465
+ * the entry lane (`serveEntryEmit`) and the dependency lane
466
+ * (`serveBuiltDependency`); the orphan type-strip lane carries no emitted map.
467
+ */
468
+ function withInlineSourceMap(served: ServedSource): ServedSource {
469
+ const source = inlineServedSourceMap(
470
+ served.source,
471
+ served.emittedFile,
472
+ served.sourceFile,
473
+ );
474
+ return source === served.source ? served : { ...served, source };
475
+ }
476
+
372
477
  /**
373
478
  * Transform a TypeScript source file that no tsconfig owns (a published or
374
479
  * vendored package that ships raw `.ts`/`.cts`/`.mts` straight under
@@ -716,16 +821,163 @@ function collectCommonJsExportNames(
716
821
  }
717
822
 
718
823
  function collectStaticCommonJsExportNames(source: string): Set<string> {
824
+ // Scan executable syntax only. Text that merely resembles an assignment —
825
+ // `exports.x =` inside a comment, string, or template-literal text — must not
826
+ // become an ESM-visible export name, or a named import of it would link to
827
+ // `undefined` for a property the CommonJS module never defines. Masking the
828
+ // inert lexical spans before matching keeps genuine top-level assignments
829
+ // (and executable `${ ... }` template substitutions) while dropping the
830
+ // decoys.
831
+ const scannable = maskCommentsAndStrings(source);
719
832
  const names = new Set<string>();
720
833
  const pattern =
721
834
  /(?:^|[^\w$])(?:exports|module\.exports)\.([A-Za-z_$][\w$]*)\s*=/g;
722
835
  let match: RegExpExecArray | null;
723
- while ((match = pattern.exec(source)) !== null) {
836
+ while ((match = pattern.exec(scannable)) !== null) {
724
837
  names.add(match[1]!);
725
838
  }
726
839
  return names;
727
840
  }
728
841
 
842
+ /**
843
+ * Blank out the interior of line comments, block comments, string literals, and
844
+ * template-literal text in emitted JavaScript, replacing each masked character
845
+ * with a space while preserving newlines, code, and template `${ ... }`
846
+ * substitutions verbatim. Positions and length are preserved so an offset in
847
+ * the masked text maps back to the same offset in the source.
848
+ *
849
+ * The input is tsgo's CommonJS emit (well-formed JavaScript), so a character
850
+ * scanner that tracks the standard comment/string/template states is sufficient
851
+ * to separate executable tokens from inert text. Regular-expression literals
852
+ * are intentionally not masked: distinguishing `/`-division from a regex
853
+ * literal needs full tokenization, and tsgo's CommonJS emit never wraps an
854
+ * `exports.<name> =` assignment inside a regex literal.
855
+ */
856
+ function maskCommentsAndStrings(source: string): string {
857
+ const out = source.split("");
858
+ const n = out.length;
859
+ const blank = (index: number): void => {
860
+ const ch = out[index];
861
+ if (ch !== "\n" && ch !== "\r") {
862
+ out[index] = " ";
863
+ }
864
+ };
865
+ // A stack of lexical contexts. The base is code; each backtick pushes a
866
+ // template context, and each `${` inside a template pushes a nested code
867
+ // context whose `braceDepth` tracks `{}` nesting so an object literal inside
868
+ // the substitution does not end it early.
869
+ interface Context {
870
+ kind: "code" | "template";
871
+ braceDepth: number;
872
+ }
873
+ const stack: Context[] = [{ kind: "code", braceDepth: 0 }];
874
+ let i = 0;
875
+ while (i < n) {
876
+ const top = stack[stack.length - 1]!;
877
+ const ch = out[i]!;
878
+ if (top.kind === "template") {
879
+ if (ch === "\\") {
880
+ blank(i);
881
+ blank(i + 1);
882
+ i += 2;
883
+ continue;
884
+ }
885
+ if (ch === "`") {
886
+ blank(i);
887
+ stack.pop();
888
+ i += 1;
889
+ continue;
890
+ }
891
+ if (ch === "$" && out[i + 1] === "{") {
892
+ // Enter a code substitution: `${` and its contents stay executable.
893
+ stack.push({ kind: "code", braceDepth: 0 });
894
+ i += 2;
895
+ continue;
896
+ }
897
+ blank(i);
898
+ i += 1;
899
+ continue;
900
+ }
901
+ // Code context.
902
+ if (ch === "/" && out[i + 1] === "/") {
903
+ blank(i);
904
+ blank(i + 1);
905
+ i += 2;
906
+ while (i < n && out[i] !== "\n") {
907
+ blank(i);
908
+ i += 1;
909
+ }
910
+ continue;
911
+ }
912
+ if (ch === "/" && out[i + 1] === "*") {
913
+ blank(i);
914
+ blank(i + 1);
915
+ i += 2;
916
+ while (i < n && !(out[i] === "*" && out[i + 1] === "/")) {
917
+ blank(i);
918
+ i += 1;
919
+ }
920
+ if (i < n) {
921
+ blank(i);
922
+ blank(i + 1);
923
+ i += 2;
924
+ }
925
+ continue;
926
+ }
927
+ if (ch === '"' || ch === "'") {
928
+ blank(i);
929
+ i += 1;
930
+ while (i < n && out[i] !== ch) {
931
+ if (out[i] === "\\") {
932
+ blank(i);
933
+ blank(i + 1);
934
+ i += 2;
935
+ continue;
936
+ }
937
+ // A bare newline ends an unterminated string; stop masking so the rest
938
+ // of the line is still scanned as code (defensive — tsgo never emits
939
+ // one).
940
+ if (out[i] === "\n") {
941
+ break;
942
+ }
943
+ blank(i);
944
+ i += 1;
945
+ }
946
+ if (i < n && out[i] === ch) {
947
+ blank(i);
948
+ i += 1;
949
+ }
950
+ continue;
951
+ }
952
+ if (ch === "`") {
953
+ blank(i);
954
+ stack.push({ kind: "template", braceDepth: 0 });
955
+ i += 1;
956
+ continue;
957
+ }
958
+ if (ch === "{") {
959
+ top.braceDepth += 1;
960
+ i += 1;
961
+ continue;
962
+ }
963
+ if (ch === "}") {
964
+ if (top.braceDepth === 0 && stack.length > 1) {
965
+ // Close the enclosing template `${ ... }` and resume template text.
966
+ stack.pop();
967
+ i += 1;
968
+ continue;
969
+ }
970
+ if (top.braceDepth > 0) {
971
+ top.braceDepth -= 1;
972
+ }
973
+ i += 1;
974
+ continue;
975
+ }
976
+ i += 1;
977
+ }
978
+ return out.join("");
979
+ }
980
+
729
981
  function collectSourceCommonJsExportNames(
730
982
  sourceFile: string,
731
983
  seen: Set<string>,
@@ -933,8 +1185,19 @@ function serveBuiltDependency(
933
1185
  };
934
1186
  }
935
1187
 
936
- /** On-disk completion marker for a built dependency, shared across processes. */
1188
+ /**
1189
+ * On-disk completion marker for a built dependency, shared across processes.
1190
+ *
1191
+ * `generation` names the exact immutable emit directory this marker describes
1192
+ * (`<cacheDir>/gen-<generation>`). Binding metadata to one generation is what
1193
+ * makes publication atomic: a reader that parses this marker reads the emit of
1194
+ * the SAME generation, never old metadata combined with a different, still
1195
+ * partially-written directory. The marker is the last thing a build writes, and
1196
+ * it is written by an atomic temp-and-rename, so a reader observes either one
1197
+ * complete old generation or one complete new generation.
1198
+ */
937
1199
  interface DependencyCacheMeta {
1200
+ generation: string;
938
1201
  rootDir: string;
939
1202
  moduleOption?: string;
940
1203
  }
@@ -956,25 +1219,28 @@ function ensureProjectBuilt(tsconfig: string): BuiltProject {
956
1219
  if (cached !== undefined) {
957
1220
  return cached;
958
1221
  }
959
- const { emitDir, lockDir, metaPath, root } = dependencyCachePaths(tsconfig);
1222
+ const { cacheDir, lockDir, metaPath, root } = dependencyCachePaths(tsconfig);
960
1223
 
961
- const reuse = readDependencyCache(emitDir, metaPath);
1224
+ const reuse = readDependencyCache(cacheDir, metaPath);
962
1225
  if (reuse !== null) {
963
1226
  builtProjects.set(tsconfig, reuse);
964
1227
  return reuse;
965
1228
  }
966
1229
 
967
1230
  fs.mkdirSync(root, { recursive: true });
968
- const built = withBuildLock(lockDir, metaPath, emitDir, () =>
969
- buildDependency(tsconfig, emitDir, metaPath),
1231
+ const built = withBuildLock(cacheDir, metaPath, lockDir, () =>
1232
+ buildDependency(tsconfig, cacheDir, metaPath),
970
1233
  );
971
1234
  builtProjects.set(tsconfig, built);
972
1235
  return built;
973
1236
  }
974
1237
 
975
1238
  interface DependencyCachePaths {
976
- emitDir: string;
1239
+ /** Container of this dependency's generation-stamped emit directories. */
1240
+ cacheDir: string;
1241
+ /** Fenced cross-process coordination directory (`<key>.lock`). */
977
1242
  lockDir: string;
1243
+ /** Atomic completion pointer (`<key>.json`) naming the live generation. */
978
1244
  metaPath: string;
979
1245
  root: string;
980
1246
  }
@@ -987,16 +1253,43 @@ function dependencyCachePaths(tsconfig: string): DependencyCachePaths {
987
1253
  .slice(0, 16);
988
1254
  const root = dependencyCacheRoot();
989
1255
  return {
990
- emitDir: path.join(root, key),
1256
+ cacheDir: path.join(root, key),
991
1257
  lockDir: path.join(root, `${key}.lock`),
992
1258
  metaPath: path.join(root, `${key}.json`),
993
1259
  root,
994
1260
  };
995
1261
  }
996
1262
 
997
- /** Reuse a dependency another process (or an earlier import) already built. */
998
- function readDependencyCache(
999
- emitDir: string,
1263
+ /** The immutable emit directory of one build generation under `cacheDir`. */
1264
+ function dependencyGenerationDir(cacheDir: string, generation: string): string {
1265
+ return path.join(cacheDir, `gen-${generation}`);
1266
+ }
1267
+
1268
+ /** A fresh 128-bit build-generation identifier. */
1269
+ function newDependencyGeneration(): string {
1270
+ return crypto.randomBytes(16).toString("hex");
1271
+ }
1272
+
1273
+ /** True for a well-formed 128-bit hex build generation. */
1274
+ function isDependencyGeneration(value: unknown): value is string {
1275
+ return typeof value === "string" && /^[0-9a-f]{32}$/.test(value);
1276
+ }
1277
+
1278
+ /**
1279
+ * Reuse a dependency another process (or an earlier import) already built.
1280
+ *
1281
+ * The completion marker names the exact emit generation, so this reads metadata
1282
+ * and emit as one unit: it returns a hit only when the marker parses to a valid
1283
+ * generation AND that generation's directory holds emitted JavaScript. A reader
1284
+ * that runs while a replacement build is populating a DIFFERENT generation
1285
+ * directory keeps returning the previous complete generation until the atomic
1286
+ * marker swap points at the new one — never a mix of old metadata and a partial
1287
+ * new emit.
1288
+ *
1289
+ * Exported for the ttsx dependency-cache regressions.
1290
+ */
1291
+ export function readDependencyCache(
1292
+ cacheDir: string,
1000
1293
  metaPath: string,
1001
1294
  ): BuiltProject | null {
1002
1295
  let meta: DependencyCacheMeta;
@@ -1005,88 +1298,121 @@ function readDependencyCache(
1005
1298
  } catch {
1006
1299
  return null;
1007
1300
  }
1301
+ if (
1302
+ !isDependencyGeneration(meta.generation) ||
1303
+ typeof meta.rootDir !== "string"
1304
+ ) {
1305
+ return null;
1306
+ }
1307
+ const emitDir = dependencyGenerationDir(cacheDir, meta.generation);
1008
1308
  if (!emittedAnything(emitDir)) {
1009
1309
  return null;
1010
1310
  }
1011
1311
  return {
1012
1312
  emitDir,
1013
1313
  emittedFiles: undefined,
1014
- moduleOption: meta.moduleOption,
1314
+ moduleOption:
1315
+ typeof meta.moduleOption === "string" ? meta.moduleOption : undefined,
1015
1316
  rootDir: meta.rootDir,
1016
1317
  };
1017
1318
  }
1018
1319
 
1019
1320
  /**
1020
- * Run `build` while holding an exclusive lock for this dependency, re-checking
1321
+ * Run `build` while holding the fenced lock for this dependency, re-checking
1021
1322
  * the cache once the lock is held (a concurrent builder may have just
1022
- * finished). A waiter polls for the winner's meta marker, and steals an
1023
- * abandoned lock (a builder that crashed) after a generous timeout so the run
1024
- * never wedges.
1323
+ * finished). A loser polls for the winner's completion marker and, only when
1324
+ * the holding generation is provably abandoned (dead owner or the steal budget
1325
+ * elapsed), retires precisely that generation before retrying — never a
1326
+ * successor's.
1025
1327
  */
1026
1328
  function withBuildLock(
1027
- lockDir: string,
1329
+ cacheDir: string,
1028
1330
  metaPath: string,
1029
- emitDir: string,
1331
+ lockDir: string,
1030
1332
  build: () => BuiltProject,
1031
1333
  ): BuiltProject {
1032
- const stealAfterMs = 600_000;
1033
1334
  for (;;) {
1335
+ const reuse = readDependencyCache(cacheDir, metaPath);
1336
+ if (reuse !== null) {
1337
+ return reuse;
1338
+ }
1339
+ let lease: DependencyBuildLockLease | null;
1034
1340
  try {
1035
- fs.mkdirSync(lockDir);
1341
+ lease = acquireDependencyBuildLock(lockDir);
1036
1342
  } catch {
1037
- const waited = waitForDependencyCache(emitDir, metaPath, stealAfterMs);
1038
- if (waited !== null) {
1039
- return waited;
1343
+ // An unusable coordination directory must not silently skip the build.
1344
+ // Generation-stamped emit and the atomic marker swap still keep every
1345
+ // reader's view of publication consistent without the lock.
1346
+ return build();
1347
+ }
1348
+ if (lease === null) {
1349
+ const waited = waitForDependencyBuild(
1350
+ cacheDir,
1351
+ metaPath,
1352
+ lockDir,
1353
+ DEP_BUILD_LOCK_STEAL_MS,
1354
+ );
1355
+ if (waited.outcome === "built") {
1356
+ return waited.built;
1040
1357
  }
1041
- fs.rmSync(lockDir, { force: true, recursive: true });
1358
+ if (waited.outcome === "abandoned") {
1359
+ // Retire only the generation this observation named. Losing the rename
1360
+ // race means the holder's own release (or another waiter) already made
1361
+ // progress, so a stale result never removes a live successor.
1362
+ reclaimDependencyBuildLock(lockDir, waited.fence);
1363
+ }
1364
+ // "released" needs no repair: the holder freed the lock normally, so
1365
+ // retry the ordinary acquisition.
1042
1366
  continue;
1043
1367
  }
1044
1368
  try {
1045
- const reuse = readDependencyCache(emitDir, metaPath);
1046
- return reuse ?? build();
1369
+ const reuseUnderLock = readDependencyCache(cacheDir, metaPath);
1370
+ return reuseUnderLock ?? build();
1047
1371
  } finally {
1048
- fs.rmSync(lockDir, { force: true, recursive: true });
1372
+ releaseDependencyBuildLock(lockDir, lease);
1049
1373
  }
1050
1374
  }
1051
1375
  }
1052
1376
 
1053
- /** Poll for a concurrent builder's completion, up to `timeoutMs`. */
1054
- function waitForDependencyCache(
1055
- emitDir: string,
1056
- metaPath: string,
1057
- timeoutMs: number,
1058
- ): BuiltProject | null {
1059
- const startedAt = Date.now();
1060
- for (;;) {
1061
- const reuse = readDependencyCache(emitDir, metaPath);
1062
- if (reuse !== null) {
1063
- return reuse;
1064
- }
1065
- if (Date.now() - startedAt > timeoutMs) {
1066
- return null;
1067
- }
1068
- sleepSync(50);
1069
- }
1070
- }
1071
-
1072
1377
  /** Block the current (synchronous) thread for `ms` without busy-spinning. */
1073
1378
  function sleepSync(ms: number): void {
1074
1379
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1075
1380
  }
1076
1381
 
1077
- /** Compile a dependency project to `emitDir` and write its completion marker. */
1382
+ /**
1383
+ * Compile a dependency project into a fresh generation directory and publish
1384
+ * its completion marker atomically.
1385
+ *
1386
+ * The emit lands in `<cacheDir>/gen-<generation>`, a directory no other
1387
+ * generation or process shares, so it is never mutated in place while a reader
1388
+ * looks at it. Only after the emit is proven non-empty is the marker written by
1389
+ * temp-and-rename, binding metadata to that exact generation. A build that
1390
+ * produced no output drops its partial directory so a failed generation can
1391
+ * never be reused.
1392
+ */
1078
1393
  function buildDependency(
1079
1394
  tsconfig: string,
1080
- emitDir: string,
1395
+ cacheDir: string,
1081
1396
  metaPath: string,
1082
1397
  ): BuiltProject {
1083
1398
  const project = readProjectConfig({ cwd: path.dirname(tsconfig), tsconfig });
1399
+ const generation = newDependencyGeneration();
1400
+ const emitDir = dependencyGenerationDir(cacheDir, generation);
1084
1401
  fs.rmSync(emitDir, { force: true, recursive: true });
1402
+ fs.mkdirSync(emitDir, { recursive: true });
1085
1403
  const result = runBuild({
1086
1404
  cwd: project.root,
1087
1405
  emit: true,
1088
1406
  forceListEmittedFiles: true,
1089
1407
  outDir: emitDir,
1408
+ // Emit a source map on the transient dependency emit (it never reaches the
1409
+ // dependency's published `lib/`) so the serve path can inline it under the
1410
+ // source URL, but only when the dependency configures none itself. Routed
1411
+ // as a dedicated build option, not a forwarded tsgo flag, so it never
1412
+ // reaches a native plugin host's argument parser (issue #353).
1413
+ forceRuntimeSourceMap:
1414
+ project.compilerOptions.sourceMap !== true &&
1415
+ project.compilerOptions.inlineSourceMap !== true,
1090
1416
  // Honour the dependency's own transform plugins: a source-shipping package
1091
1417
  // can itself depend on a transform (e.g. a fixture whose values are built
1092
1418
  // with `typia.createRandom`), and its runtime behaviour is wrong without it.
@@ -1098,6 +1424,7 @@ function buildDependency(
1098
1424
  plugins:
1099
1425
  process.env.TTSC_PLUGIN_DESCRIPTOR_LOAD === "1" ? false : undefined,
1100
1426
  quiet: true,
1427
+ resolvedProject: project,
1101
1428
  // Emit only: the entry project's up-front check is the type gate. A
1102
1429
  // dependency build pulls its own transitive sources into the program and
1103
1430
  // would otherwise fail on type diagnostics that belong to those packages
@@ -1113,6 +1440,9 @@ function buildDependency(
1113
1440
  // even on a clean build. A genuinely empty output directory is the real
1114
1441
  // failure; the caller then falls back to type-stripping the one file.
1115
1442
  if (!emittedAnything(emitDir)) {
1443
+ // Drop the failed generation so its partial directory can never be mistaken
1444
+ // for a reusable build.
1445
+ fs.rmSync(emitDir, { force: true, recursive: true });
1116
1446
  throw new Error(
1117
1447
  [
1118
1448
  `ttsx: dependency build produced no output for ${tsconfig}`,
@@ -1130,15 +1460,33 @@ function buildDependency(
1130
1460
  typeof project.compilerOptions.module === "string"
1131
1461
  ? project.compilerOptions.module
1132
1462
  : undefined;
1133
- writeDependencyMeta(metaPath, { moduleOption, rootDir });
1463
+ publishDependencyMeta(metaPath, { generation, moduleOption, rootDir });
1134
1464
  return { emitDir, emittedFiles: undefined, moduleOption, rootDir };
1135
1465
  }
1136
1466
 
1137
- function writeDependencyMeta(
1467
+ /**
1468
+ * Publish the completion marker atomically: write it to a private temp name in
1469
+ * the same directory, then rename onto `metaPath`. Node's rename replaces an
1470
+ * existing file atomically on POSIX and Windows alike, so a concurrent reader
1471
+ * sees either the whole previous marker or the whole new one, never a
1472
+ * half-written file. The marker is the LAST artifact a build writes, after its
1473
+ * generation's emit is complete, so observing the new marker guarantees the new
1474
+ * generation is complete.
1475
+ */
1476
+ function publishDependencyMeta(
1138
1477
  metaPath: string,
1139
1478
  meta: DependencyCacheMeta,
1140
1479
  ): void {
1141
- fs.writeFileSync(metaPath, JSON.stringify(meta), "utf8");
1480
+ const tmp = `${metaPath}.${process.pid}.${Date.now()}.${crypto
1481
+ .randomBytes(6)
1482
+ .toString("hex")}.tmp`;
1483
+ fs.writeFileSync(tmp, JSON.stringify(meta), "utf8");
1484
+ try {
1485
+ fs.renameSync(tmp, metaPath);
1486
+ } catch (error) {
1487
+ fs.rmSync(tmp, { force: true });
1488
+ throw error;
1489
+ }
1142
1490
  }
1143
1491
 
1144
1492
  function dependencyCacheRoot(): string {
@@ -1148,6 +1496,376 @@ function dependencyCacheRoot(): string {
1148
1496
  : path.join(os.tmpdir(), "ttsx-dep");
1149
1497
  }
1150
1498
 
1499
+ // -----------------------------------------------------------------------------
1500
+ // Fenced dependency-build lock.
1501
+ //
1502
+ // The lock serialises concurrent first-builders of one dependency so the
1503
+ // expensive `runBuild` runs once per key while the rest wait and reuse the
1504
+ // published generation. It is generation-fenced so a stale observer or a former
1505
+ // owner can never release a successor's lock:
1506
+ //
1507
+ // * `<lockDir>/current` is the held generation — a directory carrying a
1508
+ // `generation` id and an `owner.json` (pid + hostname). A contender writes a
1509
+ // private candidate and atomically renames it onto `current`; a directory
1510
+ // rename cannot replace a non-empty `current`, so exactly one contender wins
1511
+ // with no empty-owner publication window.
1512
+ // * Release and reclaim both retire a generation by renaming `current` to its
1513
+ // deterministic tombstone `<lockDir>/retired/<generation>`. The only way to
1514
+ // free `current` is to create that tombstone, so a successor can acquire only
1515
+ // after its predecessor's tombstone exists. A late or duplicate retire of an
1516
+ // already-retired generation therefore finds the tombstone occupied and
1517
+ // fails atomically, and a reclaim that named an old generation can never move
1518
+ // a different successor into that old tombstone.
1519
+ //
1520
+ // This mirrors the source-plugin v2 protocol (`buildSourcePlugin.ts`) proven by
1521
+ // issue #452 / PR #460, minus the legacy-compatibility layer: the ttsx
1522
+ // dependency cache lives under a per-run directory with no shipped on-disk
1523
+ // format to stay compatible with.
1524
+ // -----------------------------------------------------------------------------
1525
+
1526
+ const DEP_BUILD_LOCK_STEAL_MS = 600_000;
1527
+ const DEP_BUILD_LOCK_POLL_MS = 50;
1528
+ const DEP_BUILD_LOCK_STALE_MS = 30_000;
1529
+ const DEP_BUILD_LOCK_CURRENT_DIR = "current";
1530
+ const DEP_BUILD_LOCK_RETIRED_DIR = "retired";
1531
+ const DEP_BUILD_LOCK_GENERATION_FILE = "generation";
1532
+ const DEP_BUILD_LOCK_OWNER_FILE = "owner.json";
1533
+
1534
+ /** Ownership token returned only to the process that acquired `current`. */
1535
+ export type DependencyBuildLockLease = { generation: string };
1536
+
1537
+ /** Opaque identity of one observed lock generation. */
1538
+ export type DependencyBuildLockFence = { generation: string };
1539
+
1540
+ /**
1541
+ * Atomically acquire the current generation of a dependency build lock, or
1542
+ * `null` when another process already holds it. Exported for the deterministic
1543
+ * multi-process cache regressions.
1544
+ */
1545
+ export function acquireDependencyBuildLock(
1546
+ lockDir: string,
1547
+ ): DependencyBuildLockLease | null {
1548
+ ensureDependencyLockRoot(lockDir);
1549
+ const generation = newDependencyGeneration();
1550
+ const candidateDir = path.join(lockDir, `candidate-${generation}`);
1551
+ fs.mkdirSync(candidateDir);
1552
+ try {
1553
+ fs.writeFileSync(
1554
+ path.join(candidateDir, DEP_BUILD_LOCK_GENERATION_FILE),
1555
+ `${generation}\n`,
1556
+ { encoding: "utf8", flag: "wx" },
1557
+ );
1558
+ writeDependencyLockOwner(candidateDir, generation);
1559
+ const currentDir = path.join(lockDir, DEP_BUILD_LOCK_CURRENT_DIR);
1560
+ try {
1561
+ fs.renameSync(candidateDir, currentDir);
1562
+ } catch (error) {
1563
+ if (
1564
+ isMissingPathError(error) ||
1565
+ isRenameDestinationOccupied(error, currentDir)
1566
+ ) {
1567
+ return null;
1568
+ }
1569
+ throw error;
1570
+ }
1571
+ return { generation };
1572
+ } finally {
1573
+ // The candidate name carries this process's random generation and can never
1574
+ // alias `current` or another contender's candidate.
1575
+ fs.rmSync(candidateDir, { force: true, recursive: true });
1576
+ }
1577
+ }
1578
+
1579
+ /** Retire a held generation during the holder's `finally`. */
1580
+ export function releaseDependencyBuildLock(
1581
+ lockDir: string,
1582
+ lease: DependencyBuildLockLease,
1583
+ ): boolean {
1584
+ return retireDependencyBuildLock(lockDir, lease.generation);
1585
+ }
1586
+
1587
+ /**
1588
+ * Retire exactly the generation carried by an abandoned observation. Exported
1589
+ * for the deterministic multi-process cache regressions.
1590
+ */
1591
+ export function reclaimDependencyBuildLock(
1592
+ lockDir: string,
1593
+ fence: DependencyBuildLockFence,
1594
+ ): boolean {
1595
+ return retireDependencyBuildLock(lockDir, fence.generation);
1596
+ }
1597
+
1598
+ function retireDependencyBuildLock(
1599
+ lockDir: string,
1600
+ generation: string,
1601
+ ): boolean {
1602
+ if (!isDependencyGeneration(generation)) {
1603
+ return false;
1604
+ }
1605
+ const retiredDir = path.join(lockDir, DEP_BUILD_LOCK_RETIRED_DIR);
1606
+ try {
1607
+ fs.mkdirSync(retiredDir);
1608
+ } catch (error) {
1609
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
1610
+ if (isMissingPathError(error)) {
1611
+ return false;
1612
+ }
1613
+ throw error;
1614
+ }
1615
+ }
1616
+ const destination = path.join(retiredDir, generation);
1617
+ try {
1618
+ fs.renameSync(path.join(lockDir, DEP_BUILD_LOCK_CURRENT_DIR), destination);
1619
+ return true;
1620
+ } catch (error) {
1621
+ if (
1622
+ isMissingPathError(error) ||
1623
+ isRenameDestinationOccupied(error, destination)
1624
+ ) {
1625
+ return false;
1626
+ }
1627
+ throw error;
1628
+ }
1629
+ }
1630
+
1631
+ /** One observation of a dependency build lock's state. */
1632
+ export type DependencyBuildLockObservation =
1633
+ | { state: "active"; owner: string; fence: DependencyBuildLockFence }
1634
+ | { state: "abandoned"; reason: string; fence: DependencyBuildLockFence }
1635
+ | { state: "released" };
1636
+
1637
+ /**
1638
+ * Classify the current state of a dependency build lock directory. Exported for
1639
+ * the deterministic multi-process cache regressions.
1640
+ */
1641
+ export function inspectDependencyBuildLock(
1642
+ lockDir: string,
1643
+ now: number,
1644
+ ): DependencyBuildLockObservation {
1645
+ const currentDir = path.join(lockDir, DEP_BUILD_LOCK_CURRENT_DIR);
1646
+ const generation = readDependencyLockGeneration(currentDir);
1647
+ if (generation === null) {
1648
+ if (dependencyLockAgeMs(currentDir, now) === null) {
1649
+ return { state: "released" };
1650
+ }
1651
+ // `current` exists without a readable generation id. Acquisition writes the
1652
+ // id into the candidate BEFORE the atomic rename, so this is a transient or
1653
+ // corrupt state; keep waiting rather than steal on ambiguous evidence.
1654
+ return {
1655
+ state: "active",
1656
+ owner: "lock generation without a readable id",
1657
+ fence: { generation: "" },
1658
+ };
1659
+ }
1660
+ const fence: DependencyBuildLockFence = { generation };
1661
+ const owner = readDependencyLockOwner(currentDir);
1662
+ if (owner !== null) {
1663
+ const label = describeDependencyLockOwner(owner);
1664
+ if (isLocalHostName(owner.hostname) && !isProcessAlive(owner.pid)) {
1665
+ return {
1666
+ state: "abandoned",
1667
+ reason: `${label} is no longer running`,
1668
+ fence,
1669
+ };
1670
+ }
1671
+ return { state: "active", owner: label, fence };
1672
+ }
1673
+ const ageMs = dependencyLockAgeMs(currentDir, now);
1674
+ if (ageMs === null) {
1675
+ return { state: "released" };
1676
+ }
1677
+ if (ageMs > DEP_BUILD_LOCK_STALE_MS) {
1678
+ return {
1679
+ state: "abandoned",
1680
+ reason: `lock generation has no ${DEP_BUILD_LOCK_OWNER_FILE} and is ${formatDuration(
1681
+ ageMs,
1682
+ )} old`,
1683
+ fence,
1684
+ };
1685
+ }
1686
+ return {
1687
+ state: "active",
1688
+ owner: `lock generation with no ${DEP_BUILD_LOCK_OWNER_FILE}`,
1689
+ fence,
1690
+ };
1691
+ }
1692
+
1693
+ /** Outcome of one waiting session on another process's dependency build lock. */
1694
+ type DependencyBuildWaitResult =
1695
+ | { outcome: "built"; built: BuiltProject }
1696
+ | { outcome: "released" }
1697
+ | { outcome: "abandoned"; reason: string; fence: DependencyBuildLockFence };
1698
+
1699
+ /** Poll for the locked builder to publish, up to `timeoutMs`. */
1700
+ function waitForDependencyBuild(
1701
+ cacheDir: string,
1702
+ metaPath: string,
1703
+ lockDir: string,
1704
+ timeoutMs: number,
1705
+ ): DependencyBuildWaitResult {
1706
+ const startedAt = Date.now();
1707
+ for (;;) {
1708
+ const reuse = readDependencyCache(cacheDir, metaPath);
1709
+ if (reuse !== null) {
1710
+ return { outcome: "built", built: reuse };
1711
+ }
1712
+ const now = Date.now();
1713
+ const lock = inspectDependencyBuildLock(lockDir, now);
1714
+ if (lock.state === "released") {
1715
+ // The holder retired its generation between the cache check above and this
1716
+ // observation: prefer the marker if it landed in that window, otherwise
1717
+ // hand the free lock back to the caller to re-acquire.
1718
+ const built = readDependencyCache(cacheDir, metaPath);
1719
+ return built !== null
1720
+ ? { outcome: "built", built }
1721
+ : { outcome: "released" };
1722
+ }
1723
+ if (lock.state === "abandoned") {
1724
+ return { outcome: "abandoned", reason: lock.reason, fence: lock.fence };
1725
+ }
1726
+ if (now - startedAt > timeoutMs) {
1727
+ return {
1728
+ outcome: "abandoned",
1729
+ reason: `timed out after ${formatDuration(now - startedAt)}`,
1730
+ fence: lock.fence,
1731
+ };
1732
+ }
1733
+ sleepSync(DEP_BUILD_LOCK_POLL_MS);
1734
+ }
1735
+ }
1736
+
1737
+ function ensureDependencyLockRoot(lockDir: string): void {
1738
+ fs.mkdirSync(path.join(lockDir, DEP_BUILD_LOCK_RETIRED_DIR), {
1739
+ recursive: true,
1740
+ });
1741
+ }
1742
+
1743
+ interface DependencyLockOwner {
1744
+ hostname: string;
1745
+ pid: number;
1746
+ startedAt?: string;
1747
+ }
1748
+
1749
+ function writeDependencyLockOwner(
1750
+ generationDir: string,
1751
+ generation: string,
1752
+ ): void {
1753
+ fs.writeFileSync(
1754
+ path.join(generationDir, DEP_BUILD_LOCK_OWNER_FILE),
1755
+ `${JSON.stringify({
1756
+ generation,
1757
+ hostname: os.hostname(),
1758
+ pid: process.pid,
1759
+ startedAt: new Date().toISOString(),
1760
+ })}\n`,
1761
+ "utf8",
1762
+ );
1763
+ }
1764
+
1765
+ function readDependencyLockOwner(
1766
+ generationDir: string,
1767
+ ): DependencyLockOwner | null {
1768
+ try {
1769
+ const parsed = JSON.parse(
1770
+ fs.readFileSync(
1771
+ path.join(generationDir, DEP_BUILD_LOCK_OWNER_FILE),
1772
+ "utf8",
1773
+ ),
1774
+ ) as Record<string, unknown>;
1775
+ if (
1776
+ typeof parsed.hostname !== "string" ||
1777
+ typeof parsed.pid !== "number" ||
1778
+ !Number.isInteger(parsed.pid) ||
1779
+ parsed.pid <= 0
1780
+ ) {
1781
+ return null;
1782
+ }
1783
+ return {
1784
+ hostname: parsed.hostname,
1785
+ pid: parsed.pid,
1786
+ startedAt:
1787
+ typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
1788
+ };
1789
+ } catch {
1790
+ return null;
1791
+ }
1792
+ }
1793
+
1794
+ function readDependencyLockGeneration(generationDir: string): string | null {
1795
+ try {
1796
+ const generation = fs
1797
+ .readFileSync(
1798
+ path.join(generationDir, DEP_BUILD_LOCK_GENERATION_FILE),
1799
+ "utf8",
1800
+ )
1801
+ .trim();
1802
+ return isDependencyGeneration(generation) ? generation : null;
1803
+ } catch {
1804
+ return null;
1805
+ }
1806
+ }
1807
+
1808
+ /** Age of an observed lock directory, or `null` when it no longer exists. */
1809
+ function dependencyLockAgeMs(
1810
+ generationDir: string,
1811
+ now: number,
1812
+ ): number | null {
1813
+ try {
1814
+ return Math.max(0, now - fs.statSync(generationDir).mtimeMs);
1815
+ } catch (error) {
1816
+ return isMissingPathError(error) ? null : 0;
1817
+ }
1818
+ }
1819
+
1820
+ function describeDependencyLockOwner(owner: DependencyLockOwner): string {
1821
+ const started =
1822
+ owner.startedAt === undefined ? "" : ` started at ${owner.startedAt}`;
1823
+ return `pid ${owner.pid} on ${owner.hostname}${started}`;
1824
+ }
1825
+
1826
+ function isLocalHostName(hostname: string): boolean {
1827
+ return hostname.toLowerCase() === os.hostname().toLowerCase();
1828
+ }
1829
+
1830
+ function isProcessAlive(pid: number): boolean {
1831
+ try {
1832
+ process.kill(pid, 0);
1833
+ return true;
1834
+ } catch (error) {
1835
+ return (error as NodeJS.ErrnoException).code === "EPERM";
1836
+ }
1837
+ }
1838
+
1839
+ function isMissingPathError(error: unknown): boolean {
1840
+ const code = (error as NodeJS.ErrnoException).code;
1841
+ return code === "ENOENT" || code === "ENOTDIR";
1842
+ }
1843
+
1844
+ function isRenameDestinationOccupied(
1845
+ error: unknown,
1846
+ destination: string,
1847
+ ): boolean {
1848
+ const code = (error as NodeJS.ErrnoException).code;
1849
+ if (code === "EEXIST" || code === "ENOTEMPTY") {
1850
+ return true;
1851
+ }
1852
+ return (code === "EACCES" || code === "EPERM") && fs.existsSync(destination);
1853
+ }
1854
+
1855
+ /** Render a millisecond duration for lock diagnostics (`137ms`, `42s`, `9m 3s`). */
1856
+ function formatDuration(ms: number): string {
1857
+ if (!Number.isFinite(ms)) {
1858
+ return "an unknown time";
1859
+ }
1860
+ if (ms < 1_000) {
1861
+ return `${Math.max(0, Math.round(ms))}ms`;
1862
+ }
1863
+ const seconds = Math.floor(ms / 1_000);
1864
+ const minutes = Math.floor(seconds / 60);
1865
+ const remainder = seconds % 60;
1866
+ return minutes === 0 ? `${seconds}s` : `${minutes}m ${remainder}s`;
1867
+ }
1868
+
1151
1869
  /** Owning-tsconfig cache keyed by directory, mirroring `packageTypeCache`. */
1152
1870
  const tsconfigCache = new Map<string, string | null>();
1153
1871
  const moduleOptionCache = new Map<string, string | undefined>();