ttsc 0.26.2 → 0.28.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 (170) hide show
  1. package/README.md +9 -0
  2. package/cmd/graphbench/main.go +26 -16
  3. package/cmd/graphdump/main.go +37 -12
  4. package/cmd/graphdump/run_declares_the_capability_its_output_carries_test.go +137 -0
  5. package/cmd/graphdump/run_reports_an_unknown_flag_without_exiting_the_process_test.go +56 -0
  6. package/cmd/graphdump/run_reports_an_unloadable_project_without_printing_a_dump_test.go +46 -0
  7. package/cmd/ttsc/api_transform.go +28 -3
  8. package/cmd/ttscgraph/auxiliary_identity_state_tracks_retargets_not_source_contents_test.go +85 -0
  9. package/cmd/ttscgraph/disk_realpath_other.go +16 -0
  10. package/cmd/ttscgraph/disk_realpath_windows.go +56 -0
  11. package/cmd/ttscgraph/dump.go +43 -11
  12. package/cmd/ttscgraph/run_dump_claims_artifacts_only_when_it_asked_test.go +135 -0
  13. package/cmd/ttscgraph/run_dump_declares_the_capability_its_output_carries_test.go +96 -0
  14. package/cmd/ttscgraph/serve.go +357 -60
  15. package/cmd/ttscgraph/serve_adopts_a_republished_artifact_set_test.go +348 -0
  16. package/cmd/ttscgraph/serve_projects_an_artifact_no_source_owns_test.go +90 -0
  17. package/cmd/ttscgraph/serve_shards.go +31 -9
  18. package/driver/config_discovery.go +121 -0
  19. package/driver/emit_plugin.go +77 -11
  20. package/driver/graph.go +64 -11
  21. package/driver/input_observation.go +273 -0
  22. package/driver/input_observation_test.go +149 -0
  23. package/driver/plugins.go +506 -15
  24. package/driver/program.go +54 -1
  25. package/driver/resolution_candidates.go +13 -5
  26. package/driver/rewrite.go +45 -2
  27. package/driver/transform_dependencies.go +283 -0
  28. package/driver/transform_dependencies_declares_every_file_without_a_contributor_test.go +28 -0
  29. package/driver/transform_dependencies_drops_a_self_reported_dependency_test.go +21 -0
  30. package/driver/transform_dependencies_keys_a_reported_path_like_the_envelope_test.go +46 -0
  31. package/driver/transform_dependencies_requires_every_contributor_to_declare_test.go +38 -0
  32. package/driver/transform_dependencies_stays_silent_for_an_undeclared_contributor_test.go +23 -0
  33. package/driver/transform_dependencies_withdraws_completeness_after_an_unusable_dependency_test.go +37 -0
  34. package/internal/graph/artifacts.go +230 -0
  35. package/internal/graph/artifacts_index_what_a_plugin_published_test.go +154 -0
  36. package/internal/graph/artifacts_survive_a_partial_rebuild_test.go +89 -0
  37. package/internal/graph/build.go +35 -0
  38. package/internal/graph/build_releases_the_scratch_it_no_longer_reads_test.go +118 -0
  39. package/internal/graph/doc_refs_are_the_use_the_checker_already_counts_test.go +93 -0
  40. package/internal/graph/doc_refs_resolve_documentation_links_test.go +335 -0
  41. package/internal/graph/doc_tags.go +416 -0
  42. package/internal/graph/doc_tags_are_claimed_before_they_are_read_test.go +131 -0
  43. package/internal/graph/doc_tags_capture_every_unrecognized_tag_test.go +165 -0
  44. package/internal/graph/doc_tags_read_only_what_the_parser_attached_test.go +97 -0
  45. package/internal/graph/doc_tags_stop_at_the_external_boundary_test.go +59 -0
  46. package/internal/graph/doc_tags_survive_line_endings_and_repeated_blocks_test.go +146 -0
  47. package/internal/graph/dump.go +60 -13
  48. package/internal/graph/dump_checkout_paths_are_stable_test.go +2 -2
  49. package/internal/graph/dump_paths.go +2 -2
  50. package/internal/graph/dump_paths_are_portable_and_injective_test.go +1 -1
  51. package/internal/graph/edge_kinds_names_every_declared_kind_test.go +49 -0
  52. package/internal/graph/edges.go +1 -0
  53. package/internal/graph/graph.go +79 -4
  54. package/internal/graph/provenance.go +29 -2
  55. package/lib/TtscCompiler.d.ts +12 -11
  56. package/lib/TtscCompiler.js +44 -29
  57. package/lib/TtscCompiler.js.map +1 -1
  58. package/lib/compiler/internal/captureProcessOutput.js +2 -2
  59. package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
  60. package/lib/compiler/internal/compileProjectInMemory.js +4 -3
  61. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  62. package/lib/compiler/internal/project/createNativeProjectContextArgs.d.ts +9 -0
  63. package/lib/compiler/internal/project/createNativeProjectContextArgs.js +16 -4
  64. package/lib/compiler/internal/project/createNativeProjectContextArgs.js.map +1 -1
  65. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -0
  66. package/lib/compiler/internal/resolveEmittedJavaScript.js +3 -0
  67. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  68. package/lib/compiler/internal/runBuild.d.ts +1 -1
  69. package/lib/compiler/internal/runBuild.js +80 -34
  70. package/lib/compiler/internal/runBuild.js.map +1 -1
  71. package/lib/compiler/internal/runSingleFileEmit.js +2 -2
  72. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  73. package/lib/compiler/internal/sharedHostHelpers.d.ts +28 -0
  74. package/lib/compiler/internal/sharedHostHelpers.js +39 -0
  75. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  76. package/lib/compiler/internal/startResidentTransform.js +12 -4
  77. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  78. package/lib/compiler/internal/transformProjectInMemory.d.ts +3 -0
  79. package/lib/compiler/internal/transformProjectInMemory.js +239 -35
  80. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  81. package/lib/flags/schema.js +12 -5
  82. package/lib/flags/schema.js.map +1 -1
  83. package/lib/index.d.ts +1 -0
  84. package/lib/index.js +1 -0
  85. package/lib/index.js.map +1 -1
  86. package/lib/internal/createCanonicalTempDirectory.d.ts +17 -0
  87. package/lib/internal/createCanonicalTempDirectory.js +42 -0
  88. package/lib/internal/createCanonicalTempDirectory.js.map +1 -0
  89. package/lib/internal/projectInputPathIdentity.d.ts +3 -0
  90. package/lib/internal/projectInputPathIdentity.js +11 -5
  91. package/lib/internal/projectInputPathIdentity.js.map +1 -1
  92. package/lib/internal/resolveNodeBinary.d.ts +14 -0
  93. package/lib/internal/resolveNodeBinary.js +150 -0
  94. package/lib/internal/resolveNodeBinary.js.map +1 -0
  95. package/lib/internal/resolveSafeCacheCleanupTargets.d.ts +15 -0
  96. package/lib/internal/resolveSafeCacheCleanupTargets.js +61 -0
  97. package/lib/internal/resolveSafeCacheCleanupTargets.js.map +1 -0
  98. package/lib/launcher/internal/prepareExecution.d.ts +3 -0
  99. package/lib/launcher/internal/prepareExecution.js +42 -14
  100. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  101. package/lib/launcher/internal/runTtsc.js +25 -13
  102. package/lib/launcher/internal/runTtsc.js.map +1 -1
  103. package/lib/launcher/internal/runTtscserver.js +18 -3
  104. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  105. package/lib/launcher/internal/runTtsx.js +2 -0
  106. package/lib/launcher/internal/runTtsx.js.map +1 -1
  107. package/lib/launcher/internal/runtimeHooks.d.ts +47 -3
  108. package/lib/launcher/internal/runtimeHooks.js +746 -64
  109. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  110. package/lib/plugin/internal/buildSourcePlugin.d.ts +62 -8
  111. package/lib/plugin/internal/buildSourcePlugin.js +754 -72
  112. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  113. package/lib/plugin/internal/capabilityResolutionCache.d.ts +76 -0
  114. package/lib/plugin/internal/capabilityResolutionCache.js +224 -0
  115. package/lib/plugin/internal/capabilityResolutionCache.js.map +1 -0
  116. package/lib/plugin/internal/loadProjectPlugins.d.ts +17 -5
  117. package/lib/plugin/internal/loadProjectPlugins.js +1339 -77
  118. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  119. package/lib/plugin/resolveCapabilityPlugins.d.ts +55 -0
  120. package/lib/plugin/resolveCapabilityPlugins.js +118 -0
  121. package/lib/plugin/resolveCapabilityPlugins.js.map +1 -0
  122. package/lib/register.d.ts +1 -0
  123. package/lib/register.js +46 -0
  124. package/lib/register.js.map +1 -0
  125. package/lib/structures/ITtscCompilerContext.d.ts +6 -3
  126. package/lib/structures/ITtscCompilerTransformation.d.ts +38 -0
  127. package/lib/structures/ITtscPlugin.d.ts +33 -0
  128. package/package.json +16 -9
  129. package/shim/ast/parent.go +16 -3
  130. package/shim/checker/enums_gen.go +11 -0
  131. package/shim/checker/go.mod +9 -0
  132. package/shim/checker/go.sum +22 -0
  133. package/shim/checker/shim.go +78 -0
  134. package/shim/compiler/emithook.go +10 -0
  135. package/shim/compiler/incremental.go +45 -8
  136. package/src/TtscCompiler.ts +55 -33
  137. package/src/compiler/internal/captureProcessOutput.ts +3 -2
  138. package/src/compiler/internal/compileProjectInMemory.ts +4 -3
  139. package/src/compiler/internal/project/createNativeProjectContextArgs.ts +19 -4
  140. package/src/compiler/internal/resolveEmittedJavaScript.ts +6 -0
  141. package/src/compiler/internal/runBuild.ts +102 -39
  142. package/src/compiler/internal/runSingleFileEmit.ts +2 -2
  143. package/src/compiler/internal/sharedHostHelpers.ts +44 -0
  144. package/src/compiler/internal/startResidentTransform.ts +12 -3
  145. package/src/compiler/internal/transformProjectInMemory.ts +410 -39
  146. package/src/flags/schema.ts +12 -5
  147. package/src/index.ts +1 -0
  148. package/src/internal/createCanonicalTempDirectory.ts +53 -0
  149. package/src/internal/projectInputPathIdentity.ts +17 -4
  150. package/src/internal/resolveNodeBinary.ts +173 -0
  151. package/src/internal/resolveSafeCacheCleanupTargets.ts +85 -0
  152. package/src/launcher/internal/prepareExecution.ts +50 -14
  153. package/src/launcher/internal/runTtsc.ts +24 -13
  154. package/src/launcher/internal/runTtscserver.ts +18 -3
  155. package/src/launcher/internal/runTtsx.ts +2 -0
  156. package/src/launcher/internal/runtimeHooks.ts +934 -68
  157. package/src/plugin/internal/buildSourcePlugin.ts +984 -79
  158. package/src/plugin/internal/capabilityResolutionCache.ts +305 -0
  159. package/src/plugin/internal/loadProjectPlugins.ts +1718 -104
  160. package/src/plugin/resolveCapabilityPlugins.ts +171 -0
  161. package/src/register.ts +47 -0
  162. package/src/structures/ITtscCompilerContext.ts +6 -3
  163. package/src/structures/ITtscCompilerTransformation.ts +46 -0
  164. package/src/structures/ITtscPlugin.ts +36 -0
  165. package/tools/shim_audit/baseline.json +0 -2
  166. package/utility/host.go +18 -3
  167. package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +0 -5
  168. package/lib/internal/assertSafeExplicitCacheDirectory.js +0 -26
  169. package/lib/internal/assertSafeExplicitCacheDirectory.js.map +0 -1
  170. package/src/internal/assertSafeExplicitCacheDirectory.ts +0 -29
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import {
4
4
  Module,
5
+ createRequire,
5
6
  isBuiltin,
6
7
  registerHooks,
7
8
  stripTypeScriptTypes,
@@ -15,6 +16,7 @@ import { resolveEmittedJavaScript } from "../../compiler/internal/resolveEmitted
15
16
  import { resolveTsgo } from "../../compiler/internal/resolveTsgo";
16
17
  import { runBuild } from "../../compiler/internal/runBuild";
17
18
  import { outputText, spawnNative } from "../../compiler/internal/spawnNative";
19
+ import { createCanonicalTempDirectory } from "../../internal/createCanonicalTempDirectory";
18
20
  import {
19
21
  type FilesystemPathIdentityContext,
20
22
  createFilesystemPathIdentityContext,
@@ -61,6 +63,13 @@ const RESOLVABLE_EXTENSIONS = [
61
63
  ".cjs",
62
64
  ] as const;
63
65
 
66
+ /** Union of the ttsx rescue probes and Node's built-in CommonJS probes. */
67
+ const DESCRIPTOR_PROBE_EXTENSIONS = [
68
+ ...RESOLVABLE_EXTENSIONS,
69
+ ".json",
70
+ ".node",
71
+ ] as const;
72
+
64
73
  /** TypeScript source extensions these hooks compile. */
65
74
  const TYPESCRIPT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"] as const;
66
75
 
@@ -117,7 +126,7 @@ type NextLoad = (url: string, context: LoadContext) => LoadResult;
117
126
  * Runtime manifest written by `runTtsx` (the parent) and read once here. It
118
127
  * describes the already-built entry project so the hooks can serve its emit.
119
128
  */
120
- interface RuntimeManifest {
129
+ export interface RuntimeManifest {
121
130
  /** Project root of the entry's owning tsconfig. */
122
131
  projectRoot: string;
123
132
  /** Source-tree root the emit mirrors (tsgo strips this prefix). */
@@ -126,6 +135,10 @@ interface RuntimeManifest {
126
135
  emitDir: string;
127
136
  /** Emitted file list from the entry build, for source→output matching. */
128
137
  emittedFiles?: readonly string[];
138
+ /** Physical TypeScript root whose checked preparation created this manifest. */
139
+ entrySource?: string;
140
+ /** Exact JavaScript emitted for `entrySource`. */
141
+ entryFile?: string;
129
142
  /**
130
143
  * The entry tsconfig's `module` and `target`, deciding emit CJS/ESM per file.
131
144
  * `target` is not decoration: an absent `module` makes tsgo derive the module
@@ -136,25 +149,34 @@ interface RuntimeManifest {
136
149
  depCacheDir: string;
137
150
  }
138
151
 
139
- let manifestCache: RuntimeManifest | null | undefined;
152
+ let environmentManifestCache: RuntimeManifest | null | undefined;
153
+ const registeredManifests: RuntimeManifest[] = [];
140
154
 
141
- function manifest(): RuntimeManifest | null {
142
- if (manifestCache !== undefined) {
143
- return manifestCache;
155
+ function environmentManifest(): RuntimeManifest | null {
156
+ if (environmentManifestCache !== undefined) {
157
+ return environmentManifestCache;
144
158
  }
145
159
  const file = process.env.TTSX_RUNTIME_MANIFEST;
146
160
  if (file === undefined || file.length === 0) {
147
- manifestCache = null;
148
- return manifestCache;
161
+ environmentManifestCache = null;
162
+ return environmentManifestCache;
149
163
  }
150
164
  try {
151
- manifestCache = JSON.parse(
165
+ environmentManifestCache = JSON.parse(
152
166
  fs.readFileSync(file, "utf8"),
153
167
  ) as RuntimeManifest;
154
168
  } catch {
155
- manifestCache = null;
169
+ environmentManifestCache = null;
156
170
  }
157
- return manifestCache;
171
+ return environmentManifestCache;
172
+ }
173
+
174
+ /** Every checked entry emit available to this runtime, in ownership order. */
175
+ function runtimeManifests(): readonly RuntimeManifest[] {
176
+ const inherited = environmentManifest();
177
+ return inherited === null
178
+ ? registeredManifests
179
+ : [inherited, ...registeredManifests];
158
180
  }
159
181
 
160
182
  /**
@@ -235,6 +257,12 @@ function assertNodeRuntimeSupport(): void {
235
257
  }
236
258
 
237
259
  let installed = false;
260
+ let prepareRuntimeEntry: ((filename: string) => RuntimeManifest) | undefined;
261
+
262
+ export interface RuntimeHookOptions {
263
+ /** Prepare and type-check a TypeScript root discovered after registration. */
264
+ prepareEntry?: (filename: string) => RuntimeManifest;
265
+ }
238
266
 
239
267
  /**
240
268
  * Install the source-loading hooks on the current (main) thread. Idempotent:
@@ -246,10 +274,21 @@ let installed = false;
246
274
  * `require()` made from inside a CommonJS module that was itself reached
247
275
  * through an ESM `import` (the interop translator loads it on the raw CJS
248
276
  * path). The ESM graph goes through `registerHooks`; the CommonJS `require`
249
- * graph goes through `Module._extensions` — the canonical loader extension
250
- * point `ts-node`/`tsx` use for the same reason.
277
+ * graph goes through `Module._extensions` and `Module._resolveFilename` — the
278
+ * canonical loader extension points `ts-node`/`tsx` use for the same reason.
279
+ *
280
+ * Both halves of that second graph are required, and they answer different
281
+ * questions. `_extensions` decides how a file that was _found_ is compiled, and
282
+ * Node reuses its keys when it probes an extensionless request, so
283
+ * `require("./x")` reaches `x.ts` for free. Nothing there resolves a request
284
+ * that already carries an extension: `require("./x.js")` backed only by `x.ts`
285
+ * is a resolution failure before any compiler is consulted, which is why the
286
+ * rescue is installed on `_resolveFilename` as well (samchon/ttsc#1280).
251
287
  */
252
- export function installRuntimeHooks(): void {
288
+ export function installRuntimeHooks(options: RuntimeHookOptions = {}): void {
289
+ if (options.prepareEntry !== undefined) {
290
+ prepareRuntimeEntry = options.prepareEntry;
291
+ }
253
292
  if (installed) {
254
293
  return;
255
294
  }
@@ -264,6 +303,7 @@ export function installRuntimeHooks(): void {
264
303
  }
265
304
  registerHooks({ load, resolve });
266
305
  installCommonJsHook();
306
+ installCommonJsResolveHook();
267
307
  }
268
308
 
269
309
  /**
@@ -277,23 +317,137 @@ function installCommonJsHook(): void {
277
317
  _extensions: Record<
278
318
  string,
279
319
  (
280
- module: { _compile(source: string, filename: string): void },
320
+ module: {
321
+ _compile(source: string, filename: string): void;
322
+ parent?: { filename?: string | null } | null;
323
+ },
281
324
  filename: string,
282
325
  ) => void
283
326
  >;
284
327
  }
285
328
  )._extensions;
286
329
  const compile = (
287
- module: { _compile(source: string, filename: string): void },
330
+ module: {
331
+ _compile(source: string, filename: string): void;
332
+ parent?: { filename?: string | null } | null;
333
+ },
288
334
  filename: string,
289
335
  ): void => {
290
- module._compile(resolveServedSource(filename).source, filename);
336
+ const parent = module.parent?.filename;
337
+ module._compile(
338
+ resolveServedSource(
339
+ filename,
340
+ pathToFileURL(filename).href,
341
+ typeof parent !== "string" || !isTypeScriptSource(parent),
342
+ ).source,
343
+ filename,
344
+ );
291
345
  };
292
346
  for (const extension of [".ts", ".tsx", ".cts"]) {
293
347
  extensions[extension] = compile;
294
348
  }
295
349
  }
296
350
 
351
+ /**
352
+ * Map a JavaScript spelling back to its TypeScript source on the CommonJS
353
+ * resolution path, so `require("./x.js")` finds `x.ts` when nothing emitted
354
+ * `x.js`.
355
+ *
356
+ * TypeScript asks authors to write the emitted `.js` extension in a relative
357
+ * specifier, so this spelling is the documented one rather than a mistake, and
358
+ * the ESM `resolve` hook has always rescued it. The CommonJS graph could not,
359
+ * and on Node 22 that graph is reached by every `require()` inside a CommonJS
360
+ * module the ESM loader evaluated — `module.registerHooks` sees the `import()`
361
+ * of that module and nothing within it. Node 24 routes the same `require`
362
+ * through the hooks, which is why the gap was only ever visible on the oldest
363
+ * runtime ttsx supports (`engines.node` is `>=22.15.0`).
364
+ *
365
+ * The wrapper only ever answers a request Node's own resolver already refused,
366
+ * so a resolution that succeeds is never perturbed, and a request no candidate
367
+ * satisfies rethrows Node's original error with its `MODULE_NOT_FOUND` code and
368
+ * `requireStack` intact. `require.resolve` shares this entry point and is
369
+ * rescued with it, except in its `{ paths }` form, which resolves against the
370
+ * caller's list rather than the parent this reads.
371
+ */
372
+ function installCommonJsResolveHook(): void {
373
+ const internals = Module as unknown as {
374
+ _resolveFilename(
375
+ request: string,
376
+ parent: { filename?: string | null } | null | undefined,
377
+ isMain: boolean,
378
+ options?: unknown,
379
+ ): string;
380
+ };
381
+ const original = internals._resolveFilename;
382
+ internals._resolveFilename = function resolveFilename(
383
+ this: unknown,
384
+ request: string,
385
+ parent: { filename?: string | null } | null | undefined,
386
+ isMain: boolean,
387
+ options?: unknown,
388
+ ): string {
389
+ try {
390
+ return original.call(this, request, parent, isMain, options);
391
+ } catch (error) {
392
+ const rescued = rescueCommonJsRequest(request, parent, options);
393
+ if (rescued === null) {
394
+ throw error;
395
+ }
396
+ return rescued;
397
+ }
398
+ };
399
+ }
400
+
401
+ /**
402
+ * The TypeScript source a refused CommonJS request names, or `null`.
403
+ *
404
+ * Only a relative or absolute request can be rescued: a bare specifier is a
405
+ * package lookup, whose own resolver already probed every extension the
406
+ * installed `_extensions` keys declare.
407
+ *
408
+ * Two shapes decline before that. `_resolveFilename` is an internal entry point
409
+ * anything may call, so a non-string request reaches here as readily as a
410
+ * specifier does, and reading it would replace Node's own argument error with a
411
+ * `TypeError` from this file. And `require.resolve(request, { paths })`
412
+ * resolves against the caller's list, which this rescue does not consult, so
413
+ * answering it from the parent's directory would be a different question's
414
+ * answer.
415
+ */
416
+ function rescueCommonJsRequest(
417
+ request: string,
418
+ parent: { filename?: string | null } | null | undefined,
419
+ options?: unknown,
420
+ ): string | null {
421
+ if (typeof request !== "string") {
422
+ return null;
423
+ }
424
+ if (
425
+ typeof options === "object" &&
426
+ options !== null &&
427
+ Array.isArray((options as { paths?: unknown }).paths)
428
+ ) {
429
+ return null;
430
+ }
431
+ let base: string;
432
+ if (path.isAbsolute(request)) {
433
+ base = request;
434
+ } else if (isRelativeSpecifier(request)) {
435
+ const from = parent?.filename;
436
+ if (typeof from !== "string") {
437
+ return null;
438
+ }
439
+ base = path.resolve(path.dirname(from), request);
440
+ } else {
441
+ return null;
442
+ }
443
+ for (const candidate of typescriptSourcesForJavaScriptSpecifier(base)) {
444
+ if (isFile(candidate)) {
445
+ return candidate;
446
+ }
447
+ }
448
+ return null;
449
+ }
450
+
297
451
  /**
298
452
  * The module format of the entry source file, derived from the entry project's
299
453
  * compiler options (via the runtime manifest) the same way the served files are
@@ -301,15 +455,19 @@ function installCommonJsHook(): void {
301
455
  * `require` or an ESM `import`.
302
456
  */
303
457
  export function entryModuleFormat(entryFile: string): "module" | "commonjs" {
304
- const m = manifest();
458
+ const real = realPath(entryFile);
459
+ const owner = findEntryEmit(real)?.manifest;
305
460
  return moduleFormat(
306
461
  entryFile,
307
- m === null ? null : (m.moduleOptions ?? {}),
462
+ owner === undefined ? null : (owner.moduleOptions ?? {}),
308
463
  ) === "module"
309
464
  ? "module"
310
465
  : "commonjs";
311
466
  }
312
467
 
468
+ /** TypeScript URLs resolved at a JavaScript-to-TypeScript entry boundary. */
469
+ const runtimeEntryUrls = new Set<string>();
470
+
313
471
  /**
314
472
  * Rescue an extensionless or directory relative specifier that Node's resolver
315
473
  * rejected. Only runs after `nextResolve` throws, so a successful resolution is
@@ -321,24 +479,394 @@ function resolve(
321
479
  context: ResolveContext,
322
480
  nextResolve: NextResolve,
323
481
  ): ResolveResult {
482
+ recordPluginDescriptorResolutionCandidates(specifier, context.parentURL);
324
483
  try {
325
- return rememberCommonJsNamedInterop(
326
- restoreStrippedNodeBuiltinScheme(
327
- specifier,
328
- nextResolve(specifier, context),
484
+ const result = rememberRuntimeEntry(
485
+ rememberCommonJsNamedInterop(
486
+ restoreStrippedNodeBuiltinScheme(
487
+ specifier,
488
+ nextResolve(specifier, context),
489
+ ),
490
+ context,
329
491
  ),
330
492
  context,
331
493
  );
494
+ recordPluginDescriptorResolution(specifier, context.parentURL, result.url);
495
+ return result;
332
496
  } catch (error) {
333
497
  const rescued = probeRescuableSpecifier(specifier, context.parentURL);
334
498
  if (rescued === null) {
335
499
  throw error;
336
500
  }
337
- return rememberCommonJsNamedInterop(
338
- { shortCircuit: true, url: rescued },
501
+ const result = rememberRuntimeEntry(
502
+ rememberCommonJsNamedInterop(
503
+ { shortCircuit: true, url: rescued },
504
+ context,
505
+ ),
339
506
  context,
340
507
  );
508
+ recordPluginDescriptorResolution(specifier, context.parentURL, result.url);
509
+ return result;
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Fingerprint every path whose state can redirect one descriptor import.
515
+ *
516
+ * This runs before the real resolver. Reporting candidates only after the
517
+ * descriptor finished would pair an earlier descriptor result with later file
518
+ * state when a higher-priority candidate appeared during evaluation.
519
+ */
520
+ function recordPluginDescriptorResolutionCandidates(
521
+ specifier: string,
522
+ parentURL: string | undefined,
523
+ ): void {
524
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") return;
525
+ if (isBuiltin(specifier) || specifier.startsWith("node:")) return;
526
+ const parent = runtimeFilePath(parentURL);
527
+ if (parent === undefined) return;
528
+ const recorded = new Set<string>();
529
+ const record = (candidate: string): void => {
530
+ const resolved = path.resolve(candidate);
531
+ if (recorded.has(resolved)) return;
532
+ recorded.add(resolved);
533
+ recordPluginDescriptorInput({
534
+ parent,
535
+ resolved,
536
+ });
537
+ };
538
+ const recordManifestTargets = (
539
+ value: unknown,
540
+ directory: string,
541
+ allowBare: boolean = false,
542
+ ): void => {
543
+ if (typeof value === "string") {
544
+ if (
545
+ value !== "" &&
546
+ (allowBare || value.startsWith("./") || value.startsWith("../"))
547
+ ) {
548
+ recordBase(path.resolve(directory, value));
549
+ }
550
+ return;
551
+ }
552
+ if (Array.isArray(value)) {
553
+ for (const item of value) {
554
+ recordManifestTargets(item, directory, allowBare);
555
+ }
556
+ return;
557
+ }
558
+ if (value !== null && typeof value === "object") {
559
+ for (const item of Object.values(value)) {
560
+ recordManifestTargets(item, directory, allowBare);
561
+ }
562
+ }
563
+ };
564
+ const recordPackageManifests = (file: string): void => {
565
+ for (
566
+ let directory = path.dirname(file);
567
+ ;
568
+ directory = path.dirname(directory)
569
+ ) {
570
+ const manifest = path.join(directory, "package.json");
571
+ record(manifest);
572
+ if (isFile(manifest)) return;
573
+ const parentDirectory = path.dirname(directory);
574
+ if (parentDirectory === directory) return;
575
+ }
576
+ };
577
+ const localBases = (value: string): string[] => {
578
+ if (value.startsWith("file:")) return [fileURLToPath(value)];
579
+ const raw = path.resolve(path.dirname(parent), value);
580
+ const suffixStart = value.search(/[?#]/);
581
+ if (suffixStart === -1) return [raw];
582
+ const pathname = value.slice(0, suffixStart);
583
+ return pathname === ""
584
+ ? [raw]
585
+ : [...new Set([raw, path.resolve(path.dirname(parent), pathname)])];
586
+ };
587
+ const bases = new Set<string>();
588
+ const recordBase = (base: string): void => {
589
+ const resolvedBase = path.resolve(base);
590
+ if (bases.has(resolvedBase)) return;
591
+ bases.add(resolvedBase);
592
+ record(resolvedBase);
593
+ for (const candidate of typescriptSourcesForJavaScriptSpecifier(
594
+ resolvedBase,
595
+ )) {
596
+ record(candidate);
597
+ }
598
+ for (const extension of DESCRIPTOR_PROBE_EXTENSIONS) {
599
+ record(resolvedBase + extension);
600
+ }
601
+ const manifestFile = path.join(resolvedBase, "package.json");
602
+ record(manifestFile);
603
+ for (const extension of DESCRIPTOR_PROBE_EXTENSIONS) {
604
+ record(path.join(resolvedBase, `index${extension}`));
605
+ }
606
+ try {
607
+ const manifest = JSON.parse(
608
+ fs.readFileSync(manifestFile, "utf8").replace(/^\uFEFF/, ""),
609
+ ) as Record<string, unknown>;
610
+ recordManifestTargets(manifest.exports, resolvedBase);
611
+ recordManifestTargets(manifest.module, resolvedBase, true);
612
+ recordManifestTargets(manifest.main, resolvedBase, true);
613
+ } catch {
614
+ // The real resolver owns malformed package diagnostics.
615
+ }
616
+ };
617
+
618
+ if (
619
+ specifier.startsWith(".") ||
620
+ path.isAbsolute(specifier) ||
621
+ specifier.startsWith("file:")
622
+ ) {
623
+ try {
624
+ for (const base of localBases(specifier)) {
625
+ recordPackageManifests(base);
626
+ if (isFile(base)) record(base);
627
+ else recordBase(base);
628
+ }
629
+ } catch {
630
+ // The real resolver owns invalid URL spellings.
631
+ }
632
+ return;
633
+ }
634
+
635
+ const parts = specifier.split("/");
636
+ const packageParts = parts[0]?.startsWith("@")
637
+ ? parts.slice(0, 2)
638
+ : parts.slice(0, 1);
639
+ if (packageParts.some((part) => part === undefined || part === "")) return;
640
+ const packageName = packageParts.join("/");
641
+ const subpath = parts.slice(packageParts.length);
642
+ for (const searchPath of createRequire(parent).resolve.paths(specifier) ??
643
+ []) {
644
+ const packageDirectory = path.join(searchPath, packageName);
645
+ recordBase(packageDirectory);
646
+ if (subpath.length !== 0) {
647
+ recordBase(path.join(packageDirectory, ...subpath));
648
+ }
649
+ // CommonJS resolution continues past an existing but unusable package
650
+ // directory. Record every search root before the resolver runs so a
651
+ // farther selected package retains evaluation-time hashes for its
652
+ // superseding candidates.
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Report one resolved descriptor edge to the parent loader. The channel is
658
+ * armed by the generated descriptor shim only after ttsx's own bootstrap and
659
+ * imports have loaded, keeping compiler implementation files out of the
660
+ * project's persistent-cache inputs.
661
+ */
662
+ function recordPluginDescriptorResolution(
663
+ specifier: string,
664
+ parentURL: string | undefined,
665
+ resolvedURL: string,
666
+ ): void {
667
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") return;
668
+ const out = process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_OUT;
669
+ if (out === undefined || out.length === 0) return;
670
+ const resolved = runtimeFilePath(resolvedURL);
671
+ if (resolved === undefined) return;
672
+ const parent = runtimeFilePath(parentURL);
673
+ recordPluginDescriptorInput({
674
+ ...(parent === undefined ? {} : { parent }),
675
+ resolved,
676
+ specifier,
677
+ });
678
+ for (
679
+ let directory = path.dirname(resolved);
680
+ ;
681
+ directory = path.dirname(directory)
682
+ ) {
683
+ const manifest = path.join(directory, "package.json");
684
+ recordPluginDescriptorInput({
685
+ ...(parent === undefined ? {} : { parent }),
686
+ resolved: manifest,
687
+ });
688
+ if (isFile(manifest)) break;
689
+ const parentDirectory = path.dirname(directory);
690
+ if (parentDirectory === directory) break;
691
+ }
692
+ }
693
+
694
+ function recordPluginDescriptorInput(record: {
695
+ hash?: string | null;
696
+ parent?: string;
697
+ realpath?: string | null;
698
+ resolved: string;
699
+ signature?: string;
700
+ specifier?: string;
701
+ unstable?: boolean;
702
+ }): void {
703
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") return;
704
+ const out = process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_OUT;
705
+ if (out === undefined || out.length === 0) return;
706
+ recordPluginDescriptorInputOnce(record);
707
+ const resolved = path.resolve(record.resolved);
708
+ const parsed = path.parse(resolved);
709
+ let current = parsed.root;
710
+ const relative = path.relative(parsed.root, resolved);
711
+ for (const segment of relative.split(path.sep).slice(0, -1)) {
712
+ if (segment === "") continue;
713
+ current = path.join(current, segment);
714
+ try {
715
+ if (fs.lstatSync(current).isSymbolicLink()) {
716
+ recordPluginDescriptorInputOnce({ resolved: current });
717
+ }
718
+ } catch {
719
+ break;
720
+ }
721
+ }
722
+ }
723
+
724
+ /** Record one path without recursively revisiting its lexical ancestors. */
725
+ function recordPluginDescriptorInputOnce(record: {
726
+ hash?: string | null;
727
+ parent?: string;
728
+ realpath?: string | null;
729
+ resolved: string;
730
+ signature?: string;
731
+ specifier?: string;
732
+ unstable?: boolean;
733
+ }): void {
734
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") return;
735
+ const out = process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_OUT;
736
+ if (out === undefined || out.length === 0) return;
737
+ try {
738
+ const beforeSignature = pluginDescriptorInputMetadataSignature(
739
+ record.resolved,
740
+ );
741
+ const observedHash = pluginDescriptorInputHash(record.resolved);
742
+ const observedRealpath = pluginDescriptorInputRealpath(record.resolved);
743
+ const afterSignature = pluginDescriptorInputMetadataSignature(
744
+ record.resolved,
745
+ );
746
+ const unstable =
747
+ record.unstable === true ||
748
+ beforeSignature === undefined ||
749
+ afterSignature === undefined ||
750
+ beforeSignature !== afterSignature ||
751
+ (record.hash !== undefined && record.hash !== observedHash) ||
752
+ (record.realpath !== undefined && record.realpath !== observedRealpath) ||
753
+ (record.signature !== undefined && record.signature !== afterSignature);
754
+ fs.appendFileSync(
755
+ out,
756
+ `${JSON.stringify({
757
+ ...record,
758
+ hash: observedHash,
759
+ realpath: observedRealpath,
760
+ ...(unstable ? { unstable: true } : { signature: afterSignature }),
761
+ })}\n`,
762
+ "utf8",
763
+ );
764
+ } catch {
765
+ // Dependency reporting is advisory to cache reuse; the selected entry is
766
+ // still retained by the parent if this best-effort side channel fails.
767
+ }
768
+ }
769
+
770
+ function pluginDescriptorInputRealpath(file: string): string | null {
771
+ try {
772
+ return fs.realpathSync.native(file);
773
+ } catch {
774
+ return null;
775
+ }
776
+ }
777
+
778
+ /** Metadata identity that exposes content-preserving A-B-A replacement. */
779
+ function pluginDescriptorInputMetadataSignature(
780
+ file: string,
781
+ ): string | undefined {
782
+ const requested = path.resolve(file);
783
+ let current = requested;
784
+ for (;;) {
785
+ try {
786
+ const link = fs.lstatSync(current, { bigint: true });
787
+ let target = link;
788
+ if (link.isSymbolicLink()) {
789
+ try {
790
+ target = fs.statSync(current, { bigint: true });
791
+ } catch {
792
+ // A broken link's own metadata cannot expose its target appearing
793
+ // and disappearing during evaluation. Decline cache proof instead.
794
+ return undefined;
795
+ }
796
+ }
797
+ return [
798
+ path.relative(current, requested),
799
+ link.dev,
800
+ link.ino,
801
+ link.mode,
802
+ link.size,
803
+ link.mtimeNs,
804
+ link.ctimeNs,
805
+ target.dev,
806
+ target.ino,
807
+ target.mode,
808
+ target.size,
809
+ target.mtimeNs,
810
+ target.ctimeNs,
811
+ ].join(":");
812
+ } catch (error) {
813
+ if (!isMissingPathError(error)) return undefined;
814
+ const parent = path.dirname(current);
815
+ if (parent === current) return undefined;
816
+ current = parent;
817
+ }
818
+ }
819
+ }
820
+
821
+ function pluginDescriptorInputHash(file: string): string | null {
822
+ try {
823
+ if (fs.statSync(file).isDirectory()) {
824
+ return pluginDescriptorDirectoryHash();
825
+ }
826
+ return crypto
827
+ .createHash("sha256")
828
+ .update(fs.readFileSync(file))
829
+ .digest("hex");
830
+ } catch {
831
+ return null;
832
+ }
833
+ }
834
+
835
+ /** Stable public fingerprint for an existing directory candidate's kind. */
836
+ function pluginDescriptorDirectoryHash(): string {
837
+ return crypto
838
+ .createHash("sha256")
839
+ .update("ttsc:host-input:directory\0")
840
+ .digest("hex");
841
+ }
842
+
843
+ function runtimeFilePath(value: string | undefined): string | undefined {
844
+ if (value === undefined) return undefined;
845
+ if (value.startsWith("file:")) {
846
+ try {
847
+ return path.resolve(fileURLToPath(value));
848
+ } catch {
849
+ return undefined;
850
+ }
341
851
  }
852
+ return path.isAbsolute(value) ? path.resolve(value) : undefined;
853
+ }
854
+
855
+ /** Remember an ESM root until its synchronous load hook prepares the project. */
856
+ function rememberRuntimeEntry(
857
+ result: ResolveResult,
858
+ context: ResolveContext,
859
+ ): ResolveResult {
860
+ if (
861
+ result.url.startsWith("file:") &&
862
+ isTypeScriptSource(fileURLToPath(result.url)) &&
863
+ (context.parentURL === undefined ||
864
+ !context.parentURL.startsWith("file:") ||
865
+ !isTypeScriptSource(fileURLToPath(context.parentURL)))
866
+ ) {
867
+ runtimeEntryUrls.add(result.url);
868
+ }
869
+ return result;
342
870
  }
343
871
 
344
872
  /**
@@ -369,6 +897,12 @@ interface BuiltProject {
369
897
  moduleOptions: OwningModuleOptions;
370
898
  }
371
899
  const builtProjects = new Map<string, BuiltProject>();
900
+ // A descriptor evaluator's dependency emit must never be reused by another
901
+ // process. PIDs are eventually recycled while the disk cache persists, so PID
902
+ // alone cannot provide that isolation. One cryptographically random process
903
+ // nonce keeps every evaluator generation distinct while `builtProjects` still
904
+ // shares repeated imports inside this process.
905
+ const descriptorProcessCacheNonce = crypto.randomBytes(16).toString("hex");
372
906
 
373
907
  /** File URLs whose CommonJS source was reached from an ESM parent import. */
374
908
  const commonJsNamedInteropUrls = new Set<string>();
@@ -386,7 +920,11 @@ function load(
386
920
  if (!isTypeScriptSource(filename)) {
387
921
  return nextLoad(url, context);
388
922
  }
389
- const { format, source } = resolveRuntimeSource(filename, url);
923
+ const { format, source } = resolveRuntimeSource(
924
+ filename,
925
+ url,
926
+ runtimeEntryUrls.delete(url) || isProcessEntry(filename),
927
+ );
390
928
  return {
391
929
  format,
392
930
  shortCircuit: true,
@@ -397,8 +935,9 @@ function load(
397
935
  function resolveRuntimeSource(
398
936
  filename: string,
399
937
  url: string = pathToFileURL(filename).href,
938
+ prepareAsEntry: boolean = false,
400
939
  ): { format: string; source: string } {
401
- const served = resolveServedSource(filename, url);
940
+ const served = resolveServedSource(filename, url, prepareAsEntry);
402
941
  const format = moduleFormat(filename, served.moduleOptions);
403
942
  return {
404
943
  format,
@@ -413,6 +952,16 @@ function resolveRuntimeSource(
413
952
  };
414
953
  }
415
954
 
955
+ /** Whether `filename` is the TypeScript main module named on Node's argv. */
956
+ function isProcessEntry(filename: string): boolean {
957
+ const entry = process.argv[1];
958
+ return (
959
+ entry !== undefined &&
960
+ isTypeScriptSource(entry) &&
961
+ realPath(path.resolve(entry)) === realPath(filename)
962
+ );
963
+ }
964
+
416
965
  function rememberCommonJsNamedInterop(
417
966
  result: ResolveResult,
418
967
  context: ResolveContext,
@@ -467,9 +1016,9 @@ function owningModuleOptions(filename: string): OwningModuleOptions | null {
467
1016
  return null;
468
1017
  }
469
1018
  const real = realPath(filename);
470
- const m = manifest();
471
- if (m !== null && entryEmitPath(m, real) !== null) {
472
- return m.moduleOptions ?? {};
1019
+ const owner = findEntryEmit(real)?.manifest;
1020
+ if (owner !== undefined) {
1021
+ return owner.moduleOptions ?? {};
473
1022
  }
474
1023
  const tsconfig = nearestTsconfig(real);
475
1024
  if (tsconfig === null) {
@@ -481,10 +1030,7 @@ function owningModuleOptions(filename: string): OwningModuleOptions | null {
481
1030
  }
482
1031
  let options: OwningModuleOptions = {};
483
1032
  try {
484
- const project = readProjectConfig({
485
- cwd: path.dirname(tsconfig),
486
- tsconfig,
487
- });
1033
+ const project = readPluginDescriptorProjectConfig(tsconfig);
488
1034
  options = projectModuleOptions(project.compilerOptions);
489
1035
  } catch {
490
1036
  // The owning project cannot be read, so nothing is known about the format
@@ -498,6 +1044,147 @@ function owningModuleOptions(filename: string): OwningModuleOptions | null {
498
1044
  return options;
499
1045
  }
500
1046
 
1047
+ /**
1048
+ * Read one owning config while its discovered chain stays unchanged.
1049
+ *
1050
+ * A preliminary read discovers `extends`; an accepted read is bracketed by
1051
+ * equal fingerprints over that exact chain. If churn never settles, ttsx can
1052
+ * still execute the last project but reports every observed config as unstable,
1053
+ * preventing a later snapshot from certifying the torn result.
1054
+ */
1055
+ function readPluginDescriptorProjectConfig(
1056
+ tsconfig: string,
1057
+ ): ReturnType<typeof readProjectConfig> {
1058
+ const read = () =>
1059
+ readProjectConfig({ cwd: path.dirname(tsconfig), tsconfig });
1060
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") return read();
1061
+
1062
+ const observed = new Set<string>([path.resolve(tsconfig)]);
1063
+ let project: ReturnType<typeof readProjectConfig>;
1064
+ try {
1065
+ project = read();
1066
+ } catch (error) {
1067
+ recordPluginDescriptorProjectInputs(observed, true);
1068
+ throw error;
1069
+ }
1070
+ let inputs = normalizedProjectConfigPaths(project, tsconfig);
1071
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1072
+ for (const input of inputs) observed.add(input);
1073
+ const before = pluginDescriptorInputHashes(inputs);
1074
+ const beforeRealpaths = pluginDescriptorInputRealpaths(inputs);
1075
+ const beforeSignatures = pluginDescriptorInputMetadataSignatures(inputs);
1076
+ let candidate: ReturnType<typeof readProjectConfig>;
1077
+ try {
1078
+ candidate = read();
1079
+ } catch (error) {
1080
+ recordPluginDescriptorProjectInputs(observed, true);
1081
+ throw error;
1082
+ }
1083
+ const candidateInputs = normalizedProjectConfigPaths(candidate, tsconfig);
1084
+ for (const input of candidateInputs) observed.add(input);
1085
+ const after = pluginDescriptorInputHashes(candidateInputs);
1086
+ const afterRealpaths = pluginDescriptorInputRealpaths(candidateInputs);
1087
+ const afterSignatures =
1088
+ pluginDescriptorInputMetadataSignatures(candidateInputs);
1089
+ if (
1090
+ equalPluginDescriptorInputLists(inputs, candidateInputs) &&
1091
+ equalPluginDescriptorInputHashes(before, after) &&
1092
+ equalPluginDescriptorInputHashes(beforeRealpaths, afterRealpaths) &&
1093
+ beforeSignatures !== undefined &&
1094
+ afterSignatures !== undefined &&
1095
+ equalPluginDescriptorInputHashes(beforeSignatures, afterSignatures)
1096
+ ) {
1097
+ for (const input of candidateInputs) {
1098
+ recordPluginDescriptorInput({
1099
+ hash: after[input]!,
1100
+ realpath: afterRealpaths[input]!,
1101
+ resolved: input,
1102
+ signature: afterSignatures[input]!,
1103
+ });
1104
+ }
1105
+ return candidate;
1106
+ }
1107
+ project = candidate;
1108
+ inputs = candidateInputs;
1109
+ }
1110
+ recordPluginDescriptorProjectInputs(observed, true);
1111
+ return project;
1112
+ }
1113
+
1114
+ function normalizedProjectConfigPaths(
1115
+ project: ReturnType<typeof readProjectConfig>,
1116
+ requestedConfig: string,
1117
+ ): string[] {
1118
+ return [
1119
+ ...new Set(
1120
+ [requestedConfig, ...project.configPaths].map((file) =>
1121
+ path.resolve(file),
1122
+ ),
1123
+ ),
1124
+ ].sort();
1125
+ }
1126
+
1127
+ function pluginDescriptorInputHashes(
1128
+ inputs: readonly string[],
1129
+ ): Record<string, string | null> {
1130
+ return Object.fromEntries(
1131
+ inputs.map((input) => [input, pluginDescriptorInputHash(input)]),
1132
+ );
1133
+ }
1134
+
1135
+ function pluginDescriptorInputRealpaths(
1136
+ inputs: readonly string[],
1137
+ ): Record<string, string | null> {
1138
+ return Object.fromEntries(
1139
+ inputs.map((input) => [input, pluginDescriptorInputRealpath(input)]),
1140
+ );
1141
+ }
1142
+
1143
+ function pluginDescriptorInputMetadataSignatures(
1144
+ inputs: readonly string[],
1145
+ ): Record<string, string> | undefined {
1146
+ const output: Record<string, string> = {};
1147
+ for (const input of inputs) {
1148
+ const signature = pluginDescriptorInputMetadataSignature(input);
1149
+ if (signature === undefined) return undefined;
1150
+ output[path.resolve(input)] = signature;
1151
+ }
1152
+ return output;
1153
+ }
1154
+
1155
+ function equalPluginDescriptorInputLists(
1156
+ first: readonly string[],
1157
+ second: readonly string[],
1158
+ ): boolean {
1159
+ return (
1160
+ first.length === second.length &&
1161
+ first.every((input, index) => input === second[index])
1162
+ );
1163
+ }
1164
+
1165
+ function equalPluginDescriptorInputHashes(
1166
+ first: Readonly<Record<string, string | null>>,
1167
+ second: Readonly<Record<string, string | null>>,
1168
+ ): boolean {
1169
+ return (
1170
+ Object.keys(first).length === Object.keys(second).length &&
1171
+ Object.entries(first).every(([input, hash]) => second[input] === hash)
1172
+ );
1173
+ }
1174
+
1175
+ function recordPluginDescriptorProjectInputs(
1176
+ inputs: Iterable<string>,
1177
+ unstable: boolean,
1178
+ ): void {
1179
+ for (const input of inputs) {
1180
+ const resolved = path.resolve(input);
1181
+ recordPluginDescriptorInput({
1182
+ resolved,
1183
+ ...(unstable ? { unstable: true } : {}),
1184
+ });
1185
+ }
1186
+ }
1187
+
501
1188
  /** Narrow a resolved project's compiler options to the emit-format pair. */
502
1189
  export function projectModuleOptions(
503
1190
  compilerOptions: Record<string, unknown>,
@@ -522,12 +1209,32 @@ export function projectModuleOptions(
522
1209
  function resolveServedSource(
523
1210
  filename: string,
524
1211
  url: string = pathToFileURL(filename).href,
1212
+ prepareAsEntry: boolean = false,
525
1213
  ): ServedSource {
526
1214
  const real = realPath(filename);
527
- const served = serveEntryEmit(real);
1215
+ // Only the public preload can prepare a newly discovered root. Direct ttsx
1216
+ // has one pre-built manifest and historically relies on trailing-stem
1217
+ // recovery when Windows presents the same source through its short and long
1218
+ // temp-path spellings (the lint TypeScript-config loader is one such case).
1219
+ // Treating that boundary as prepare-only would discard the existing emit and
1220
+ // feed ESM source into CommonJS interop, producing ERR_REQUIRE_CYCLE_MODULE.
1221
+ const prepareEntry = prepareAsEntry ? prepareRuntimeEntry : undefined;
1222
+ // A JavaScript-to-TypeScript boundary is a new checked root unless an
1223
+ // existing manifest proves exact ownership. Trailing-stem recovery is not
1224
+ // ownership evidence: two out-of-include `index.ts` roots can otherwise map
1225
+ // to the first manifest's `index.js`, skipping the second root's diagnostics.
1226
+ let served = serveEntryEmit(real, prepareEntry === undefined);
528
1227
  if (served !== null) {
529
1228
  return withInlineSourceMap(served);
530
1229
  }
1230
+ if (prepareEntry !== undefined) {
1231
+ registeredManifests.push(prepareEntry(real));
1232
+ served = serveEntryEmit(real);
1233
+ if (served === null) {
1234
+ throw new Error(`ttsx: prepared entry emit not found for ${filename}`);
1235
+ }
1236
+ return withInlineSourceMap(served);
1237
+ }
531
1238
  const built = serveDependencyEmit(real);
532
1239
  if (built !== null) {
533
1240
  return withInlineSourceMap(built);
@@ -610,7 +1317,7 @@ function emitOrphanAsCommonJs(filename: string): string | null {
610
1317
  return hit;
611
1318
  }
612
1319
  }
613
- const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "ttsx-orphan-"));
1320
+ const outDir = createCanonicalTempDirectory("ttsx-orphan-");
614
1321
  try {
615
1322
  const res = spawnNative(
616
1323
  tsgo,
@@ -673,7 +1380,7 @@ function emitCommonJsForNameScan(filename: string): string | null {
673
1380
  commonJsNameScanSources.set(real, null);
674
1381
  return null;
675
1382
  }
676
- const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "ttsx-export-scan-"));
1383
+ const outDir = createCanonicalTempDirectory("ttsx-export-scan-");
677
1384
  try {
678
1385
  const res = spawnNative(
679
1386
  tsgo,
@@ -1166,45 +1873,95 @@ function isIdentifierName(name: string): boolean {
1166
1873
  * `rootDir` cannot have a mirrored emit, so it falls through to the dependency
1167
1874
  * paths.
1168
1875
  */
1169
- function serveEntryEmit(real: string): ServedSource | null {
1170
- const m = manifest();
1171
- if (m === null) {
1172
- return null;
1173
- }
1174
- const emitted = entryEmitPath(m, real);
1175
- if (emitted === null) {
1876
+ function serveEntryEmit(
1877
+ real: string,
1878
+ allowStemFallback: boolean = true,
1879
+ ): ServedSource | null {
1880
+ const owner = findEntryEmit(real, allowStemFallback);
1881
+ if (owner === null) {
1176
1882
  return null;
1177
1883
  }
1178
- const source = readFileOrNull(emitted);
1884
+ const source = readFileOrNull(owner.emittedFile);
1179
1885
  return source === null
1180
1886
  ? null
1181
1887
  : {
1182
- emittedFile: emitted,
1183
- moduleOptions: m.moduleOptions ?? {},
1888
+ emittedFile: owner.emittedFile,
1889
+ moduleOptions: owner.manifest.moduleOptions ?? {},
1184
1890
  source,
1185
1891
  sourceFile: real,
1186
1892
  };
1187
1893
  }
1188
1894
 
1895
+ /**
1896
+ * Find the manifest that owns `real`. Explicit prepared roots win, followed by
1897
+ * exact mirrored paths across every manifest. Only then may legacy stem
1898
+ * recovery run, so one manifest's approximate match cannot shadow another's
1899
+ * exact emit.
1900
+ */
1901
+ function findEntryEmit(
1902
+ real: string,
1903
+ allowStemFallback: boolean = true,
1904
+ ): { emittedFile: string; manifest: RuntimeManifest } | null {
1905
+ const manifests = runtimeManifests();
1906
+ for (const candidate of manifests) {
1907
+ if (
1908
+ candidate.entrySource !== undefined &&
1909
+ candidate.entryFile !== undefined &&
1910
+ realPath(candidate.entrySource) === real &&
1911
+ fs.existsSync(candidate.entryFile)
1912
+ ) {
1913
+ return { emittedFile: candidate.entryFile, manifest: candidate };
1914
+ }
1915
+ }
1916
+ for (const candidate of manifests) {
1917
+ const emitted = entryEmitPath(candidate, real, false);
1918
+ if (emitted !== null) {
1919
+ return { emittedFile: emitted, manifest: candidate };
1920
+ }
1921
+ }
1922
+ if (!allowStemFallback) {
1923
+ return null;
1924
+ }
1925
+ for (const candidate of manifests) {
1926
+ const emitted = entryEmitPath(candidate, real, true);
1927
+ if (emitted !== null) {
1928
+ return { emittedFile: emitted, manifest: candidate };
1929
+ }
1930
+ }
1931
+ return null;
1932
+ }
1933
+
1189
1934
  /**
1190
1935
  * The entry project's emitted JavaScript for `real`, or `null` when that
1191
1936
  * project did not emit it. Shared with `owningModuleOptions` so "the entry
1192
1937
  * project owns this file" means exactly one thing in both places.
1193
1938
  */
1194
- function entryEmitPath(m: RuntimeManifest, real: string): string | null {
1195
- const cached = entryEmitPathCache.get(real);
1196
- if (cached !== undefined) {
1197
- return cached;
1939
+ function entryEmitPath(
1940
+ m: RuntimeManifest,
1941
+ real: string,
1942
+ allowStemFallback: boolean = true,
1943
+ ): string | null {
1944
+ const cache = allowStemFallback
1945
+ ? entryEmitPathCache
1946
+ : exactEntryEmitPathCache;
1947
+ let manifestCache = cache.get(m);
1948
+ if (manifestCache === undefined) {
1949
+ manifestCache = new Map<string, string | null>();
1950
+ cache.set(m, manifestCache);
1951
+ }
1952
+ if (manifestCache.has(real)) {
1953
+ return manifestCache.get(real) ?? null;
1198
1954
  }
1199
1955
  const resolved = isWithin(real, m.rootDir)
1200
1956
  ? resolveEmittedJavaScript({
1957
+ allowStemFallback,
1201
1958
  emittedFiles: m.emittedFiles,
1202
1959
  outDir: m.emitDir,
1203
1960
  projectRoot: m.rootDir,
1204
1961
  sourceFile: real,
1205
1962
  })
1206
1963
  : null;
1207
- entryEmitPathCache.set(real, resolved);
1964
+ manifestCache.set(real, resolved);
1208
1965
  return resolved;
1209
1966
  }
1210
1967
 
@@ -1212,10 +1969,18 @@ function entryEmitPath(m: RuntimeManifest, real: string): string | null {
1212
1969
  * Memo for `entryEmitPath`, because it is now on the `resolve` hook's path
1213
1970
  * through `owningModuleOptions` — once per import specifier — and a miss inside
1214
1971
  * `resolveEmittedJavaScript` walks the whole emit tree. The entry emit is
1215
- * written once before the run starts and never changes under it, so one answer
1216
- * per path is the only one there is.
1972
+ * written once before the run starts and never changes under it. Registration
1973
+ * can add several independent entry emits, so the manifest is part of the cache
1974
+ * identity as well as the source path.
1217
1975
  */
1218
- const entryEmitPathCache = new Map<string, string | null>();
1976
+ const entryEmitPathCache = new WeakMap<
1977
+ RuntimeManifest,
1978
+ Map<string, string | null>
1979
+ >();
1980
+ const exactEntryEmitPathCache = new WeakMap<
1981
+ RuntimeManifest,
1982
+ Map<string, string | null>
1983
+ >();
1219
1984
 
1220
1985
  /**
1221
1986
  * True when `real` is `directory` itself or sits beneath it. Handles a root
@@ -1351,11 +2116,7 @@ interface DependencyCachePaths {
1351
2116
  }
1352
2117
 
1353
2118
  function dependencyCachePaths(tsconfig: string): DependencyCachePaths {
1354
- const key = crypto
1355
- .createHash("sha256")
1356
- .update(tsconfig)
1357
- .digest("hex")
1358
- .slice(0, 16);
2119
+ const key = dependencyCacheKey(tsconfig);
1359
2120
  const root = dependencyCacheRoot();
1360
2121
  return {
1361
2122
  cacheDir: path.join(root, key),
@@ -1365,6 +2126,38 @@ function dependencyCachePaths(tsconfig: string): DependencyCachePaths {
1365
2126
  };
1366
2127
  }
1367
2128
 
2129
+ /** Derive one dependency cache key; exported for isolation regressions. */
2130
+ export function dependencyCacheKey(
2131
+ tsconfig: string,
2132
+ options: {
2133
+ descriptorLoad?: boolean;
2134
+ descriptorNonce?: string;
2135
+ } = {},
2136
+ ): string {
2137
+ const descriptorLoad =
2138
+ options.descriptorLoad ?? process.env.TTSC_PLUGIN_DESCRIPTOR_LOAD === "1";
2139
+ return (
2140
+ crypto
2141
+ .createHash("sha256")
2142
+ .update(tsconfig)
2143
+ // Descriptor evaluation promises a result bound to this process's exact
2144
+ // input observations. Reusing an emit another evaluator built can pair
2145
+ // that process's old source/config bytes with this process's later hashes.
2146
+ // Keep ordinary ttsx worker sharing, but isolate descriptor builds with a
2147
+ // non-reusable process nonce; the in-process `builtProjects` map still
2148
+ // compiles each owning project once.
2149
+ .update(
2150
+ descriptorLoad
2151
+ ? `\0descriptor-process:${
2152
+ options.descriptorNonce ?? descriptorProcessCacheNonce
2153
+ }`
2154
+ : "",
2155
+ )
2156
+ .digest("hex")
2157
+ .slice(0, 16)
2158
+ );
2159
+ }
2160
+
1368
2161
  /** The immutable emit directory of one build generation under `cacheDir`. */
1369
2162
  function dependencyGenerationDir(cacheDir: string, generation: string): string {
1370
2163
  return path.join(cacheDir, `gen-${generation}`);
@@ -1517,7 +2310,7 @@ function buildDependency(
1517
2310
  cacheDir: string,
1518
2311
  metaPath: string,
1519
2312
  ): BuiltProject {
1520
- const project = readProjectConfig({ cwd: path.dirname(tsconfig), tsconfig });
2313
+ const project = readPluginDescriptorProjectConfig(tsconfig);
1521
2314
  const generation = newDependencyGeneration();
1522
2315
  const emitDir = dependencyGenerationDir(cacheDir, generation);
1523
2316
  fs.rmSync(emitDir, { force: true, recursive: true });
@@ -1612,10 +2405,28 @@ function publishDependencyMeta(
1612
2405
  }
1613
2406
  }
1614
2407
 
1615
- function dependencyCacheRoot(): string {
1616
- const m = manifest();
1617
- return m !== null && m.depCacheDir.length !== 0
1618
- ? m.depCacheDir
2408
+ export function dependencyCacheRoot(
2409
+ env: NodeJS.ProcessEnv = process.env,
2410
+ ): string {
2411
+ // Descriptor evaluators are disposable and must not leave one isolated emit
2412
+ // generation in the shared temp cache per load. Their result file already
2413
+ // lives in the evaluator-owned directory that the parent removes in
2414
+ // `finally`; put dependency emits beside it so that cleanup owns both.
2415
+ if (
2416
+ env.TTSC_PLUGIN_DESCRIPTOR_LOAD === "1" &&
2417
+ typeof env.TTSC_PLUGIN_DESCRIPTOR_OUT === "string" &&
2418
+ path.isAbsolute(env.TTSC_PLUGIN_DESCRIPTOR_OUT)
2419
+ ) {
2420
+ return path.join(
2421
+ path.dirname(env.TTSC_PLUGIN_DESCRIPTOR_OUT),
2422
+ "dependency-cache",
2423
+ );
2424
+ }
2425
+ const owner = runtimeManifests().find(
2426
+ (candidate) => candidate.depCacheDir.length !== 0,
2427
+ );
2428
+ return owner !== undefined
2429
+ ? owner.depCacheDir
1619
2430
  : path.join(os.tmpdir(), "ttsx-dep");
1620
2431
  }
1621
2432
 
@@ -1990,7 +2801,12 @@ function formatDuration(ms: number): string {
1990
2801
  }
1991
2802
 
1992
2803
  /** Owning-tsconfig cache keyed by directory, mirroring `packageTypeCache`. */
1993
- const tsconfigCache = new Map<string, string | null>();
2804
+ interface ITsconfigLookup {
2805
+ candidates: readonly string[];
2806
+ result: string | null;
2807
+ }
2808
+
2809
+ const tsconfigCache = new Map<string, ITsconfigLookup>();
1994
2810
  const moduleOptionsCache = new Map<string, OwningModuleOptions | null>();
1995
2811
 
1996
2812
  /**
@@ -2011,13 +2827,30 @@ function nearestTsconfig(file: string): string | null {
2011
2827
  for (;;) {
2012
2828
  const cached = tsconfigCache.get(directory);
2013
2829
  if (cached !== undefined) {
2014
- return rememberTsconfig(chain, cached);
2830
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") {
2831
+ return rememberTsconfig(chain, cached.result, cached.candidates);
2832
+ }
2833
+ // Bracket the cached selection with the same candidate observations the
2834
+ // parent later reconciles. A nearer config created between a liveness
2835
+ // check and reporting must conflict, not be paired with the cached
2836
+ // farther result and certified as current.
2837
+ recordPluginDescriptorTsconfigCandidates(cached.candidates);
2838
+ const current = nearestExistingTsconfig(cached.candidates);
2839
+ recordPluginDescriptorTsconfigCandidates(cached.candidates);
2840
+ if (current === cached.result) {
2841
+ return rememberTsconfig(chain, cached.result, cached.candidates);
2842
+ }
2843
+ // Descriptor factories can deliberately create a config before a lazy
2844
+ // import. A lookup cached while an earlier import ran must not keep
2845
+ // serving the orphan lane after the nearest candidate changed.
2846
+ tsconfigCache.delete(directory);
2015
2847
  }
2016
2848
  if (path.basename(directory) === "node_modules") {
2017
2849
  return rememberTsconfig(chain, null);
2018
2850
  }
2019
2851
  chain.push(directory);
2020
2852
  const candidate = path.join(directory, "tsconfig.json");
2853
+ recordPluginDescriptorTsconfigCandidates([candidate]);
2021
2854
  if (isFile(candidate)) {
2022
2855
  return rememberTsconfig(chain, candidate);
2023
2856
  }
@@ -2029,16 +2862,40 @@ function nearestTsconfig(file: string): string | null {
2029
2862
  }
2030
2863
  }
2031
2864
 
2865
+ function nearestExistingTsconfig(candidates: readonly string[]): string | null {
2866
+ for (const candidate of candidates) {
2867
+ if (isFile(candidate)) return path.resolve(candidate);
2868
+ }
2869
+ return null;
2870
+ }
2871
+
2032
2872
  function rememberTsconfig(
2033
2873
  directories: readonly string[],
2034
2874
  result: string | null,
2875
+ tailCandidates: readonly string[] = [],
2035
2876
  ): string | null {
2036
- for (const directory of directories) {
2037
- tsconfigCache.set(directory, result);
2877
+ let candidates = [...tailCandidates];
2878
+ for (let index = directories.length - 1; index >= 0; index -= 1) {
2879
+ const directory = directories[index]!;
2880
+ candidates = [path.join(directory, "tsconfig.json"), ...candidates];
2881
+ tsconfigCache.set(directory, { candidates, result });
2038
2882
  }
2039
2883
  return result;
2040
2884
  }
2041
2885
 
2886
+ /** Report every owning-config candidate observed by the nearest-config walk. */
2887
+ function recordPluginDescriptorTsconfigCandidates(
2888
+ candidates: readonly string[],
2889
+ ): void {
2890
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") return;
2891
+ for (const candidate of candidates) {
2892
+ const resolved = path.resolve(candidate);
2893
+ recordPluginDescriptorInput({
2894
+ resolved,
2895
+ });
2896
+ }
2897
+ }
2898
+
2042
2899
  function readFileOrNull(file: string | null): string | null {
2043
2900
  if (file === null) {
2044
2901
  return null;
@@ -2143,6 +3000,15 @@ const JS_TO_TS_EXTENSIONS: ReadonlyMap<string, readonly string[]> = new Map([
2143
3000
  [".cjs", [".cts"]],
2144
3001
  ]);
2145
3002
 
3003
+ /** Candidate sources that can satisfy one missing JavaScript spelling. */
3004
+ function typescriptSourcesForJavaScriptSpecifier(file: string): string[] {
3005
+ const extension = path.extname(file).toLowerCase();
3006
+ const substitutions = JS_TO_TS_EXTENSIONS.get(extension);
3007
+ if (substitutions === undefined) return [];
3008
+ const stem = file.slice(0, file.length - extension.length);
3009
+ return substitutions.map((candidate) => stem + candidate);
3010
+ }
3011
+
2146
3012
  /**
2147
3013
  * Rescue a `specifier` that Node's resolver rejected: map a JavaScript
2148
3014
  * extension back to its TypeScript source, or probe candidate extensions /