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
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * package surface remains small and stable.
8
8
  */
9
9
 
10
+ export * from "./plugin/resolveCapabilityPlugins";
10
11
  export * from "./TtscCompiler";
11
12
  export * from "./TtscService";
12
13
  export * from "./structures/index";
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export interface CanonicalTempDirectoryOperations {
6
+ lstat(location: string): { isDirectory(): boolean };
7
+ mkdtemp(prefix: string): string;
8
+ realpath(location: string): string;
9
+ }
10
+
11
+ const FILESYSTEM_OPERATIONS: CanonicalTempDirectoryOperations = {
12
+ lstat: fs.lstatSync,
13
+ mkdtemp: fs.mkdtempSync,
14
+ realpath: fs.realpathSync.native,
15
+ };
16
+
17
+ /**
18
+ * Create a unique temporary directory under a frozen physical parent.
19
+ *
20
+ * The parent is resolved before creation so retargeting the caller's
21
+ * TEMP/TMPDIR alias cannot redirect later writes or recursive cleanup. The
22
+ * postflight accepts only a real directory that resolves to a direct child of
23
+ * that same parent; a rejected child is deliberately left untouched because no
24
+ * safe ownership identity was established.
25
+ */
26
+ export function createCanonicalTempDirectory(
27
+ prefix: string,
28
+ parent: string = os.tmpdir(),
29
+ operations: CanonicalTempDirectoryOperations = FILESYSTEM_OPERATIONS,
30
+ ): string {
31
+ if (prefix.length === 0 || path.basename(prefix) !== prefix) {
32
+ throw new TypeError("ttsc: temporary directory prefix must be a basename");
33
+ }
34
+ const physicalParent = operations.realpath(path.resolve(parent));
35
+ if (!operations.lstat(physicalParent).isDirectory()) {
36
+ throw new Error(
37
+ `ttsc: temporary directory parent is not a directory: ${physicalParent}`,
38
+ );
39
+ }
40
+ const directory = operations.mkdtemp(path.join(physicalParent, prefix));
41
+ if (!operations.lstat(directory).isDirectory()) {
42
+ throw new Error(
43
+ `ttsc: temporary directory postflight is not a directory: ${directory}`,
44
+ );
45
+ }
46
+ const physicalDirectory = operations.realpath(directory);
47
+ if (path.dirname(physicalDirectory) !== physicalParent) {
48
+ throw new Error(
49
+ `ttsc: temporary directory escaped its physical parent: ${physicalDirectory}`,
50
+ );
51
+ }
52
+ return physicalDirectory;
53
+ }
@@ -11,7 +11,9 @@ export type FilesystemPathIdentity = {
11
11
 
12
12
  export type FilesystemPathIdentityOperations = {
13
13
  caseSensitive(directory: string): boolean;
14
+ lstat?(location: string): fs.Stats | fs.BigIntStats;
14
15
  platform: NodeJS.Platform;
16
+ readdir?(directory: string): string[];
15
17
  realpath(location: string): string;
16
18
  throwOnRealpathError: boolean;
17
19
  };
@@ -53,10 +55,16 @@ export function createFilesystemPathIdentityContext(
53
55
  const pathApi = platform === "win32" ? path.win32 : path;
54
56
  const throwOnRealpathError = operations.throwOnRealpathError ?? true;
55
57
  const realpath = operations.realpath ?? physicalRealpath;
58
+ const lstat = operations.lstat ?? fs.lstatSync;
59
+ const readdir = operations.readdir ?? fs.readdirSync;
56
60
  const caseSensitive =
57
61
  operations.caseSensitive ??
58
62
  ((directory: string) =>
59
- filesystemDirectoryIsCaseSensitive(directory, platform));
63
+ filesystemDirectoryIsCaseSensitive(directory, platform, {
64
+ lstat,
65
+ readdir,
66
+ realpath,
67
+ }));
60
68
 
61
69
  const resolve = (location: string): FilesystemPathIdentity => {
62
70
  const normalized = resolveFilesystemPath(location, platform);
@@ -210,10 +218,15 @@ function physicalRealpath(location: string): string {
210
218
  function filesystemDirectoryIsCaseSensitive(
211
219
  directory: string,
212
220
  platform: NodeJS.Platform,
221
+ operations: {
222
+ lstat(location: string): fs.Stats | fs.BigIntStats;
223
+ readdir(directory: string): string[];
224
+ realpath(location: string): string;
225
+ },
213
226
  ): boolean {
214
227
  let entries: string[];
215
228
  try {
216
- entries = fs.readdirSync(directory);
229
+ entries = operations.readdir(directory);
217
230
  } catch {
218
231
  return unprovenCaseSensitivity(platform);
219
232
  }
@@ -229,7 +242,7 @@ function filesystemDirectoryIsCaseSensitive(
229
242
  const alternate = alternateCase(name);
230
243
  if (alternate === name) continue;
231
244
  try {
232
- fs.lstatSync(path.join(directory, alternate));
245
+ operations.lstat(path.join(directory, alternate));
233
246
  return false;
234
247
  } catch (error) {
235
248
  if (isMissingFilesystemEntry(error)) {
@@ -254,7 +267,7 @@ function filesystemDirectoryIsCaseSensitive(
254
267
  const alternate = alternateCase(name);
255
268
  if (alternate !== name) {
256
269
  try {
257
- physicalRealpath(path.join(parent, alternate));
270
+ operations.realpath(path.join(parent, alternate));
258
271
  return false;
259
272
  } catch (error) {
260
273
  if (isMissingFilesystemEntry(error) === false) throw error;
@@ -0,0 +1,173 @@
1
+ import childProcess from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export type JavaScriptRuntimeCapabilities = {
6
+ bun: boolean;
7
+ executable?: string;
8
+ registerHooks: boolean;
9
+ };
10
+
11
+ type RuntimeCapabilityCacheEntry = {
12
+ capabilities: JavaScriptRuntimeCapabilities;
13
+ identity: string;
14
+ };
15
+
16
+ const runtimeCapabilityCache = new Map<string, RuntimeCapabilityCacheEntry>();
17
+
18
+ /** Probe an interpreter instead of inferring its identity from the host. */
19
+ export function javascriptRuntimeCapabilities(
20
+ runtime: string,
21
+ env: NodeJS.ProcessEnv,
22
+ cwd: string,
23
+ ): JavaScriptRuntimeCapabilities {
24
+ const effectiveEnv = { ...process.env, ...env };
25
+ const cacheKey = runtimeCapabilityCacheKey(runtime, effectiveEnv);
26
+ const beforeIdentity = runtimeExecutableIdentity(runtime);
27
+ if (cacheKey !== undefined && beforeIdentity !== undefined) {
28
+ const cached = runtimeCapabilityCache.get(cacheKey);
29
+ if (cached?.identity === beforeIdentity) return { ...cached.capabilities };
30
+ runtimeCapabilityCache.delete(cacheKey);
31
+ }
32
+ const result = childProcess.spawnSync(
33
+ runtime,
34
+ [
35
+ "-e",
36
+ `const Module = require("node:module"); process.stdout.write(JSON.stringify({ bun: typeof globalThis.Bun === "object", executable: process.execPath, registerHooks: typeof Module.registerHooks === "function" }));`,
37
+ ],
38
+ {
39
+ cwd,
40
+ encoding: "utf8",
41
+ env: effectiveEnv,
42
+ timeout: 30_000,
43
+ windowsHide: true,
44
+ },
45
+ );
46
+ let capabilities: JavaScriptRuntimeCapabilities = {
47
+ bun: false,
48
+ registerHooks: false,
49
+ };
50
+ if (result.status === 0) {
51
+ try {
52
+ const parsed = JSON.parse(
53
+ result.stdout,
54
+ ) as Partial<JavaScriptRuntimeCapabilities>;
55
+ capabilities = {
56
+ bun: parsed.bun === true,
57
+ ...(typeof parsed.executable === "string" &&
58
+ path.isAbsolute(parsed.executable)
59
+ ? { executable: path.resolve(parsed.executable) }
60
+ : {}),
61
+ registerHooks: parsed.registerHooks === true,
62
+ };
63
+ } catch {
64
+ // An incompatible executable is not a Node runtime candidate.
65
+ }
66
+ }
67
+ // Cache successful absolute candidates only when the child reports that same
68
+ // executable and both its lexical link and physical target retain the same
69
+ // filesystem identity. Relative/bare commands and wrappers can resolve
70
+ // differently by cwd/PATH/environment, negative probes can become valid later,
71
+ // and NODE_OPTIONS can load mutable user code, so none of those states are
72
+ // memoized. This removes a process spawn from the common path without
73
+ // authorizing a replaced or redirected runtime in a long-lived host.
74
+ const afterIdentity = runtimeExecutableIdentity(runtime);
75
+ if (
76
+ cacheKey !== undefined &&
77
+ capabilities.executable !== undefined &&
78
+ beforeIdentity !== undefined &&
79
+ beforeIdentity === afterIdentity &&
80
+ sameRuntimeExecutable(runtime, capabilities.executable)
81
+ ) {
82
+ runtimeCapabilityCache.set(cacheKey, {
83
+ capabilities: { ...capabilities },
84
+ identity: afterIdentity,
85
+ });
86
+ }
87
+ return capabilities;
88
+ }
89
+
90
+ /** True when a probe ran the candidate itself rather than a mutable wrapper. */
91
+ function sameRuntimeExecutable(candidate: string, executable: string): boolean {
92
+ try {
93
+ const candidateStats = fs.statSync(candidate, { bigint: true });
94
+ const executableStats = fs.statSync(executable, { bigint: true });
95
+ return (
96
+ (candidateStats.ino !== 0n &&
97
+ candidateStats.dev === executableStats.dev &&
98
+ candidateStats.ino === executableStats.ino) ||
99
+ fs.realpathSync.native(candidate) === fs.realpathSync.native(executable)
100
+ );
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /** Stable cache key only for an absolute runtime with no preload authority. */
107
+ function runtimeCapabilityCacheKey(
108
+ runtime: string,
109
+ env: NodeJS.ProcessEnv,
110
+ ): string | undefined {
111
+ if (!path.isAbsolute(runtime) || env.NODE_OPTIONS?.trim()) return undefined;
112
+ return path.resolve(runtime);
113
+ }
114
+
115
+ /** Identity of an executable spelling and the physical file it selects. */
116
+ function runtimeExecutableIdentity(runtime: string): string | undefined {
117
+ if (!path.isAbsolute(runtime)) return undefined;
118
+ try {
119
+ const lexical = fs.lstatSync(runtime, { bigint: true });
120
+ const physicalPath = fs.realpathSync.native(runtime);
121
+ const physical = fs.statSync(physicalPath, { bigint: true });
122
+ return [
123
+ physicalPath,
124
+ lexical.dev,
125
+ lexical.ino,
126
+ lexical.mode,
127
+ lexical.size,
128
+ lexical.mtimeNs,
129
+ lexical.ctimeNs,
130
+ physical.dev,
131
+ physical.ino,
132
+ physical.mode,
133
+ physical.size,
134
+ physical.mtimeNs,
135
+ physical.ctimeNs,
136
+ ].join("\0");
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Locate a real Node runtime for ttsx and native JavaScript config loaders.
144
+ *
145
+ * Bun can directly evaluate a descriptor, but it does not implement the
146
+ * synchronous `module.registerHooks` contract used by those loaders.
147
+ */
148
+ export function resolveNodeBinary(
149
+ env: NodeJS.ProcessEnv = process.env,
150
+ cwd: string = process.cwd(),
151
+ ): string | undefined {
152
+ const candidates = [
153
+ env.TTSC_NODE_BINARY,
154
+ process.env.TTSC_NODE_BINARY,
155
+ process.execPath,
156
+ "node",
157
+ ];
158
+ const seen = new Set<string>();
159
+ for (const candidate of candidates) {
160
+ if (candidate === undefined || candidate.trim() === "") continue;
161
+ if (seen.has(candidate)) continue;
162
+ seen.add(candidate);
163
+ const capabilities = javascriptRuntimeCapabilities(candidate, env, cwd);
164
+ if (
165
+ !capabilities.bun &&
166
+ capabilities.registerHooks &&
167
+ capabilities.executable !== undefined
168
+ ) {
169
+ return capabilities.executable;
170
+ }
171
+ }
172
+ return undefined;
173
+ }
@@ -0,0 +1,85 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ type FilesystemPathIdentityOperations,
6
+ createFilesystemPathIdentityContext,
7
+ isFilesystemPathIdentityWithin,
8
+ resolveFilesystemPath,
9
+ } from "./projectInputPathIdentity";
10
+
11
+ export interface SafeCacheCleanupTarget {
12
+ exists: boolean;
13
+ path: string;
14
+ requestedPath: string;
15
+ }
16
+
17
+ /**
18
+ * Resolve one cache-clean transaction to physical deletion targets.
19
+ *
20
+ * Every target is proved before the caller removes the first one. Existing
21
+ * aliases are pinned to their physical spelling, while a missing suffix keeps
22
+ * the identity of its nearest existing parent. Identity errors other than a
23
+ * genuinely missing path fail closed.
24
+ */
25
+ export function resolveSafeCacheCleanupTargets(
26
+ projectRoot: string,
27
+ cacheDirectories: readonly string[],
28
+ operations: Partial<FilesystemPathIdentityOperations> = {},
29
+ ): SafeCacheCleanupTarget[] {
30
+ const identities = createFilesystemPathIdentityContext(operations);
31
+ const lstat = operations.lstat ?? fs.lstatSync;
32
+ const project = identities.resolve(projectRoot);
33
+ return cacheDirectories.map((cacheDirectory) => {
34
+ const requestedCache = resolveFilesystemPath(cacheDirectory);
35
+ // Fix every mutable alias ancestor before inspecting the terminal entry.
36
+ // All later identity checks and deletion paths use this physical-parent
37
+ // candidate, so a concurrent retarget cannot move ownership mid-proof.
38
+ const pinnedCache = path.join(
39
+ identities.resolve(path.dirname(requestedCache)).path,
40
+ path.basename(requestedCache),
41
+ );
42
+ const cache = identities.resolve(pinnedCache);
43
+ if (
44
+ requestedCache === path.parse(requestedCache).root ||
45
+ cache.path === path.parse(cache.path).root
46
+ ) {
47
+ throw new Error(
48
+ `ttsc: refusing to clean cache directory ${JSON.stringify(requestedCache)} because filesystem roots are never valid cache directories`,
49
+ );
50
+ }
51
+ if (isFilesystemPathIdentityWithin(cache.key, project.key)) {
52
+ throw new Error(
53
+ `ttsc: refusing to clean cache directory ${JSON.stringify(requestedCache)} because it equals or contains project root ${JSON.stringify(project.path)}; choose a dedicated cache directory`,
54
+ );
55
+ }
56
+ const status = lstatIfPresent(pinnedCache, lstat);
57
+ // Recursive rm removes a terminal symlink or junction itself rather than
58
+ // following it. Preserve that behavior while pinning any mutable alias in
59
+ // its ancestors to the physical parent selected by this transaction.
60
+ const deletionPath = status?.isSymbolicLink() ? pinnedCache : cache.path;
61
+ return {
62
+ exists: status !== undefined,
63
+ path: deletionPath,
64
+ requestedPath: requestedCache,
65
+ };
66
+ });
67
+ }
68
+
69
+ function lstatIfPresent(
70
+ location: string,
71
+ lstat: NonNullable<FilesystemPathIdentityOperations["lstat"]>,
72
+ ): fs.Stats | fs.BigIntStats | undefined {
73
+ try {
74
+ return lstat(location);
75
+ } catch (error) {
76
+ if (
77
+ error instanceof Error &&
78
+ "code" in error &&
79
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
80
+ ) {
81
+ return undefined;
82
+ }
83
+ throw error;
84
+ }
85
+ }
@@ -10,8 +10,6 @@ import { createFilesystemPathIdentityContext } from "../../internal/projectInput
10
10
  import type { TtscCommonOptions } from "../../structures/internal/TtscCommonOptions";
11
11
  import { type OwningModuleOptions, projectModuleOptions } from "./runtimeHooks";
12
12
 
13
- /** Subdirectory name that isolates concurrent ttsx processes by PID. */
14
- const PROCESS_CACHE_KEY = String(process.pid);
15
13
  /**
16
14
  * Maximum number of ancestor directories above the project root that the
17
15
  * virtual filesystem overlay mirrors. Three levels covers the common monorepo
@@ -32,12 +30,15 @@ export function prepareExecution(
32
30
  options: TtscCommonOptions & {
33
31
  cacheDir?: string;
34
32
  project?: string;
33
+ /** Internal cache key for more than one checked entry in this process. */
34
+ runtimeCacheKey?: string;
35
35
  } = {},
36
36
  ): {
37
37
  cleanupDir: string;
38
38
  emitDir: string;
39
39
  emittedFiles?: readonly string[];
40
40
  entryFile: string;
41
+ entrySource: string;
41
42
  moduleOptions: OwningModuleOptions;
42
43
  projectRoot: string;
43
44
  rootDir: string;
@@ -74,6 +75,7 @@ export function prepareExecution(
74
75
  emitDir: context.emitDir,
75
76
  emittedFiles: context.emittedFiles ?? undefined,
76
77
  entryFile: emittedEntry,
78
+ entrySource: entry,
77
79
  moduleOptions: context.moduleOptions,
78
80
  projectRoot: context.root,
79
81
  rootDir: context.runtimeRootDir,
@@ -147,6 +149,19 @@ function resolveEntrySpelling(cwd: string, entryFile: string): string {
147
149
  return identities.resolve(path.resolve(cwd, entryFile)).path;
148
150
  }
149
151
 
152
+ /**
153
+ * Directory-safe identity for one prepared runtime. Direct `ttsx` retains its
154
+ * historical PID directory; the public preload supplies a distinct key for
155
+ * every late TypeScript root so one preparation cannot erase another's emit.
156
+ */
157
+ function resolveRuntimeCacheKey(runtimeCacheKey: string | undefined): string {
158
+ const key = runtimeCacheKey ?? String(process.pid);
159
+ if (!/^[A-Za-z0-9._-]+$/.test(key) || key === "." || key === "..") {
160
+ throw new Error(`ttsx: invalid runtime cache key ${JSON.stringify(key)}`);
161
+ }
162
+ return key;
163
+ }
164
+
150
165
  /**
151
166
  * @param discoveryFile - The entry as the user named it. Project discovery
152
167
  * walks up from here, so it must not be retargeted through a symlink.
@@ -168,21 +183,30 @@ function createProjectContext(
168
183
  const tsconfig = project.path;
169
184
  const root = project.root;
170
185
  const explicitCacheDir = resolveCacheDir(cwd, options.cacheDir);
171
- const cacheDir =
186
+ const cacheDirSpelling =
172
187
  explicitCacheDir ??
173
188
  path.join(root, "node_modules", ".cache", "ttsc", "ttsx");
174
- const processDir = path.join(cacheDir, "project", PROCESS_CACHE_KEY);
175
- const virtualRoot = path.join(processDir, "fs");
189
+ const runtimeCacheKey = resolveRuntimeCacheKey(options.runtimeCacheKey);
176
190
  // Resolved once: it now costs a realpath (and, for a missing directory on
177
191
  // Windows, a case-sensitivity probe) rather than a string join.
178
192
  const runtimeRootDir = resolveRuntimeSourceRoot(project);
193
+ fs.mkdirSync(cacheDirSpelling, { recursive: true });
194
+ // Pin the cache parent before deriving a generation path. Descriptors run
195
+ // after this point and may retarget a caller-controlled symlink or junction;
196
+ // every later runtime write and recursive cleanup must remain below the
197
+ // physical parent selected here.
198
+ const cacheDir =
199
+ createFilesystemPathIdentityContext().resolve(cacheDirSpelling).path;
200
+ const processDir = path.join(cacheDir, "project", runtimeCacheKey);
201
+ const virtualRoot = path.join(processDir, "fs");
179
202
  return {
180
203
  project,
181
204
  tsconfig,
182
205
  root,
183
206
  cacheDir,
207
+ runtimeCacheKey,
184
208
  processDir,
185
- pluginCacheDir: explicitCacheDir,
209
+ pluginCacheDir: explicitCacheDir === undefined ? undefined : cacheDir,
186
210
  virtualRoot,
187
211
  emitDir: project.compilerOptions.outDir
188
212
  ? virtualPath(virtualRoot, project.compilerOptions.outDir)
@@ -361,7 +385,7 @@ function buildEntryProject(
361
385
  const rootDir = commonAncestorDirectory(path.dirname(entry), context.root);
362
386
  const tsconfig = path.join(
363
387
  context.root,
364
- `.ttsx-entry.${PROCESS_CACHE_KEY}.tsconfig.json`,
388
+ `.ttsx-entry.${context.runtimeCacheKey}.tsconfig.json`,
365
389
  );
366
390
  fs.writeFileSync(
367
391
  tsconfig,
@@ -430,8 +454,8 @@ function buildEntryProject(
430
454
  fs.rmSync(tsconfig, { force: true });
431
455
  } catch {
432
456
  // Best effort: a leftover synthesized tsconfig must not mask a build
433
- // failure, and it is PID-scoped so it can never be mistaken for a real
434
- // project config.
457
+ // failure, and it is runtime-scoped so it can never be mistaken for a
458
+ // real project config.
435
459
  }
436
460
  }
437
461
  }
@@ -575,6 +599,9 @@ function isDirectorySymlinkTarget(realEntry: string): boolean {
575
599
  */
576
600
  function collectLinkDirectories(projectRoot: string): string[] {
577
601
  const out: string[] = [];
602
+ const identities = createFilesystemPathIdentityContext({
603
+ throwOnRealpathError: false,
604
+ });
578
605
  let current = projectRoot;
579
606
  for (let depth = 0; depth <= MAX_VIRTUAL_PARENT_DEPTH; depth += 1) {
580
607
  out.push(current);
@@ -586,7 +613,7 @@ function collectLinkDirectories(projectRoot: string): string[] {
586
613
  break;
587
614
  }
588
615
  const parent = path.dirname(current);
589
- if (parent === current || isUnsafeVirtualParent(parent)) {
616
+ if (parent === current || isUnsafeVirtualParent(parent, identities)) {
590
617
  break;
591
618
  }
592
619
  current = parent;
@@ -594,10 +621,19 @@ function collectLinkDirectories(projectRoot: string): string[] {
594
621
  return out.reverse();
595
622
  }
596
623
 
597
- function isUnsafeVirtualParent(directory: string): boolean {
598
- const resolved = path.resolve(directory);
599
- const root = path.parse(resolved).root;
600
- return resolved === root || resolved === path.resolve(os.tmpdir());
624
+ /**
625
+ * Whether mirroring `directory` would reach a filesystem or temporary root.
626
+ * Identity comparison is required on Windows, where `os.tmpdir()` can carry an
627
+ * 8.3 component while a project created below it is returned in long spelling.
628
+ */
629
+ function isUnsafeVirtualParent(
630
+ directory: string,
631
+ identities: ReturnType<typeof createFilesystemPathIdentityContext>,
632
+ ): boolean {
633
+ const resolved = identities.resolve(directory);
634
+ const root = identities.resolve(path.parse(resolved.path).root);
635
+ const temporaryRoot = identities.resolve(os.tmpdir());
636
+ return resolved.key === root.key || resolved.key === temporaryRoot.key;
601
637
  }
602
638
 
603
639
  /**
@@ -17,7 +17,7 @@ import {
17
17
  parseFlags,
18
18
  } from "../../flags/parser";
19
19
  import { resolveFlagSpec } from "../../flags/schema";
20
- import { assertSafeExplicitCacheDirectory } from "../../internal/assertSafeExplicitCacheDirectory";
20
+ import { resolveSafeCacheCleanupTargets } from "../../internal/resolveSafeCacheCleanupTargets";
21
21
  import {
22
22
  isPathWithin,
23
23
  legacyGlobalCacheTargets,
@@ -207,9 +207,6 @@ function runClean(argv: readonly string[]): number {
207
207
  const explicitCacheDir = options.cacheDir
208
208
  ? path.resolve(cwd, options.cacheDir)
209
209
  : undefined;
210
- if (explicitCacheDir !== undefined) {
211
- assertSafeExplicitCacheDirectory(projectRoot, explicitCacheDir);
212
- }
213
210
  const targets = explicitCacheDir
214
211
  ? // Explicit `ttsc clean --cache-dir X`: the user names X as the cache to
215
212
  // remove for this command, so remove it wholesale plus the legacy
@@ -223,11 +220,19 @@ function runClean(argv: readonly string[]): number {
223
220
  // possibly-shared root is never deleted) plus the pre-0.17 machine-global
224
221
  // cache so upgraders reclaim that disk.
225
222
  [...resolveCleanTargets(projectRoot), ...legacyGlobalCacheTargets()];
223
+ // Check the complete deletion set before removing the first directory. An
224
+ // environment-selected TTSC_GO_CACHE_DIR and a legacy-global cache can be as
225
+ // destructive as an explicit --cache-dir when either equals or contains the
226
+ // project through its lexical spelling or a filesystem alias.
227
+ const safeTargets = resolveSafeCacheCleanupTargets(projectRoot, targets);
226
228
  const removed: string[] = [];
227
- for (const target of targets) {
228
- if (!fs.existsSync(target)) continue;
229
- fs.rmSync(target, { recursive: true, force: true });
230
- removed.push(target);
229
+ const visited = new Set<string>();
230
+ for (const target of safeTargets) {
231
+ if (visited.has(target.path)) continue;
232
+ visited.add(target.path);
233
+ if (!target.exists || !fs.existsSync(target.path)) continue;
234
+ fs.rmSync(target.path, { recursive: true, force: true });
235
+ removed.push(target.requestedPath);
231
236
  }
232
237
  if (removed.length === 0) {
233
238
  process.stdout.write(
@@ -269,24 +274,28 @@ function runCachePaths(argv: readonly string[]): number {
269
274
  const cacheDir = resolveCacheDir(cwd, options.cacheDir);
270
275
  const projectRoot = resolveCleanProjectRoot(cwd, options.tsconfig);
271
276
  const paths = resolveSourceBuildCachePaths(projectRoot, cacheDir);
272
- // The minimal directory set a CI cache step must persist. The Go build cache
273
- // is folded in only when it lives outside the ttsc cache root (an explicit
274
- // GOCACHE / TTSC_GO_CACHE_DIR); by default it nests under `root`.
277
+ // Legacy combined roots retained for JSON compatibility. `requiredRoots`
278
+ // identifies the compiled binaries that skip cold builds;
279
+ // `acceleratorRoots` separately identifies the optional Go object cache.
275
280
  const cacheableRoots = [
276
281
  paths.root,
277
282
  ...(isPathWithin(paths.goBuildRoot, paths.root) ? [] : [paths.goBuildRoot]),
278
283
  ];
284
+ const requiredRoots = [paths.pluginRoot];
285
+ const acceleratorRoots = [paths.goBuildRoot];
279
286
  if (options.json) {
280
287
  process.stdout.write(
281
288
  `${JSON.stringify(
282
289
  {
283
290
  cacheRoot: paths.root,
291
+ acceleratorRoots,
284
292
  cacheableRoots,
285
293
  cwd,
286
294
  goBuildCacheRoot: paths.goBuildRoot,
287
295
  goBuildCacheSource: paths.goBuildRootSource,
288
296
  pluginCacheRoot: paths.pluginRoot,
289
297
  projectRoot,
298
+ requiredRoots,
290
299
  },
291
300
  null,
292
301
  2,
@@ -301,8 +310,10 @@ function runCachePaths(argv: readonly string[]): number {
301
310
  ` plugin binaries ${paths.pluginRoot}`,
302
311
  ` go build cache ${paths.goBuildRoot} (${paths.goBuildRootSource})`,
303
312
  "",
304
- "Persist these between CI jobs to skip cold plugin rebuilds:",
305
- ...cacheableRoots.map((root) => ` ${root}`),
313
+ "Persist this required cache to skip cold plugin rebuilds:",
314
+ ...requiredRoots.map((root) => ` ${root}`),
315
+ "Optionally persist this Go object accelerator for future cache misses:",
316
+ ...acceleratorRoots.map((root) => ` ${root}`),
306
317
  "",
307
318
  ].join("\n"),
308
319
  );