ttsc 0.26.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +10 -1
  2. package/cmd/ttsc/api_transform.go +17 -3
  3. package/cmd/ttscgraph/auxiliary_identity_state_tracks_retargets_not_source_contents_test.go +85 -0
  4. package/cmd/ttscgraph/disk_realpath_other.go +16 -0
  5. package/cmd/ttscgraph/disk_realpath_windows.go +56 -0
  6. package/cmd/ttscgraph/serve.go +104 -35
  7. package/driver/diagnostics_preamble.go +284 -0
  8. package/driver/emit_plugin.go +71 -19
  9. package/driver/graph.go +64 -11
  10. package/driver/input_observation.go +273 -0
  11. package/driver/input_observation_test.go +149 -0
  12. package/driver/plugins.go +384 -4
  13. package/driver/program.go +81 -15
  14. package/driver/resolution_candidates.go +13 -5
  15. package/driver/rewrite.go +47 -4
  16. package/internal/graph/diagnostics.go +4 -3
  17. package/internal/graph/provenance.go +10 -1
  18. package/lib/TtscCompiler.d.ts +12 -11
  19. package/lib/TtscCompiler.js +44 -29
  20. package/lib/TtscCompiler.js.map +1 -1
  21. package/lib/compiler/internal/captureProcessOutput.js +2 -2
  22. package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
  23. package/lib/compiler/internal/compileProjectInMemory.js +11 -3
  24. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  25. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -0
  26. package/lib/compiler/internal/resolveEmittedJavaScript.js +3 -0
  27. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  28. package/lib/compiler/internal/runBuild.d.ts +29 -1
  29. package/lib/compiler/internal/runBuild.js +117 -32
  30. package/lib/compiler/internal/runBuild.js.map +1 -1
  31. package/lib/compiler/internal/runSingleFileEmit.js +9 -2
  32. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  33. package/lib/compiler/internal/sharedHostHelpers.d.ts +28 -0
  34. package/lib/compiler/internal/sharedHostHelpers.js +39 -0
  35. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  36. package/lib/compiler/internal/startResidentTransform.js +12 -4
  37. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  38. package/lib/compiler/internal/transformProjectInMemory.d.ts +3 -0
  39. package/lib/compiler/internal/transformProjectInMemory.js +239 -35
  40. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  41. package/lib/flags/schema.js +33 -5
  42. package/lib/flags/schema.js.map +1 -1
  43. package/lib/internal/createCanonicalTempDirectory.d.ts +17 -0
  44. package/lib/internal/createCanonicalTempDirectory.js +42 -0
  45. package/lib/internal/createCanonicalTempDirectory.js.map +1 -0
  46. package/lib/internal/projectInputPathIdentity.d.ts +3 -0
  47. package/lib/internal/projectInputPathIdentity.js +11 -5
  48. package/lib/internal/projectInputPathIdentity.js.map +1 -1
  49. package/lib/internal/resolveNodeBinary.d.ts +14 -0
  50. package/lib/internal/resolveNodeBinary.js +150 -0
  51. package/lib/internal/resolveNodeBinary.js.map +1 -0
  52. package/lib/internal/resolveSafeCacheCleanupTargets.d.ts +15 -0
  53. package/lib/internal/resolveSafeCacheCleanupTargets.js +61 -0
  54. package/lib/internal/resolveSafeCacheCleanupTargets.js.map +1 -0
  55. package/lib/launcher/internal/assertNoSolutionBuild.d.ts +31 -0
  56. package/lib/launcher/internal/assertNoSolutionBuild.js +38 -0
  57. package/lib/launcher/internal/assertNoSolutionBuild.js.map +1 -0
  58. package/lib/launcher/internal/prepareExecution.d.ts +3 -0
  59. package/lib/launcher/internal/prepareExecution.js +73 -26
  60. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  61. package/lib/launcher/internal/runTtsc.js +27 -13
  62. package/lib/launcher/internal/runTtsc.js.map +1 -1
  63. package/lib/launcher/internal/runTtscserver.js +18 -3
  64. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  65. package/lib/launcher/internal/runTtsx.js +4 -0
  66. package/lib/launcher/internal/runTtsx.js.map +1 -1
  67. package/lib/launcher/internal/runtimeHooks.d.ts +37 -1
  68. package/lib/launcher/internal/runtimeHooks.js +660 -62
  69. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  70. package/lib/plugin/internal/buildSourcePlugin.d.ts +62 -8
  71. package/lib/plugin/internal/buildSourcePlugin.js +754 -72
  72. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  73. package/lib/plugin/internal/loadProjectPlugins.d.ts +17 -5
  74. package/lib/plugin/internal/loadProjectPlugins.js +1339 -77
  75. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  76. package/lib/register.d.ts +1 -0
  77. package/lib/register.js +46 -0
  78. package/lib/register.js.map +1 -0
  79. package/lib/structures/ITtscCompilerContext.d.ts +6 -3
  80. package/lib/structures/ITtscCompilerTransformation.d.ts +31 -0
  81. package/lib/structures/ITtscPlugin.d.ts +33 -0
  82. package/package.json +16 -9
  83. package/shim/checker/go.mod +9 -0
  84. package/shim/checker/go.sum +22 -0
  85. package/shim/checker/shim.go +75 -0
  86. package/shim/compiler/emit_sourcemap.go +56 -15
  87. package/shim/compiler/incremental.go +45 -8
  88. package/src/TtscCompiler.ts +55 -33
  89. package/src/compiler/internal/captureProcessOutput.ts +3 -2
  90. package/src/compiler/internal/compileProjectInMemory.ts +11 -3
  91. package/src/compiler/internal/resolveEmittedJavaScript.ts +6 -0
  92. package/src/compiler/internal/runBuild.ts +172 -36
  93. package/src/compiler/internal/runSingleFileEmit.ts +9 -2
  94. package/src/compiler/internal/sharedHostHelpers.ts +44 -0
  95. package/src/compiler/internal/startResidentTransform.ts +12 -3
  96. package/src/compiler/internal/transformProjectInMemory.ts +410 -39
  97. package/src/flags/schema.ts +35 -5
  98. package/src/internal/createCanonicalTempDirectory.ts +53 -0
  99. package/src/internal/projectInputPathIdentity.ts +17 -4
  100. package/src/internal/resolveNodeBinary.ts +173 -0
  101. package/src/internal/resolveSafeCacheCleanupTargets.ts +85 -0
  102. package/src/launcher/internal/assertNoSolutionBuild.ts +40 -0
  103. package/src/launcher/internal/prepareExecution.ts +78 -26
  104. package/src/launcher/internal/runTtsc.ts +26 -13
  105. package/src/launcher/internal/runTtscserver.ts +18 -3
  106. package/src/launcher/internal/runTtsx.ts +4 -0
  107. package/src/launcher/internal/runtimeHooks.ts +830 -66
  108. package/src/plugin/internal/buildSourcePlugin.ts +984 -79
  109. package/src/plugin/internal/loadProjectPlugins.ts +1718 -104
  110. package/src/register.ts +47 -0
  111. package/src/structures/ITtscCompilerContext.ts +6 -3
  112. package/src/structures/ITtscCompilerTransformation.ts +39 -0
  113. package/src/structures/ITtscPlugin.ts +36 -0
  114. package/tools/shim_audit/baseline.json +0 -1
  115. package/utility/host.go +9 -3
  116. package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +0 -5
  117. package/lib/internal/assertSafeExplicitCacheDirectory.js +0 -26
  118. package/lib/internal/assertSafeExplicitCacheDirectory.js.map +0 -1
  119. package/src/internal/assertSafeExplicitCacheDirectory.ts +0 -29
@@ -219,6 +219,29 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
219
219
  description: "Print the launcher version and exit.",
220
220
  },
221
221
 
222
+ // -------------------------------------------------------------------------
223
+ // Solution build mode — declared so the launcher can refuse it in its own
224
+ // voice instead of forwarding it.
225
+ //
226
+ // `createTsgoBuildArgs` opens the forwarded argv with `-p <tsconfig>` because
227
+ // ttsc resolves the project itself (extends chains, plugin config discovery,
228
+ // cache keys, resident session identity) and pins the result. A forwarded
229
+ // `--build` therefore always lands after `-p`, and tsgo answers with TS6369
230
+ // "Option '--build' must be the first command line argument" — a diagnostic
231
+ // that contradicts the command line the user actually typed. ttsc's plugin,
232
+ // cache, and emit architecture is built around one resolved project, so
233
+ // solution mode is unsupported rather than merely misordered.
234
+ // -------------------------------------------------------------------------
235
+ {
236
+ name: "--build",
237
+ aliases: ["-b"],
238
+ kind: "boolean",
239
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
240
+ consumedBy: ["launcher"],
241
+ description:
242
+ "Refused by the launcher: ttsc pins one resolved project, so tsgo's solution-build mode is unsupported.",
243
+ },
244
+
222
245
  // -------------------------------------------------------------------------
223
246
  // Project location: shared by every subcommand.
224
247
  // -------------------------------------------------------------------------
@@ -612,10 +635,17 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
612
635
  },
613
636
 
614
637
  // -------------------------------------------------------------------------
615
- // `--tsgo-args` — JSON-encoded passthrough envelope the launcher emits on
616
- // the way down to native sidecars. The sidecars decode it back into the
617
- // tsgo argv. Listed here so the schema describes every flag the Go layers
618
- // accept, not just the user-facing ones.
638
+ // `--tsgo-args` — JSON-encoded passthrough envelope a native sidecar decodes
639
+ // back into the tsgo argv. Listed here so the schema describes every flag
640
+ // the Go layers accept, not just the user-facing ones.
641
+ //
642
+ // The launcher no longer emits it. A CLI flag is fatal to any host whose
643
+ // `flag.FlagSet` does not declare it, and this one was added to a plugin
644
+ // protocol third-party hosts had already frozen, so every forwarded compiler
645
+ // flag exited 2 on a typia/nestia-shaped sidecar (issue #1188). The payload
646
+ // now rides the `TTSC_TSGO_ARGS` environment variable, which an unaware host
647
+ // simply ignores. ttsc's own hosts still accept the flag so an older
648
+ // launcher, or an embedder that composes sidecar argv itself, keeps working.
619
649
  // -------------------------------------------------------------------------
620
650
  {
621
651
  name: "--tsgo-args",
@@ -631,7 +661,7 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
631
661
  ],
632
662
  consumedBy: ["host", "lint"],
633
663
  description:
634
- "JSON-encoded tsgo passthrough argv (internal: emitted by runBuild).",
664
+ "JSON-encoded tsgo passthrough argv (internal: accepted by ttsc's own hosts; the launcher forwards it in TTSC_TSGO_ARGS).",
635
665
  },
636
666
  {
637
667
  name: "--plugins-json",
@@ -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
+ }
@@ -0,0 +1,40 @@
1
+ import { type ParseResult } from "../../flags/parser";
2
+
3
+ /**
4
+ * Refuse tsgo's solution-build mode (`--build` / `-b`) in the launcher's own
5
+ * voice, for whichever launcher parsed the arguments.
6
+ *
7
+ * Every ttsc build lane hands tsgo an argument list that opens with the project
8
+ * ttsc resolved (`-p <tsconfig>` in `createTsgoBuildArgs`), because ttsc — not
9
+ * tsgo — owns the `extends` chain, plugin config discovery, cache keys, and the
10
+ * resident session's identity, and pins that one resolved project instead of
11
+ * forwarding raw argv. A forwarded `--build` therefore always arrives after
12
+ * `-p`, and tsgo replies "Option '--build' must be the first command line
13
+ * argument" even though the user wrote it first. No spelling of the command
14
+ * line can satisfy that diagnostic, so the flag is rejected here rather than
15
+ * forwarded into an error that blames the user's argument order.
16
+ *
17
+ * Both launchers refuse it, because both reach tsgo through the same pinned
18
+ * project. `ttsx` additionally builds one entry inside that project, so a mode
19
+ * whose whole purpose is compiling many projects has nothing to mean there
20
+ * either.
21
+ *
22
+ * Presence — not the parsed boolean — is what is refused: `--build false` still
23
+ * asks for a mode ttsc does not implement, and silently consuming it would drop
24
+ * the flag with no diagnostic at all.
25
+ *
26
+ * What this deliberately does not reach is a `--build` that belongs to the
27
+ * program `ttsx` is running. `runTtsx` parses with
28
+ * `forwardAfterFirstPositional` and `honorDoubleDashSeparator`, so a token
29
+ * after the entry lands in `result.tail` and never in `result.values`; reading
30
+ * `values` is what keeps `ttsx script.ts --build` the user program's own flag.
31
+ */
32
+ export function assertNoSolutionBuild(
33
+ result: ParseResult,
34
+ errorPrefix: string,
35
+ ): void {
36
+ if (!result.values.has("--build")) return;
37
+ throw new Error(
38
+ `${errorPrefix} --build (solution mode) is not supported; ttsc resolves and pins one project per run, so compile each referenced project with its own ttsc -p <tsconfig> run`,
39
+ );
40
+ }