ttsc 0.16.12 → 0.17.1

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.
@@ -5,8 +5,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.buildSourcePlugin = buildSourcePlugin;
7
7
  exports.resolvePluginCacheRoot = resolvePluginCacheRoot;
8
- exports.resolveGlobalPluginCacheRoot = resolveGlobalPluginCacheRoot;
9
- exports.defaultPluginCacheCleanTargets = defaultPluginCacheCleanTargets;
8
+ exports.resolveSourceBuildCachePaths = resolveSourceBuildCachePaths;
9
+ exports.resolveCleanTargets = resolveCleanTargets;
10
+ exports.legacyGlobalCacheTargets = legacyGlobalCacheTargets;
11
+ exports.isPathWithin = isPathWithin;
10
12
  exports.computeCacheKey = computeCacheKey;
11
13
  const node_child_process_1 = require("node:child_process");
12
14
  const node_crypto_1 = __importDefault(require("node:crypto"));
@@ -106,15 +108,36 @@ const CONTRIB_DIRNAME = "contrib";
106
108
  // runtimeHooks.ts.
107
109
  const PLUGIN_BUILD_LOCK_STEAL_MS = 600_000;
108
110
  const PLUGIN_BUILD_LOCK_POLL_MS = 50;
109
- const GLOBAL_CACHE_DIRNAME = "ttsc";
111
+ // The default cache lives INSIDE the workspace, at
112
+ // `<workspaceRoot>/node_modules/.cache/ttsc`, so `rm -rf node_modules` (or
113
+ // deleting the repo) reclaims every compiled plugin binary and Go object file.
114
+ // This is the `find-cache-dir` convention (Babel, webpack, ESLint, Nuxt, …): a
115
+ // disposable build cache under `node_modules/.cache/<tool>`. ttsc keeps NO
116
+ // global (`~/.cache`) cache — a machine-wide cache silently grew to hundreds of
117
+ // GB across tsgo/plugin version bumps, so it was removed outright. See
118
+ // resolveSourceBuildCacheRoot for the (override → workspace-local) priority.
119
+ const NODE_MODULES_DIRNAME = "node_modules";
120
+ const LOCAL_CACHE_PARENT_DIRNAME = ".cache";
121
+ const TTSC_CACHE_DIRNAME = "ttsc";
110
122
  const PLUGIN_CACHE_DIRNAME = "plugins";
123
+ const GO_BUILD_CACHE_DIRNAME = "go-build";
124
+ // Directories whose presence marks a monorepo/workspace root, so every package
125
+ // in the workspace shares ONE cache and a plugin builds once, not once per
126
+ // package. `package.json` with a `workspaces` field (yarn/npm/bun) is checked
127
+ // separately in isWorkspaceRootDir.
128
+ const WORKSPACE_ROOT_MARKER_FILES = ["pnpm-workspace.yaml"];
111
129
  const CACHE_LAST_USED_FILE = ".last-used";
112
130
  const CACHE_GC_MARKER_FILE = ".gc-last-run";
113
- const GLOBAL_CACHE_GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
114
- const GLOBAL_CACHE_ENTRY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
115
- const GLOBAL_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
116
- const GLOBAL_CACHE_TARGET_BYTES = Math.floor(GLOBAL_CACHE_MAX_BYTES * 0.8);
117
- const GLOBAL_CACHE_PROTECTED_AGE_MS = 60 * 60 * 1000;
131
+ // The plugin binary cache is content-keyed, so a project that bumps tsgo/typia
132
+ // many times leaves one stale entry per superseded key. An opportunistic GC
133
+ // (once/day) evicts entries unused for 30 days and, past a 2 GB ceiling, the
134
+ // least-recently-used down to 80%. It is scoped to the resolved cache root only
135
+ // — ttsc never scans a shared or global location.
136
+ const PLUGIN_CACHE_GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
137
+ const PLUGIN_CACHE_ENTRY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
138
+ const PLUGIN_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
139
+ const PLUGIN_CACHE_TARGET_BYTES = Math.floor(PLUGIN_CACHE_MAX_BYTES * 0.8);
140
+ const PLUGIN_CACHE_PROTECTED_AGE_MS = 60 * 60 * 1000;
118
141
  /** Build one Go source plugin into a cached executable. */
119
142
  function buildSourcePlugin(opts) {
120
143
  const { dir, entry, source } = resolveSourceBuildTarget(opts);
@@ -131,8 +154,9 @@ function buildSourcePlugin(opts) {
131
154
  ttscVersion: opts.ttscVersion,
132
155
  tsgoVersion: opts.tsgoVersion,
133
156
  });
134
- const root = resolvePluginCacheRoot(opts.baseDir, opts.cacheDir);
135
- const cacheDir = node_path_1.default.join(root, key);
157
+ const paths = resolveSourceBuildCachePaths(opts.baseDir, opts.cacheDir);
158
+ maybePrunePluginCache(paths, opts.cacheDir);
159
+ const cacheDir = node_path_1.default.join(paths.pluginRoot, key);
136
160
  const binaryName = process.platform === "win32" ? "plugin.exe" : "plugin";
137
161
  const binaryPath = node_path_1.default.join(cacheDir, binaryName);
138
162
  if (node_fs_1.default.existsSync(binaryPath)) {
@@ -149,6 +173,7 @@ function buildSourcePlugin(opts) {
149
173
  goBinary,
150
174
  key,
151
175
  label: opts.label ?? "source plugin",
176
+ goBuildCacheRoot: paths.goBuildRoot,
152
177
  overlayDirs,
153
178
  pluginName: opts.pluginName,
154
179
  quiet: opts.quiet === true,
@@ -180,7 +205,7 @@ function compileSourcePlugin(opts) {
180
205
  }
181
206
  writeGoWork(scratchDir, opts.overlayDirs, opts.goBinary, opts.pluginName);
182
207
  const scratchBinaryName = process.platform === "win32" ? ".ttsc-plugin.exe" : ".ttsc-plugin";
183
- runGoBuild(scratchDir, opts.entry, scratchBinaryName, opts.pluginName, opts.goBinary);
208
+ runGoBuild(scratchDir, opts.entry, scratchBinaryName, opts.pluginName, opts.goBinary, opts.goBuildCacheRoot);
184
209
  const builtBinary = node_path_1.default.join(scratchDir, scratchBinaryName);
185
210
  publishBuiltBinary(builtBinary, opts.binaryPath);
186
211
  touchCacheEntry(opts.cacheDir);
@@ -525,7 +550,7 @@ function readGoModInfo(dir, goBinary, pluginName) {
525
550
  if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, "go.mod"))) {
526
551
  return emptyGoModInfo();
527
552
  }
528
- const result = (0, node_child_process_1.spawnSync)(goBinary, ["mod", "edit", "-json"], {
553
+ const result = spawnGoTool(goBinary, ["mod", "edit", "-json"], {
529
554
  cwd: dir,
530
555
  encoding: "utf8",
531
556
  env: goBuildEnv(goBinary),
@@ -585,12 +610,12 @@ function isTtscManagedModulePath(modulePath) {
585
610
  modulePath === TSGO_GO_MODULE_PATH ||
586
611
  modulePath.startsWith("github.com/microsoft/typescript-go/shim/"));
587
612
  }
588
- function runGoBuild(cwd, entry, binaryName, pluginName, goBinary) {
613
+ function runGoBuild(cwd, entry, binaryName, pluginName, goBinary, goBuildCacheRoot) {
589
614
  ensureExecutableGoToolchain(goBinary);
590
- const result = (0, node_child_process_1.spawnSync)(goBinary, ["build", "-o", binaryName, entry], {
615
+ const result = spawnGoTool(goBinary, ["build", "-o", binaryName, entry], {
591
616
  cwd,
592
617
  encoding: "utf8",
593
- env: goBuildEnv(goBinary),
618
+ env: goBuildEnv(goBinary, goBuildCacheRoot),
594
619
  maxBuffer: 1024 * 1024 * 64,
595
620
  windowsHide: true,
596
621
  });
@@ -609,9 +634,30 @@ function goToolchainNotFoundMessage(pluginName) {
609
634
  `Reinstall ttsc with optional dependencies so the bundled Go compiler is present, ` +
610
635
  `or set TTSC_GO_BINARY to an absolute path.`);
611
636
  }
612
- function goBuildEnv(goBinary) {
637
+ function spawnGoTool(goBinary, args, options) {
638
+ const shell = shouldSpawnGoToolThroughShell(goBinary);
639
+ // Under shell:true (a Windows .cmd/.bat wrapper) Node does not quote the
640
+ // command, so a wrapper at a path containing a space would be split by cmd.
641
+ // Quote it here; the go arguments ttsc passes never contain spaces, and
642
+ // quoting a space-free path is harmless.
643
+ return (0, node_child_process_1.spawnSync)(shell ? `"${goBinary}"` : goBinary, [...args], {
644
+ ...options,
645
+ shell,
646
+ });
647
+ }
648
+ function shouldSpawnGoToolThroughShell(goBinary) {
649
+ return process.platform === "win32" && /\.(?:bat|cmd)$/i.test(goBinary);
650
+ }
651
+ function goBuildEnv(goBinary, goBuildCacheRoot) {
613
652
  const env = { ...process.env };
614
653
  env.GOWORK = "auto";
654
+ // Only the actual `go build` needs ttsc's GOCACHE; read-only metadata spawns
655
+ // (`go mod edit`, `go env`, `go version`) call goBuildEnv with no cache root
656
+ // and inherit the ambient GOCACHE, which they never write to anyway. GOCACHE
657
+ // is not part of the plugin cache key, so this cannot affect it.
658
+ if (goBuildCacheRoot) {
659
+ env.GOCACHE = goBuildCacheRoot;
660
+ }
615
661
  const goRoot = inferGoRoot(goBinary);
616
662
  if (goRoot && !env.GOROOT) {
617
663
  env.GOROOT = goRoot;
@@ -712,77 +758,225 @@ function walkForGoMod(dir, out) {
712
758
  /**
713
759
  * Resolve the directory where compiled plugin binaries are cached.
714
760
  *
715
- * Priority order:
761
+ * Delegates to {@link resolveSourceBuildCachePaths}; kept as a thin accessor for
762
+ * callers (and tests) that only need the plugin-binary root. Triggers the
763
+ * opportunistic project-cache GC as a side effect for the default location.
764
+ */
765
+ function resolvePluginCacheRoot(projectRoot, cacheDir) {
766
+ const paths = resolveSourceBuildCachePaths(projectRoot, cacheDir);
767
+ maybePrunePluginCache(paths, cacheDir);
768
+ return paths.pluginRoot;
769
+ }
770
+ /**
771
+ * Resolve all source-plugin build cache directories for one invocation.
772
+ *
773
+ * `pluginRoot` stores compiled plugin binaries; `goBuildRoot` is the Go object
774
+ * cache passed as `GOCACHE` while ttsc builds those binaries. Both live under a
775
+ * single `root`, so persisting one directory covers the whole source-build
776
+ * cache without depending on ttsc internals.
777
+ */
778
+ function resolveSourceBuildCachePaths(projectRoot, cacheDir, env = process.env) {
779
+ const root = resolveSourceBuildCacheRoot(projectRoot, cacheDir, env);
780
+ const goBuild = resolveGoBuildCacheRoot(root, projectRoot, env);
781
+ return {
782
+ root,
783
+ pluginRoot: node_path_1.default.join(root, PLUGIN_CACHE_DIRNAME),
784
+ goBuildRoot: goBuild.root,
785
+ goBuildRootSource: goBuild.source,
786
+ };
787
+ }
788
+ /**
789
+ * Resolve the cache root for one invocation.
790
+ *
791
+ * Priority:
716
792
  *
717
- * 1. `cacheDir` option (resolved relative to `projectRoot`).
718
- * 2. `TTSC_CACHE_DIR` environment variable.
719
- * 3. Platform-specific global user cache (XDG / AppData / Library/Caches).
793
+ * 1. Explicit `cacheDir` option (resolved relative to `projectRoot`);
794
+ * 2. `TTSC_CACHE_DIR` environment variable (resolved absolute);
795
+ * 3. `<workspaceRoot>/node_modules/.cache/ttsc` — project-local by default.
720
796
  *
721
- * When falling back to the global cache, a GC pass is triggered
722
- * opportunistically to evict old or oversized entries before the cache is
723
- * used.
797
+ * There is deliberately NO global (`~/.cache`) fallback: the cache is scoped to
798
+ * the workspace so it can never accumulate machine-wide, and `rm -rf
799
+ * node_modules` reclaims it.
724
800
  */
725
- function resolvePluginCacheRoot(projectRoot, cacheDir) {
801
+ function resolveSourceBuildCacheRoot(projectRoot, cacheDir, env) {
726
802
  if (cacheDir) {
727
- return node_path_1.default.resolve(projectRoot, cacheDir, PLUGIN_CACHE_DIRNAME);
803
+ return node_path_1.default.resolve(projectRoot, cacheDir);
728
804
  }
729
- if (process.env.TTSC_CACHE_DIR) {
730
- return node_path_1.default.resolve(process.env.TTSC_CACHE_DIR, PLUGIN_CACHE_DIRNAME);
805
+ if (env.TTSC_CACHE_DIR) {
806
+ // Anchor a relative TTSC_CACHE_DIR to the project root (not the process
807
+ // cwd) so a programmatic host whose cwd differs from the project still
808
+ // resolves — and later cleans — the same cache. Absolute values pass
809
+ // through path.resolve unchanged.
810
+ return node_path_1.default.resolve(projectRoot, env.TTSC_CACHE_DIR);
731
811
  }
732
- const root = resolveGlobalPluginCacheRoot();
733
- maybePruneGlobalPluginCache(root);
734
- return root;
812
+ return node_path_1.default.join(resolveWorkspaceRoot(projectRoot), NODE_MODULES_DIRNAME, LOCAL_CACHE_PARENT_DIRNAME, TTSC_CACHE_DIRNAME);
735
813
  }
736
814
  /**
737
- * Return the absolute path to the global plugin binary cache root.
815
+ * Resolve the monorepo/workspace root for `projectRoot` so every package shares
816
+ * one cache and a plugin builds once per workspace, not once per package.
738
817
  *
739
- * The path follows platform conventions (XDG on Linux, AppData on Windows,
740
- * Library/Caches on macOS) and does not depend on project-local config. Used by
741
- * `ttsc cache clean` to enumerate cache locations.
818
+ * Walks up from `projectRoot` and returns, in order of preference: the NEAREST
819
+ * ancestor that is a workspace root (holds `pnpm-workspace.yaml`, or a
820
+ * `package.json` with a `workspaces` field); else the nearest ancestor that
821
+ * already contains a `node_modules` directory; else `projectRoot` itself.
822
+ *
823
+ * Nearest (not highest) so an unrelated ancestor that happens to declare
824
+ * `workspaces` — for example a `package.json` in the user's home directory —
825
+ * cannot pull the cache above the project's real monorepo root.
742
826
  */
743
- function resolveGlobalPluginCacheRoot() {
744
- return node_path_1.default.join(resolveUserCacheRoot(), GLOBAL_CACHE_DIRNAME, PLUGIN_CACHE_DIRNAME);
827
+ function resolveWorkspaceRoot(projectRoot) {
828
+ let dir = node_path_1.default.resolve(projectRoot);
829
+ let nearestNodeModulesOwner = null;
830
+ for (;;) {
831
+ if (isWorkspaceRootDir(dir)) {
832
+ return dir;
833
+ }
834
+ if (nearestNodeModulesOwner === null &&
835
+ node_fs_1.default.existsSync(node_path_1.default.join(dir, NODE_MODULES_DIRNAME))) {
836
+ nearestNodeModulesOwner = dir;
837
+ }
838
+ const parent = node_path_1.default.dirname(dir);
839
+ if (parent === dir) {
840
+ break;
841
+ }
842
+ dir = parent;
843
+ }
844
+ return nearestNodeModulesOwner ?? node_path_1.default.resolve(projectRoot);
845
+ }
846
+ function isWorkspaceRootDir(dir) {
847
+ for (const marker of WORKSPACE_ROOT_MARKER_FILES) {
848
+ if (node_fs_1.default.existsSync(node_path_1.default.join(dir, marker))) {
849
+ return true;
850
+ }
851
+ }
852
+ return packageJsonDeclaresWorkspaces(node_path_1.default.join(dir, "package.json"));
853
+ }
854
+ function packageJsonDeclaresWorkspaces(packageJsonPath) {
855
+ let text;
856
+ try {
857
+ text = node_fs_1.default.readFileSync(packageJsonPath, "utf8");
858
+ }
859
+ catch {
860
+ return false;
861
+ }
862
+ try {
863
+ const workspaces = JSON.parse(text)
864
+ .workspaces;
865
+ // A real workspace root declares a NON-EMPTY package list (an array, or an
866
+ // object with a `packages` array). Ignore `false`, `[]`, `null`, or `{}` so
867
+ // a disabled or empty declaration on an unrelated ancestor cannot hijack
868
+ // the cache location.
869
+ if (Array.isArray(workspaces)) {
870
+ return workspaces.length > 0;
871
+ }
872
+ if (workspaces !== null && typeof workspaces === "object") {
873
+ const packages = workspaces.packages;
874
+ return Array.isArray(packages) && packages.length > 0;
875
+ }
876
+ return false;
877
+ }
878
+ catch {
879
+ return false;
880
+ }
881
+ }
882
+ function resolveGoBuildCacheRoot(root, projectRoot, env) {
883
+ if (env.TTSC_GO_CACHE_DIR) {
884
+ // Anchor a relative TTSC_GO_CACHE_DIR to the project root, matching
885
+ // TTSC_CACHE_DIR, so the build and a later `clean` from a different cwd
886
+ // agree on the directory. Absolute values pass through unchanged.
887
+ return {
888
+ root: node_path_1.default.resolve(projectRoot, env.TTSC_GO_CACHE_DIR),
889
+ source: "TTSC_GO_CACHE_DIR",
890
+ };
891
+ }
892
+ if (env.GOCACHE && env.GOCACHE.length > 0) {
893
+ return {
894
+ root: env.GOCACHE,
895
+ source: "GOCACHE",
896
+ };
897
+ }
898
+ return {
899
+ root: node_path_1.default.join(root, GO_BUILD_CACHE_DIRNAME),
900
+ source: "ttsc-cache",
901
+ };
902
+ }
903
+ function maybePrunePluginCache(paths, cacheDir) {
904
+ // GC only the default (workspace-local) location. When the user pins an
905
+ // explicit `cacheDir`/`TTSC_CACHE_DIR`, they own its lifetime, so ttsc must
906
+ // not delete entries out from under them.
907
+ if (!cacheDir && !process.env.TTSC_CACHE_DIR) {
908
+ prunePluginCacheRoot(paths.pluginRoot);
909
+ }
745
910
  }
746
911
  /**
747
- * Return all directories that `ttsc cache clean` should wipe for a project.
912
+ * Return every directory `ttsc clean` should remove for `projectRoot`.
748
913
  *
749
- * Covers three locations where plugin binaries may accumulate: the global user
750
- * cache, the project-local `node_modules/.ttsc` cache (legacy), and the
751
- * project-local `.ttsc` cache.
914
+ * Covers the resolved cache root (which holds `plugins/` and, when ttsc-owned,
915
+ * `go-build/`), a ttsc-owned Go build cache that lives OUTSIDE that root
916
+ * (`TTSC_GO_CACHE_DIR`), and the two legacy project-local caches. A
917
+ * user-provided `GOCACHE` is never removed. Pure over `env`, so the CLI passes
918
+ * `process.env` and a programmatic caller can pass an injected environment.
752
919
  */
753
- function defaultPluginCacheCleanTargets(projectRoot) {
754
- return [
755
- resolveGlobalPluginCacheRoot(),
756
- node_path_1.default.join(projectRoot, "node_modules", ".ttsc"),
757
- node_path_1.default.join(projectRoot, ".ttsc"),
758
- ];
920
+ function resolveCleanTargets(projectRoot, cacheDir, env = process.env) {
921
+ const paths = resolveSourceBuildCachePaths(projectRoot, cacheDir, env);
922
+ // Remove ttsc-OWNED directories only, never the parent cache root.
923
+ const targets = [paths.pluginRoot];
924
+ // ttsc's nested `<root>/go-build` is only safe to delete when we are certain
925
+ // the root belongs to ttsc: the default `node_modules/.cache/ttsc`, or a root
926
+ // the user explicitly named `ttsc`. Under a shared root (e.g.
927
+ // `TTSC_CACHE_DIR=~/.cache`) a bare `<root>/go-build` could be the user's
928
+ // machine-wide GOCACHE, so it must never be removed by name.
929
+ const isTtscOwnedRoot = (!cacheDir && !env.TTSC_CACHE_DIR) ||
930
+ node_path_1.default.basename(paths.root) === TTSC_CACHE_DIRNAME;
931
+ if (isTtscOwnedRoot) {
932
+ targets.push(node_path_1.default.join(paths.root, GO_BUILD_CACHE_DIRNAME));
933
+ }
934
+ // An explicit TTSC_GO_CACHE_DIR is a ttsc-dedicated external cache; a
935
+ // user-provided GOCACHE (source "GOCACHE") is never removed.
936
+ if (paths.goBuildRootSource === "TTSC_GO_CACHE_DIR") {
937
+ targets.push(paths.goBuildRoot);
938
+ }
939
+ targets.push(node_path_1.default.join(projectRoot, NODE_MODULES_DIRNAME, ".ttsc"));
940
+ targets.push(node_path_1.default.join(projectRoot, ".ttsc"));
941
+ return targets;
759
942
  }
760
- function resolveUserCacheRoot() {
943
+ /**
944
+ * Machine-global cache directories created by pre-0.17 ttsc releases (XDG /
945
+ * AppData / Library / `~/.cache`). ttsc no longer writes to any of these, but
946
+ * an upgraded machine can still hold a multi-GB orphaned cache here, so `ttsc
947
+ * clean` offers them for removal to reclaim that disk. Each entry is the whole
948
+ * `<userCacheRoot>/ttsc` directory (both its `plugins` and `go-build`), which
949
+ * was entirely ttsc-owned in those releases and is safe to remove.
950
+ */
951
+ function legacyGlobalCacheTargets() {
952
+ const roots = new Set();
953
+ const home = node_os_1.default.homedir();
761
954
  const xdg = process.env.XDG_CACHE_HOME;
762
955
  if (xdg && node_path_1.default.isAbsolute(xdg)) {
763
- return xdg;
956
+ roots.add(node_path_1.default.join(xdg, TTSC_CACHE_DIRNAME));
764
957
  }
765
958
  if (process.platform === "win32") {
766
959
  const local = process.env.LOCALAPPDATA;
767
960
  if (local && node_path_1.default.isAbsolute(local)) {
768
- return local;
961
+ roots.add(node_path_1.default.join(local, TTSC_CACHE_DIRNAME));
769
962
  }
770
- const home = node_os_1.default.homedir();
771
963
  if (home) {
772
- return node_path_1.default.join(home, "AppData", "Local");
964
+ roots.add(node_path_1.default.join(home, "AppData", "Local", TTSC_CACHE_DIRNAME));
773
965
  }
774
966
  }
775
- if (process.platform === "darwin") {
776
- const home = node_os_1.default.homedir();
777
- if (home) {
778
- return node_path_1.default.join(home, "Library", "Caches");
779
- }
967
+ else if (process.platform === "darwin" && home) {
968
+ roots.add(node_path_1.default.join(home, "Library", "Caches", TTSC_CACHE_DIRNAME));
780
969
  }
781
- const home = node_os_1.default.homedir();
782
970
  if (home) {
783
- return node_path_1.default.join(home, ".cache");
971
+ roots.add(node_path_1.default.join(home, ".cache", TTSC_CACHE_DIRNAME));
784
972
  }
785
- return node_path_1.default.join(node_os_1.default.tmpdir(), "ttsc-cache");
973
+ return [...roots];
974
+ }
975
+ /** Report whether `child` equals `parent` or is nested beneath it. */
976
+ function isPathWithin(child, parent) {
977
+ const rel = node_path_1.default.relative(parent, child);
978
+ return (rel === "" ||
979
+ (rel !== ".." && !rel.startsWith(`..${node_path_1.default.sep}`) && !node_path_1.default.isAbsolute(rel)));
786
980
  }
787
981
  function resolveGoCompiler() {
788
982
  const explicit = process.env.TTSC_GO_BINARY;
@@ -942,7 +1136,7 @@ function computeGoCompilerIdentity(goBinary, resolved) {
942
1136
  if (!node_fs_1.default.existsSync(resolved)) {
943
1137
  return "missing";
944
1138
  }
945
- const version = (0, node_child_process_1.spawnSync)(goBinary, ["version"], {
1139
+ const version = spawnGoTool(goBinary, ["version"], {
946
1140
  encoding: "utf8",
947
1141
  windowsHide: true,
948
1142
  });
@@ -967,14 +1161,28 @@ function resolveExecutableIdentityPath(binary) {
967
1161
  return resolveRealPath(candidate);
968
1162
  }
969
1163
  if (process.platform === "win32") {
970
- const executable = `${candidate}.exe`;
971
- if (node_fs_1.default.existsSync(executable)) {
972
- return resolveRealPath(executable);
1164
+ // Probe every PATHEXT extension, not just `.exe`, so a compiler backed by
1165
+ // a `.cmd`/`.bat` wrapper resolves to its real file and is hashed into the
1166
+ // cache key. Otherwise the wrapper reads as "missing" and a change to it
1167
+ // would not invalidate the cached plugin binary.
1168
+ for (const ext of windowsExecutableExtensions()) {
1169
+ const executable = `${candidate}${ext}`;
1170
+ if (node_fs_1.default.existsSync(executable)) {
1171
+ return resolveRealPath(executable);
1172
+ }
973
1173
  }
974
1174
  }
975
1175
  }
976
1176
  return binary;
977
1177
  }
1178
+ function windowsExecutableExtensions() {
1179
+ const pathext = process.env.PATHEXT;
1180
+ const raw = pathext && pathext.length > 0 ? pathext : ".COM;.EXE;.BAT;.CMD";
1181
+ return raw
1182
+ .split(";")
1183
+ .map((ext) => ext.trim().toLowerCase())
1184
+ .filter((ext) => ext.length > 0);
1185
+ }
978
1186
  function readPathEnvironment() {
979
1187
  return process.env.PATH ?? process.env.Path ?? "";
980
1188
  }
@@ -1003,7 +1211,7 @@ function hashGoBuildEnvironment(hash, goBinary, cwd) {
1003
1211
  function resolveGoBuildEnvironment(goBinary, cwd) {
1004
1212
  const values = new Map();
1005
1213
  if (goBinary !== undefined) {
1006
- const result = (0, node_child_process_1.spawnSync)(goBinary, ["env", "-json", ...GO_BUILD_ENV_KEYS], {
1214
+ const result = spawnGoTool(goBinary, ["env", "-json", ...GO_BUILD_ENV_KEYS], {
1007
1215
  cwd,
1008
1216
  encoding: "utf8",
1009
1217
  env: goBuildEnv(goBinary),
@@ -1169,47 +1377,47 @@ function touchCacheEntry(cacheDir) {
1169
1377
  // Cache hits must not fail because metadata touch failed.
1170
1378
  }
1171
1379
  }
1172
- function maybePruneGlobalPluginCache(root) {
1380
+ function prunePluginCacheRoot(root) {
1173
1381
  try {
1174
1382
  node_fs_1.default.mkdirSync(root, { recursive: true });
1175
1383
  const marker = node_path_1.default.join(root, CACHE_GC_MARKER_FILE);
1176
1384
  const now = Date.now();
1177
1385
  const lastRun = readTimestamp(marker);
1178
- if (lastRun !== null && now - lastRun < GLOBAL_CACHE_GC_INTERVAL_MS) {
1386
+ if (lastRun !== null && now - lastRun < PLUGIN_CACHE_GC_INTERVAL_MS) {
1179
1387
  return;
1180
1388
  }
1181
1389
  node_fs_1.default.writeFileSync(marker, `${now}\n`);
1182
- pruneGlobalPluginCache(root, now);
1390
+ prunePluginCacheEntries(root, now);
1183
1391
  }
1184
1392
  catch {
1185
- // Global cache GC is opportunistic; builds still proceed when it fails.
1393
+ // Plugin-cache GC is opportunistic; builds still proceed when it fails.
1186
1394
  }
1187
1395
  }
1188
- function pruneGlobalPluginCache(root, now) {
1189
- const entries = collectGlobalCacheEntries(root, now);
1396
+ function prunePluginCacheEntries(root, now) {
1397
+ const entries = collectPluginCacheEntries(root, now);
1190
1398
  for (const entry of entries) {
1191
- if (now - entry.lastUsedAt <= GLOBAL_CACHE_ENTRY_MAX_AGE_MS) {
1399
+ if (now - entry.lastUsedAt <= PLUGIN_CACHE_ENTRY_MAX_AGE_MS) {
1192
1400
  continue;
1193
1401
  }
1194
1402
  removeCacheEntry(entry);
1195
1403
  }
1196
- const remaining = collectGlobalCacheEntries(root, now);
1404
+ const remaining = collectPluginCacheEntries(root, now);
1197
1405
  let total = remaining.reduce((sum, entry) => sum + entry.size, 0);
1198
- if (total <= GLOBAL_CACHE_MAX_BYTES) {
1406
+ if (total <= PLUGIN_CACHE_MAX_BYTES) {
1199
1407
  return;
1200
1408
  }
1201
1409
  for (const entry of remaining.sort((a, b) => a.lastUsedAt - b.lastUsedAt)) {
1202
- if (total <= GLOBAL_CACHE_TARGET_BYTES) {
1410
+ if (total <= PLUGIN_CACHE_TARGET_BYTES) {
1203
1411
  return;
1204
1412
  }
1205
- if (now - entry.lastUsedAt <= GLOBAL_CACHE_PROTECTED_AGE_MS) {
1413
+ if (now - entry.lastUsedAt <= PLUGIN_CACHE_PROTECTED_AGE_MS) {
1206
1414
  continue;
1207
1415
  }
1208
1416
  removeCacheEntry(entry);
1209
1417
  total -= entry.size;
1210
1418
  }
1211
1419
  }
1212
- function collectGlobalCacheEntries(root, now) {
1420
+ function collectPluginCacheEntries(root, now) {
1213
1421
  const entries = [];
1214
1422
  let dirents;
1215
1423
  try {