ttsc 0.16.11 → 0.17.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.
@@ -1,4 +1,8 @@
1
- import { spawnSync } from "node:child_process";
1
+ import {
2
+ type SpawnSyncOptionsWithStringEncoding,
3
+ type SpawnSyncReturns,
4
+ spawnSync,
5
+ } from "node:child_process";
2
6
  import crypto from "node:crypto";
3
7
  import fs from "node:fs";
4
8
  import { createRequire } from "node:module";
@@ -100,15 +104,36 @@ const CONTRIB_DIRNAME = "contrib";
100
104
  // runtimeHooks.ts.
101
105
  const PLUGIN_BUILD_LOCK_STEAL_MS = 600_000;
102
106
  const PLUGIN_BUILD_LOCK_POLL_MS = 50;
103
- const GLOBAL_CACHE_DIRNAME = "ttsc";
107
+ // The default cache lives INSIDE the workspace, at
108
+ // `<workspaceRoot>/node_modules/.cache/ttsc`, so `rm -rf node_modules` (or
109
+ // deleting the repo) reclaims every compiled plugin binary and Go object file.
110
+ // This is the `find-cache-dir` convention (Babel, webpack, ESLint, Nuxt, …): a
111
+ // disposable build cache under `node_modules/.cache/<tool>`. ttsc keeps NO
112
+ // global (`~/.cache`) cache — a machine-wide cache silently grew to hundreds of
113
+ // GB across tsgo/plugin version bumps, so it was removed outright. See
114
+ // resolveSourceBuildCacheRoot for the (override → workspace-local) priority.
115
+ const NODE_MODULES_DIRNAME = "node_modules";
116
+ const LOCAL_CACHE_PARENT_DIRNAME = ".cache";
117
+ const TTSC_CACHE_DIRNAME = "ttsc";
104
118
  const PLUGIN_CACHE_DIRNAME = "plugins";
119
+ const GO_BUILD_CACHE_DIRNAME = "go-build";
120
+ // Directories whose presence marks a monorepo/workspace root, so every package
121
+ // in the workspace shares ONE cache and a plugin builds once, not once per
122
+ // package. `package.json` with a `workspaces` field (yarn/npm/bun) is checked
123
+ // separately in isWorkspaceRootDir.
124
+ const WORKSPACE_ROOT_MARKER_FILES: readonly string[] = ["pnpm-workspace.yaml"];
105
125
  const CACHE_LAST_USED_FILE = ".last-used";
106
126
  const CACHE_GC_MARKER_FILE = ".gc-last-run";
107
- const GLOBAL_CACHE_GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
108
- const GLOBAL_CACHE_ENTRY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
109
- const GLOBAL_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
110
- const GLOBAL_CACHE_TARGET_BYTES = Math.floor(GLOBAL_CACHE_MAX_BYTES * 0.8);
111
- const GLOBAL_CACHE_PROTECTED_AGE_MS = 60 * 60 * 1000;
127
+ // The plugin binary cache is content-keyed, so a project that bumps tsgo/typia
128
+ // many times leaves one stale entry per superseded key. An opportunistic GC
129
+ // (once/day) evicts entries unused for 30 days and, past a 2 GB ceiling, the
130
+ // least-recently-used down to 80%. It is scoped to the resolved cache root only
131
+ // — ttsc never scans a shared or global location.
132
+ const PLUGIN_CACHE_GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
133
+ const PLUGIN_CACHE_ENTRY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
134
+ const PLUGIN_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
135
+ const PLUGIN_CACHE_TARGET_BYTES = Math.floor(PLUGIN_CACHE_MAX_BYTES * 0.8);
136
+ const PLUGIN_CACHE_PROTECTED_AGE_MS = 60 * 60 * 1000;
112
137
 
113
138
  /** One contributor's resolved Go source plus its target sub-package name. */
114
139
  export interface ITtscBuildContributor {
@@ -118,6 +143,18 @@ export interface ITtscBuildContributor {
118
143
  source: string;
119
144
  }
120
145
 
146
+ /** Source-plugin cache locations resolved for one ttsc invocation. */
147
+ export interface ITtscSourceBuildCachePaths {
148
+ /** Root directory containing all ttsc-owned source build caches. */
149
+ root: string;
150
+ /** Directory containing content-addressed compiled plugin binaries. */
151
+ pluginRoot: string;
152
+ /** Directory passed to Go as `GOCACHE` for source-plugin builds. */
153
+ goBuildRoot: string;
154
+ /** How `goBuildRoot` was selected. */
155
+ goBuildRootSource: "ttsc-cache" | "TTSC_GO_CACHE_DIR" | "GOCACHE";
156
+ }
157
+
121
158
  /** Build one Go source plugin into a cached executable. */
122
159
  export function buildSourcePlugin(opts: {
123
160
  source: string;
@@ -145,8 +182,9 @@ export function buildSourcePlugin(opts: {
145
182
  ttscVersion: opts.ttscVersion,
146
183
  tsgoVersion: opts.tsgoVersion,
147
184
  });
148
- const root = resolvePluginCacheRoot(opts.baseDir, opts.cacheDir);
149
- const cacheDir = path.join(root, key);
185
+ const paths = resolveSourceBuildCachePaths(opts.baseDir, opts.cacheDir);
186
+ maybePrunePluginCache(paths, opts.cacheDir);
187
+ const cacheDir = path.join(paths.pluginRoot, key);
150
188
  const binaryName = process.platform === "win32" ? "plugin.exe" : "plugin";
151
189
  const binaryPath = path.join(cacheDir, binaryName);
152
190
  if (fs.existsSync(binaryPath)) {
@@ -164,6 +202,7 @@ export function buildSourcePlugin(opts: {
164
202
  goBinary,
165
203
  key,
166
204
  label: opts.label ?? "source plugin",
205
+ goBuildCacheRoot: paths.goBuildRoot,
167
206
  overlayDirs,
168
207
  pluginName: opts.pluginName,
169
208
  quiet: opts.quiet === true,
@@ -180,6 +219,7 @@ function compileSourcePlugin(opts: {
180
219
  dir: string;
181
220
  entry: string;
182
221
  goBinary: string;
222
+ goBuildCacheRoot: string;
183
223
  key: string;
184
224
  label: string;
185
225
  overlayDirs: readonly string[];
@@ -223,6 +263,7 @@ function compileSourcePlugin(opts: {
223
263
  scratchBinaryName,
224
264
  opts.pluginName,
225
265
  opts.goBinary,
266
+ opts.goBuildCacheRoot,
226
267
  );
227
268
  const builtBinary = path.join(scratchDir, scratchBinaryName);
228
269
  publishBuiltBinary(builtBinary, opts.binaryPath);
@@ -678,7 +719,7 @@ function readGoModInfo(
678
719
  return emptyGoModInfo();
679
720
  }
680
721
 
681
- const result = spawnSync(goBinary, ["mod", "edit", "-json"], {
722
+ const result = spawnGoTool(goBinary, ["mod", "edit", "-json"], {
682
723
  cwd: dir,
683
724
  encoding: "utf8",
684
725
  env: goBuildEnv(goBinary),
@@ -765,12 +806,13 @@ function runGoBuild(
765
806
  binaryName: string,
766
807
  pluginName: string,
767
808
  goBinary: string,
809
+ goBuildCacheRoot: string,
768
810
  ): void {
769
811
  ensureExecutableGoToolchain(goBinary);
770
- const result = spawnSync(goBinary, ["build", "-o", binaryName, entry], {
812
+ const result = spawnGoTool(goBinary, ["build", "-o", binaryName, entry], {
771
813
  cwd,
772
814
  encoding: "utf8",
773
- env: goBuildEnv(goBinary),
815
+ env: goBuildEnv(goBinary, goBuildCacheRoot),
774
816
  maxBuffer: 1024 * 1024 * 64,
775
817
  windowsHide: true,
776
818
  });
@@ -797,9 +839,39 @@ function goToolchainNotFoundMessage(pluginName: string): string {
797
839
  );
798
840
  }
799
841
 
800
- function goBuildEnv(goBinary: string): NodeJS.ProcessEnv {
842
+ function spawnGoTool(
843
+ goBinary: string,
844
+ args: readonly string[],
845
+ options: SpawnSyncOptionsWithStringEncoding,
846
+ ): SpawnSyncReturns<string> {
847
+ const shell = shouldSpawnGoToolThroughShell(goBinary);
848
+ // Under shell:true (a Windows .cmd/.bat wrapper) Node does not quote the
849
+ // command, so a wrapper at a path containing a space would be split by cmd.
850
+ // Quote it here; the go arguments ttsc passes never contain spaces, and
851
+ // quoting a space-free path is harmless.
852
+ return spawnSync(shell ? `"${goBinary}"` : goBinary, [...args], {
853
+ ...options,
854
+ shell,
855
+ });
856
+ }
857
+
858
+ function shouldSpawnGoToolThroughShell(goBinary: string): boolean {
859
+ return process.platform === "win32" && /\.(?:bat|cmd)$/i.test(goBinary);
860
+ }
861
+
862
+ function goBuildEnv(
863
+ goBinary: string,
864
+ goBuildCacheRoot?: string,
865
+ ): NodeJS.ProcessEnv {
801
866
  const env = { ...process.env };
802
867
  env.GOWORK = "auto";
868
+ // Only the actual `go build` needs ttsc's GOCACHE; read-only metadata spawns
869
+ // (`go mod edit`, `go env`, `go version`) call goBuildEnv with no cache root
870
+ // and inherit the ambient GOCACHE, which they never write to anyway. GOCACHE
871
+ // is not part of the plugin cache key, so this cannot affect it.
872
+ if (goBuildCacheRoot) {
873
+ env.GOCACHE = goBuildCacheRoot;
874
+ }
803
875
  const goRoot = inferGoRoot(goBinary);
804
876
  if (goRoot && !env.GOROOT) {
805
877
  env.GOROOT = goRoot;
@@ -893,87 +965,267 @@ function walkForGoMod(dir: string, out: string[]): void {
893
965
  /**
894
966
  * Resolve the directory where compiled plugin binaries are cached.
895
967
  *
896
- * Priority order:
897
- *
898
- * 1. `cacheDir` option (resolved relative to `projectRoot`).
899
- * 2. `TTSC_CACHE_DIR` environment variable.
900
- * 3. Platform-specific global user cache (XDG / AppData / Library/Caches).
901
- *
902
- * When falling back to the global cache, a GC pass is triggered
903
- * opportunistically to evict old or oversized entries before the cache is
904
- * used.
968
+ * Delegates to {@link resolveSourceBuildCachePaths}; kept as a thin accessor for
969
+ * callers (and tests) that only need the plugin-binary root. Triggers the
970
+ * opportunistic project-cache GC as a side effect for the default location.
905
971
  */
906
972
  export function resolvePluginCacheRoot(
907
973
  projectRoot: string,
908
974
  cacheDir?: string,
975
+ ): string {
976
+ const paths = resolveSourceBuildCachePaths(projectRoot, cacheDir);
977
+ maybePrunePluginCache(paths, cacheDir);
978
+ return paths.pluginRoot;
979
+ }
980
+
981
+ /**
982
+ * Resolve all source-plugin build cache directories for one invocation.
983
+ *
984
+ * `pluginRoot` stores compiled plugin binaries; `goBuildRoot` is the Go object
985
+ * cache passed as `GOCACHE` while ttsc builds those binaries. Both live under a
986
+ * single `root`, so persisting one directory covers the whole source-build
987
+ * cache without depending on ttsc internals.
988
+ */
989
+ export function resolveSourceBuildCachePaths(
990
+ projectRoot: string,
991
+ cacheDir?: string,
992
+ env: NodeJS.ProcessEnv = process.env,
993
+ ): ITtscSourceBuildCachePaths {
994
+ const root = resolveSourceBuildCacheRoot(projectRoot, cacheDir, env);
995
+ const goBuild = resolveGoBuildCacheRoot(root, projectRoot, env);
996
+ return {
997
+ root,
998
+ pluginRoot: path.join(root, PLUGIN_CACHE_DIRNAME),
999
+ goBuildRoot: goBuild.root,
1000
+ goBuildRootSource: goBuild.source,
1001
+ };
1002
+ }
1003
+
1004
+ /**
1005
+ * Resolve the cache root for one invocation.
1006
+ *
1007
+ * Priority:
1008
+ *
1009
+ * 1. Explicit `cacheDir` option (resolved relative to `projectRoot`);
1010
+ * 2. `TTSC_CACHE_DIR` environment variable (resolved absolute);
1011
+ * 3. `<workspaceRoot>/node_modules/.cache/ttsc` — project-local by default.
1012
+ *
1013
+ * There is deliberately NO global (`~/.cache`) fallback: the cache is scoped to
1014
+ * the workspace so it can never accumulate machine-wide, and `rm -rf
1015
+ * node_modules` reclaims it.
1016
+ */
1017
+ function resolveSourceBuildCacheRoot(
1018
+ projectRoot: string,
1019
+ cacheDir: string | undefined,
1020
+ env: NodeJS.ProcessEnv,
909
1021
  ): string {
910
1022
  if (cacheDir) {
911
- return path.resolve(projectRoot, cacheDir, PLUGIN_CACHE_DIRNAME);
1023
+ return path.resolve(projectRoot, cacheDir);
912
1024
  }
913
- if (process.env.TTSC_CACHE_DIR) {
914
- return path.resolve(process.env.TTSC_CACHE_DIR, PLUGIN_CACHE_DIRNAME);
1025
+ if (env.TTSC_CACHE_DIR) {
1026
+ // Anchor a relative TTSC_CACHE_DIR to the project root (not the process
1027
+ // cwd) so a programmatic host whose cwd differs from the project still
1028
+ // resolves — and later cleans — the same cache. Absolute values pass
1029
+ // through path.resolve unchanged.
1030
+ return path.resolve(projectRoot, env.TTSC_CACHE_DIR);
915
1031
  }
916
- const root = resolveGlobalPluginCacheRoot();
917
- maybePruneGlobalPluginCache(root);
918
- return root;
1032
+ return path.join(
1033
+ resolveWorkspaceRoot(projectRoot),
1034
+ NODE_MODULES_DIRNAME,
1035
+ LOCAL_CACHE_PARENT_DIRNAME,
1036
+ TTSC_CACHE_DIRNAME,
1037
+ );
919
1038
  }
920
1039
 
921
1040
  /**
922
- * Return the absolute path to the global plugin binary cache root.
1041
+ * Resolve the monorepo/workspace root for `projectRoot` so every package shares
1042
+ * one cache and a plugin builds once per workspace, not once per package.
1043
+ *
1044
+ * Walks up from `projectRoot` and returns, in order of preference: the NEAREST
1045
+ * ancestor that is a workspace root (holds `pnpm-workspace.yaml`, or a
1046
+ * `package.json` with a `workspaces` field); else the nearest ancestor that
1047
+ * already contains a `node_modules` directory; else `projectRoot` itself.
923
1048
  *
924
- * The path follows platform conventions (XDG on Linux, AppData on Windows,
925
- * Library/Caches on macOS) and does not depend on project-local config. Used by
926
- * `ttsc cache clean` to enumerate cache locations.
1049
+ * Nearest (not highest) so an unrelated ancestor that happens to declare
1050
+ * `workspaces` — for example a `package.json` in the user's home directory —
1051
+ * cannot pull the cache above the project's real monorepo root.
927
1052
  */
928
- export function resolveGlobalPluginCacheRoot(): string {
929
- return path.join(
930
- resolveUserCacheRoot(),
931
- GLOBAL_CACHE_DIRNAME,
932
- PLUGIN_CACHE_DIRNAME,
933
- );
1053
+ function resolveWorkspaceRoot(projectRoot: string): string {
1054
+ let dir = path.resolve(projectRoot);
1055
+ let nearestNodeModulesOwner: string | null = null;
1056
+ for (;;) {
1057
+ if (isWorkspaceRootDir(dir)) {
1058
+ return dir;
1059
+ }
1060
+ if (
1061
+ nearestNodeModulesOwner === null &&
1062
+ fs.existsSync(path.join(dir, NODE_MODULES_DIRNAME))
1063
+ ) {
1064
+ nearestNodeModulesOwner = dir;
1065
+ }
1066
+ const parent = path.dirname(dir);
1067
+ if (parent === dir) {
1068
+ break;
1069
+ }
1070
+ dir = parent;
1071
+ }
1072
+ return nearestNodeModulesOwner ?? path.resolve(projectRoot);
1073
+ }
1074
+
1075
+ function isWorkspaceRootDir(dir: string): boolean {
1076
+ for (const marker of WORKSPACE_ROOT_MARKER_FILES) {
1077
+ if (fs.existsSync(path.join(dir, marker))) {
1078
+ return true;
1079
+ }
1080
+ }
1081
+ return packageJsonDeclaresWorkspaces(path.join(dir, "package.json"));
1082
+ }
1083
+
1084
+ function packageJsonDeclaresWorkspaces(packageJsonPath: string): boolean {
1085
+ let text: string;
1086
+ try {
1087
+ text = fs.readFileSync(packageJsonPath, "utf8");
1088
+ } catch {
1089
+ return false;
1090
+ }
1091
+ try {
1092
+ const workspaces = (JSON.parse(text) as { workspaces?: unknown })
1093
+ .workspaces;
1094
+ // A real workspace root declares a NON-EMPTY package list (an array, or an
1095
+ // object with a `packages` array). Ignore `false`, `[]`, `null`, or `{}` so
1096
+ // a disabled or empty declaration on an unrelated ancestor cannot hijack
1097
+ // the cache location.
1098
+ if (Array.isArray(workspaces)) {
1099
+ return workspaces.length > 0;
1100
+ }
1101
+ if (workspaces !== null && typeof workspaces === "object") {
1102
+ const packages = (workspaces as { packages?: unknown }).packages;
1103
+ return Array.isArray(packages) && packages.length > 0;
1104
+ }
1105
+ return false;
1106
+ } catch {
1107
+ return false;
1108
+ }
1109
+ }
1110
+
1111
+ function resolveGoBuildCacheRoot(
1112
+ root: string,
1113
+ projectRoot: string,
1114
+ env: NodeJS.ProcessEnv,
1115
+ ): {
1116
+ root: string;
1117
+ source: ITtscSourceBuildCachePaths["goBuildRootSource"];
1118
+ } {
1119
+ if (env.TTSC_GO_CACHE_DIR) {
1120
+ // Anchor a relative TTSC_GO_CACHE_DIR to the project root, matching
1121
+ // TTSC_CACHE_DIR, so the build and a later `clean` from a different cwd
1122
+ // agree on the directory. Absolute values pass through unchanged.
1123
+ return {
1124
+ root: path.resolve(projectRoot, env.TTSC_GO_CACHE_DIR),
1125
+ source: "TTSC_GO_CACHE_DIR",
1126
+ };
1127
+ }
1128
+ if (env.GOCACHE && env.GOCACHE.length > 0) {
1129
+ return {
1130
+ root: env.GOCACHE,
1131
+ source: "GOCACHE",
1132
+ };
1133
+ }
1134
+ return {
1135
+ root: path.join(root, GO_BUILD_CACHE_DIRNAME),
1136
+ source: "ttsc-cache",
1137
+ };
1138
+ }
1139
+
1140
+ function maybePrunePluginCache(
1141
+ paths: ITtscSourceBuildCachePaths,
1142
+ cacheDir?: string,
1143
+ ): void {
1144
+ // GC only the default (workspace-local) location. When the user pins an
1145
+ // explicit `cacheDir`/`TTSC_CACHE_DIR`, they own its lifetime, so ttsc must
1146
+ // not delete entries out from under them.
1147
+ if (!cacheDir && !process.env.TTSC_CACHE_DIR) {
1148
+ prunePluginCacheRoot(paths.pluginRoot);
1149
+ }
934
1150
  }
935
1151
 
936
1152
  /**
937
- * Return all directories that `ttsc cache clean` should wipe for a project.
1153
+ * Return every directory `ttsc clean` should remove for `projectRoot`.
938
1154
  *
939
- * Covers three locations where plugin binaries may accumulate: the global user
940
- * cache, the project-local `node_modules/.ttsc` cache (legacy), and the
941
- * project-local `.ttsc` cache.
1155
+ * Covers the resolved cache root (which holds `plugins/` and, when ttsc-owned,
1156
+ * `go-build/`), a ttsc-owned Go build cache that lives OUTSIDE that root
1157
+ * (`TTSC_GO_CACHE_DIR`), and the two legacy project-local caches. A
1158
+ * user-provided `GOCACHE` is never removed. Pure over `env`, so the CLI passes
1159
+ * `process.env` and a programmatic caller can pass an injected environment.
942
1160
  */
943
- export function defaultPluginCacheCleanTargets(projectRoot: string): string[] {
944
- return [
945
- resolveGlobalPluginCacheRoot(),
946
- path.join(projectRoot, "node_modules", ".ttsc"),
947
- path.join(projectRoot, ".ttsc"),
948
- ];
1161
+ export function resolveCleanTargets(
1162
+ projectRoot: string,
1163
+ cacheDir?: string,
1164
+ env: NodeJS.ProcessEnv = process.env,
1165
+ ): string[] {
1166
+ const paths = resolveSourceBuildCachePaths(projectRoot, cacheDir, env);
1167
+ // Remove ttsc-OWNED directories only, never the parent cache root.
1168
+ const targets = [paths.pluginRoot];
1169
+ // ttsc's nested `<root>/go-build` is only safe to delete when we are certain
1170
+ // the root belongs to ttsc: the default `node_modules/.cache/ttsc`, or a root
1171
+ // the user explicitly named `ttsc`. Under a shared root (e.g.
1172
+ // `TTSC_CACHE_DIR=~/.cache`) a bare `<root>/go-build` could be the user's
1173
+ // machine-wide GOCACHE, so it must never be removed by name.
1174
+ const isTtscOwnedRoot =
1175
+ (!cacheDir && !env.TTSC_CACHE_DIR) ||
1176
+ path.basename(paths.root) === TTSC_CACHE_DIRNAME;
1177
+ if (isTtscOwnedRoot) {
1178
+ targets.push(path.join(paths.root, GO_BUILD_CACHE_DIRNAME));
1179
+ }
1180
+ // An explicit TTSC_GO_CACHE_DIR is a ttsc-dedicated external cache; a
1181
+ // user-provided GOCACHE (source "GOCACHE") is never removed.
1182
+ if (paths.goBuildRootSource === "TTSC_GO_CACHE_DIR") {
1183
+ targets.push(paths.goBuildRoot);
1184
+ }
1185
+ targets.push(path.join(projectRoot, NODE_MODULES_DIRNAME, ".ttsc"));
1186
+ targets.push(path.join(projectRoot, ".ttsc"));
1187
+ return targets;
949
1188
  }
950
1189
 
951
- function resolveUserCacheRoot(): string {
1190
+ /**
1191
+ * Machine-global cache directories created by pre-0.17 ttsc releases (XDG /
1192
+ * AppData / Library / `~/.cache`). ttsc no longer writes to any of these, but
1193
+ * an upgraded machine can still hold a multi-GB orphaned cache here, so `ttsc
1194
+ * clean` offers them for removal to reclaim that disk. Each entry is the whole
1195
+ * `<userCacheRoot>/ttsc` directory (both its `plugins` and `go-build`), which
1196
+ * was entirely ttsc-owned in those releases and is safe to remove.
1197
+ */
1198
+ export function legacyGlobalCacheTargets(): string[] {
1199
+ const roots = new Set<string>();
1200
+ const home = os.homedir();
952
1201
  const xdg = process.env.XDG_CACHE_HOME;
953
1202
  if (xdg && path.isAbsolute(xdg)) {
954
- return xdg;
1203
+ roots.add(path.join(xdg, TTSC_CACHE_DIRNAME));
955
1204
  }
956
1205
  if (process.platform === "win32") {
957
1206
  const local = process.env.LOCALAPPDATA;
958
1207
  if (local && path.isAbsolute(local)) {
959
- return local;
1208
+ roots.add(path.join(local, TTSC_CACHE_DIRNAME));
960
1209
  }
961
- const home = os.homedir();
962
- if (home) {
963
- return path.join(home, "AppData", "Local");
964
- }
965
- }
966
- if (process.platform === "darwin") {
967
- const home = os.homedir();
968
1210
  if (home) {
969
- return path.join(home, "Library", "Caches");
1211
+ roots.add(path.join(home, "AppData", "Local", TTSC_CACHE_DIRNAME));
970
1212
  }
1213
+ } else if (process.platform === "darwin" && home) {
1214
+ roots.add(path.join(home, "Library", "Caches", TTSC_CACHE_DIRNAME));
971
1215
  }
972
- const home = os.homedir();
973
1216
  if (home) {
974
- return path.join(home, ".cache");
1217
+ roots.add(path.join(home, ".cache", TTSC_CACHE_DIRNAME));
975
1218
  }
976
- return path.join(os.tmpdir(), "ttsc-cache");
1219
+ return [...roots];
1220
+ }
1221
+
1222
+ /** Report whether `child` equals `parent` or is nested beneath it. */
1223
+ export function isPathWithin(child: string, parent: string): boolean {
1224
+ const rel = path.relative(parent, child);
1225
+ return (
1226
+ rel === "" ||
1227
+ (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel))
1228
+ );
977
1229
  }
978
1230
 
979
1231
  function resolveGoCompiler(): string {
@@ -1186,7 +1438,7 @@ function computeGoCompilerIdentity(goBinary: string, resolved: string): string {
1186
1438
  if (!fs.existsSync(resolved)) {
1187
1439
  return "missing";
1188
1440
  }
1189
- const version = spawnSync(goBinary, ["version"], {
1441
+ const version = spawnGoTool(goBinary, ["version"], {
1190
1442
  encoding: "utf8",
1191
1443
  windowsHide: true,
1192
1444
  });
@@ -1212,15 +1464,30 @@ function resolveExecutableIdentityPath(binary: string): string {
1212
1464
  return resolveRealPath(candidate);
1213
1465
  }
1214
1466
  if (process.platform === "win32") {
1215
- const executable = `${candidate}.exe`;
1216
- if (fs.existsSync(executable)) {
1217
- return resolveRealPath(executable);
1467
+ // Probe every PATHEXT extension, not just `.exe`, so a compiler backed by
1468
+ // a `.cmd`/`.bat` wrapper resolves to its real file and is hashed into the
1469
+ // cache key. Otherwise the wrapper reads as "missing" and a change to it
1470
+ // would not invalidate the cached plugin binary.
1471
+ for (const ext of windowsExecutableExtensions()) {
1472
+ const executable = `${candidate}${ext}`;
1473
+ if (fs.existsSync(executable)) {
1474
+ return resolveRealPath(executable);
1475
+ }
1218
1476
  }
1219
1477
  }
1220
1478
  }
1221
1479
  return binary;
1222
1480
  }
1223
1481
 
1482
+ function windowsExecutableExtensions(): readonly string[] {
1483
+ const pathext = process.env.PATHEXT;
1484
+ const raw = pathext && pathext.length > 0 ? pathext : ".COM;.EXE;.BAT;.CMD";
1485
+ return raw
1486
+ .split(";")
1487
+ .map((ext) => ext.trim().toLowerCase())
1488
+ .filter((ext) => ext.length > 0);
1489
+ }
1490
+
1224
1491
  function readPathEnvironment(): string {
1225
1492
  return process.env.PATH ?? process.env.Path ?? "";
1226
1493
  }
@@ -1259,13 +1526,17 @@ function resolveGoBuildEnvironment(
1259
1526
  ): Map<string, string> {
1260
1527
  const values = new Map<string, string>();
1261
1528
  if (goBinary !== undefined) {
1262
- const result = spawnSync(goBinary, ["env", "-json", ...GO_BUILD_ENV_KEYS], {
1263
- cwd,
1264
- encoding: "utf8",
1265
- env: goBuildEnv(goBinary),
1266
- maxBuffer: 1024 * 1024,
1267
- windowsHide: true,
1268
- });
1529
+ const result = spawnGoTool(
1530
+ goBinary,
1531
+ ["env", "-json", ...GO_BUILD_ENV_KEYS],
1532
+ {
1533
+ cwd,
1534
+ encoding: "utf8",
1535
+ env: goBuildEnv(goBinary),
1536
+ maxBuffer: 1024 * 1024,
1537
+ windowsHide: true,
1538
+ },
1539
+ );
1269
1540
  if (result.error === undefined && result.status === 0) {
1270
1541
  try {
1271
1542
  const parsed = JSON.parse(result.stdout) as Record<string, unknown>;
@@ -1430,41 +1701,41 @@ function touchCacheEntry(cacheDir: string): void {
1430
1701
  }
1431
1702
  }
1432
1703
 
1433
- function maybePruneGlobalPluginCache(root: string): void {
1704
+ function prunePluginCacheRoot(root: string): void {
1434
1705
  try {
1435
1706
  fs.mkdirSync(root, { recursive: true });
1436
1707
  const marker = path.join(root, CACHE_GC_MARKER_FILE);
1437
1708
  const now = Date.now();
1438
1709
  const lastRun = readTimestamp(marker);
1439
- if (lastRun !== null && now - lastRun < GLOBAL_CACHE_GC_INTERVAL_MS) {
1710
+ if (lastRun !== null && now - lastRun < PLUGIN_CACHE_GC_INTERVAL_MS) {
1440
1711
  return;
1441
1712
  }
1442
1713
  fs.writeFileSync(marker, `${now}\n`);
1443
- pruneGlobalPluginCache(root, now);
1714
+ prunePluginCacheEntries(root, now);
1444
1715
  } catch {
1445
- // Global cache GC is opportunistic; builds still proceed when it fails.
1716
+ // Plugin-cache GC is opportunistic; builds still proceed when it fails.
1446
1717
  }
1447
1718
  }
1448
1719
 
1449
- function pruneGlobalPluginCache(root: string, now: number): void {
1450
- const entries = collectGlobalCacheEntries(root, now);
1720
+ function prunePluginCacheEntries(root: string, now: number): void {
1721
+ const entries = collectPluginCacheEntries(root, now);
1451
1722
  for (const entry of entries) {
1452
- if (now - entry.lastUsedAt <= GLOBAL_CACHE_ENTRY_MAX_AGE_MS) {
1723
+ if (now - entry.lastUsedAt <= PLUGIN_CACHE_ENTRY_MAX_AGE_MS) {
1453
1724
  continue;
1454
1725
  }
1455
1726
  removeCacheEntry(entry);
1456
1727
  }
1457
1728
 
1458
- const remaining = collectGlobalCacheEntries(root, now);
1729
+ const remaining = collectPluginCacheEntries(root, now);
1459
1730
  let total = remaining.reduce((sum, entry) => sum + entry.size, 0);
1460
- if (total <= GLOBAL_CACHE_MAX_BYTES) {
1731
+ if (total <= PLUGIN_CACHE_MAX_BYTES) {
1461
1732
  return;
1462
1733
  }
1463
1734
  for (const entry of remaining.sort((a, b) => a.lastUsedAt - b.lastUsedAt)) {
1464
- if (total <= GLOBAL_CACHE_TARGET_BYTES) {
1735
+ if (total <= PLUGIN_CACHE_TARGET_BYTES) {
1465
1736
  return;
1466
1737
  }
1467
- if (now - entry.lastUsedAt <= GLOBAL_CACHE_PROTECTED_AGE_MS) {
1738
+ if (now - entry.lastUsedAt <= PLUGIN_CACHE_PROTECTED_AGE_MS) {
1468
1739
  continue;
1469
1740
  }
1470
1741
  removeCacheEntry(entry);
@@ -1472,17 +1743,17 @@ function pruneGlobalPluginCache(root: string, now: number): void {
1472
1743
  }
1473
1744
  }
1474
1745
 
1475
- interface GlobalCacheEntry {
1746
+ interface PluginCacheEntry {
1476
1747
  dir: string;
1477
1748
  lastUsedAt: number;
1478
1749
  size: number;
1479
1750
  }
1480
1751
 
1481
- function collectGlobalCacheEntries(
1752
+ function collectPluginCacheEntries(
1482
1753
  root: string,
1483
1754
  now: number,
1484
- ): GlobalCacheEntry[] {
1485
- const entries: GlobalCacheEntry[] = [];
1755
+ ): PluginCacheEntry[] {
1756
+ const entries: PluginCacheEntry[] = [];
1486
1757
  let dirents: fs.Dirent[];
1487
1758
  try {
1488
1759
  dirents = fs.readdirSync(root, { withFileTypes: true });
@@ -1557,7 +1828,7 @@ function directorySize(dir: string): number {
1557
1828
  return total;
1558
1829
  }
1559
1830
 
1560
- function removeCacheEntry(entry: GlobalCacheEntry): void {
1831
+ function removeCacheEntry(entry: PluginCacheEntry): void {
1561
1832
  try {
1562
1833
  fs.rmSync(entry.dir, { recursive: true, force: true });
1563
1834
  } catch {