ttsc 0.26.1 → 0.27.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 (119) hide show
  1. package/README.md +10 -1
  2. package/cmd/ttsc/api_transform.go +17 -3
  3. package/cmd/ttscgraph/auxiliary_identity_state_tracks_retargets_not_source_contents_test.go +85 -0
  4. package/cmd/ttscgraph/disk_realpath_other.go +16 -0
  5. package/cmd/ttscgraph/disk_realpath_windows.go +56 -0
  6. package/cmd/ttscgraph/serve.go +104 -35
  7. package/driver/diagnostics_preamble.go +284 -0
  8. package/driver/emit_plugin.go +71 -19
  9. package/driver/graph.go +64 -11
  10. package/driver/input_observation.go +273 -0
  11. package/driver/input_observation_test.go +149 -0
  12. package/driver/plugins.go +384 -4
  13. package/driver/program.go +81 -15
  14. package/driver/resolution_candidates.go +13 -5
  15. package/driver/rewrite.go +47 -4
  16. package/internal/graph/diagnostics.go +4 -3
  17. package/internal/graph/provenance.go +10 -1
  18. package/lib/TtscCompiler.d.ts +12 -11
  19. package/lib/TtscCompiler.js +44 -29
  20. package/lib/TtscCompiler.js.map +1 -1
  21. package/lib/compiler/internal/captureProcessOutput.js +2 -2
  22. package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
  23. package/lib/compiler/internal/compileProjectInMemory.js +11 -3
  24. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  25. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -0
  26. package/lib/compiler/internal/resolveEmittedJavaScript.js +3 -0
  27. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  28. package/lib/compiler/internal/runBuild.d.ts +29 -1
  29. package/lib/compiler/internal/runBuild.js +117 -32
  30. package/lib/compiler/internal/runBuild.js.map +1 -1
  31. package/lib/compiler/internal/runSingleFileEmit.js +9 -2
  32. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  33. package/lib/compiler/internal/sharedHostHelpers.d.ts +28 -0
  34. package/lib/compiler/internal/sharedHostHelpers.js +39 -0
  35. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  36. package/lib/compiler/internal/startResidentTransform.js +12 -4
  37. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  38. package/lib/compiler/internal/transformProjectInMemory.d.ts +3 -0
  39. package/lib/compiler/internal/transformProjectInMemory.js +239 -35
  40. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  41. package/lib/flags/schema.js +33 -5
  42. package/lib/flags/schema.js.map +1 -1
  43. package/lib/internal/createCanonicalTempDirectory.d.ts +17 -0
  44. package/lib/internal/createCanonicalTempDirectory.js +42 -0
  45. package/lib/internal/createCanonicalTempDirectory.js.map +1 -0
  46. package/lib/internal/projectInputPathIdentity.d.ts +3 -0
  47. package/lib/internal/projectInputPathIdentity.js +11 -5
  48. package/lib/internal/projectInputPathIdentity.js.map +1 -1
  49. package/lib/internal/resolveNodeBinary.d.ts +14 -0
  50. package/lib/internal/resolveNodeBinary.js +150 -0
  51. package/lib/internal/resolveNodeBinary.js.map +1 -0
  52. package/lib/internal/resolveSafeCacheCleanupTargets.d.ts +15 -0
  53. package/lib/internal/resolveSafeCacheCleanupTargets.js +61 -0
  54. package/lib/internal/resolveSafeCacheCleanupTargets.js.map +1 -0
  55. package/lib/launcher/internal/assertNoSolutionBuild.d.ts +31 -0
  56. package/lib/launcher/internal/assertNoSolutionBuild.js +38 -0
  57. package/lib/launcher/internal/assertNoSolutionBuild.js.map +1 -0
  58. package/lib/launcher/internal/prepareExecution.d.ts +3 -0
  59. package/lib/launcher/internal/prepareExecution.js +73 -26
  60. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  61. package/lib/launcher/internal/runTtsc.js +27 -13
  62. package/lib/launcher/internal/runTtsc.js.map +1 -1
  63. package/lib/launcher/internal/runTtscserver.js +18 -3
  64. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  65. package/lib/launcher/internal/runTtsx.js +4 -0
  66. package/lib/launcher/internal/runTtsx.js.map +1 -1
  67. package/lib/launcher/internal/runtimeHooks.d.ts +37 -1
  68. package/lib/launcher/internal/runtimeHooks.js +660 -62
  69. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  70. package/lib/plugin/internal/buildSourcePlugin.d.ts +62 -8
  71. package/lib/plugin/internal/buildSourcePlugin.js +754 -72
  72. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  73. package/lib/plugin/internal/loadProjectPlugins.d.ts +17 -5
  74. package/lib/plugin/internal/loadProjectPlugins.js +1339 -77
  75. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  76. package/lib/register.d.ts +1 -0
  77. package/lib/register.js +46 -0
  78. package/lib/register.js.map +1 -0
  79. package/lib/structures/ITtscCompilerContext.d.ts +6 -3
  80. package/lib/structures/ITtscCompilerTransformation.d.ts +31 -0
  81. package/lib/structures/ITtscPlugin.d.ts +33 -0
  82. package/package.json +16 -9
  83. package/shim/checker/go.mod +9 -0
  84. package/shim/checker/go.sum +22 -0
  85. package/shim/checker/shim.go +75 -0
  86. package/shim/compiler/emit_sourcemap.go +56 -15
  87. package/shim/compiler/incremental.go +45 -8
  88. package/src/TtscCompiler.ts +55 -33
  89. package/src/compiler/internal/captureProcessOutput.ts +3 -2
  90. package/src/compiler/internal/compileProjectInMemory.ts +11 -3
  91. package/src/compiler/internal/resolveEmittedJavaScript.ts +6 -0
  92. package/src/compiler/internal/runBuild.ts +172 -36
  93. package/src/compiler/internal/runSingleFileEmit.ts +9 -2
  94. package/src/compiler/internal/sharedHostHelpers.ts +44 -0
  95. package/src/compiler/internal/startResidentTransform.ts +12 -3
  96. package/src/compiler/internal/transformProjectInMemory.ts +410 -39
  97. package/src/flags/schema.ts +35 -5
  98. package/src/internal/createCanonicalTempDirectory.ts +53 -0
  99. package/src/internal/projectInputPathIdentity.ts +17 -4
  100. package/src/internal/resolveNodeBinary.ts +173 -0
  101. package/src/internal/resolveSafeCacheCleanupTargets.ts +85 -0
  102. package/src/launcher/internal/assertNoSolutionBuild.ts +40 -0
  103. package/src/launcher/internal/prepareExecution.ts +78 -26
  104. package/src/launcher/internal/runTtsc.ts +26 -13
  105. package/src/launcher/internal/runTtscserver.ts +18 -3
  106. package/src/launcher/internal/runTtsx.ts +4 -0
  107. package/src/launcher/internal/runtimeHooks.ts +830 -66
  108. package/src/plugin/internal/buildSourcePlugin.ts +984 -79
  109. package/src/plugin/internal/loadProjectPlugins.ts +1718 -104
  110. package/src/register.ts +47 -0
  111. package/src/structures/ITtscCompilerContext.ts +6 -3
  112. package/src/structures/ITtscCompilerTransformation.ts +39 -0
  113. package/src/structures/ITtscPlugin.ts +36 -0
  114. package/tools/shim_audit/baseline.json +0 -1
  115. package/utility/host.go +9 -3
  116. package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +0 -5
  117. package/lib/internal/assertSafeExplicitCacheDirectory.js +0 -26
  118. package/lib/internal/assertSafeExplicitCacheDirectory.js.map +0 -1
  119. 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:
@@ -249,7 +277,10 @@ let installed = false;
249
277
  * graph goes through `Module._extensions` — the canonical loader extension
250
278
  * point `ts-node`/`tsx` use for the same reason.
251
279
  */
252
- export function installRuntimeHooks(): void {
280
+ export function installRuntimeHooks(options: RuntimeHookOptions = {}): void {
281
+ if (options.prepareEntry !== undefined) {
282
+ prepareRuntimeEntry = options.prepareEntry;
283
+ }
253
284
  if (installed) {
254
285
  return;
255
286
  }
@@ -277,17 +308,31 @@ function installCommonJsHook(): void {
277
308
  _extensions: Record<
278
309
  string,
279
310
  (
280
- module: { _compile(source: string, filename: string): void },
311
+ module: {
312
+ _compile(source: string, filename: string): void;
313
+ parent?: { filename?: string | null } | null;
314
+ },
281
315
  filename: string,
282
316
  ) => void
283
317
  >;
284
318
  }
285
319
  )._extensions;
286
320
  const compile = (
287
- module: { _compile(source: string, filename: string): void },
321
+ module: {
322
+ _compile(source: string, filename: string): void;
323
+ parent?: { filename?: string | null } | null;
324
+ },
288
325
  filename: string,
289
326
  ): void => {
290
- module._compile(resolveServedSource(filename).source, filename);
327
+ const parent = module.parent?.filename;
328
+ module._compile(
329
+ resolveServedSource(
330
+ filename,
331
+ pathToFileURL(filename).href,
332
+ typeof parent !== "string" || !isTypeScriptSource(parent),
333
+ ).source,
334
+ filename,
335
+ );
291
336
  };
292
337
  for (const extension of [".ts", ".tsx", ".cts"]) {
293
338
  extensions[extension] = compile;
@@ -301,15 +346,19 @@ function installCommonJsHook(): void {
301
346
  * `require` or an ESM `import`.
302
347
  */
303
348
  export function entryModuleFormat(entryFile: string): "module" | "commonjs" {
304
- const m = manifest();
349
+ const real = realPath(entryFile);
350
+ const owner = findEntryEmit(real)?.manifest;
305
351
  return moduleFormat(
306
352
  entryFile,
307
- m === null ? null : (m.moduleOptions ?? {}),
353
+ owner === undefined ? null : (owner.moduleOptions ?? {}),
308
354
  ) === "module"
309
355
  ? "module"
310
356
  : "commonjs";
311
357
  }
312
358
 
359
+ /** TypeScript URLs resolved at a JavaScript-to-TypeScript entry boundary. */
360
+ const runtimeEntryUrls = new Set<string>();
361
+
313
362
  /**
314
363
  * Rescue an extensionless or directory relative specifier that Node's resolver
315
364
  * rejected. Only runs after `nextResolve` throws, so a successful resolution is
@@ -321,24 +370,394 @@ function resolve(
321
370
  context: ResolveContext,
322
371
  nextResolve: NextResolve,
323
372
  ): ResolveResult {
373
+ recordPluginDescriptorResolutionCandidates(specifier, context.parentURL);
324
374
  try {
325
- return rememberCommonJsNamedInterop(
326
- restoreStrippedNodeBuiltinScheme(
327
- specifier,
328
- nextResolve(specifier, context),
375
+ const result = rememberRuntimeEntry(
376
+ rememberCommonJsNamedInterop(
377
+ restoreStrippedNodeBuiltinScheme(
378
+ specifier,
379
+ nextResolve(specifier, context),
380
+ ),
381
+ context,
329
382
  ),
330
383
  context,
331
384
  );
385
+ recordPluginDescriptorResolution(specifier, context.parentURL, result.url);
386
+ return result;
332
387
  } catch (error) {
333
388
  const rescued = probeRescuableSpecifier(specifier, context.parentURL);
334
389
  if (rescued === null) {
335
390
  throw error;
336
391
  }
337
- return rememberCommonJsNamedInterop(
338
- { shortCircuit: true, url: rescued },
392
+ const result = rememberRuntimeEntry(
393
+ rememberCommonJsNamedInterop(
394
+ { shortCircuit: true, url: rescued },
395
+ context,
396
+ ),
339
397
  context,
340
398
  );
399
+ recordPluginDescriptorResolution(specifier, context.parentURL, result.url);
400
+ return result;
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Fingerprint every path whose state can redirect one descriptor import.
406
+ *
407
+ * This runs before the real resolver. Reporting candidates only after the
408
+ * descriptor finished would pair an earlier descriptor result with later file
409
+ * state when a higher-priority candidate appeared during evaluation.
410
+ */
411
+ function recordPluginDescriptorResolutionCandidates(
412
+ specifier: string,
413
+ parentURL: string | undefined,
414
+ ): void {
415
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") return;
416
+ if (isBuiltin(specifier) || specifier.startsWith("node:")) return;
417
+ const parent = runtimeFilePath(parentURL);
418
+ if (parent === undefined) return;
419
+ const recorded = new Set<string>();
420
+ const record = (candidate: string): void => {
421
+ const resolved = path.resolve(candidate);
422
+ if (recorded.has(resolved)) return;
423
+ recorded.add(resolved);
424
+ recordPluginDescriptorInput({
425
+ parent,
426
+ resolved,
427
+ });
428
+ };
429
+ const recordManifestTargets = (
430
+ value: unknown,
431
+ directory: string,
432
+ allowBare: boolean = false,
433
+ ): void => {
434
+ if (typeof value === "string") {
435
+ if (
436
+ value !== "" &&
437
+ (allowBare || value.startsWith("./") || value.startsWith("../"))
438
+ ) {
439
+ recordBase(path.resolve(directory, value));
440
+ }
441
+ return;
442
+ }
443
+ if (Array.isArray(value)) {
444
+ for (const item of value) {
445
+ recordManifestTargets(item, directory, allowBare);
446
+ }
447
+ return;
448
+ }
449
+ if (value !== null && typeof value === "object") {
450
+ for (const item of Object.values(value)) {
451
+ recordManifestTargets(item, directory, allowBare);
452
+ }
453
+ }
454
+ };
455
+ const recordPackageManifests = (file: string): void => {
456
+ for (
457
+ let directory = path.dirname(file);
458
+ ;
459
+ directory = path.dirname(directory)
460
+ ) {
461
+ const manifest = path.join(directory, "package.json");
462
+ record(manifest);
463
+ if (isFile(manifest)) return;
464
+ const parentDirectory = path.dirname(directory);
465
+ if (parentDirectory === directory) return;
466
+ }
467
+ };
468
+ const localBases = (value: string): string[] => {
469
+ if (value.startsWith("file:")) return [fileURLToPath(value)];
470
+ const raw = path.resolve(path.dirname(parent), value);
471
+ const suffixStart = value.search(/[?#]/);
472
+ if (suffixStart === -1) return [raw];
473
+ const pathname = value.slice(0, suffixStart);
474
+ return pathname === ""
475
+ ? [raw]
476
+ : [...new Set([raw, path.resolve(path.dirname(parent), pathname)])];
477
+ };
478
+ const bases = new Set<string>();
479
+ const recordBase = (base: string): void => {
480
+ const resolvedBase = path.resolve(base);
481
+ if (bases.has(resolvedBase)) return;
482
+ bases.add(resolvedBase);
483
+ record(resolvedBase);
484
+ for (const candidate of typescriptSourcesForJavaScriptSpecifier(
485
+ resolvedBase,
486
+ )) {
487
+ record(candidate);
488
+ }
489
+ for (const extension of DESCRIPTOR_PROBE_EXTENSIONS) {
490
+ record(resolvedBase + extension);
491
+ }
492
+ const manifestFile = path.join(resolvedBase, "package.json");
493
+ record(manifestFile);
494
+ for (const extension of DESCRIPTOR_PROBE_EXTENSIONS) {
495
+ record(path.join(resolvedBase, `index${extension}`));
496
+ }
497
+ try {
498
+ const manifest = JSON.parse(
499
+ fs.readFileSync(manifestFile, "utf8").replace(/^\uFEFF/, ""),
500
+ ) as Record<string, unknown>;
501
+ recordManifestTargets(manifest.exports, resolvedBase);
502
+ recordManifestTargets(manifest.module, resolvedBase, true);
503
+ recordManifestTargets(manifest.main, resolvedBase, true);
504
+ } catch {
505
+ // The real resolver owns malformed package diagnostics.
506
+ }
507
+ };
508
+
509
+ if (
510
+ specifier.startsWith(".") ||
511
+ path.isAbsolute(specifier) ||
512
+ specifier.startsWith("file:")
513
+ ) {
514
+ try {
515
+ for (const base of localBases(specifier)) {
516
+ recordPackageManifests(base);
517
+ if (isFile(base)) record(base);
518
+ else recordBase(base);
519
+ }
520
+ } catch {
521
+ // The real resolver owns invalid URL spellings.
522
+ }
523
+ return;
524
+ }
525
+
526
+ const parts = specifier.split("/");
527
+ const packageParts = parts[0]?.startsWith("@")
528
+ ? parts.slice(0, 2)
529
+ : parts.slice(0, 1);
530
+ if (packageParts.some((part) => part === undefined || part === "")) return;
531
+ const packageName = packageParts.join("/");
532
+ const subpath = parts.slice(packageParts.length);
533
+ for (const searchPath of createRequire(parent).resolve.paths(specifier) ??
534
+ []) {
535
+ const packageDirectory = path.join(searchPath, packageName);
536
+ recordBase(packageDirectory);
537
+ if (subpath.length !== 0) {
538
+ recordBase(path.join(packageDirectory, ...subpath));
539
+ }
540
+ // CommonJS resolution continues past an existing but unusable package
541
+ // directory. Record every search root before the resolver runs so a
542
+ // farther selected package retains evaluation-time hashes for its
543
+ // superseding candidates.
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Report one resolved descriptor edge to the parent loader. The channel is
549
+ * armed by the generated descriptor shim only after ttsx's own bootstrap and
550
+ * imports have loaded, keeping compiler implementation files out of the
551
+ * project's persistent-cache inputs.
552
+ */
553
+ function recordPluginDescriptorResolution(
554
+ specifier: string,
555
+ parentURL: string | undefined,
556
+ resolvedURL: string,
557
+ ): void {
558
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") return;
559
+ const out = process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_OUT;
560
+ if (out === undefined || out.length === 0) return;
561
+ const resolved = runtimeFilePath(resolvedURL);
562
+ if (resolved === undefined) return;
563
+ const parent = runtimeFilePath(parentURL);
564
+ recordPluginDescriptorInput({
565
+ ...(parent === undefined ? {} : { parent }),
566
+ resolved,
567
+ specifier,
568
+ });
569
+ for (
570
+ let directory = path.dirname(resolved);
571
+ ;
572
+ directory = path.dirname(directory)
573
+ ) {
574
+ const manifest = path.join(directory, "package.json");
575
+ recordPluginDescriptorInput({
576
+ ...(parent === undefined ? {} : { parent }),
577
+ resolved: manifest,
578
+ });
579
+ if (isFile(manifest)) break;
580
+ const parentDirectory = path.dirname(directory);
581
+ if (parentDirectory === directory) break;
582
+ }
583
+ }
584
+
585
+ function recordPluginDescriptorInput(record: {
586
+ hash?: string | null;
587
+ parent?: string;
588
+ realpath?: string | null;
589
+ resolved: string;
590
+ signature?: string;
591
+ specifier?: string;
592
+ unstable?: boolean;
593
+ }): void {
594
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") return;
595
+ const out = process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_OUT;
596
+ if (out === undefined || out.length === 0) return;
597
+ recordPluginDescriptorInputOnce(record);
598
+ const resolved = path.resolve(record.resolved);
599
+ const parsed = path.parse(resolved);
600
+ let current = parsed.root;
601
+ const relative = path.relative(parsed.root, resolved);
602
+ for (const segment of relative.split(path.sep).slice(0, -1)) {
603
+ if (segment === "") continue;
604
+ current = path.join(current, segment);
605
+ try {
606
+ if (fs.lstatSync(current).isSymbolicLink()) {
607
+ recordPluginDescriptorInputOnce({ resolved: current });
608
+ }
609
+ } catch {
610
+ break;
611
+ }
612
+ }
613
+ }
614
+
615
+ /** Record one path without recursively revisiting its lexical ancestors. */
616
+ function recordPluginDescriptorInputOnce(record: {
617
+ hash?: string | null;
618
+ parent?: string;
619
+ realpath?: string | null;
620
+ resolved: string;
621
+ signature?: string;
622
+ specifier?: string;
623
+ unstable?: boolean;
624
+ }): void {
625
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") return;
626
+ const out = process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_OUT;
627
+ if (out === undefined || out.length === 0) return;
628
+ try {
629
+ const beforeSignature = pluginDescriptorInputMetadataSignature(
630
+ record.resolved,
631
+ );
632
+ const observedHash = pluginDescriptorInputHash(record.resolved);
633
+ const observedRealpath = pluginDescriptorInputRealpath(record.resolved);
634
+ const afterSignature = pluginDescriptorInputMetadataSignature(
635
+ record.resolved,
636
+ );
637
+ const unstable =
638
+ record.unstable === true ||
639
+ beforeSignature === undefined ||
640
+ afterSignature === undefined ||
641
+ beforeSignature !== afterSignature ||
642
+ (record.hash !== undefined && record.hash !== observedHash) ||
643
+ (record.realpath !== undefined && record.realpath !== observedRealpath) ||
644
+ (record.signature !== undefined && record.signature !== afterSignature);
645
+ fs.appendFileSync(
646
+ out,
647
+ `${JSON.stringify({
648
+ ...record,
649
+ hash: observedHash,
650
+ realpath: observedRealpath,
651
+ ...(unstable ? { unstable: true } : { signature: afterSignature }),
652
+ })}\n`,
653
+ "utf8",
654
+ );
655
+ } catch {
656
+ // Dependency reporting is advisory to cache reuse; the selected entry is
657
+ // still retained by the parent if this best-effort side channel fails.
658
+ }
659
+ }
660
+
661
+ function pluginDescriptorInputRealpath(file: string): string | null {
662
+ try {
663
+ return fs.realpathSync.native(file);
664
+ } catch {
665
+ return null;
666
+ }
667
+ }
668
+
669
+ /** Metadata identity that exposes content-preserving A-B-A replacement. */
670
+ function pluginDescriptorInputMetadataSignature(
671
+ file: string,
672
+ ): string | undefined {
673
+ const requested = path.resolve(file);
674
+ let current = requested;
675
+ for (;;) {
676
+ try {
677
+ const link = fs.lstatSync(current, { bigint: true });
678
+ let target = link;
679
+ if (link.isSymbolicLink()) {
680
+ try {
681
+ target = fs.statSync(current, { bigint: true });
682
+ } catch {
683
+ // A broken link's own metadata cannot expose its target appearing
684
+ // and disappearing during evaluation. Decline cache proof instead.
685
+ return undefined;
686
+ }
687
+ }
688
+ return [
689
+ path.relative(current, requested),
690
+ link.dev,
691
+ link.ino,
692
+ link.mode,
693
+ link.size,
694
+ link.mtimeNs,
695
+ link.ctimeNs,
696
+ target.dev,
697
+ target.ino,
698
+ target.mode,
699
+ target.size,
700
+ target.mtimeNs,
701
+ target.ctimeNs,
702
+ ].join(":");
703
+ } catch (error) {
704
+ if (!isMissingPathError(error)) return undefined;
705
+ const parent = path.dirname(current);
706
+ if (parent === current) return undefined;
707
+ current = parent;
708
+ }
709
+ }
710
+ }
711
+
712
+ function pluginDescriptorInputHash(file: string): string | null {
713
+ try {
714
+ if (fs.statSync(file).isDirectory()) {
715
+ return pluginDescriptorDirectoryHash();
716
+ }
717
+ return crypto
718
+ .createHash("sha256")
719
+ .update(fs.readFileSync(file))
720
+ .digest("hex");
721
+ } catch {
722
+ return null;
723
+ }
724
+ }
725
+
726
+ /** Stable public fingerprint for an existing directory candidate's kind. */
727
+ function pluginDescriptorDirectoryHash(): string {
728
+ return crypto
729
+ .createHash("sha256")
730
+ .update("ttsc:host-input:directory\0")
731
+ .digest("hex");
732
+ }
733
+
734
+ function runtimeFilePath(value: string | undefined): string | undefined {
735
+ if (value === undefined) return undefined;
736
+ if (value.startsWith("file:")) {
737
+ try {
738
+ return path.resolve(fileURLToPath(value));
739
+ } catch {
740
+ return undefined;
741
+ }
341
742
  }
743
+ return path.isAbsolute(value) ? path.resolve(value) : undefined;
744
+ }
745
+
746
+ /** Remember an ESM root until its synchronous load hook prepares the project. */
747
+ function rememberRuntimeEntry(
748
+ result: ResolveResult,
749
+ context: ResolveContext,
750
+ ): ResolveResult {
751
+ if (
752
+ result.url.startsWith("file:") &&
753
+ isTypeScriptSource(fileURLToPath(result.url)) &&
754
+ (context.parentURL === undefined ||
755
+ !context.parentURL.startsWith("file:") ||
756
+ !isTypeScriptSource(fileURLToPath(context.parentURL)))
757
+ ) {
758
+ runtimeEntryUrls.add(result.url);
759
+ }
760
+ return result;
342
761
  }
343
762
 
344
763
  /**
@@ -369,6 +788,12 @@ interface BuiltProject {
369
788
  moduleOptions: OwningModuleOptions;
370
789
  }
371
790
  const builtProjects = new Map<string, BuiltProject>();
791
+ // A descriptor evaluator's dependency emit must never be reused by another
792
+ // process. PIDs are eventually recycled while the disk cache persists, so PID
793
+ // alone cannot provide that isolation. One cryptographically random process
794
+ // nonce keeps every evaluator generation distinct while `builtProjects` still
795
+ // shares repeated imports inside this process.
796
+ const descriptorProcessCacheNonce = crypto.randomBytes(16).toString("hex");
372
797
 
373
798
  /** File URLs whose CommonJS source was reached from an ESM parent import. */
374
799
  const commonJsNamedInteropUrls = new Set<string>();
@@ -386,7 +811,11 @@ function load(
386
811
  if (!isTypeScriptSource(filename)) {
387
812
  return nextLoad(url, context);
388
813
  }
389
- const { format, source } = resolveRuntimeSource(filename, url);
814
+ const { format, source } = resolveRuntimeSource(
815
+ filename,
816
+ url,
817
+ runtimeEntryUrls.delete(url) || isProcessEntry(filename),
818
+ );
390
819
  return {
391
820
  format,
392
821
  shortCircuit: true,
@@ -397,8 +826,9 @@ function load(
397
826
  function resolveRuntimeSource(
398
827
  filename: string,
399
828
  url: string = pathToFileURL(filename).href,
829
+ prepareAsEntry: boolean = false,
400
830
  ): { format: string; source: string } {
401
- const served = resolveServedSource(filename, url);
831
+ const served = resolveServedSource(filename, url, prepareAsEntry);
402
832
  const format = moduleFormat(filename, served.moduleOptions);
403
833
  return {
404
834
  format,
@@ -413,6 +843,16 @@ function resolveRuntimeSource(
413
843
  };
414
844
  }
415
845
 
846
+ /** Whether `filename` is the TypeScript main module named on Node's argv. */
847
+ function isProcessEntry(filename: string): boolean {
848
+ const entry = process.argv[1];
849
+ return (
850
+ entry !== undefined &&
851
+ isTypeScriptSource(entry) &&
852
+ realPath(path.resolve(entry)) === realPath(filename)
853
+ );
854
+ }
855
+
416
856
  function rememberCommonJsNamedInterop(
417
857
  result: ResolveResult,
418
858
  context: ResolveContext,
@@ -467,9 +907,9 @@ function owningModuleOptions(filename: string): OwningModuleOptions | null {
467
907
  return null;
468
908
  }
469
909
  const real = realPath(filename);
470
- const m = manifest();
471
- if (m !== null && entryEmitPath(m, real) !== null) {
472
- return m.moduleOptions ?? {};
910
+ const owner = findEntryEmit(real)?.manifest;
911
+ if (owner !== undefined) {
912
+ return owner.moduleOptions ?? {};
473
913
  }
474
914
  const tsconfig = nearestTsconfig(real);
475
915
  if (tsconfig === null) {
@@ -481,10 +921,7 @@ function owningModuleOptions(filename: string): OwningModuleOptions | null {
481
921
  }
482
922
  let options: OwningModuleOptions = {};
483
923
  try {
484
- const project = readProjectConfig({
485
- cwd: path.dirname(tsconfig),
486
- tsconfig,
487
- });
924
+ const project = readPluginDescriptorProjectConfig(tsconfig);
488
925
  options = projectModuleOptions(project.compilerOptions);
489
926
  } catch {
490
927
  // The owning project cannot be read, so nothing is known about the format
@@ -498,6 +935,147 @@ function owningModuleOptions(filename: string): OwningModuleOptions | null {
498
935
  return options;
499
936
  }
500
937
 
938
+ /**
939
+ * Read one owning config while its discovered chain stays unchanged.
940
+ *
941
+ * A preliminary read discovers `extends`; an accepted read is bracketed by
942
+ * equal fingerprints over that exact chain. If churn never settles, ttsx can
943
+ * still execute the last project but reports every observed config as unstable,
944
+ * preventing a later snapshot from certifying the torn result.
945
+ */
946
+ function readPluginDescriptorProjectConfig(
947
+ tsconfig: string,
948
+ ): ReturnType<typeof readProjectConfig> {
949
+ const read = () =>
950
+ readProjectConfig({ cwd: path.dirname(tsconfig), tsconfig });
951
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") return read();
952
+
953
+ const observed = new Set<string>([path.resolve(tsconfig)]);
954
+ let project: ReturnType<typeof readProjectConfig>;
955
+ try {
956
+ project = read();
957
+ } catch (error) {
958
+ recordPluginDescriptorProjectInputs(observed, true);
959
+ throw error;
960
+ }
961
+ let inputs = normalizedProjectConfigPaths(project, tsconfig);
962
+ for (let attempt = 0; attempt < 3; attempt += 1) {
963
+ for (const input of inputs) observed.add(input);
964
+ const before = pluginDescriptorInputHashes(inputs);
965
+ const beforeRealpaths = pluginDescriptorInputRealpaths(inputs);
966
+ const beforeSignatures = pluginDescriptorInputMetadataSignatures(inputs);
967
+ let candidate: ReturnType<typeof readProjectConfig>;
968
+ try {
969
+ candidate = read();
970
+ } catch (error) {
971
+ recordPluginDescriptorProjectInputs(observed, true);
972
+ throw error;
973
+ }
974
+ const candidateInputs = normalizedProjectConfigPaths(candidate, tsconfig);
975
+ for (const input of candidateInputs) observed.add(input);
976
+ const after = pluginDescriptorInputHashes(candidateInputs);
977
+ const afterRealpaths = pluginDescriptorInputRealpaths(candidateInputs);
978
+ const afterSignatures =
979
+ pluginDescriptorInputMetadataSignatures(candidateInputs);
980
+ if (
981
+ equalPluginDescriptorInputLists(inputs, candidateInputs) &&
982
+ equalPluginDescriptorInputHashes(before, after) &&
983
+ equalPluginDescriptorInputHashes(beforeRealpaths, afterRealpaths) &&
984
+ beforeSignatures !== undefined &&
985
+ afterSignatures !== undefined &&
986
+ equalPluginDescriptorInputHashes(beforeSignatures, afterSignatures)
987
+ ) {
988
+ for (const input of candidateInputs) {
989
+ recordPluginDescriptorInput({
990
+ hash: after[input]!,
991
+ realpath: afterRealpaths[input]!,
992
+ resolved: input,
993
+ signature: afterSignatures[input]!,
994
+ });
995
+ }
996
+ return candidate;
997
+ }
998
+ project = candidate;
999
+ inputs = candidateInputs;
1000
+ }
1001
+ recordPluginDescriptorProjectInputs(observed, true);
1002
+ return project;
1003
+ }
1004
+
1005
+ function normalizedProjectConfigPaths(
1006
+ project: ReturnType<typeof readProjectConfig>,
1007
+ requestedConfig: string,
1008
+ ): string[] {
1009
+ return [
1010
+ ...new Set(
1011
+ [requestedConfig, ...project.configPaths].map((file) =>
1012
+ path.resolve(file),
1013
+ ),
1014
+ ),
1015
+ ].sort();
1016
+ }
1017
+
1018
+ function pluginDescriptorInputHashes(
1019
+ inputs: readonly string[],
1020
+ ): Record<string, string | null> {
1021
+ return Object.fromEntries(
1022
+ inputs.map((input) => [input, pluginDescriptorInputHash(input)]),
1023
+ );
1024
+ }
1025
+
1026
+ function pluginDescriptorInputRealpaths(
1027
+ inputs: readonly string[],
1028
+ ): Record<string, string | null> {
1029
+ return Object.fromEntries(
1030
+ inputs.map((input) => [input, pluginDescriptorInputRealpath(input)]),
1031
+ );
1032
+ }
1033
+
1034
+ function pluginDescriptorInputMetadataSignatures(
1035
+ inputs: readonly string[],
1036
+ ): Record<string, string> | undefined {
1037
+ const output: Record<string, string> = {};
1038
+ for (const input of inputs) {
1039
+ const signature = pluginDescriptorInputMetadataSignature(input);
1040
+ if (signature === undefined) return undefined;
1041
+ output[path.resolve(input)] = signature;
1042
+ }
1043
+ return output;
1044
+ }
1045
+
1046
+ function equalPluginDescriptorInputLists(
1047
+ first: readonly string[],
1048
+ second: readonly string[],
1049
+ ): boolean {
1050
+ return (
1051
+ first.length === second.length &&
1052
+ first.every((input, index) => input === second[index])
1053
+ );
1054
+ }
1055
+
1056
+ function equalPluginDescriptorInputHashes(
1057
+ first: Readonly<Record<string, string | null>>,
1058
+ second: Readonly<Record<string, string | null>>,
1059
+ ): boolean {
1060
+ return (
1061
+ Object.keys(first).length === Object.keys(second).length &&
1062
+ Object.entries(first).every(([input, hash]) => second[input] === hash)
1063
+ );
1064
+ }
1065
+
1066
+ function recordPluginDescriptorProjectInputs(
1067
+ inputs: Iterable<string>,
1068
+ unstable: boolean,
1069
+ ): void {
1070
+ for (const input of inputs) {
1071
+ const resolved = path.resolve(input);
1072
+ recordPluginDescriptorInput({
1073
+ resolved,
1074
+ ...(unstable ? { unstable: true } : {}),
1075
+ });
1076
+ }
1077
+ }
1078
+
501
1079
  /** Narrow a resolved project's compiler options to the emit-format pair. */
502
1080
  export function projectModuleOptions(
503
1081
  compilerOptions: Record<string, unknown>,
@@ -522,12 +1100,32 @@ export function projectModuleOptions(
522
1100
  function resolveServedSource(
523
1101
  filename: string,
524
1102
  url: string = pathToFileURL(filename).href,
1103
+ prepareAsEntry: boolean = false,
525
1104
  ): ServedSource {
526
1105
  const real = realPath(filename);
527
- const served = serveEntryEmit(real);
1106
+ // Only the public preload can prepare a newly discovered root. Direct ttsx
1107
+ // has one pre-built manifest and historically relies on trailing-stem
1108
+ // recovery when Windows presents the same source through its short and long
1109
+ // temp-path spellings (the lint TypeScript-config loader is one such case).
1110
+ // Treating that boundary as prepare-only would discard the existing emit and
1111
+ // feed ESM source into CommonJS interop, producing ERR_REQUIRE_CYCLE_MODULE.
1112
+ const prepareEntry = prepareAsEntry ? prepareRuntimeEntry : undefined;
1113
+ // A JavaScript-to-TypeScript boundary is a new checked root unless an
1114
+ // existing manifest proves exact ownership. Trailing-stem recovery is not
1115
+ // ownership evidence: two out-of-include `index.ts` roots can otherwise map
1116
+ // to the first manifest's `index.js`, skipping the second root's diagnostics.
1117
+ let served = serveEntryEmit(real, prepareEntry === undefined);
528
1118
  if (served !== null) {
529
1119
  return withInlineSourceMap(served);
530
1120
  }
1121
+ if (prepareEntry !== undefined) {
1122
+ registeredManifests.push(prepareEntry(real));
1123
+ served = serveEntryEmit(real);
1124
+ if (served === null) {
1125
+ throw new Error(`ttsx: prepared entry emit not found for ${filename}`);
1126
+ }
1127
+ return withInlineSourceMap(served);
1128
+ }
531
1129
  const built = serveDependencyEmit(real);
532
1130
  if (built !== null) {
533
1131
  return withInlineSourceMap(built);
@@ -610,7 +1208,7 @@ function emitOrphanAsCommonJs(filename: string): string | null {
610
1208
  return hit;
611
1209
  }
612
1210
  }
613
- const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "ttsx-orphan-"));
1211
+ const outDir = createCanonicalTempDirectory("ttsx-orphan-");
614
1212
  try {
615
1213
  const res = spawnNative(
616
1214
  tsgo,
@@ -673,7 +1271,7 @@ function emitCommonJsForNameScan(filename: string): string | null {
673
1271
  commonJsNameScanSources.set(real, null);
674
1272
  return null;
675
1273
  }
676
- const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "ttsx-export-scan-"));
1274
+ const outDir = createCanonicalTempDirectory("ttsx-export-scan-");
677
1275
  try {
678
1276
  const res = spawnNative(
679
1277
  tsgo,
@@ -1166,45 +1764,95 @@ function isIdentifierName(name: string): boolean {
1166
1764
  * `rootDir` cannot have a mirrored emit, so it falls through to the dependency
1167
1765
  * paths.
1168
1766
  */
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) {
1767
+ function serveEntryEmit(
1768
+ real: string,
1769
+ allowStemFallback: boolean = true,
1770
+ ): ServedSource | null {
1771
+ const owner = findEntryEmit(real, allowStemFallback);
1772
+ if (owner === null) {
1176
1773
  return null;
1177
1774
  }
1178
- const source = readFileOrNull(emitted);
1775
+ const source = readFileOrNull(owner.emittedFile);
1179
1776
  return source === null
1180
1777
  ? null
1181
1778
  : {
1182
- emittedFile: emitted,
1183
- moduleOptions: m.moduleOptions ?? {},
1779
+ emittedFile: owner.emittedFile,
1780
+ moduleOptions: owner.manifest.moduleOptions ?? {},
1184
1781
  source,
1185
1782
  sourceFile: real,
1186
1783
  };
1187
1784
  }
1188
1785
 
1786
+ /**
1787
+ * Find the manifest that owns `real`. Explicit prepared roots win, followed by
1788
+ * exact mirrored paths across every manifest. Only then may legacy stem
1789
+ * recovery run, so one manifest's approximate match cannot shadow another's
1790
+ * exact emit.
1791
+ */
1792
+ function findEntryEmit(
1793
+ real: string,
1794
+ allowStemFallback: boolean = true,
1795
+ ): { emittedFile: string; manifest: RuntimeManifest } | null {
1796
+ const manifests = runtimeManifests();
1797
+ for (const candidate of manifests) {
1798
+ if (
1799
+ candidate.entrySource !== undefined &&
1800
+ candidate.entryFile !== undefined &&
1801
+ realPath(candidate.entrySource) === real &&
1802
+ fs.existsSync(candidate.entryFile)
1803
+ ) {
1804
+ return { emittedFile: candidate.entryFile, manifest: candidate };
1805
+ }
1806
+ }
1807
+ for (const candidate of manifests) {
1808
+ const emitted = entryEmitPath(candidate, real, false);
1809
+ if (emitted !== null) {
1810
+ return { emittedFile: emitted, manifest: candidate };
1811
+ }
1812
+ }
1813
+ if (!allowStemFallback) {
1814
+ return null;
1815
+ }
1816
+ for (const candidate of manifests) {
1817
+ const emitted = entryEmitPath(candidate, real, true);
1818
+ if (emitted !== null) {
1819
+ return { emittedFile: emitted, manifest: candidate };
1820
+ }
1821
+ }
1822
+ return null;
1823
+ }
1824
+
1189
1825
  /**
1190
1826
  * The entry project's emitted JavaScript for `real`, or `null` when that
1191
1827
  * project did not emit it. Shared with `owningModuleOptions` so "the entry
1192
1828
  * project owns this file" means exactly one thing in both places.
1193
1829
  */
1194
- function entryEmitPath(m: RuntimeManifest, real: string): string | null {
1195
- const cached = entryEmitPathCache.get(real);
1196
- if (cached !== undefined) {
1197
- return cached;
1830
+ function entryEmitPath(
1831
+ m: RuntimeManifest,
1832
+ real: string,
1833
+ allowStemFallback: boolean = true,
1834
+ ): string | null {
1835
+ const cache = allowStemFallback
1836
+ ? entryEmitPathCache
1837
+ : exactEntryEmitPathCache;
1838
+ let manifestCache = cache.get(m);
1839
+ if (manifestCache === undefined) {
1840
+ manifestCache = new Map<string, string | null>();
1841
+ cache.set(m, manifestCache);
1842
+ }
1843
+ if (manifestCache.has(real)) {
1844
+ return manifestCache.get(real) ?? null;
1198
1845
  }
1199
1846
  const resolved = isWithin(real, m.rootDir)
1200
1847
  ? resolveEmittedJavaScript({
1848
+ allowStemFallback,
1201
1849
  emittedFiles: m.emittedFiles,
1202
1850
  outDir: m.emitDir,
1203
1851
  projectRoot: m.rootDir,
1204
1852
  sourceFile: real,
1205
1853
  })
1206
1854
  : null;
1207
- entryEmitPathCache.set(real, resolved);
1855
+ manifestCache.set(real, resolved);
1208
1856
  return resolved;
1209
1857
  }
1210
1858
 
@@ -1212,10 +1860,18 @@ function entryEmitPath(m: RuntimeManifest, real: string): string | null {
1212
1860
  * Memo for `entryEmitPath`, because it is now on the `resolve` hook's path
1213
1861
  * through `owningModuleOptions` — once per import specifier — and a miss inside
1214
1862
  * `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.
1863
+ * written once before the run starts and never changes under it. Registration
1864
+ * can add several independent entry emits, so the manifest is part of the cache
1865
+ * identity as well as the source path.
1217
1866
  */
1218
- const entryEmitPathCache = new Map<string, string | null>();
1867
+ const entryEmitPathCache = new WeakMap<
1868
+ RuntimeManifest,
1869
+ Map<string, string | null>
1870
+ >();
1871
+ const exactEntryEmitPathCache = new WeakMap<
1872
+ RuntimeManifest,
1873
+ Map<string, string | null>
1874
+ >();
1219
1875
 
1220
1876
  /**
1221
1877
  * True when `real` is `directory` itself or sits beneath it. Handles a root
@@ -1351,11 +2007,7 @@ interface DependencyCachePaths {
1351
2007
  }
1352
2008
 
1353
2009
  function dependencyCachePaths(tsconfig: string): DependencyCachePaths {
1354
- const key = crypto
1355
- .createHash("sha256")
1356
- .update(tsconfig)
1357
- .digest("hex")
1358
- .slice(0, 16);
2010
+ const key = dependencyCacheKey(tsconfig);
1359
2011
  const root = dependencyCacheRoot();
1360
2012
  return {
1361
2013
  cacheDir: path.join(root, key),
@@ -1365,6 +2017,38 @@ function dependencyCachePaths(tsconfig: string): DependencyCachePaths {
1365
2017
  };
1366
2018
  }
1367
2019
 
2020
+ /** Derive one dependency cache key; exported for isolation regressions. */
2021
+ export function dependencyCacheKey(
2022
+ tsconfig: string,
2023
+ options: {
2024
+ descriptorLoad?: boolean;
2025
+ descriptorNonce?: string;
2026
+ } = {},
2027
+ ): string {
2028
+ const descriptorLoad =
2029
+ options.descriptorLoad ?? process.env.TTSC_PLUGIN_DESCRIPTOR_LOAD === "1";
2030
+ return (
2031
+ crypto
2032
+ .createHash("sha256")
2033
+ .update(tsconfig)
2034
+ // Descriptor evaluation promises a result bound to this process's exact
2035
+ // input observations. Reusing an emit another evaluator built can pair
2036
+ // that process's old source/config bytes with this process's later hashes.
2037
+ // Keep ordinary ttsx worker sharing, but isolate descriptor builds with a
2038
+ // non-reusable process nonce; the in-process `builtProjects` map still
2039
+ // compiles each owning project once.
2040
+ .update(
2041
+ descriptorLoad
2042
+ ? `\0descriptor-process:${
2043
+ options.descriptorNonce ?? descriptorProcessCacheNonce
2044
+ }`
2045
+ : "",
2046
+ )
2047
+ .digest("hex")
2048
+ .slice(0, 16)
2049
+ );
2050
+ }
2051
+
1368
2052
  /** The immutable emit directory of one build generation under `cacheDir`. */
1369
2053
  function dependencyGenerationDir(cacheDir: string, generation: string): string {
1370
2054
  return path.join(cacheDir, `gen-${generation}`);
@@ -1517,7 +2201,7 @@ function buildDependency(
1517
2201
  cacheDir: string,
1518
2202
  metaPath: string,
1519
2203
  ): BuiltProject {
1520
- const project = readProjectConfig({ cwd: path.dirname(tsconfig), tsconfig });
2204
+ const project = readPluginDescriptorProjectConfig(tsconfig);
1521
2205
  const generation = newDependencyGeneration();
1522
2206
  const emitDir = dependencyGenerationDir(cacheDir, generation);
1523
2207
  fs.rmSync(emitDir, { force: true, recursive: true });
@@ -1527,6 +2211,13 @@ function buildDependency(
1527
2211
  emit: true,
1528
2212
  forceListEmittedFiles: true,
1529
2213
  outDir: emitDir,
2214
+ // The generation directory is an `outDir` this lane injected, not one the
2215
+ // dependency declared, and tsgo demands an explicit `rootDir` (TS5011) as
2216
+ // soon as any `outDir` is in play. Pinning the root tsgo would infer keeps
2217
+ // a source-shipping dependency that declares no output buildable, and it is
2218
+ // the same root `resolveDependencySourceRoot` publishes for it below —
2219
+ // without it that dependency falls back to type-stripping (issue #1172).
2220
+ pinInferredRootDir: true,
1530
2221
  // Emit a source map on the transient dependency emit (it never reaches the
1531
2222
  // dependency's published `lib/`) so the serve path can inline it under the
1532
2223
  // source URL, but only when the dependency configures none itself. Routed
@@ -1605,10 +2296,28 @@ function publishDependencyMeta(
1605
2296
  }
1606
2297
  }
1607
2298
 
1608
- function dependencyCacheRoot(): string {
1609
- const m = manifest();
1610
- return m !== null && m.depCacheDir.length !== 0
1611
- ? m.depCacheDir
2299
+ export function dependencyCacheRoot(
2300
+ env: NodeJS.ProcessEnv = process.env,
2301
+ ): string {
2302
+ // Descriptor evaluators are disposable and must not leave one isolated emit
2303
+ // generation in the shared temp cache per load. Their result file already
2304
+ // lives in the evaluator-owned directory that the parent removes in
2305
+ // `finally`; put dependency emits beside it so that cleanup owns both.
2306
+ if (
2307
+ env.TTSC_PLUGIN_DESCRIPTOR_LOAD === "1" &&
2308
+ typeof env.TTSC_PLUGIN_DESCRIPTOR_OUT === "string" &&
2309
+ path.isAbsolute(env.TTSC_PLUGIN_DESCRIPTOR_OUT)
2310
+ ) {
2311
+ return path.join(
2312
+ path.dirname(env.TTSC_PLUGIN_DESCRIPTOR_OUT),
2313
+ "dependency-cache",
2314
+ );
2315
+ }
2316
+ const owner = runtimeManifests().find(
2317
+ (candidate) => candidate.depCacheDir.length !== 0,
2318
+ );
2319
+ return owner !== undefined
2320
+ ? owner.depCacheDir
1612
2321
  : path.join(os.tmpdir(), "ttsx-dep");
1613
2322
  }
1614
2323
 
@@ -1983,7 +2692,12 @@ function formatDuration(ms: number): string {
1983
2692
  }
1984
2693
 
1985
2694
  /** Owning-tsconfig cache keyed by directory, mirroring `packageTypeCache`. */
1986
- const tsconfigCache = new Map<string, string | null>();
2695
+ interface ITsconfigLookup {
2696
+ candidates: readonly string[];
2697
+ result: string | null;
2698
+ }
2699
+
2700
+ const tsconfigCache = new Map<string, ITsconfigLookup>();
1987
2701
  const moduleOptionsCache = new Map<string, OwningModuleOptions | null>();
1988
2702
 
1989
2703
  /**
@@ -2004,13 +2718,30 @@ function nearestTsconfig(file: string): string | null {
2004
2718
  for (;;) {
2005
2719
  const cached = tsconfigCache.get(directory);
2006
2720
  if (cached !== undefined) {
2007
- return rememberTsconfig(chain, cached);
2721
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") {
2722
+ return rememberTsconfig(chain, cached.result, cached.candidates);
2723
+ }
2724
+ // Bracket the cached selection with the same candidate observations the
2725
+ // parent later reconciles. A nearer config created between a liveness
2726
+ // check and reporting must conflict, not be paired with the cached
2727
+ // farther result and certified as current.
2728
+ recordPluginDescriptorTsconfigCandidates(cached.candidates);
2729
+ const current = nearestExistingTsconfig(cached.candidates);
2730
+ recordPluginDescriptorTsconfigCandidates(cached.candidates);
2731
+ if (current === cached.result) {
2732
+ return rememberTsconfig(chain, cached.result, cached.candidates);
2733
+ }
2734
+ // Descriptor factories can deliberately create a config before a lazy
2735
+ // import. A lookup cached while an earlier import ran must not keep
2736
+ // serving the orphan lane after the nearest candidate changed.
2737
+ tsconfigCache.delete(directory);
2008
2738
  }
2009
2739
  if (path.basename(directory) === "node_modules") {
2010
2740
  return rememberTsconfig(chain, null);
2011
2741
  }
2012
2742
  chain.push(directory);
2013
2743
  const candidate = path.join(directory, "tsconfig.json");
2744
+ recordPluginDescriptorTsconfigCandidates([candidate]);
2014
2745
  if (isFile(candidate)) {
2015
2746
  return rememberTsconfig(chain, candidate);
2016
2747
  }
@@ -2022,16 +2753,40 @@ function nearestTsconfig(file: string): string | null {
2022
2753
  }
2023
2754
  }
2024
2755
 
2756
+ function nearestExistingTsconfig(candidates: readonly string[]): string | null {
2757
+ for (const candidate of candidates) {
2758
+ if (isFile(candidate)) return path.resolve(candidate);
2759
+ }
2760
+ return null;
2761
+ }
2762
+
2025
2763
  function rememberTsconfig(
2026
2764
  directories: readonly string[],
2027
2765
  result: string | null,
2766
+ tailCandidates: readonly string[] = [],
2028
2767
  ): string | null {
2029
- for (const directory of directories) {
2030
- tsconfigCache.set(directory, result);
2768
+ let candidates = [...tailCandidates];
2769
+ for (let index = directories.length - 1; index >= 0; index -= 1) {
2770
+ const directory = directories[index]!;
2771
+ candidates = [path.join(directory, "tsconfig.json"), ...candidates];
2772
+ tsconfigCache.set(directory, { candidates, result });
2031
2773
  }
2032
2774
  return result;
2033
2775
  }
2034
2776
 
2777
+ /** Report every owning-config candidate observed by the nearest-config walk. */
2778
+ function recordPluginDescriptorTsconfigCandidates(
2779
+ candidates: readonly string[],
2780
+ ): void {
2781
+ if (process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE !== "1") return;
2782
+ for (const candidate of candidates) {
2783
+ const resolved = path.resolve(candidate);
2784
+ recordPluginDescriptorInput({
2785
+ resolved,
2786
+ });
2787
+ }
2788
+ }
2789
+
2035
2790
  function readFileOrNull(file: string | null): string | null {
2036
2791
  if (file === null) {
2037
2792
  return null;
@@ -2136,6 +2891,15 @@ const JS_TO_TS_EXTENSIONS: ReadonlyMap<string, readonly string[]> = new Map([
2136
2891
  [".cjs", [".cts"]],
2137
2892
  ]);
2138
2893
 
2894
+ /** Candidate sources that can satisfy one missing JavaScript spelling. */
2895
+ function typescriptSourcesForJavaScriptSpecifier(file: string): string[] {
2896
+ const extension = path.extname(file).toLowerCase();
2897
+ const substitutions = JS_TO_TS_EXTENSIONS.get(extension);
2898
+ if (substitutions === undefined) return [];
2899
+ const stem = file.slice(0, file.length - extension.length);
2900
+ return substitutions.map((candidate) => stem + candidate);
2901
+ }
2902
+
2139
2903
  /**
2140
2904
  * Rescue a `specifier` that Node's resolver rejected: map a JavaScript
2141
2905
  * extension back to its TypeScript source, or probe candidate extensions /