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
@@ -1,12 +1,18 @@
1
1
  import childProcess from "node:child_process";
2
+ import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import { createRequire } from "node:module";
4
- import os from "node:os";
5
5
  import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
6
7
 
7
8
  import { findNearestGoMod } from "../../compiler/internal/paths";
8
9
  import { readJsonFile } from "../../compiler/internal/project/readConfigJson";
9
10
  import { readProjectConfig } from "../../compiler/internal/project/readProjectConfig";
11
+ import { createCanonicalTempDirectory } from "../../internal/createCanonicalTempDirectory";
12
+ import {
13
+ javascriptRuntimeCapabilities,
14
+ resolveNodeBinary,
15
+ } from "../../internal/resolveNodeBinary";
10
16
  import type { ITtscPlugin } from "../../structures/ITtscPlugin";
11
17
  import type { ITtscPluginContributor } from "../../structures/ITtscPluginContributor";
12
18
  import type { ITtscPluginFactoryContext } from "../../structures/ITtscPluginFactoryContext";
@@ -22,10 +28,6 @@ import {
22
28
 
23
29
  const GO_MOD_SEARCH_MAX_DEPTH = 3;
24
30
 
25
- type TtscPluginFactory<T = ITtscProjectPluginConfig> = (
26
- context: ITtscPluginFactoryContext<T>,
27
- ) => ITtscPlugin;
28
-
29
31
  type ProjectPluginEntry = {
30
32
  baseDir: string;
31
33
  config: ITtscProjectPluginConfig;
@@ -35,27 +37,37 @@ type PackageManifest = {
35
37
  dependencies?: Record<string, unknown>;
36
38
  devDependencies?: Record<string, unknown>;
37
39
  exports?: unknown;
40
+ main?: unknown;
41
+ module?: unknown;
38
42
  name?: unknown;
39
43
  ttsc?: unknown;
40
44
  };
41
45
 
46
+ type ProjectHostInputSnapshot = {
47
+ hostInputHashes: Record<string, string | null>;
48
+ hostInputRealpaths: Record<string, string | null>;
49
+ hostInputs: string[];
50
+ project: ITtscParsedProjectConfig;
51
+ };
52
+
42
53
  /**
43
54
  * Resolve, load, and build all native plugin sidecars for a TypeScript project.
44
55
  *
45
56
  * Reads the project config, discovers plugin entries (from tsconfig and package
46
57
  * auto-discovery), validates and composes their descriptors, then invokes
47
58
  * `buildSourcePlugin` to compile each Go source package into a cached binary.
48
- * Returns the ordered set of loaded native plugins alongside the parsed project
49
- * config.
59
+ * Returns the ordered native plugins, parsed project config, and exact
60
+ * JavaScript-host files that universally influence the loaded selection.
50
61
  *
51
62
  * @param options.binary - Absolute path to the ttsc native helper binary.
52
63
  * @param options.cacheDir - Override the plugin binary cache directory.
53
64
  * @param options.cwd - Working directory for resolving relative paths.
54
65
  * @param options.entries - Explicit plugin entries; `false` disables all
55
66
  * plugins (skips both tsconfig entries and package auto-discovery).
56
- * @param options.env - Effective environment for source-plugin builds and the
57
- * `ttsx` descriptor child (`{ ...process.env, ...context.env }`). Defaults to
58
- * `process.env` for CLI callers, so ambient behavior is unchanged.
67
+ * @param options.env - Effective environment for source-plugin builds and
68
+ * isolated descriptor evaluators, including the `ttsx` fallback (`{
69
+ * ...process.env, ...context.env }`). Defaults to `process.env` for CLI
70
+ * callers, so ambient behavior is unchanged.
59
71
  * @param options.file - Path to the tsconfig/jsconfig file.
60
72
  * @param options.pluginConfigDir - Caller-declared anchor for plugin
61
73
  * config-file discovery (see `ITtscPluginFactoryContext.pluginConfigDir`).
@@ -74,16 +86,28 @@ export function loadProjectPlugins(options: {
74
86
  projectRoot?: string;
75
87
  tsconfig?: string;
76
88
  }): {
89
+ hostInputHashes: Record<string, string | null>;
90
+ hostInputRealpaths: Record<string, string | null>;
91
+ hostInputs: string[];
77
92
  nativePlugins: ITtscLoadedNativePlugin[];
78
93
  project: ITtscParsedProjectConfig;
79
94
  } {
80
- const effectiveEnv = options.env ?? process.env;
81
- const project = readProjectConfig({
95
+ // Snapshot the caller environment before `withPluginLoaderEnv` injects
96
+ // host-owned Node/ttsx locators into process.env. Under a Bun parent the
97
+ // direct descriptor evaluator must remain Bun unless the caller explicitly
98
+ // selected another runtime.
99
+ const effectiveEnv = { ...(options.env ?? process.env) };
100
+ const projectSnapshot = readProjectHostInputSnapshot({
82
101
  cwd: options.cwd,
83
102
  file: options.file,
103
+ includePluginDiscovery: options.entries === undefined,
84
104
  projectRoot: options.projectRoot,
85
105
  tsconfig: options.tsconfig,
86
106
  });
107
+ const { project } = projectSnapshot;
108
+ const projectHostInputs = projectSnapshot.hostInputs;
109
+ const projectHostInputHashes = projectSnapshot.hostInputHashes;
110
+ const projectHostInputRealpaths = projectSnapshot.hostInputRealpaths;
87
111
  const entries: ProjectPluginEntry[] =
88
112
  options.entries === false
89
113
  ? []
@@ -93,6 +117,17 @@ export function loadProjectPlugins(options: {
93
117
  if (entries.length === 0) {
94
118
  options.onWatchInputs?.([]);
95
119
  return {
120
+ ...collectHostInputSnapshot(
121
+ project,
122
+ {},
123
+ [],
124
+ projectHostInputs,
125
+ revalidateHostInputHashes(projectHostInputHashes, projectHostInputs),
126
+ revalidateHostInputRealpaths(
127
+ projectHostInputRealpaths,
128
+ projectHostInputs,
129
+ ),
130
+ ),
96
131
  nativePlugins: [],
97
132
  project,
98
133
  };
@@ -108,16 +143,67 @@ export function loadProjectPlugins(options: {
108
143
  projectRoot: project.root,
109
144
  tsconfig: project.path,
110
145
  };
111
- const plugins = composePluginSources(
112
- entries,
113
- entries.map((entry) =>
114
- loadPluginEntry(
146
+ const loadedEntries = withPluginLoaderEnv(() =>
147
+ entries.map((entry) => {
148
+ const specifier = entry.config.transform;
149
+ if (typeof specifier !== "string" || specifier.length === 0) {
150
+ throw new Error(
151
+ `ttsc: plugin entry is missing a string "transform" field`,
152
+ );
153
+ }
154
+ const entryCandidates = collectModuleResolutionCandidates(
155
+ specifier,
156
+ path.join(entry.baseDir, "package.json"),
157
+ undefined,
158
+ );
159
+ // Capture every candidate before resolution chooses the descriptor entry.
160
+ // A post-resolution snapshot could bless a higher-priority file created
161
+ // after the resolver had already selected the old entry.
162
+ const entryCandidateHashes = hashHostInputPaths(entryCandidates);
163
+ const entryCandidateRealpaths = realpathHostInputPaths(entryCandidates);
164
+ const request = resolvePluginRequest(specifier, entry.baseDir);
165
+ const loaded = loadPluginEntry(
115
166
  entry.config,
116
167
  { ...context, plugin: entry.config },
117
- entry.baseDir,
168
+ request,
118
169
  effectiveEnv,
119
- ),
120
- ),
170
+ );
171
+ const loadedHostInputHashes = mergeObservedHostInputHashes(
172
+ loaded.hostInputHashes,
173
+ hashHostInputPaths(Object.keys(loaded.hostInputHashes)),
174
+ );
175
+ const loadedHostInputRealpaths = mergeObservedHostInputRealpaths(
176
+ loaded.hostInputRealpaths,
177
+ realpathHostInputPaths(Object.keys(loaded.hostInputRealpaths)),
178
+ );
179
+ const hostInputHashes = mergeObservedHostInputHashes(
180
+ entryCandidateHashes,
181
+ loadedHostInputHashes,
182
+ );
183
+ const hostInputRealpaths = mergeObservedHostInputRealpaths(
184
+ entryCandidateRealpaths,
185
+ loadedHostInputRealpaths,
186
+ );
187
+ for (const input of loaded.hostInputs) {
188
+ const absolute = path.resolve(input);
189
+ if (
190
+ !Object.prototype.hasOwnProperty.call(loadedHostInputHashes, absolute)
191
+ ) {
192
+ delete hostInputHashes[absolute];
193
+ }
194
+ }
195
+ return {
196
+ ...loaded,
197
+ hostInputHashes,
198
+ hostInputRealpaths,
199
+ hostInputs: [...loaded.hostInputs, ...entryCandidates],
200
+ request,
201
+ };
202
+ }),
203
+ );
204
+ const plugins = composePluginSources(
205
+ entries,
206
+ loadedEntries.map((entry) => entry.plugin),
121
207
  );
122
208
 
123
209
  const ttscVersion = readTtscVersion();
@@ -142,6 +228,17 @@ export function loadProjectPlugins(options: {
142
228
  kind === "linked"
143
229
  ? `linked_${String(index).padStart(6, "0")}`
144
230
  : undefined;
231
+ const hostInputs = validatePluginHostInputs(plugin, index);
232
+ const pluginHostInputHashes = validatePluginHostInputHashes(
233
+ plugin,
234
+ index,
235
+ hostInputs,
236
+ );
237
+ const pluginHostInputRealpaths = validatePluginHostInputRealpaths(
238
+ plugin,
239
+ index,
240
+ hostInputs,
241
+ );
145
242
  return {
146
243
  capabilities: plugin.capabilities,
147
244
  contributors,
@@ -152,6 +249,20 @@ export function loadProjectPlugins(options: {
152
249
  name: plugin.name,
153
250
  reportsTypeScriptDiagnostics:
154
251
  plugin.reportsTypeScriptDiagnostics === true,
252
+ request: loadedEntries[index]!.request,
253
+ hostInputHashes: mergePluginHostInputHashes(
254
+ loadedEntries[index]!.hostInputHashes,
255
+ pluginHostInputHashes,
256
+ loadedEntries[index]!.hostInputs,
257
+ hostInputs,
258
+ ),
259
+ hostInputRealpaths: mergePluginHostInputHashes(
260
+ loadedEntries[index]!.hostInputRealpaths,
261
+ pluginHostInputRealpaths,
262
+ loadedEntries[index]!.hostInputs,
263
+ hostInputs,
264
+ ),
265
+ hostInputs: [...loadedEntries[index]!.hostInputs, ...hostInputs],
155
266
  source,
156
267
  stage,
157
268
  };
@@ -242,11 +353,586 @@ export function loadProjectPlugins(options: {
242
353
  };
243
354
  });
244
355
  return {
356
+ ...collectHostInputSnapshot(
357
+ project,
358
+ context,
359
+ records,
360
+ projectHostInputs,
361
+ revalidateHostInputHashes(projectHostInputHashes, projectHostInputs),
362
+ revalidateHostInputRealpaths(
363
+ projectHostInputRealpaths,
364
+ projectHostInputs,
365
+ ),
366
+ ),
245
367
  nativePlugins: orderNativePlugins(nativePlugins),
246
368
  project,
247
369
  };
248
370
  }
249
371
 
372
+ /**
373
+ * Collect exact JavaScript-host files that universally influence the loaded
374
+ * transform. Program files belong to the native reference graph; this list is
375
+ * deliberately limited to config ancestry, descriptor entries, the project
376
+ * manifest controlling auto-discovery, and explicit plugin config files.
377
+ */
378
+ function collectHostInputSnapshot(
379
+ project: ITtscParsedProjectConfig,
380
+ context: { pluginConfigDir?: string },
381
+ records: readonly {
382
+ config: ITtscProjectPluginConfig;
383
+ hostInputHashes: Readonly<Record<string, string | null>>;
384
+ hostInputRealpaths: Readonly<Record<string, string | null>>;
385
+ hostInputs: readonly string[];
386
+ request: string;
387
+ }[],
388
+ baselineInputs: readonly string[],
389
+ baselineHashes: Readonly<Record<string, string | null>>,
390
+ baselineRealpaths: Readonly<Record<string, string | null>>,
391
+ ): {
392
+ hostInputHashes: Record<string, string | null>;
393
+ hostInputRealpaths: Record<string, string | null>;
394
+ hostInputs: string[];
395
+ } {
396
+ const inputs = new Set<string>(
397
+ baselineInputs.map((file) => path.resolve(file)),
398
+ );
399
+ const configBase = context.pluginConfigDir ?? path.dirname(project.path);
400
+ for (const record of records) {
401
+ inputs.add(path.resolve(record.request));
402
+ for (const hostInput of record.hostInputs) {
403
+ inputs.add(path.resolve(hostInput));
404
+ }
405
+ const descriptorManifest = findNearestPackageJson(record.request);
406
+ if (descriptorManifest !== undefined) {
407
+ inputs.add(path.resolve(descriptorManifest));
408
+ }
409
+ const configFile = record.config.configFile;
410
+ if (typeof configFile === "string" && configFile.trim() !== "") {
411
+ inputs.add(
412
+ path.isAbsolute(configFile)
413
+ ? path.resolve(configFile)
414
+ : path.resolve(configBase, configFile),
415
+ );
416
+ }
417
+ }
418
+ const hostInputs = [...inputs].sort();
419
+ const evaluationHashes = mergeObservedHostInputHashes(
420
+ baselineHashes,
421
+ ...records.map((record) => record.hostInputHashes),
422
+ );
423
+ const evaluationRealpaths = mergeObservedHostInputRealpaths(
424
+ baselineRealpaths,
425
+ ...records.map((record) => record.hostInputRealpaths),
426
+ );
427
+ for (const record of records) {
428
+ for (const input of record.hostInputs) {
429
+ const absolute = path.resolve(input);
430
+ if (
431
+ !Object.prototype.hasOwnProperty.call(record.hostInputHashes, absolute)
432
+ ) {
433
+ delete evaluationHashes[absolute];
434
+ }
435
+ if (
436
+ !Object.prototype.hasOwnProperty.call(
437
+ record.hostInputRealpaths,
438
+ absolute,
439
+ )
440
+ ) {
441
+ delete evaluationRealpaths[absolute];
442
+ }
443
+ }
444
+ }
445
+ const hostInputHashes = Object.fromEntries(
446
+ hostInputs.flatMap((file) =>
447
+ Object.prototype.hasOwnProperty.call(evaluationHashes, file)
448
+ ? ([[file, evaluationHashes[file]!]] as const)
449
+ : [],
450
+ ),
451
+ );
452
+ const hostInputRealpaths = Object.fromEntries(
453
+ hostInputs.flatMap((file) =>
454
+ Object.prototype.hasOwnProperty.call(evaluationRealpaths, file)
455
+ ? ([[file, evaluationRealpaths[file]!]] as const)
456
+ : [],
457
+ ),
458
+ );
459
+ return { hostInputHashes, hostInputRealpaths, hostInputs };
460
+ }
461
+
462
+ /**
463
+ * Read one project configuration while its complete discovery surface remains
464
+ * unchanged. The preliminary read discovers the input set; the accepted read is
465
+ * bracketed by equal fingerprints for that same set. A repeatedly changing
466
+ * project is still returned so compilation can make progress, but without any
467
+ * cache proof for the ambiguous snapshot.
468
+ */
469
+ function readProjectHostInputSnapshot(options: {
470
+ cwd?: string;
471
+ file?: string;
472
+ includePluginDiscovery: boolean;
473
+ projectRoot?: string;
474
+ tsconfig?: string;
475
+ }): ProjectHostInputSnapshot {
476
+ const { includePluginDiscovery, ...projectOptions } = options;
477
+ let project = readProjectConfig(projectOptions);
478
+ let hostInputs = collectProjectHostInputs(project, includePluginDiscovery);
479
+ const observedInputs = new Set(hostInputs);
480
+ for (let attempt = 0; attempt < 3; attempt++) {
481
+ const before = hashHostInputPaths(hostInputs);
482
+ const beforeRealpaths = realpathHostInputPaths(hostInputs);
483
+ const beforeSignatures = hostInputMetadataSignatures(hostInputs);
484
+ const candidateProject = readProjectConfig(projectOptions);
485
+ const candidateInputs = collectProjectHostInputs(
486
+ candidateProject,
487
+ includePluginDiscovery,
488
+ );
489
+ for (const input of candidateInputs) observedInputs.add(input);
490
+ const after = hashHostInputPaths(candidateInputs);
491
+ const afterRealpaths = realpathHostInputPaths(candidateInputs);
492
+ const afterSignatures = hostInputMetadataSignatures(candidateInputs);
493
+ if (
494
+ equalHostInputLists(hostInputs, candidateInputs) &&
495
+ equalHostInputHashes(before, after) &&
496
+ equalHostInputHashes(beforeRealpaths, afterRealpaths) &&
497
+ beforeSignatures !== undefined &&
498
+ afterSignatures !== undefined &&
499
+ equalHostInputHashes(beforeSignatures, afterSignatures)
500
+ ) {
501
+ return {
502
+ hostInputHashes: after,
503
+ hostInputRealpaths: afterRealpaths,
504
+ hostInputs: candidateInputs,
505
+ project: candidateProject,
506
+ };
507
+ }
508
+ project = candidateProject;
509
+ hostInputs = candidateInputs;
510
+ }
511
+ return {
512
+ hostInputHashes: {},
513
+ hostInputRealpaths: {},
514
+ hostInputs: [...observedInputs].sort(),
515
+ project,
516
+ };
517
+ }
518
+
519
+ function equalHostInputLists(
520
+ first: readonly string[],
521
+ second: readonly string[],
522
+ ): boolean {
523
+ return (
524
+ first.length === second.length &&
525
+ first.every(
526
+ (file, index) => path.resolve(file) === path.resolve(second[index]!),
527
+ )
528
+ );
529
+ }
530
+
531
+ function equalHostInputHashes(
532
+ first: Readonly<Record<string, string | null>>,
533
+ second: Readonly<Record<string, string | null>>,
534
+ ): boolean {
535
+ const files = Object.keys(first);
536
+ return (
537
+ files.length === Object.keys(second).length &&
538
+ files.every(
539
+ (file) =>
540
+ Object.prototype.hasOwnProperty.call(second, file) &&
541
+ first[file] === second[file],
542
+ )
543
+ );
544
+ }
545
+
546
+ /** Retain proof only for inputs whose initial and final observations agree. */
547
+ function revalidateHostInputHashes(
548
+ initial: Readonly<Record<string, string | null>>,
549
+ inputs: readonly string[],
550
+ ): Record<string, string | null> {
551
+ const current = hashHostInputPaths(inputs);
552
+ const output: Record<string, string | null> = {};
553
+ for (const input of inputs) {
554
+ const absolute = path.resolve(input);
555
+ if (
556
+ Object.prototype.hasOwnProperty.call(initial, absolute) &&
557
+ initial[absolute] === current[absolute]
558
+ ) {
559
+ output[absolute] = current[absolute]!;
560
+ }
561
+ }
562
+ return output;
563
+ }
564
+
565
+ /** Keep physical-identity proof only while the same path resolves identically. */
566
+ function revalidateHostInputRealpaths(
567
+ initial: Readonly<Record<string, string | null>>,
568
+ inputs: readonly string[],
569
+ ): Record<string, string | null> {
570
+ const current = realpathHostInputPaths(inputs);
571
+ return Object.fromEntries(
572
+ inputs.flatMap((input) => {
573
+ const absolute = path.resolve(input);
574
+ return Object.prototype.hasOwnProperty.call(initial, absolute) &&
575
+ initial[absolute] === current[absolute]
576
+ ? ([[absolute, current[absolute]!]] as const)
577
+ : [];
578
+ }),
579
+ );
580
+ }
581
+
582
+ function hashHostInput(file: string): string | null {
583
+ try {
584
+ if (fs.statSync(file).isDirectory()) {
585
+ return crypto
586
+ .createHash("sha256")
587
+ .update("ttsc:host-input:directory\0")
588
+ .digest("hex");
589
+ }
590
+ return crypto
591
+ .createHash("sha256")
592
+ .update(fs.readFileSync(file))
593
+ .digest("hex");
594
+ } catch {
595
+ return null;
596
+ }
597
+ }
598
+
599
+ export function hashHostInputPaths(
600
+ files: readonly string[],
601
+ ): Record<string, string | null> {
602
+ return Object.fromEntries(
603
+ files.map((file) => [path.resolve(file), hashHostInput(file)]),
604
+ );
605
+ }
606
+
607
+ /** Physical path selected by a host input, or null while it is unresolved. */
608
+ export function realpathHostInput(file: string): string | null {
609
+ try {
610
+ return fs.realpathSync.native(file);
611
+ } catch {
612
+ return null;
613
+ }
614
+ }
615
+
616
+ export function realpathHostInputPaths(
617
+ files: readonly string[],
618
+ ): Record<string, string | null> {
619
+ return Object.fromEntries(
620
+ files.map((file) => [path.resolve(file), realpathHostInput(file)]),
621
+ );
622
+ }
623
+
624
+ /**
625
+ * Metadata identity that survives content-preserving reads but exposes A-B-A
626
+ * replacement. Missing paths are tied to the nearest existing ancestor whose
627
+ * directory metadata changes when the missing branch appears or disappears.
628
+ */
629
+ function hostInputMetadataSignature(file: string): string | undefined {
630
+ const requested = path.resolve(file);
631
+ let current = requested;
632
+ for (;;) {
633
+ try {
634
+ const link = fs.lstatSync(current, { bigint: true });
635
+ let target = link;
636
+ if (link.isSymbolicLink()) {
637
+ try {
638
+ target = fs.statSync(current, { bigint: true });
639
+ } catch {
640
+ // A broken link's own metadata cannot expose its target appearing
641
+ // and disappearing during evaluation. Decline cache proof instead.
642
+ return undefined;
643
+ }
644
+ }
645
+ return [
646
+ path.relative(current, requested),
647
+ link.dev,
648
+ link.ino,
649
+ link.mode,
650
+ link.size,
651
+ link.mtimeNs,
652
+ link.ctimeNs,
653
+ target.dev,
654
+ target.ino,
655
+ target.mode,
656
+ target.size,
657
+ target.mtimeNs,
658
+ target.ctimeNs,
659
+ ].join(":");
660
+ } catch (error) {
661
+ if (!isMissingPathError(error)) return undefined;
662
+ const parent = path.dirname(current);
663
+ if (parent === current) return undefined;
664
+ current = parent;
665
+ }
666
+ }
667
+ }
668
+
669
+ function hostInputMetadataSignatures(
670
+ files: readonly string[],
671
+ ): Record<string, string> | undefined {
672
+ const output: Record<string, string> = {};
673
+ for (const file of files) {
674
+ const signature = hostInputMetadataSignature(file);
675
+ if (signature === undefined) return undefined;
676
+ output[path.resolve(file)] = signature;
677
+ }
678
+ return output;
679
+ }
680
+
681
+ function isMissingPathError(error: unknown): boolean {
682
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
683
+ return code === "ENOENT" || code === "ENOTDIR";
684
+ }
685
+
686
+ /** Return config ancestry and the manifest controlling package discovery. */
687
+ export function collectProjectHostInputs(
688
+ project: ITtscParsedProjectConfig,
689
+ includePluginDiscovery: boolean = true,
690
+ ): string[] {
691
+ const inputs = new Set<string>(
692
+ project.configPaths.map((file) => path.resolve(file)),
693
+ );
694
+ if (!includePluginDiscovery) return [...inputs].sort();
695
+ const manifestCandidates = collectNearestPackageJsonCandidates(project.root);
696
+ for (const candidate of manifestCandidates) inputs.add(candidate);
697
+ const manifest = manifestCandidates.find(existingFile);
698
+ if (manifest !== undefined) {
699
+ const projectManifest = readPackageManifest(manifest);
700
+ if (projectManifest !== undefined) {
701
+ const projectRoot = path.dirname(manifest);
702
+ for (const dependency of directDependencyNames(projectManifest)) {
703
+ const dependencyManifest = resolveDependencyPackageJson(
704
+ dependency,
705
+ projectRoot,
706
+ );
707
+ for (const candidate of collectDependencyManifestCandidates(
708
+ dependency,
709
+ manifest,
710
+ dependencyManifest,
711
+ )) {
712
+ inputs.add(candidate);
713
+ }
714
+ if (dependencyManifest !== undefined) {
715
+ inputs.add(path.resolve(dependencyManifest));
716
+ }
717
+ }
718
+ }
719
+ }
720
+ return [...inputs].sort();
721
+ }
722
+
723
+ // The direct evaluator preloads ttsx's supported Node hook, whose
724
+ // extensionless rescue order includes TypeScript and ESM/CJS spellings in
725
+ // addition to Node's ordinary CommonJS probes.
726
+ const MODULE_PROBE_EXTENSIONS = [
727
+ ".ts",
728
+ ".tsx",
729
+ ".mts",
730
+ ".cts",
731
+ ".js",
732
+ ".mjs",
733
+ ".cjs",
734
+ ".json",
735
+ ".node",
736
+ ] as const;
737
+
738
+ /** Record every file whose later appearance can change one module resolution. */
739
+ function collectModuleResolutionCandidates(
740
+ specifier: string,
741
+ parentFile: string,
742
+ resolvedFile: string | undefined,
743
+ ): string[] {
744
+ const inputs = new Set<string>();
745
+ const recordedBases = new Set<string>();
746
+ const candidates = (base: string): string[] => [
747
+ base,
748
+ ...MODULE_PROBE_EXTENSIONS.map((extension) => base + extension),
749
+ path.join(base, "package.json"),
750
+ ...MODULE_PROBE_EXTENSIONS.map((extension) =>
751
+ path.join(base, `index${extension}`),
752
+ ),
753
+ ];
754
+ const recordManifestTargets = (
755
+ value: unknown,
756
+ directory: string,
757
+ allowBare: boolean = false,
758
+ ): void => {
759
+ if (typeof value === "string") {
760
+ if (
761
+ value !== "" &&
762
+ (allowBare || value.startsWith("./") || value.startsWith("../"))
763
+ ) {
764
+ recordBase(path.resolve(directory, value));
765
+ }
766
+ return;
767
+ }
768
+ if (Array.isArray(value)) {
769
+ for (const item of value) {
770
+ recordManifestTargets(item, directory, allowBare);
771
+ }
772
+ return;
773
+ }
774
+ if (isRecord(value)) {
775
+ for (const item of Object.values(value)) {
776
+ recordManifestTargets(item, directory, allowBare);
777
+ }
778
+ }
779
+ };
780
+ const recordBase = (base: string): void => {
781
+ const resolvedBase = path.resolve(base);
782
+ if (recordedBases.has(resolvedBase)) return;
783
+ recordedBases.add(resolvedBase);
784
+ for (const candidate of candidates(resolvedBase)) {
785
+ inputs.add(path.resolve(candidate));
786
+ }
787
+ try {
788
+ const manifest = readJsonFile(path.join(resolvedBase, "package.json"));
789
+ if (isRecord(manifest)) {
790
+ recordManifestTargets(manifest.exports, resolvedBase);
791
+ recordManifestTargets(manifest.module, resolvedBase, true);
792
+ recordManifestTargets(manifest.main, resolvedBase, true);
793
+ }
794
+ } catch {
795
+ // A malformed selected manifest is reported by normal resolution/load.
796
+ }
797
+ };
798
+ const selectedBy = (base: string): boolean => {
799
+ if (resolvedFile === undefined) return false;
800
+ let selected: string;
801
+ try {
802
+ selected = fs.realpathSync.native(resolvedFile);
803
+ } catch {
804
+ selected = path.resolve(resolvedFile);
805
+ }
806
+ for (const candidate of candidates(base)) {
807
+ try {
808
+ const canonical = fs.realpathSync.native(candidate);
809
+ const relative = path.relative(canonical, selected);
810
+ if (
811
+ relative === "" ||
812
+ (fs.statSync(canonical).isDirectory() &&
813
+ relative !== ".." &&
814
+ !relative.startsWith(`..${path.sep}`) &&
815
+ !path.isAbsolute(relative))
816
+ ) {
817
+ return true;
818
+ }
819
+ } catch {
820
+ // Missing candidates are the inputs this function intentionally keeps.
821
+ }
822
+ }
823
+ return false;
824
+ };
825
+ const localBases = (): string[] => {
826
+ if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
827
+ const directory = path.dirname(parentFile);
828
+ const raw = path.resolve(directory, specifier);
829
+ const suffixStart = specifier.search(/[?#]/);
830
+ if (suffixStart === -1) return [raw];
831
+ const pathname = specifier.slice(0, suffixStart);
832
+ return pathname === ""
833
+ ? [raw]
834
+ : [...new Set([raw, path.resolve(directory, pathname)])];
835
+ };
836
+
837
+ if (
838
+ specifier.startsWith(".") ||
839
+ path.isAbsolute(specifier) ||
840
+ specifier.startsWith("file:")
841
+ ) {
842
+ try {
843
+ for (const base of localBases()) {
844
+ // An existing exact file wins before extension and directory probes.
845
+ // Its own recorded identity is therefore sufficient; siblings cannot
846
+ // supersede it while it exists.
847
+ if (
848
+ selectedByExactFile(base, resolvedFile) ||
849
+ (resolvedFile === undefined && existingFile(base))
850
+ ) {
851
+ inputs.add(path.resolve(base));
852
+ } else {
853
+ recordBase(base);
854
+ }
855
+ }
856
+ } catch {
857
+ // Invalid URL spellings are diagnosed by the real resolver.
858
+ }
859
+ return [...inputs];
860
+ }
861
+ const parts = specifier.split("/");
862
+ const packageParts = parts[0]?.startsWith("@")
863
+ ? parts.slice(0, 2)
864
+ : parts.slice(0, 1);
865
+ if (packageParts.some((part) => part === undefined || part === "")) {
866
+ return [...inputs];
867
+ }
868
+ const packageName = packageParts.join("/");
869
+ const subpath = parts.slice(packageParts.length);
870
+ const searchPaths = createRequire(parentFile).resolve.paths(specifier) ?? [];
871
+ for (const searchPath of searchPaths) {
872
+ const packageDirectory = path.join(searchPath, packageName);
873
+ recordBase(packageDirectory);
874
+ if (subpath.length !== 0) {
875
+ recordBase(path.join(packageDirectory, ...subpath));
876
+ }
877
+ if (selectedBy(packageDirectory)) break;
878
+ }
879
+ return [...inputs];
880
+ }
881
+
882
+ function existingFile(file: string): boolean {
883
+ try {
884
+ return fs.statSync(file).isFile();
885
+ } catch {
886
+ return false;
887
+ }
888
+ }
889
+
890
+ function selectedByExactFile(
891
+ candidate: string,
892
+ selected: string | undefined,
893
+ ): boolean {
894
+ if (selected === undefined) return false;
895
+ try {
896
+ return (
897
+ fs.realpathSync.native(candidate) === fs.realpathSync.native(selected)
898
+ );
899
+ } catch {
900
+ return path.resolve(candidate) === path.resolve(selected);
901
+ }
902
+ }
903
+
904
+ /** Record package manifests whose later appearance can redirect discovery. */
905
+ function collectDependencyManifestCandidates(
906
+ packageName: string,
907
+ parentFile: string,
908
+ selectedManifest: string | undefined,
909
+ ): string[] {
910
+ const out: string[] = [];
911
+ let selectedDirectory: string | undefined;
912
+ if (selectedManifest !== undefined) {
913
+ try {
914
+ selectedDirectory = fs.realpathSync.native(
915
+ path.dirname(selectedManifest),
916
+ );
917
+ } catch {
918
+ selectedDirectory = path.resolve(path.dirname(selectedManifest));
919
+ }
920
+ }
921
+ for (const searchPath of createRequire(parentFile).resolve.paths(
922
+ packageName,
923
+ ) ?? []) {
924
+ const packageDirectory = path.join(searchPath, ...packageName.split("/"));
925
+ out.push(path.join(packageDirectory, "package.json"));
926
+ if (selectedDirectory === undefined) continue;
927
+ try {
928
+ if (fs.realpathSync.native(packageDirectory) === selectedDirectory) break;
929
+ } catch {
930
+ // Keep searching until the selected package directory is reached.
931
+ }
932
+ }
933
+ return out;
934
+ }
935
+
250
936
  function composePluginSources(
251
937
  entries: readonly ProjectPluginEntry[],
252
938
  plugins: readonly ITtscPlugin[],
@@ -521,7 +1207,7 @@ function resolveDependencyPackageJson(
521
1207
  ): string | undefined {
522
1208
  const direct = path.join(projectRoot, "node_modules", ...name.split("/"));
523
1209
  const directManifest = path.join(direct, "package.json");
524
- if (fs.existsSync(directManifest)) {
1210
+ if (existingFile(directManifest)) {
525
1211
  return resolveRealPath(directManifest);
526
1212
  }
527
1213
  const projectPackage = path.join(projectRoot, "package.json");
@@ -538,18 +1224,23 @@ function resolveDependencyPackageJson(
538
1224
  }
539
1225
 
540
1226
  function findNearestPackageJson(location: string): string | undefined {
1227
+ const selected =
1228
+ collectNearestPackageJsonCandidates(location).find(existingFile);
1229
+ return selected === undefined ? undefined : resolveRealPath(selected);
1230
+ }
1231
+
1232
+ /** Every package-scope candidate through the first regular manifest file. */
1233
+ function collectNearestPackageJsonCandidates(location: string): string[] {
541
1234
  let current = fs.statSync(location).isDirectory()
542
1235
  ? location
543
1236
  : path.dirname(location);
1237
+ const candidates: string[] = [];
544
1238
  while (true) {
545
- const manifest = path.join(current, "package.json");
546
- if (fs.existsSync(manifest)) {
547
- return resolveRealPath(manifest);
548
- }
1239
+ const manifest = path.resolve(current, "package.json");
1240
+ candidates.push(manifest);
1241
+ if (existingFile(manifest)) return candidates;
549
1242
  const parent = path.dirname(current);
550
- if (parent === current) {
551
- return undefined;
552
- }
1243
+ if (parent === current) return candidates;
553
1244
  current = parent;
554
1245
  }
555
1246
  }
@@ -561,7 +1252,7 @@ function findNearestPackageJson(location: string): string | undefined {
561
1252
  * message worse here than anywhere else.
562
1253
  */
563
1254
  function readPackageManifest(file: string): PackageManifest | undefined {
564
- if (!fs.existsSync(file)) {
1255
+ if (!existingFile(file)) {
565
1256
  return undefined;
566
1257
  }
567
1258
  const parsed = readJsonFile(file);
@@ -606,55 +1297,42 @@ function orderNativePlugins(
606
1297
  function loadPluginEntry(
607
1298
  entry: ITtscProjectPluginConfig,
608
1299
  base: Omit<ITtscPluginFactoryContext, "dirname" | "filename">,
609
- baseDir: string,
1300
+ request: string,
610
1301
  effectiveEnv: NodeJS.ProcessEnv,
611
- ): ITtscPlugin {
612
- return withPluginLoaderEnv(() => {
613
- const specifier = entry.transform;
614
- if (typeof specifier !== "string" || specifier.length === 0) {
615
- throw new Error(
616
- `ttsc: plugin entry is missing a string "transform" field`,
617
- );
618
- }
619
-
620
- const request = resolvePluginRequest(specifier, baseDir);
621
- // `dirname`/`filename` are per-entry: each plugin entry resolves to its own
622
- // descriptor module, so they are derived here from the resolved `request`
623
- // rather than carried on the shared base context. They give factories a
624
- // load-mode-independent stand-in for `__dirname`/`__filename`, which are
625
- // undefined when a descriptor loads through ttsx or as ESM.
626
- const context: ITtscPluginFactoryContext = {
627
- ...base,
628
- dirname: path.dirname(request),
629
- filename: request,
1302
+ ): {
1303
+ hostInputHashes: Record<string, string | null>;
1304
+ hostInputRealpaths: Record<string, string | null>;
1305
+ hostInputs: string[];
1306
+ plugin: ITtscPlugin;
1307
+ } {
1308
+ const specifier = entry.transform;
1309
+ if (typeof specifier !== "string" || specifier.length === 0) {
1310
+ throw new Error(`ttsc: plugin entry is missing a string "transform" field`);
1311
+ }
1312
+
1313
+ // `dirname`/`filename` are per-entry: each plugin entry resolves to its own
1314
+ // descriptor module, so they are derived here from the resolved `request`
1315
+ // rather than carried on the shared base context. They give factories a
1316
+ // load-mode-independent stand-in for `__dirname`/`__filename`, which are
1317
+ // undefined when a descriptor loads through ttsx or as ESM.
1318
+ const context: ITtscPluginFactoryContext = {
1319
+ ...base,
1320
+ dirname: path.dirname(request),
1321
+ filename: request,
1322
+ };
1323
+ const loaded = loadPluginDescriptor(request, context, effectiveEnv);
1324
+ if (isTtscPlugin(loaded.descriptor)) {
1325
+ rejectJsTransformFunctions(specifier, loaded.descriptor);
1326
+ return {
1327
+ hostInputHashes: loaded.hostInputHashes,
1328
+ hostInputRealpaths: loaded.hostInputRealpaths,
1329
+ hostInputs: loaded.inputs,
1330
+ plugin: loaded.descriptor,
630
1331
  };
631
- const mod = requirePluginEntry(request, context, effectiveEnv) as {
632
- createTtscPlugin?: TtscPluginFactory;
633
- default?: ITtscPlugin | TtscPluginFactory;
634
- } & Partial<Record<"plugin", ITtscPlugin | TtscPluginFactory>>;
635
- const candidate =
636
- mod.createTtscPlugin ??
637
- mod.default ??
638
- mod.plugin ??
639
- (mod as unknown as ITtscPlugin | TtscPluginFactory);
640
- if (typeof candidate === "function") {
641
- const plugin = candidate(context);
642
- if (!isTtscPlugin(plugin)) {
643
- throw new Error(
644
- `ttsc: plugin "${specifier}" does not export a valid ttsc plugin`,
645
- );
646
- }
647
- rejectJsTransformFunctions(specifier, plugin);
648
- return plugin;
649
- }
650
- if (isTtscPlugin(candidate)) {
651
- rejectJsTransformFunctions(specifier, candidate);
652
- return candidate;
653
- }
654
- throw new Error(
655
- `ttsc: plugin "${specifier}" does not export a valid ttsc plugin`,
656
- );
657
- });
1332
+ }
1333
+ throw new Error(
1334
+ `ttsc: plugin "${specifier}" does not export a valid ttsc plugin`,
1335
+ );
658
1336
  }
659
1337
 
660
1338
  /**
@@ -672,25 +1350,761 @@ function loadPluginEntry(
672
1350
  * never runs and cannot deadlock. A package that loads directly (a compiled
673
1351
  * descriptor, or Bun's native `.ts`) never reaches the fallback.
674
1352
  */
675
- function requirePluginEntry(
1353
+ function loadPluginDescriptor(
676
1354
  request: string,
677
1355
  context: ITtscPluginFactoryContext,
678
1356
  effectiveEnv: NodeJS.ProcessEnv,
679
- ): unknown {
1357
+ ): IsolatedPluginDescriptor {
680
1358
  try {
681
- return require(request);
1359
+ return loadCommonJsDescriptor(request, context, effectiveEnv);
682
1360
  } catch (error) {
683
- if (!TS_SOURCE_PATTERN.test(request)) {
1361
+ if (
1362
+ !TS_SOURCE_PATTERN.test(request) ||
1363
+ !(error instanceof CommonJsDescriptorLoadError) ||
1364
+ !error.retryWithTtsx
1365
+ ) {
684
1366
  throw error;
685
1367
  }
686
- const descriptor = loadDescriptorViaTtsx(request, context, effectiveEnv);
687
- if (descriptor === undefined) {
1368
+ const loaded = loadDescriptorViaTtsx(request, context, effectiveEnv);
1369
+ if (loaded === undefined) {
688
1370
  throw error;
689
1371
  }
690
- return { default: descriptor };
1372
+ return loaded;
691
1373
  }
692
1374
  }
693
1375
 
1376
+ interface IsolatedPluginDescriptor {
1377
+ descriptor: unknown;
1378
+ hostInputHashes: Record<string, string | null>;
1379
+ hostInputRealpaths: Record<string, string | null>;
1380
+ inputs: string[];
1381
+ }
1382
+
1383
+ class CommonJsDescriptorLoadError extends Error {
1384
+ public constructor(
1385
+ message: string,
1386
+ public readonly retryWithTtsx: boolean,
1387
+ ) {
1388
+ super(message);
1389
+ this.name = "CommonJsDescriptorLoadError";
1390
+ }
1391
+ }
1392
+
1393
+ /**
1394
+ * Evaluate one CommonJS descriptor in a fresh runtime module-cache generation.
1395
+ *
1396
+ * Reloading an external descriptor dependency inside this process has no safe
1397
+ * cache operation: retaining it serves stale exports, while deleting it splits
1398
+ * any application singleton that required the same module first. Isolation
1399
+ * gives every descriptor load current bytes without mutating the host's cache.
1400
+ * The child invokes the factory before walking its graph, so lazy `require()`
1401
+ * calls are included, and a failed first load cannot strand poisoned children.
1402
+ */
1403
+ function loadCommonJsDescriptor(
1404
+ request: string,
1405
+ context: ITtscPluginFactoryContext,
1406
+ effectiveEnv: NodeJS.ProcessEnv,
1407
+ ): IsolatedPluginDescriptor {
1408
+ const runtime = pluginDescriptorRuntimeBinary(effectiveEnv);
1409
+ const runtimeCapabilities = javascriptRuntimeCapabilities(
1410
+ runtime,
1411
+ effectiveEnv,
1412
+ context.projectRoot,
1413
+ );
1414
+ const node =
1415
+ !runtimeCapabilities.bun &&
1416
+ runtimeCapabilities.registerHooks &&
1417
+ runtimeCapabilities.executable !== undefined
1418
+ ? runtimeCapabilities.executable
1419
+ : resolveNodeBinary(effectiveEnv, context.projectRoot);
1420
+ const dir = createEvaluationTempDir();
1421
+ const out = path.join(dir, "descriptor.json");
1422
+ const inputsOut = path.join(dir, "descriptor-inputs.ndjson");
1423
+ const diagnostics = path.join(dir, "descriptor.stderr");
1424
+ const bunConfig = path.join(dir, "bunfig.toml");
1425
+ const runtimeHookPreload = path.join(
1426
+ __dirname,
1427
+ "..",
1428
+ "..",
1429
+ "launcher",
1430
+ "internal",
1431
+ "runtimeHookPreload.js",
1432
+ );
1433
+ try {
1434
+ if (runtimeCapabilities.bun) {
1435
+ // A descriptor receives exactly the environment supplied by its ttsc
1436
+ // invocation. Bun otherwise auto-loads project `.env*`, local/global
1437
+ // bunfig preloads and loaders, and may install a missing package from the
1438
+ // network. Those implicit authorities are neither part of Node's loader
1439
+ // contract nor reproducible host inputs, so isolate this evaluator from
1440
+ // them while retaining Bun's native TypeScript/module semantics.
1441
+ fs.writeFileSync(bunConfig, "", "utf8");
1442
+ }
1443
+ const diagnosticsFd = fs.openSync(diagnostics, "w");
1444
+ let result: ReturnType<typeof childProcess.spawnSync>;
1445
+ try {
1446
+ result = childProcess.spawnSync(
1447
+ runtime,
1448
+ [
1449
+ ...(runtimeCapabilities.bun
1450
+ ? [
1451
+ "--no-env-file",
1452
+ "--no-install",
1453
+ `--config=${bunConfig}`,
1454
+ `--tsconfig-override=${context.tsconfig}`,
1455
+ ]
1456
+ : []),
1457
+ ...(runtimeCapabilities.registerHooks
1458
+ ? ["--require", runtimeHookPreload]
1459
+ : []),
1460
+ "-e",
1461
+ COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE,
1462
+ ],
1463
+ {
1464
+ cwd: context.projectRoot,
1465
+ env: {
1466
+ ...effectiveEnv,
1467
+ // The direct evaluator may be Bun, but ttsx and native config
1468
+ // loaders require a real Node runtime with synchronous hooks.
1469
+ ...(node === undefined ? {} : { TTSC_NODE_BINARY: node }),
1470
+ TTSC_TTSX_BINARY:
1471
+ effectiveEnv.TTSC_TTSX_BINARY ?? process.env.TTSC_TTSX_BINARY,
1472
+ TTSC_PLUGIN_CONTEXT: JSON.stringify(context),
1473
+ TTSC_PLUGIN_DESCRIPTOR_LOAD: "1",
1474
+ TTSC_PLUGIN_DESCRIPTOR_OUT: out,
1475
+ TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE: "1",
1476
+ TTSC_PLUGIN_DESCRIPTOR_INPUTS_OUT: inputsOut,
1477
+ TTSC_PLUGIN_ENTRY: request,
1478
+ },
1479
+ // Hold direct-evaluator diagnostics until its retry decision is
1480
+ // known. A successful ttsx fallback must not inherit the expected
1481
+ // loader stack from the discarded first attempt.
1482
+ stdio: ["ignore", diagnosticsFd, diagnosticsFd],
1483
+ windowsHide: true,
1484
+ },
1485
+ );
1486
+ } finally {
1487
+ fs.closeSync(diagnosticsFd);
1488
+ }
1489
+ const failure = commonJsDescriptorProcessFailure(result, request);
1490
+ if (failure !== undefined) {
1491
+ const reason = pluginDescriptorFailureReason(out);
1492
+ const retryWithTtsx = commonJsDescriptorRetryWithTtsx(out);
1493
+ if (!retryWithTtsx) replayEvaluationDiagnostics(diagnostics);
1494
+ throw new CommonJsDescriptorLoadError(
1495
+ reason === "" ? failure.message : `${failure.message}\n${reason}`,
1496
+ retryWithTtsx,
1497
+ );
1498
+ }
1499
+ replayEvaluationDiagnostics(diagnostics);
1500
+ if (!fs.existsSync(out)) {
1501
+ throw new Error(
1502
+ `ttsc: plugin descriptor "${request}" evaluation in an isolated process produced no descriptor output.`,
1503
+ );
1504
+ }
1505
+ let parsed: unknown;
1506
+ try {
1507
+ parsed = JSON.parse(fs.readFileSync(out, "utf8"));
1508
+ } catch (error) {
1509
+ throw new Error(
1510
+ `ttsc: plugin descriptor "${request}" produced invalid isolated output: ${errorMessage(error)}`,
1511
+ );
1512
+ }
1513
+ if (!isRecord(parsed) || !Array.isArray(parsed.inputs)) {
1514
+ throw new Error(
1515
+ `ttsc: plugin descriptor "${request}" produced an invalid isolated result`,
1516
+ );
1517
+ }
1518
+ const parsedHashes = isRecord(parsed.inputHashes)
1519
+ ? Object.fromEntries(
1520
+ Object.entries(parsed.inputHashes).flatMap(([file, hash]) =>
1521
+ typeof hash === "string" || hash === null
1522
+ ? [[path.resolve(file), hash]]
1523
+ : [],
1524
+ ),
1525
+ )
1526
+ : {};
1527
+ const parsedRealpaths = isRecord(parsed.inputRealpaths)
1528
+ ? Object.fromEntries(
1529
+ Object.entries(parsed.inputRealpaths).flatMap(([file, realpath]) =>
1530
+ (typeof realpath === "string" && path.isAbsolute(realpath)) ||
1531
+ realpath === null
1532
+ ? [
1533
+ [
1534
+ path.resolve(file),
1535
+ realpath === null ? null : path.resolve(realpath),
1536
+ ],
1537
+ ]
1538
+ : [],
1539
+ ),
1540
+ )
1541
+ : {};
1542
+ const stableParsedRealpaths = mergeObservedHostInputRealpaths(
1543
+ parsedRealpaths,
1544
+ realpathHostInputPaths(Object.keys(parsedRealpaths)),
1545
+ );
1546
+ for (const input of parsed.inputs) {
1547
+ const absolute = path.resolve(String(input));
1548
+ if (
1549
+ !Object.prototype.hasOwnProperty.call(stableParsedRealpaths, absolute)
1550
+ ) {
1551
+ delete parsedHashes[absolute];
1552
+ }
1553
+ }
1554
+ const runtimeInputs = readTtsxDescriptorInputs(inputsOut, request);
1555
+ return {
1556
+ descriptor: parsed.descriptor,
1557
+ hostInputHashes: omitUnstableHostInputHashes(
1558
+ mergeObservedHostInputHashes(
1559
+ parsedHashes,
1560
+ runtimeInputs.hostInputHashes,
1561
+ ),
1562
+ runtimeInputs.unstableInputs,
1563
+ ),
1564
+ hostInputRealpaths: mergeObservedHostInputRealpaths(
1565
+ stableParsedRealpaths,
1566
+ runtimeInputs.hostInputRealpaths,
1567
+ ),
1568
+ inputs: [
1569
+ ...new Set([
1570
+ ...parsed.inputs.map((input) => String(input)),
1571
+ ...runtimeInputs.inputs,
1572
+ ]),
1573
+ ].sort(),
1574
+ };
1575
+ } finally {
1576
+ removeEvaluationTempDir(dir);
1577
+ }
1578
+ }
1579
+
1580
+ /** Validate plugin-declared universal host inputs. */
1581
+ function validatePluginHostInputs(
1582
+ plugin: ITtscPlugin,
1583
+ index: number,
1584
+ ): string[] {
1585
+ const label = plugin.name ?? `#${index + 1}`;
1586
+ if (plugin.hostInputs === undefined) return [];
1587
+ if (!Array.isArray(plugin.hostInputs)) {
1588
+ throw new Error(
1589
+ `ttsc: plugin ${JSON.stringify(label)} has invalid "hostInputs"; expected an array of absolute paths`,
1590
+ );
1591
+ }
1592
+ return plugin.hostInputs.map((file) => {
1593
+ if (typeof file !== "string" || !path.isAbsolute(file)) {
1594
+ throw new Error(
1595
+ `ttsc: plugin ${JSON.stringify(label)} has invalid "hostInputs" entry ${JSON.stringify(file)}; expected an absolute path`,
1596
+ );
1597
+ }
1598
+ return path.resolve(file);
1599
+ });
1600
+ }
1601
+
1602
+ /** Validate descriptor-supplied evaluation fingerprints for host inputs. */
1603
+ function validatePluginHostInputHashes(
1604
+ plugin: ITtscPlugin,
1605
+ index: number,
1606
+ hostInputs: readonly string[],
1607
+ ): Record<string, string | null> {
1608
+ const label = plugin.name ?? `#${index + 1}`;
1609
+ if (plugin.hostInputHashes === undefined) return {};
1610
+ if (
1611
+ !isRecord(plugin.hostInputHashes) ||
1612
+ Array.isArray(plugin.hostInputHashes)
1613
+ ) {
1614
+ throw new Error(
1615
+ `ttsc: plugin ${JSON.stringify(label)} has invalid "hostInputHashes"; expected an object keyed by absolute hostInputs paths`,
1616
+ );
1617
+ }
1618
+ const allowed = new Set(hostInputs.map((file) => path.resolve(file)));
1619
+ const output: Record<string, string | null> = {};
1620
+ for (const [file, hash] of Object.entries(plugin.hostInputHashes)) {
1621
+ if (!path.isAbsolute(file)) {
1622
+ throw new Error(
1623
+ `ttsc: plugin ${JSON.stringify(label)} has invalid "hostInputHashes" key ${JSON.stringify(file)}; expected an absolute path`,
1624
+ );
1625
+ }
1626
+ const absolute = path.resolve(file);
1627
+ if (!allowed.has(absolute)) {
1628
+ throw new Error(
1629
+ `ttsc: plugin ${JSON.stringify(label)} fingerprints ${JSON.stringify(file)} without listing it in "hostInputs"`,
1630
+ );
1631
+ }
1632
+ if (
1633
+ hash !== null &&
1634
+ (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))
1635
+ ) {
1636
+ throw new Error(
1637
+ `ttsc: plugin ${JSON.stringify(label)} has invalid fingerprint for ${JSON.stringify(file)}; expected a lowercase SHA-256 digest or null`,
1638
+ );
1639
+ }
1640
+ output[absolute] = hash;
1641
+ }
1642
+ return output;
1643
+ }
1644
+
1645
+ /** Validate descriptor-supplied physical identities for host inputs. */
1646
+ function validatePluginHostInputRealpaths(
1647
+ plugin: ITtscPlugin,
1648
+ index: number,
1649
+ hostInputs: readonly string[],
1650
+ ): Record<string, string | null> {
1651
+ const label = plugin.name ?? `#${index + 1}`;
1652
+ if (plugin.hostInputRealpaths === undefined) return {};
1653
+ if (
1654
+ !isRecord(plugin.hostInputRealpaths) ||
1655
+ Array.isArray(plugin.hostInputRealpaths)
1656
+ ) {
1657
+ throw new Error(
1658
+ `ttsc: plugin ${JSON.stringify(label)} has invalid "hostInputRealpaths"; expected an object keyed by absolute hostInputs paths`,
1659
+ );
1660
+ }
1661
+ const allowed = new Set(hostInputs.map((file) => path.resolve(file)));
1662
+ const output: Record<string, string | null> = {};
1663
+ for (const [file, realpath] of Object.entries(plugin.hostInputRealpaths)) {
1664
+ if (!path.isAbsolute(file)) {
1665
+ throw new Error(
1666
+ `ttsc: plugin ${JSON.stringify(label)} has invalid "hostInputRealpaths" key ${JSON.stringify(file)}; expected an absolute path`,
1667
+ );
1668
+ }
1669
+ const absolute = path.resolve(file);
1670
+ if (!allowed.has(absolute)) {
1671
+ throw new Error(
1672
+ `ttsc: plugin ${JSON.stringify(label)} identifies ${JSON.stringify(file)} without listing it in "hostInputs"`,
1673
+ );
1674
+ }
1675
+ if (
1676
+ realpath !== null &&
1677
+ (typeof realpath !== "string" || !path.isAbsolute(realpath))
1678
+ ) {
1679
+ throw new Error(
1680
+ `ttsc: plugin ${JSON.stringify(label)} has invalid physical identity for ${JSON.stringify(file)}; expected an absolute realpath or null`,
1681
+ );
1682
+ }
1683
+ output[absolute] =
1684
+ realpath === null ? null : path.resolve(realpath as string);
1685
+ }
1686
+ return output;
1687
+ }
1688
+
1689
+ /** Merge snapshots and omit every path observed in contradictory states. */
1690
+ function mergeObservedHostInputHashes(
1691
+ ...sources: Readonly<Record<string, string | null>>[]
1692
+ ): Record<string, string | null> {
1693
+ const conflicts = new Set<string>();
1694
+ const output: Record<string, string | null> = {};
1695
+ for (const source of sources) {
1696
+ for (const [file, hash] of Object.entries(source)) {
1697
+ const absolute = path.resolve(file);
1698
+ if (conflicts.has(absolute)) continue;
1699
+ if (
1700
+ Object.prototype.hasOwnProperty.call(output, absolute) &&
1701
+ output[absolute] !== hash
1702
+ ) {
1703
+ delete output[absolute];
1704
+ conflicts.add(absolute);
1705
+ continue;
1706
+ }
1707
+ output[absolute] = hash;
1708
+ }
1709
+ }
1710
+ return output;
1711
+ }
1712
+
1713
+ const mergeObservedHostInputRealpaths = mergeObservedHostInputHashes;
1714
+
1715
+ /** Prevent a later plugin claim from reviving an unstable loader input. */
1716
+ function mergePluginHostInputHashes(
1717
+ first: Readonly<Record<string, string | null>>,
1718
+ second: Readonly<Record<string, string | null>>,
1719
+ loaderInputs: readonly string[],
1720
+ pluginInputs: readonly string[],
1721
+ ): Record<string, string | null> {
1722
+ const output = { ...first };
1723
+ const guarded = new Set(loaderInputs.map((file) => path.resolve(file)));
1724
+ for (const input of pluginInputs) {
1725
+ const absolute = path.resolve(input);
1726
+ if (!Object.prototype.hasOwnProperty.call(second, absolute)) {
1727
+ delete output[absolute];
1728
+ }
1729
+ }
1730
+ for (const [file, hash] of Object.entries(second)) {
1731
+ const absolute = path.resolve(file);
1732
+ if (
1733
+ guarded.has(absolute) &&
1734
+ !Object.prototype.hasOwnProperty.call(first, absolute)
1735
+ ) {
1736
+ continue;
1737
+ }
1738
+ if (
1739
+ Object.prototype.hasOwnProperty.call(output, absolute) &&
1740
+ output[absolute] !== hash
1741
+ ) {
1742
+ delete output[absolute];
1743
+ continue;
1744
+ }
1745
+ output[absolute] = hash;
1746
+ }
1747
+ return output;
1748
+ }
1749
+
1750
+ function omitUnstableHostInputHashes(
1751
+ hashes: Record<string, string | null>,
1752
+ unstableInputs: readonly string[],
1753
+ ): Record<string, string | null> {
1754
+ for (const input of unstableInputs) delete hashes[path.resolve(input)];
1755
+ return hashes;
1756
+ }
1757
+
1758
+ /** CommonJS evaluator emitted into a clean Node process for every load. */
1759
+ export const COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE = [
1760
+ `const crypto = require("node:crypto");`,
1761
+ `const fs = require("node:fs");`,
1762
+ `const Module = require("node:module");`,
1763
+ `const path = require("node:path");`,
1764
+ `const { fileURLToPath } = require("node:url");`,
1765
+ `const out = process.env.TTSC_PLUGIN_DESCRIPTOR_OUT;`,
1766
+ `let retryWithTtsx = false;`,
1767
+ `const moduleLoadFailures = new WeakMap();`,
1768
+ `const moduleResolutionFailures = new WeakMap();`,
1769
+ `function existingFile(file) { try { return fs.statSync(file).isFile(); } catch { return false; } }`,
1770
+ `function shouldRetryWithTtsx(error) {`,
1771
+ ` const loadFailure = error && (typeof error === "object" || typeof error === "function") ? moduleLoadFailures.get(error) : undefined;`,
1772
+ ` if (loadFailure?.retryWithTtsx === true) return true;`,
1773
+ ` const failure = error && (typeof error === "object" || typeof error === "function") ? moduleResolutionFailures.get(error) : undefined;`,
1774
+ ` return failure?.retryWithTtsx === true;`,
1775
+ `}`,
1776
+ `try {`,
1777
+ ` const request = process.env.TTSC_PLUGIN_ENTRY;`,
1778
+ ` const context = JSON.parse(process.env.TTSC_PLUGIN_CONTEXT);`,
1779
+ ` const inputs = new Set();`,
1780
+ ` const inputHashes = new Map();`,
1781
+ ` const inputRealpaths = new Map();`,
1782
+ ` const inputSignatures = new Map();`,
1783
+ ` const unstableInputHashes = new Set();`,
1784
+ ` function missingPathError(error) { return error && (error.code === "ENOENT" || error.code === "ENOTDIR"); }`,
1785
+ ` function inputMetadataSignature(file) {`,
1786
+ ` const requested = path.resolve(file);`,
1787
+ ` let current = requested;`,
1788
+ ` for (;;) {`,
1789
+ ` try {`,
1790
+ ` const link = fs.lstatSync(current, { bigint: true });`,
1791
+ ` let target = link;`,
1792
+ ` if (link.isSymbolicLink()) {`,
1793
+ ` try { target = fs.statSync(current, { bigint: true }); }`,
1794
+ ` catch { return undefined; }`,
1795
+ ` }`,
1796
+ ` return [path.relative(current, requested), link.dev, link.ino, link.mode, link.size, link.mtimeNs, link.ctimeNs, target.dev, target.ino, target.mode, target.size, target.mtimeNs, target.ctimeNs].join(":");`,
1797
+ ` } catch (error) {`,
1798
+ ` if (!missingPathError(error)) return undefined;`,
1799
+ ` const parent = path.dirname(current);`,
1800
+ ` if (parent === current) return undefined;`,
1801
+ ` current = parent;`,
1802
+ ` }`,
1803
+ ` }`,
1804
+ ` }`,
1805
+ ` function recordOneInput(file) {`,
1806
+ ` file = path.resolve(file);`,
1807
+ ` inputs.add(file);`,
1808
+ ` if (unstableInputHashes.has(file)) return;`,
1809
+ ` const beforeSignature = inputMetadataSignature(file);`,
1810
+ ` let observedRealpath;`,
1811
+ ` try { observedRealpath = fs.realpathSync.native(file); } catch { observedRealpath = null; }`,
1812
+ ` let observed;`,
1813
+ ` try { observed = fs.statSync(file).isDirectory() ? crypto.createHash("sha256").update("ttsc:host-input:directory\\0").digest("hex") : crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); } catch { observed = null; }`,
1814
+ ` const afterSignature = inputMetadataSignature(file);`,
1815
+ ` if (beforeSignature === undefined || afterSignature === undefined || beforeSignature !== afterSignature || (inputSignatures.has(file) && inputSignatures.get(file) !== afterSignature) || (inputRealpaths.has(file) && inputRealpaths.get(file) !== observedRealpath) || (inputHashes.has(file) && inputHashes.get(file) !== observed)) { inputHashes.delete(file); inputRealpaths.delete(file); inputSignatures.delete(file); unstableInputHashes.add(file); return; }`,
1816
+ ` inputSignatures.set(file, afterSignature);`,
1817
+ ` inputRealpaths.set(file, observedRealpath);`,
1818
+ ` inputHashes.set(file, observed);`,
1819
+ ` }`,
1820
+ ` function recordInput(file) {`,
1821
+ ` file = path.resolve(file);`,
1822
+ ` recordOneInput(file);`,
1823
+ ` const parsed = path.parse(file);`,
1824
+ ` let current = parsed.root;`,
1825
+ ` const relative = path.relative(parsed.root, file);`,
1826
+ ` for (const segment of relative.split(path.sep).slice(0, -1)) {`,
1827
+ ` if (segment === "") continue;`,
1828
+ ` current = path.join(current, segment);`,
1829
+ ` try {`,
1830
+ ` if (fs.lstatSync(current).isSymbolicLink()) recordOneInput(current);`,
1831
+ ` } catch {`,
1832
+ ` break;`,
1833
+ ` }`,
1834
+ ` }`,
1835
+ ` }`,
1836
+ ` function asFile(resolved) {`,
1837
+ ` if (typeof resolved !== "string") return undefined;`,
1838
+ ` if (!resolved.startsWith("file:")) return path.isAbsolute(resolved) ? path.resolve(resolved) : undefined;`,
1839
+ ` try { return path.resolve(fileURLToPath(resolved)); } catch { return undefined; }`,
1840
+ ` }`,
1841
+ ` function recordFile(resolved) {`,
1842
+ ` const file = asFile(resolved);`,
1843
+ ` if (file === undefined) return;`,
1844
+ ` recordInput(file);`,
1845
+ ` for (let directory = path.dirname(file);;) {`,
1846
+ ` const manifest = path.join(directory, "package.json");`,
1847
+ ` recordInput(manifest);`,
1848
+ ` if (existingFile(manifest)) break;`,
1849
+ ` const parent = path.dirname(directory);`,
1850
+ ` if (parent === directory) break;`,
1851
+ ` directory = parent;`,
1852
+ ` }`,
1853
+ ` }`,
1854
+ ` function recordPackageManifests(file) {`,
1855
+ ` for (let directory = path.dirname(path.resolve(file));;) {`,
1856
+ ` const manifest = path.join(directory, "package.json");`,
1857
+ ` recordInput(manifest);`,
1858
+ ` if (existingFile(manifest)) return;`,
1859
+ ` const parent = path.dirname(directory);`,
1860
+ ` if (parent === directory) return;`,
1861
+ ` directory = parent;`,
1862
+ ` }`,
1863
+ ` }`,
1864
+ ` recordFile(request);`,
1865
+ ` const moduleProbeExtensions = typeof globalThis.Bun === "object" ? [".tsx", ".jsx", ".ts", ".mjs", ".js", ".cjs", ".json"] : [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"];`,
1866
+ ` const jsToTsProbeExtensions = new Map([[".js", [".ts", ".tsx"]], [".jsx", [".tsx"]], [".mjs", [".mts"]], [".cjs", [".cts"]]]);`,
1867
+ ` function sourceSubstitutionCandidates(base) {`,
1868
+ ` const extension = path.extname(base).toLowerCase();`,
1869
+ ` const substitutions = jsToTsProbeExtensions.get(extension);`,
1870
+ ` if (substitutions === undefined) return [];`,
1871
+ ` const stem = base.slice(0, base.length - extension.length);`,
1872
+ ` return substitutions.map((candidate) => stem + candidate);`,
1873
+ ` }`,
1874
+ ` function moduleCandidates(base) {`,
1875
+ ` return [base, ...sourceSubstitutionCandidates(base), ...moduleProbeExtensions.map((extension) => base + extension), path.join(base, "package.json"), ...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension))];`,
1876
+ ` }`,
1877
+ ` const recordedModuleBases = new Set();`,
1878
+ ` function recordManifestTargets(value, directory, allowBare = false) {`,
1879
+ ` if (typeof value === "string") {`,
1880
+ ` if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));`,
1881
+ ` return;`,
1882
+ ` }`,
1883
+ ` if (Array.isArray(value)) { for (const item of value) recordManifestTargets(item, directory, allowBare); return; }`,
1884
+ ` if (value && typeof value === "object") for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);`,
1885
+ ` }`,
1886
+ ` function recordModuleCandidates(base) {`,
1887
+ ` const resolvedBase = path.resolve(base);`,
1888
+ ` if (recordedModuleBases.has(resolvedBase)) return;`,
1889
+ ` recordedModuleBases.add(resolvedBase);`,
1890
+ ` for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);`,
1891
+ ` try {`,
1892
+ ` const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));`,
1893
+ ` recordManifestTargets(manifest.exports, resolvedBase);`,
1894
+ ` recordManifestTargets(manifest.module, resolvedBase, true);`,
1895
+ ` recordManifestTargets(manifest.main, resolvedBase, true);`,
1896
+ ` } catch {}`,
1897
+ ` }`,
1898
+ ` function candidateSelected(base, selected) {`,
1899
+ ` if (selected === undefined) return false;`,
1900
+ ` for (const candidate of moduleCandidates(base)) {`,
1901
+ ` try {`,
1902
+ ` const canonical = fs.realpathSync.native(candidate);`,
1903
+ ` const relative = path.relative(canonical, selected);`,
1904
+ ` if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;`,
1905
+ ` } catch {}`,
1906
+ ` }`,
1907
+ ` return false;`,
1908
+ ` }`,
1909
+ ` function localBases(specifier, parentFile) {`,
1910
+ ` if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];`,
1911
+ ` const directory = path.dirname(parentFile);`,
1912
+ ` const raw = path.resolve(directory, specifier);`,
1913
+ ` const suffixStart = specifier.search(/[?#]/);`,
1914
+ ` if (suffixStart === -1) return [raw];`,
1915
+ ` const pathname = specifier.slice(0, suffixStart);`,
1916
+ ` return pathname === "" ? [raw] : [...new Set([raw, path.resolve(directory, pathname)])];`,
1917
+ ` }`,
1918
+ ` function recordResolutionCandidates(specifier, parent, resolved) {`,
1919
+ ` const parentFile = asFile(parent);`,
1920
+ ` if (typeof specifier !== "string" || parentFile === undefined) return;`,
1921
+ ` let selected;`,
1922
+ ` try { const file = asFile(resolved); selected = file === undefined ? undefined : fs.realpathSync.native(file); } catch {}`,
1923
+ ` if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {`,
1924
+ ` try {`,
1925
+ ` for (const base of localBases(specifier, parentFile)) {`,
1926
+ ` recordPackageManifests(base);`,
1927
+ ` let exact = false;`,
1928
+ ` try { exact = selected === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === selected; } catch {}`,
1929
+ ` if (exact) recordInput(base);`,
1930
+ ` if (!exact) recordModuleCandidates(base);`,
1931
+ ` }`,
1932
+ ` } catch {}`,
1933
+ ` return;`,
1934
+ ` }`,
1935
+ ` if (Module.isBuiltin(specifier) || specifier.startsWith("#")) return;`,
1936
+ ` const parts = specifier.split("/");`,
1937
+ ` const packageParts = parts[0].startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);`,
1938
+ ` if (packageParts.some((part) => part === undefined || part === "")) return;`,
1939
+ ` const packageName = packageParts.join("/");`,
1940
+ ` const subpath = parts.slice(packageParts.length);`,
1941
+ ` const searchPaths = Module.createRequire(parentFile).resolve.paths(specifier) ?? [];`,
1942
+ ` for (const searchPath of searchPaths) {`,
1943
+ ` const packageDirectory = path.join(searchPath, packageName);`,
1944
+ ` recordModuleCandidates(packageDirectory);`,
1945
+ ` if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));`,
1946
+ ` if (candidateSelected(packageDirectory, selected)) break;`,
1947
+ ` }`,
1948
+ ` }`,
1949
+ ` function scanBunModuleGraph(entry) {`,
1950
+ ` if (typeof globalThis.Bun !== "object" || typeof globalThis.Bun.Transpiler !== "function") return;`,
1951
+ ` const scanned = new Set();`,
1952
+ ` function scan(file) {`,
1953
+ ` file = asFile(file);`,
1954
+ ` if (file === undefined) return;`,
1955
+ ` let canonical;`,
1956
+ ` try { canonical = fs.realpathSync.native(file); } catch { return; }`,
1957
+ ` if (scanned.has(canonical)) return;`,
1958
+ ` scanned.add(canonical);`,
1959
+ ` const extension = path.extname(canonical).toLowerCase();`,
1960
+ ` if (![".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"].includes(extension)) return;`,
1961
+ ` const loader = extension === ".tsx" ? "tsx" : extension === ".jsx" ? "jsx" : [".ts", ".mts", ".cts"].includes(extension) ? "ts" : "js";`,
1962
+ ` let imports;`,
1963
+ ` try {`,
1964
+ ` const source = fs.readFileSync(canonical, "utf8");`,
1965
+ ` imports = new globalThis.Bun.Transpiler({ loader }).scan(source).imports;`,
1966
+ ` } catch { return; }`,
1967
+ ` for (const imported of imports) {`,
1968
+ ` const specifier = imported && imported.path;`,
1969
+ ` if (typeof specifier !== "string" || specifier.startsWith("node:") || Module.isBuiltin(specifier)) continue;`,
1970
+ ` recordResolutionCandidates(specifier, canonical, undefined);`,
1971
+ ` try {`,
1972
+ ` const resolved = globalThis.Bun.resolveSync(specifier, path.dirname(canonical));`,
1973
+ ` recordResolutionCandidates(specifier, canonical, resolved);`,
1974
+ ` recordFile(resolved);`,
1975
+ ` scan(resolved);`,
1976
+ ` } catch {`,
1977
+ ` // The real descriptor load below owns user-facing resolution errors.`,
1978
+ ` }`,
1979
+ ` }`,
1980
+ ` }`,
1981
+ ` scan(entry);`,
1982
+ ` }`,
1983
+ ` if (typeof Module.registerHooks === "function") {`,
1984
+ ` Module.registerHooks({`,
1985
+ ` load(url, loadContext, nextLoad) {`,
1986
+ ` try { return nextLoad(url, loadContext); }`,
1987
+ ` catch (error) {`,
1988
+ ` if (error && (typeof error === "object" || typeof error === "function")) moduleLoadFailures.set(error, { retryWithTtsx: error.code === "ERR_UNKNOWN_FILE_EXTENSION" });`,
1989
+ ` throw error;`,
1990
+ ` }`,
1991
+ ` },`,
1992
+ ` resolve(specifier, resolveContext, nextResolve) {`,
1993
+ ` recordResolutionCandidates(specifier, resolveContext.parentURL, undefined);`,
1994
+ ` let resolved;`,
1995
+ ` try {`,
1996
+ ` resolved = nextResolve(specifier, resolveContext);`,
1997
+ ` } catch (error) {`,
1998
+ ` if (error && (typeof error === "object" || typeof error === "function")) {`,
1999
+ ` const parent = asFile(resolveContext.parentURL);`,
2000
+ ` const anchor = typeof parent === "string" ? parent : request;`,
2001
+ ` const candidate = specifier.startsWith(".") && path.extname(specifier) === "" ? path.resolve(path.dirname(anchor), specifier) : undefined;`,
2002
+ ` const retryWithTtsx = candidate !== undefined && [".ts", ".cts", ".mts", ".tsx"].some((extension) => existingFile(candidate + extension) || existingFile(path.join(candidate, "index" + extension)));`,
2003
+ ` moduleResolutionFailures.set(error, { retryWithTtsx });`,
2004
+ ` }`,
2005
+ ` throw error;`,
2006
+ ` }`,
2007
+ ` const url = typeof resolved === "string" ? resolved : resolved && resolved.url;`,
2008
+ ` recordResolutionCandidates(specifier, resolveContext.parentURL, url);`,
2009
+ ` recordFile(url);`,
2010
+ ` return resolved;`,
2011
+ ` },`,
2012
+ ` });`,
2013
+ ` }`,
2014
+ ` if (typeof globalThis.Bun === "object" && typeof globalThis.Bun.plugin === "function") {`,
2015
+ ` let insideBunResolve = false;`,
2016
+ ` globalThis.Bun.plugin({`,
2017
+ ` name: "ttsc-plugin-descriptor-inputs",`,
2018
+ ` setup(build) {`,
2019
+ ` build.onResolve({ filter: /.*/ }, (args) => {`,
2020
+ ` if (insideBunResolve || args.path.startsWith("node:")) return;`,
2021
+ ` try {`,
2022
+ ` insideBunResolve = true;`,
2023
+ ` const importer = args.importer && args.importer.startsWith("file:") ? fileURLToPath(args.importer) : args.importer;`,
2024
+ ` const from = importer ? path.dirname(importer) : process.cwd();`,
2025
+ ` recordResolutionCandidates(args.path, importer, undefined);`,
2026
+ ` const resolved = globalThis.Bun.resolveSync(args.path, from);`,
2027
+ ` recordResolutionCandidates(args.path, importer, resolved);`,
2028
+ ` recordFile(resolved);`,
2029
+ ` } catch {`,
2030
+ ` // The real resolver below owns user-facing resolution errors.`,
2031
+ ` } finally {`,
2032
+ ` insideBunResolve = false;`,
2033
+ ` }`,
2034
+ ` });`,
2035
+ ` },`,
2036
+ ` });`,
2037
+ ` }`,
2038
+ ` scanBunModuleGraph(request);`,
2039
+ ` let mod;`,
2040
+ ` try {`,
2041
+ ` mod = require(request);`,
2042
+ ` } catch (error) {`,
2043
+ ` retryWithTtsx = shouldRetryWithTtsx(error);`,
2044
+ ` throw error;`,
2045
+ ` }`,
2046
+ ` const candidate = mod.createTtscPlugin ?? mod.default ?? mod.plugin ?? mod;`,
2047
+ ` const descriptor = typeof candidate === "function" ? candidate(context) : candidate;`,
2048
+ ` if (descriptor && typeof descriptor === "object" && ("transformSource" in descriptor || "transformOutput" in descriptor)) {`,
2049
+ ` throw new Error("ttsc: plugin descriptor declares unsupported JS transform functions; declare a native backend instead");`,
2050
+ ` }`,
2051
+ // Serialize the descriptor while the module-resolution hooks are still
2052
+ // collecting. Getters are allowed by JavaScript's object model and can
2053
+ // lazily require a config; snapshotting inputs first would silently omit
2054
+ // that influence.
2055
+ ` const serializedDescriptor = JSON.stringify(descriptor);`,
2056
+ ` for (const input of [...inputs]) recordInput(input);`,
2057
+ ` const payload = { inputHashes: Object.fromEntries(inputHashes), inputRealpaths: Object.fromEntries(inputRealpaths), inputs: [...inputs].sort() };`,
2058
+ ` if (serializedDescriptor !== undefined) payload.descriptor = JSON.parse(serializedDescriptor);`,
2059
+ ` fs.writeFileSync(out, JSON.stringify(payload));`,
2060
+ `} catch (error) {`,
2061
+ ` const message = error instanceof Error ? error.message : String(error);`,
2062
+ ` const stack = error instanceof Error && error.stack ? error.stack : String(error);`,
2063
+ ` try {`,
2064
+ ` fs.writeFileSync(out, JSON.stringify({ __ttscLoaderError: message, __ttscRetryWithTtsx: retryWithTtsx }));`,
2065
+ ` } catch {}`,
2066
+ ` try { process.stderr.write(stack + "\\n"); } catch {}`,
2067
+ ` process.exit(1);`,
2068
+ `}`,
2069
+ ``,
2070
+ ].join("\n");
2071
+
2072
+ /** Read the isolated loader's explicit retry classification. */
2073
+ function commonJsDescriptorRetryWithTtsx(file: string): boolean {
2074
+ try {
2075
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
2076
+ return isRecord(parsed) && parsed.__ttscRetryWithTtsx === true;
2077
+ } catch {
2078
+ return false;
2079
+ }
2080
+ }
2081
+
2082
+ function commonJsDescriptorProcessFailure(
2083
+ result: {
2084
+ error?: Error;
2085
+ signal: NodeJS.Signals | null;
2086
+ status: number | null;
2087
+ },
2088
+ request: string,
2089
+ ): Error | undefined {
2090
+ if (result.error !== undefined) {
2091
+ return new Error(
2092
+ `ttsc: failed to launch an isolated process for plugin descriptor "${request}": ${result.error.message}`,
2093
+ );
2094
+ }
2095
+ if (result.signal !== null) {
2096
+ return new Error(
2097
+ `ttsc: plugin descriptor "${request}" isolated evaluation was killed by signal ${result.signal}.`,
2098
+ );
2099
+ }
2100
+ if (result.status !== 0) {
2101
+ return new Error(
2102
+ `ttsc: plugin descriptor "${request}" isolated evaluation failed with exit code ${String(result.status)}`,
2103
+ );
2104
+ }
2105
+ return undefined;
2106
+ }
2107
+
694
2108
  const TS_SOURCE_PATTERN = /\.(?:[cm]?ts|tsx)$/i;
695
2109
 
696
2110
  /**
@@ -714,6 +2128,11 @@ export const PLUGIN_DESCRIPTOR_SHIM_SOURCE = [
714
2128
  // both — "Cannot find module ./missing" is as actionable as anything the
715
2129
  // factory could have said.
716
2130
  `try {`,
2131
+ // Runtime hooks are installed before this shim loads. Arm their internal
2132
+ // side channel only for the descriptor import itself, after this shim's own
2133
+ // imports have resolved, so ttsc implementation files never become project
2134
+ // cache inputs.
2135
+ ` process.env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE = "1";`,
717
2136
  ` const mod = await import(pathToFileURL(process.env.TTSC_PLUGIN_ENTRY).href);`,
718
2137
  ` const context = JSON.parse(process.env.TTSC_PLUGIN_CONTEXT);`,
719
2138
  ` const candidate = mod.createTtscPlugin ?? mod.default ?? mod.plugin ?? mod;`,
@@ -748,36 +2167,34 @@ function loadDescriptorViaTtsx(
748
2167
  request: string,
749
2168
  context: ITtscPluginFactoryContext,
750
2169
  effectiveEnv: NodeJS.ProcessEnv,
751
- ): unknown {
2170
+ ): IsolatedPluginDescriptor | undefined {
752
2171
  // Binary discovery prefers the instance environment, then the ambient
753
2172
  // process.env (where `withPluginLoaderEnv` injects ttsc's own node/ttsx paths
754
2173
  // just before this runs), then the running interpreter.
755
- const node =
756
- effectiveEnv.TTSC_NODE_BINARY ??
757
- process.env.TTSC_NODE_BINARY ??
758
- process.execPath;
2174
+ const node = resolveNodeBinary(effectiveEnv, context.projectRoot);
759
2175
  const ttsx = effectiveEnv.TTSC_TTSX_BINARY ?? process.env.TTSC_TTSX_BINARY;
760
- if (ttsx === undefined || ttsx.length === 0) {
2176
+ if (node === undefined || ttsx === undefined || ttsx.length === 0) {
761
2177
  return undefined;
762
2178
  }
763
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ttsc-plugin-descriptor-"));
2179
+ const dir = createEvaluationTempDir();
764
2180
  const out = path.join(dir, "descriptor.json");
2181
+ const inputsOut = path.join(dir, "descriptor-inputs.ndjson");
765
2182
  const shim = path.join(dir, "load-descriptor.mts");
766
2183
  // ttsx type-checks and builds the shim's own project, so it needs a tsconfig
767
2184
  // to anchor on; a minimal one is enough (the shim is `@ts-nocheck`).
768
- fs.writeFileSync(
769
- path.join(dir, "tsconfig.json"),
770
- JSON.stringify({
771
- compilerOptions: {
772
- module: "nodenext",
773
- moduleResolution: "nodenext",
774
- skipLibCheck: true,
775
- target: "es2022",
776
- },
777
- }),
778
- );
779
- fs.writeFileSync(shim, PLUGIN_DESCRIPTOR_SHIM_SOURCE);
780
2185
  try {
2186
+ fs.writeFileSync(
2187
+ path.join(dir, "tsconfig.json"),
2188
+ JSON.stringify({
2189
+ compilerOptions: {
2190
+ module: "nodenext",
2191
+ moduleResolution: "nodenext",
2192
+ skipLibCheck: true,
2193
+ target: "es2022",
2194
+ },
2195
+ }),
2196
+ );
2197
+ fs.writeFileSync(shim, PLUGIN_DESCRIPTOR_SHIM_SOURCE);
781
2198
  const result = childProcess.spawnSync(node, [ttsx, "--no-plugins", shim], {
782
2199
  cwd: context.projectRoot,
783
2200
  encoding: "utf8",
@@ -794,11 +2211,13 @@ function loadDescriptorViaTtsx(
794
2211
  dirname: context.dirname,
795
2212
  filename: context.filename,
796
2213
  plugin: context.plugin,
2214
+ pluginConfigDir: context.pluginConfigDir,
797
2215
  projectRoot: context.projectRoot,
798
2216
  tsconfig: context.tsconfig,
799
2217
  }),
800
2218
  TTSC_PLUGIN_DESCRIPTOR_LOAD: "1",
801
2219
  TTSC_PLUGIN_DESCRIPTOR_OUT: out,
2220
+ TTSC_PLUGIN_DESCRIPTOR_INPUTS_OUT: inputsOut,
802
2221
  TTSC_PLUGIN_ENTRY: request,
803
2222
  },
804
2223
  // Both child streams are human output, and they go straight to this
@@ -826,7 +2245,16 @@ ${reason}`);
826
2245
  }
827
2246
  const text = fs.readFileSync(out, "utf8");
828
2247
  try {
829
- return JSON.parse(text);
2248
+ const inputSnapshot = readTtsxDescriptorInputs(inputsOut, request);
2249
+ return {
2250
+ descriptor: JSON.parse(text),
2251
+ hostInputHashes: omitUnstableHostInputHashes(
2252
+ inputSnapshot.hostInputHashes,
2253
+ inputSnapshot.unstableInputs,
2254
+ ),
2255
+ hostInputRealpaths: inputSnapshot.hostInputRealpaths,
2256
+ inputs: inputSnapshot.inputs,
2257
+ };
830
2258
  } catch (error) {
831
2259
  throw new Error(
832
2260
  `ttsc: plugin descriptor "${request}" produced invalid JSON: ${errorMessage(error)}`,
@@ -837,6 +2265,140 @@ ${reason}`);
837
2265
  }
838
2266
  }
839
2267
 
2268
+ interface TtsxDescriptorResolutionRecord {
2269
+ hash?: string | null;
2270
+ parent?: string;
2271
+ realpath?: string | null;
2272
+ resolved?: string;
2273
+ signature?: string;
2274
+ specifier?: string;
2275
+ unstable?: boolean;
2276
+ }
2277
+
2278
+ /**
2279
+ * Expand the ttsx runtime's selected module edges into the same exact and
2280
+ * missing resolution inputs used by the direct isolated evaluator.
2281
+ */
2282
+ function readTtsxDescriptorInputs(
2283
+ file: string,
2284
+ request: string,
2285
+ ): {
2286
+ hostInputHashes: Record<string, string | null>;
2287
+ hostInputRealpaths: Record<string, string | null>;
2288
+ inputs: string[];
2289
+ unstableInputs: string[];
2290
+ } {
2291
+ const inputs = new Set<string>([path.resolve(request)]);
2292
+ const hashes = new Map<string, string | null>();
2293
+ const realpaths = new Map<string, string | null>();
2294
+ const signatures = new Map<string, string>();
2295
+ const unstableInputs = new Set<string>();
2296
+ let text: string;
2297
+ try {
2298
+ text = fs.readFileSync(file, "utf8");
2299
+ } catch {
2300
+ return {
2301
+ hostInputHashes: {},
2302
+ hostInputRealpaths: {},
2303
+ inputs: [...inputs],
2304
+ unstableInputs: [],
2305
+ };
2306
+ }
2307
+ for (const line of text.split(/\r?\n/)) {
2308
+ if (line.trim() === "") continue;
2309
+ let record: TtsxDescriptorResolutionRecord;
2310
+ try {
2311
+ record = JSON.parse(line) as TtsxDescriptorResolutionRecord;
2312
+ } catch {
2313
+ continue;
2314
+ }
2315
+ if (typeof record.resolved === "string") {
2316
+ const resolved = path.resolve(record.resolved);
2317
+ inputs.add(resolved);
2318
+ if (record.unstable === true) {
2319
+ hashes.delete(resolved);
2320
+ realpaths.delete(resolved);
2321
+ signatures.delete(resolved);
2322
+ unstableInputs.add(resolved);
2323
+ continue;
2324
+ }
2325
+ if (
2326
+ typeof record.signature !== "string" ||
2327
+ (signatures.has(resolved) &&
2328
+ signatures.get(resolved) !== record.signature)
2329
+ ) {
2330
+ hashes.delete(resolved);
2331
+ realpaths.delete(resolved);
2332
+ signatures.delete(resolved);
2333
+ unstableInputs.add(resolved);
2334
+ continue;
2335
+ }
2336
+ signatures.set(resolved, record.signature);
2337
+ if (
2338
+ (typeof record.realpath === "string" &&
2339
+ path.isAbsolute(record.realpath)) ||
2340
+ record.realpath === null
2341
+ ) {
2342
+ const observed =
2343
+ record.realpath === null ? null : path.resolve(record.realpath);
2344
+ if (realpaths.has(resolved) && realpaths.get(resolved) !== observed) {
2345
+ hashes.delete(resolved);
2346
+ realpaths.delete(resolved);
2347
+ signatures.delete(resolved);
2348
+ unstableInputs.add(resolved);
2349
+ continue;
2350
+ }
2351
+ realpaths.set(resolved, observed);
2352
+ }
2353
+ if (typeof record.hash === "string" || record.hash === null) {
2354
+ if (unstableInputs.has(resolved)) {
2355
+ // Keep a previously observed contradiction unstable.
2356
+ } else if (
2357
+ hashes.has(resolved) &&
2358
+ hashes.get(resolved) !== record.hash
2359
+ ) {
2360
+ hashes.delete(resolved);
2361
+ realpaths.delete(resolved);
2362
+ signatures.delete(resolved);
2363
+ unstableInputs.add(resolved);
2364
+ } else {
2365
+ hashes.set(resolved, record.hash);
2366
+ }
2367
+ }
2368
+ }
2369
+ if (
2370
+ typeof record.specifier === "string" &&
2371
+ typeof record.parent === "string"
2372
+ ) {
2373
+ for (const candidate of collectModuleResolutionCandidates(
2374
+ record.specifier,
2375
+ record.parent,
2376
+ record.resolved,
2377
+ )) {
2378
+ inputs.add(path.resolve(candidate));
2379
+ }
2380
+ }
2381
+ }
2382
+ for (const [resolved, observed] of realpaths) {
2383
+ if (
2384
+ realpathHostInput(resolved) === observed &&
2385
+ signatures.get(resolved) === hostInputMetadataSignature(resolved)
2386
+ ) {
2387
+ continue;
2388
+ }
2389
+ hashes.delete(resolved);
2390
+ realpaths.delete(resolved);
2391
+ signatures.delete(resolved);
2392
+ unstableInputs.add(resolved);
2393
+ }
2394
+ return {
2395
+ hostInputHashes: Object.fromEntries(hashes),
2396
+ hostInputRealpaths: Object.fromEntries(realpaths),
2397
+ inputs: [...inputs].sort(),
2398
+ unstableInputs: [...unstableInputs],
2399
+ };
2400
+ }
2401
+
840
2402
  function errorMessage(error: unknown): string {
841
2403
  return error instanceof Error ? error.message : String(error);
842
2404
  }
@@ -844,7 +2406,10 @@ function errorMessage(error: unknown): string {
844
2406
  function withPluginLoaderEnv<T>(run: () => T): T {
845
2407
  const previousNode = process.env.TTSC_NODE_BINARY;
846
2408
  const previousTtsx = process.env.TTSC_TTSX_BINARY;
847
- process.env.TTSC_NODE_BINARY ??= process.execPath;
2409
+ const node = resolveNodeBinary({}, process.cwd());
2410
+ if (process.env.TTSC_NODE_BINARY === undefined && node !== undefined) {
2411
+ process.env.TTSC_NODE_BINARY = node;
2412
+ }
848
2413
  process.env.TTSC_TTSX_BINARY ??= path.join(
849
2414
  __dirname,
850
2415
  "..",
@@ -860,6 +2425,47 @@ function withPluginLoaderEnv<T>(run: () => T): T {
860
2425
  }
861
2426
  }
862
2427
 
2428
+ /**
2429
+ * Select the executable for isolated descriptor evaluation.
2430
+ *
2431
+ * Under Bun, `process.execPath` names Bun itself. Bun implements enough of the
2432
+ * Node module surface to evaluate descriptors natively, but not Node's
2433
+ * synchronous `module.registerHooks` preload. The caller therefore recognizes
2434
+ * this default and omits the Node-only preload while retaining the fresh
2435
+ * process boundary. An explicitly configured Node executable remains
2436
+ * authoritative and receives the preload as usual.
2437
+ */
2438
+ function pluginDescriptorRuntimeBinary(env: NodeJS.ProcessEnv): string {
2439
+ if (
2440
+ env.TTSC_NODE_BINARY === undefined &&
2441
+ typeof (process.versions as Record<string, string | undefined>).bun ===
2442
+ "string"
2443
+ ) {
2444
+ return process.execPath;
2445
+ }
2446
+ return (
2447
+ env.TTSC_NODE_BINARY ?? process.env.TTSC_NODE_BINARY ?? process.execPath
2448
+ );
2449
+ }
2450
+
2451
+ /** Replay a child's human output without imposing a fixed output ceiling. */
2452
+ function replayEvaluationDiagnostics(file: string): void {
2453
+ let fd: number | undefined;
2454
+ try {
2455
+ fd = fs.openSync(file, "r");
2456
+ const buffer = Buffer.allocUnsafe(64 * 1024);
2457
+ for (;;) {
2458
+ const length = fs.readSync(fd, buffer, 0, buffer.length, null);
2459
+ if (length === 0) break;
2460
+ fs.writeSync(2, buffer, 0, length);
2461
+ }
2462
+ } catch {
2463
+ // Diagnostic replay must never replace the descriptor result.
2464
+ } finally {
2465
+ if (fd !== undefined) fs.closeSync(fd);
2466
+ }
2467
+ }
2468
+
863
2469
  function restoreEnv(
864
2470
  key: "TTSC_NODE_BINARY" | "TTSC_TTSX_BINARY",
865
2471
  value: string | undefined,
@@ -1181,7 +2787,7 @@ function resolvePluginExportCondition(
1181
2787
  return null;
1182
2788
  }
1183
2789
  const file = path.resolve(path.dirname(packageJson), resolved);
1184
- return fs.existsSync(file) ? resolveRealPath(file) : null;
2790
+ return existingFile(file) ? resolveRealPath(file) : null;
1185
2791
  }
1186
2792
 
1187
2793
  /**
@@ -1413,6 +3019,14 @@ function readTsgoVersion(projectRoot: string): string {
1413
3019
  }
1414
3020
  }
1415
3021
 
3022
+ /**
3023
+ * Create an evaluator directory whose cleanup path cannot follow a retargeted
3024
+ * parent alias.
3025
+ */
3026
+ function createEvaluationTempDir(): string {
3027
+ return createCanonicalTempDirectory("ttsc-plugin-descriptor-");
3028
+ }
3029
+
1416
3030
  /**
1417
3031
  * Remove an evaluation temp directory without letting cleanup replace a result.
1418
3032
  *