ttsc 0.32.1 → 0.33.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 (111) hide show
  1. package/cmd/ttsc-cache/account.go +94 -0
  2. package/cmd/ttsc-cache/interruption_test.go +176 -0
  3. package/cmd/ttsc-cache/main.go +252 -0
  4. package/cmd/ttsc-cache/merge.go +324 -0
  5. package/cmd/ttsc-cache/merge_test.go +161 -0
  6. package/cmd/ttsc-cache/prepare.go +330 -0
  7. package/cmd/ttsc-cache/prepare_test.go +456 -0
  8. package/cmd/ttsc-cache/protocol_test.go +119 -0
  9. package/cmd/ttsc-cache/publisher_seal_test.go +135 -0
  10. package/cmd/ttsc-cache/seal.go +427 -0
  11. package/cmd/ttsc-cache/stock_go_failure_test.go +94 -0
  12. package/cmd/ttsc-cache/stock_go_test.go +163 -0
  13. package/cmd/ttsc-cache/store.go +249 -0
  14. package/cmd/ttsc-cache/store_test.go +104 -0
  15. package/cmd/ttsc-cache/tuple.go +107 -0
  16. package/cmd/ttsc-cache/tuple_test.go +62 -0
  17. package/cmd/ttsc-cache/usage.go +73 -0
  18. package/driver/configloaderscratch/canonical_directory_other.go +34 -0
  19. package/driver/configloaderscratch/canonical_directory_windows.go +60 -0
  20. package/driver/configloaderscratch/identity_temp_base.go +81 -0
  21. package/driver/configloaderscratch/identity_temp_base_posix_test.go +124 -0
  22. package/driver/configloaderscratch/identity_temp_base_windows_test.go +156 -0
  23. package/driver/emit_provenance.go +7 -5
  24. package/driver/resolutioninputs/recorder.cjs +16 -4
  25. package/driver/rewrite.go +25 -16
  26. package/driver/write_file_if_changed.go +69 -0
  27. package/driver/write_file_if_changed_test.go +148 -0
  28. package/internal/packageartifacts/manifest.go +288 -0
  29. package/internal/packageartifacts/manifest_test.go +127 -0
  30. package/lib/compiler/internal/build/BuildExecution.d.ts +11 -7
  31. package/lib/compiler/internal/build/BuildExecution.js +22 -8
  32. package/lib/compiler/internal/build/BuildExecution.js.map +1 -1
  33. package/lib/compiler/internal/build/ResidentCheckWatchSession.d.ts +10 -10
  34. package/lib/compiler/internal/build/ResidentCheckWatchSession.js +19 -14
  35. package/lib/compiler/internal/build/ResidentCheckWatchSession.js.map +1 -1
  36. package/lib/compiler/internal/build/RunBuildOptions.d.ts +8 -1
  37. package/lib/compiler/internal/build/TsgoArguments.d.ts +16 -0
  38. package/lib/compiler/internal/build/TsgoArguments.js +19 -0
  39. package/lib/compiler/internal/build/TsgoArguments.js.map +1 -1
  40. package/lib/compiler/internal/captureProcessOutput.d.ts +16 -13
  41. package/lib/compiler/internal/captureProcessOutput.js +22 -14
  42. package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
  43. package/lib/compiler/internal/startResidentTransform.js +3 -1
  44. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  45. package/lib/compiler/internal/transformProjectInMemory.js +5 -1
  46. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  47. package/lib/launcher/internal/runtime/PluginDescriptorInputReport.d.ts +43 -0
  48. package/lib/launcher/internal/runtime/PluginDescriptorInputReport.js +106 -0
  49. package/lib/launcher/internal/runtime/PluginDescriptorInputReport.js.map +1 -0
  50. package/lib/launcher/internal/runtime/installRuntimeHooks.js +15 -29
  51. package/lib/launcher/internal/runtime/installRuntimeHooks.js.map +1 -1
  52. package/lib/launcher/internal/ttscserver/runTtscserver.js +1 -0
  53. package/lib/launcher/internal/ttscserver/runTtscserver.js.map +1 -1
  54. package/lib/launcher/internal/watch/WatchTopology.js +5 -4
  55. package/lib/launcher/internal/watch/WatchTopology.js.map +1 -1
  56. package/lib/plugin/internal/CapabilityResolutionFormat.js +4 -3
  57. package/lib/plugin/internal/CapabilityResolutionFormat.js.map +1 -1
  58. package/lib/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.js +6 -0
  59. package/lib/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.js.map +1 -1
  60. package/lib/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.js +6 -0
  61. package/lib/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.js.map +1 -1
  62. package/lib/plugin/internal/load/PluginDescriptorEvaluationCache.js +3 -3
  63. package/lib/plugin/internal/load/PluginDescriptorEvaluationCache.js.map +1 -1
  64. package/lib/plugin/internal/load/descriptorRecordEnvironment.d.ts +15 -0
  65. package/lib/plugin/internal/load/descriptorRecordEnvironment.js +24 -0
  66. package/lib/plugin/internal/load/descriptorRecordEnvironment.js.map +1 -0
  67. package/lib/plugin/internal/load/loadProjectPlugins.js +28 -26
  68. package/lib/plugin/internal/load/loadProjectPlugins.js.map +1 -1
  69. package/lib/plugin/internal/source/SourceBuildCacheLayout.d.ts +2 -0
  70. package/lib/plugin/internal/source/SourceBuildCacheLayout.js +2 -0
  71. package/lib/plugin/internal/source/SourceBuildCacheLayout.js.map +1 -1
  72. package/lib/plugin/internal/source/SourcePackageArtifacts.d.ts +145 -0
  73. package/lib/plugin/internal/source/SourcePackageArtifacts.js +612 -0
  74. package/lib/plugin/internal/source/SourcePackageArtifacts.js.map +1 -0
  75. package/lib/plugin/internal/source/buildSourcePlugin.js +54 -4
  76. package/lib/plugin/internal/source/buildSourcePlugin.js.map +1 -1
  77. package/lib/plugin/internal/source/prunePackageArtifactRoot.d.ts +19 -0
  78. package/lib/plugin/internal/source/prunePackageArtifactRoot.js +95 -0
  79. package/lib/plugin/internal/source/prunePackageArtifactRoot.js.map +1 -0
  80. package/lib/plugin/internal/source/resolveCleanTargets.js +1 -1
  81. package/lib/plugin/internal/source/resolveCleanTargets.js.map +1 -1
  82. package/lib/plugin/internal/source/resolveSourceBuildCachePaths.js +9 -4
  83. package/lib/plugin/internal/source/resolveSourceBuildCachePaths.js.map +1 -1
  84. package/lib/plugin/internal/source/selectedRecordIdentityTempParent.d.ts +18 -0
  85. package/lib/plugin/internal/source/selectedRecordIdentityTempParent.js +46 -0
  86. package/lib/plugin/internal/source/selectedRecordIdentityTempParent.js.map +1 -0
  87. package/package.json +8 -8
  88. package/src/compiler/internal/build/BuildExecution.ts +30 -10
  89. package/src/compiler/internal/build/ResidentCheckWatchSession.ts +23 -22
  90. package/src/compiler/internal/build/RunBuildOptions.ts +9 -1
  91. package/src/compiler/internal/build/TsgoArguments.ts +22 -0
  92. package/src/compiler/internal/captureProcessOutput.ts +28 -14
  93. package/src/compiler/internal/startResidentTransform.ts +7 -1
  94. package/src/compiler/internal/transformProjectInMemory.ts +9 -1
  95. package/src/launcher/internal/runtime/PluginDescriptorInputReport.ts +100 -0
  96. package/src/launcher/internal/runtime/installRuntimeHooks.ts +57 -55
  97. package/src/launcher/internal/ttscserver/runTtscserver.ts +5 -0
  98. package/src/launcher/internal/watch/WatchTopology.ts +5 -4
  99. package/src/plugin/internal/CapabilityResolutionFormat.ts +4 -3
  100. package/src/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.ts +4 -0
  101. package/src/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.ts +4 -0
  102. package/src/plugin/internal/load/PluginDescriptorEvaluationCache.ts +3 -3
  103. package/src/plugin/internal/load/descriptorRecordEnvironment.ts +24 -0
  104. package/src/plugin/internal/load/loadProjectPlugins.ts +45 -29
  105. package/src/plugin/internal/source/SourceBuildCacheLayout.ts +3 -0
  106. package/src/plugin/internal/source/SourcePackageArtifacts.ts +787 -0
  107. package/src/plugin/internal/source/buildSourcePlugin.ts +82 -12
  108. package/src/plugin/internal/source/prunePackageArtifactRoot.ts +100 -0
  109. package/src/plugin/internal/source/resolveCleanTargets.ts +1 -0
  110. package/src/plugin/internal/source/resolveSourceBuildCachePaths.ts +11 -4
  111. package/src/plugin/internal/source/selectedRecordIdentityTempParent.ts +40 -0
@@ -21,6 +21,7 @@ import { PluginBuildLockProtocol } from "./PluginBuildLockProtocol";
21
21
  import { PluginContentIdentities } from "./PluginContentIdentities";
22
22
  import { SourceBuildCacheLayout } from "./SourceBuildCacheLayout";
23
23
  import type { SourceBuildFilesystemOperations } from "./SourceBuildFilesystemOperations";
24
+ import { SourcePackageArtifacts } from "./SourcePackageArtifacts";
24
25
  import { SourcePluginAdmission } from "./SourcePluginAdmission";
25
26
  import { SourcePluginWorkspace } from "./SourcePluginWorkspace";
26
27
  import { acquirePluginBuildLock } from "./acquirePluginBuildLock";
@@ -31,6 +32,7 @@ import { ensureExecutableGoToolchain } from "./ensureExecutableGoToolchain";
31
32
  import { pluginModuleReplaceDirectories } from "./pluginModuleReplaceDirectories";
32
33
  import { pluginSourceDigest } from "./pluginSourceDigest";
33
34
  import { pruneGoBuildCacheRoot } from "./pruneGoBuildCacheRoot";
35
+ import { prunePackageArtifactRoot } from "./prunePackageArtifactRoot";
34
36
  import { prunePluginCacheRoot } from "./prunePluginCacheRoot";
35
37
  import { reclaimPluginBuildLock } from "./reclaimPluginBuildLock";
36
38
  import { releasePluginBuildLock } from "./releasePluginBuildLock";
@@ -383,6 +385,11 @@ function buildSourcePluginAttempt(
383
385
  pluginName: opts.pluginName,
384
386
  quiet,
385
387
  source,
388
+ artifactRoot: path.join(
389
+ paths.root,
390
+ SourceBuildCacheLayout.PACKAGE_ARTIFACTS_DIRNAME,
391
+ ),
392
+ ttscVersion: opts.ttscVersion,
386
393
  });
387
394
  },
388
395
  );
@@ -447,6 +454,7 @@ const CONTRIB_DIRNAME = "contrib";
447
454
 
448
455
  /** Run the actual `go build` and publish the binary; assumes the lock is held. */
449
456
  function compileSourcePlugin(opts: {
457
+ artifactRoot: string;
450
458
  binaryPath: string;
451
459
  cacheDir: string;
452
460
  contributors: readonly ITtscBuildContributor[];
@@ -476,6 +484,7 @@ function compileSourcePlugin(opts: {
476
484
  pluginName: string;
477
485
  quiet: boolean;
478
486
  source: string;
487
+ ttscVersion: string;
479
488
  }): string {
480
489
  OwnedSynchronousProcess.checkpoint();
481
490
  if (!opts.quiet) {
@@ -574,6 +583,8 @@ function compileSourcePlugin(opts: {
574
583
  const scratchBinaryName =
575
584
  process.platform === "win32" ? ".ttsc-plugin.exe" : ".ttsc-plugin";
576
585
  let attemptedGoBuildCacheRoot: string | undefined;
586
+ let publisherRecording: ReturnType<typeof SourcePackageArtifacts.record> =
587
+ null;
577
588
  try {
578
589
  withGoBuildCacheLease(
579
590
  opts.goBuildCacheRoot,
@@ -616,16 +627,57 @@ function compileSourcePlugin(opts: {
616
627
  );
617
628
  });
618
629
  }
619
- runGoBuild(
620
- scratchDir,
621
- opts.entry,
622
- scratchBinaryName,
623
- opts.pluginName,
624
- opts.goBinary,
625
- goBuildCacheRoot,
626
- opts.env,
627
- opts.normalizeGoToolPermissions,
628
- );
630
+ const recording = SourcePackageArtifacts.record({
631
+ cwd: scratchDir,
632
+ entry: opts.entry,
633
+ env: GoSourceInputs.goBuildEnv(
634
+ opts.goBinary,
635
+ goBuildCacheRoot,
636
+ opts.env,
637
+ ),
638
+ goBinary: opts.goBinary,
639
+ key: opts.key,
640
+ pluginName: opts.pluginName,
641
+ ttscVersion: opts.ttscVersion,
642
+ });
643
+ publisherRecording = recording;
644
+ const artifacts =
645
+ recording === null
646
+ ? SourcePackageArtifacts.prepare({
647
+ artifactRoot: opts.artifactRoot,
648
+ cwd: scratchDir,
649
+ entry: opts.entry,
650
+ env: GoSourceInputs.goBuildEnv(
651
+ opts.goBinary,
652
+ goBuildCacheRoot,
653
+ opts.env,
654
+ ),
655
+ goBinary: opts.goBinary,
656
+ pluginName: opts.pluginName,
657
+ quiet: opts.quiet,
658
+ ttscVersion: opts.ttscVersion,
659
+ })
660
+ : null;
661
+ try {
662
+ runGoBuild(
663
+ scratchDir,
664
+ opts.entry,
665
+ scratchBinaryName,
666
+ opts.pluginName,
667
+ opts.goBinary,
668
+ goBuildCacheRoot,
669
+ recording?.env ?? artifacts!.env,
670
+ opts.normalizeGoToolPermissions,
671
+ recording?.actionGraph,
672
+ );
673
+ } finally {
674
+ if (artifacts !== null)
675
+ SourcePackageArtifacts.reportUsage(
676
+ artifacts,
677
+ opts.pluginName,
678
+ opts.quiet,
679
+ );
680
+ }
629
681
  OwnedSynchronousProcess.checkpoint();
630
682
  if (process.env.TTSC_E2E_TRACE) {
631
683
  E2ETrace.capabilityResolution(
@@ -655,6 +707,7 @@ function compileSourcePlugin(opts: {
655
707
  // grow the cache unchecked behind a fresh daily marker. Cancellation
656
708
  // still releases the lease and scratch, but admits no new maintenance.
657
709
  pruneGoBuildCacheRoot(attemptedGoBuildCacheRoot, { force: true });
710
+ prunePackageArtifactRoot(attemptedGoBuildCacheRoot, opts.artifactRoot);
658
711
  }
659
712
  }
660
713
  // The toolchain was read for the key before the lock wait and the build,
@@ -685,6 +738,8 @@ function compileSourcePlugin(opts: {
685
738
  );
686
739
  throw pluginBuildEnvironmentChanged(opts.pluginName);
687
740
  }
741
+ if (publisherRecording !== null)
742
+ SourcePackageArtifacts.seal(publisherRecording);
688
743
  const builtBinary = path.join(scratchDir, scratchBinaryName);
689
744
  OwnedSynchronousProcess.checkpoint();
690
745
  publishBuiltBinary(builtBinary, opts.binaryPath);
@@ -697,7 +752,14 @@ function compileSourcePlugin(opts: {
697
752
  SourceNativeRetirement.releaseResource(
698
753
  scratchDir,
699
754
  () => {
700
- fs.rmSync(scratchDir, { recursive: true, force: true });
755
+ // Retry transient Windows removal refusals only after native closure
756
+ // has authorized this resource callback.
757
+ fs.rmSync(scratchDir, {
758
+ recursive: true,
759
+ force: true,
760
+ maxRetries: 10,
761
+ retryDelay: 100,
762
+ });
701
763
  SourceNativeRetirement.forget(opts.cacheDir);
702
764
  },
703
765
  taskFailure,
@@ -1194,12 +1256,20 @@ function runGoBuild(
1194
1256
  goBuildCacheRoot: string,
1195
1257
  env: NodeJS.ProcessEnv,
1196
1258
  normalizeGoToolPermissions: boolean,
1259
+ actionGraph?: string,
1197
1260
  ): void {
1198
1261
  OwnedSynchronousProcess.checkpoint();
1199
1262
  ensureExecutableGoToolchain(goBinary, normalizeGoToolPermissions);
1200
1263
  const result = spawnGoTool(
1201
1264
  goBinary,
1202
- ["build", ...GoSourceInputs.BUILD_FLAGS, "-o", binaryName, entry],
1265
+ [
1266
+ "build",
1267
+ ...GoSourceInputs.BUILD_FLAGS,
1268
+ ...(actionGraph ? [`-debug-actiongraph=${actionGraph}`] : []),
1269
+ "-o",
1270
+ binaryName,
1271
+ entry,
1272
+ ],
1203
1273
  {
1204
1274
  cwd,
1205
1275
  encoding: "utf8",
@@ -0,0 +1,100 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { SourceNativeRetirement } from "../../../internal/SourceNativeRetirement";
5
+ import { GoBuildCacheCoordination } from "./GoBuildCacheCoordination";
6
+ import { SourceBuildCacheLayout } from "./SourceBuildCacheLayout";
7
+
8
+ /**
9
+ * Reclaim a default-owned package artifact population after size or idle age.
10
+ *
11
+ * The entire base and overlay share an eviction generation. A Go-cache
12
+ * maintenance intent excludes cooperating builders while the native guard
13
+ * reader retains unknown process-tree outcomes. Explicit cache roots are never
14
+ * passed here. Metadata accounting is bounded and cannot establish a hard disk
15
+ * ceiling while another process writes or a protected generation remains.
16
+ *
17
+ * @evidence contracts/common.md#principled-implementation The same maintenance and native-guard gates used by the owning Go cache precede deletion of the sibling artifact population.
18
+ * @evidence contracts/common.md#clear-and-simple-design One idle/size decision reclaims a complete base-and-overlay generation, leaving future builds to prepare a fresh root.
19
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts Unknown files, aliases and unresolved native owners refuse reclamation rather than being silently ignored.
20
+ * @evidence contracts/common.md#meaningful-documentation The comment states whole-generation eviction and its concurrency and accounting limits.
21
+ * @evidence contracts/portability.md#os-neutral-implementation Native path, directory and metadata APIs inspect ordinary entries on Windows and POSIX.
22
+ * @evidence contracts/performance.md#efficient-algorithms A bounded metadata walk visits at most 100000 entries and six directory levels; it reads no body bytes.
23
+ * @evidenceExclude contracts/performance.md#reuse-equivalent-work This routine decides deletion from mutable ownership and filesystem state, not reusable compilation identity.
24
+ * @evidence contracts/performance.md#bound-retention-and-release-resources Sixteen GiB or 30 idle days triggers best-effort generation removal; active/unknown owners, locked files and errors can defer reclamation without a hard ceiling.
25
+ */
26
+ export function prunePackageArtifactRoot(
27
+ goBuildRoot: string,
28
+ artifactRoot: string,
29
+ now: number = Date.now(),
30
+ ): void {
31
+ let intent:
32
+ | GoBuildCacheCoordination.GoBuildCacheCoordinationRecord
33
+ | undefined;
34
+ try {
35
+ const cacheRoot =
36
+ GoBuildCacheCoordination.canonicalGoBuildCacheRoot(goBuildRoot);
37
+ const parent = fs.realpathSync.native(path.dirname(artifactRoot));
38
+ if (
39
+ parent !== path.dirname(cacheRoot) ||
40
+ path.basename(artifactRoot) !==
41
+ SourceBuildCacheLayout.PACKAGE_ARTIFACTS_DIRNAME
42
+ )
43
+ return;
44
+ const info = fs.lstatSync(artifactRoot);
45
+ if (!info.isDirectory() || info.isSymbolicLink()) return;
46
+ const physical = fs.realpathSync.native(artifactRoot);
47
+ if (path.dirname(physical) !== parent) return;
48
+ if (SourceNativeRetirement.isProtected(physical)) return;
49
+
50
+ intent = GoBuildCacheCoordination.createGoBuildCacheCoordinationRecord(
51
+ cacheRoot,
52
+ GoBuildCacheCoordination.GO_BUILD_CACHE_MAINTENANCE_DIR,
53
+ );
54
+ if (!intent.startHeartbeat()) return;
55
+ if (
56
+ GoBuildCacheCoordination.collectLiveGoBuildCacheCoordinationRecords(
57
+ cacheRoot,
58
+ GoBuildCacheCoordination.GO_BUILD_CACHE_LEASE_DIR,
59
+ now,
60
+ ).length !== 0 ||
61
+ SourceNativeRetirement.isProtected(physical)
62
+ )
63
+ return;
64
+
65
+ const marker = path.join(physical, ".last-used");
66
+ const lastUsed = SourceBuildCacheLayout.readTimestamp(marker);
67
+ const expired = lastUsed !== null && now - lastUsed > 30 * DAY_MS;
68
+ const bytes = countArtifactBytes(physical);
69
+ if (expired || bytes > 16 * GIB) fs.rmSync(physical, { recursive: true });
70
+ } catch {
71
+ // A failed ownership observation or locked Windows body defers collection.
72
+ } finally {
73
+ intent?.finish();
74
+ }
75
+ }
76
+
77
+ const DAY_MS = 24 * 60 * 60 * 1000;
78
+ const GIB = 1024 * 1024 * 1024;
79
+
80
+ function countArtifactBytes(root: string): number {
81
+ let entries = 0;
82
+ let total = 0;
83
+ const walk = (directory: string, depth: number): void => {
84
+ if (depth > 6) throw new Error("artifact directory depth exceeds policy");
85
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
86
+ if (++entries > 100000)
87
+ throw new Error("artifact entry count exceeds policy");
88
+ const child = path.join(directory, entry.name);
89
+ const info = fs.lstatSync(child);
90
+ if (info.isSymbolicLink()) throw new Error("aliased artifact entry");
91
+ if (info.isDirectory()) walk(child, depth + 1);
92
+ else if (info.isFile()) total += info.size;
93
+ else throw new Error("unknown artifact entry");
94
+ if (!Number.isSafeInteger(total))
95
+ throw new Error("artifact size exceeds policy");
96
+ }
97
+ };
98
+ walk(root, 0);
99
+ return total;
100
+ }
@@ -55,6 +55,7 @@ export function resolveCleanTargets(
55
55
  if (isTtscOwnedRoot) {
56
56
  targets.push(
57
57
  path.join(paths.root, SourceBuildCacheLayout.GO_BUILD_CACHE_DIRNAME),
58
+ path.join(paths.root, SourceBuildCacheLayout.PACKAGE_ARTIFACTS_DIRNAME),
58
59
  );
59
60
  }
60
61
  // An explicit TTSC_GO_CACHE_DIR is a ttsc-dedicated external cache; a
@@ -94,8 +94,10 @@ function resolveSourceBuildCacheRoot(
94
94
  * Walks up from `projectRoot` and returns, in order of preference: the NEAREST
95
95
  * ancestor that is a workspace root (holds `pnpm-workspace.yaml`, or a
96
96
  * `package.json` with a `workspaces` field); else the nearest ancestor that
97
- * contains an installation; else the outermost ttsc-only cache owner at or
98
- * below the nearest ordinary package manifest, or `projectRoot` itself.
97
+ * contains an installation within the package boundary; else the outermost
98
+ * ttsc-only cache owner at or below the nearest ordinary package manifest, or
99
+ * `projectRoot` itself. An unrelated ancestor's installation without its own
100
+ * package manifest cannot take ownership after a package boundary is crossed.
99
101
  *
100
102
  * A nonempty, unmarked ttsc-only `.cache/ttsc` payload is fallback cache
101
103
  * evidence rather than an installation. Empty or marked ttsc roots remain
@@ -116,13 +118,18 @@ function resolveWorkspaceRoot(projectRoot: string): string {
116
118
  if (isWorkspaceRootDir(dir)) {
117
119
  return dir;
118
120
  }
121
+ const packageHere = hasPackageManifest(dir);
119
122
  const nodeModules = classifyNodeModulesBoundary(dir);
120
- if (nearestNodeModulesOwner === null && nodeModules === "installation") {
123
+ if (
124
+ nearestNodeModulesOwner === null &&
125
+ nodeModules === "installation" &&
126
+ (!packageBoundarySeen || packageHere)
127
+ ) {
121
128
  nearestNodeModulesOwner = dir;
122
129
  } else if (nodeModules === "ttsc-cache" && !packageBoundarySeen) {
123
130
  outermostTtscCacheOwner = dir;
124
131
  }
125
- packageBoundarySeen ||= hasPackageManifest(dir);
132
+ packageBoundarySeen ||= packageHere;
126
133
  const parent = path.dirname(dir);
127
134
  if (parent === dir) {
128
135
  break;
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Return an existing ordinary identities directory below a selected record
6
+ * root, or leave temporary placement to the caller's system default.
7
+ *
8
+ * The returned physical path is a direct child of the selected root. The caller
9
+ * still uses createCanonicalTempDirectory to verify its own allocation; these
10
+ * observations do not hold the namespace against later mutation.
11
+ *
12
+ * @evidence contracts/common.md#principled-implementation Native realpath and link-preserving stat admit only a present ordinary identities directory whose physical parent is the selected root; an unproved path supplies no alternate temporary parent.
13
+ * @evidence contracts/common.md#clear-and-simple-design One small selector shares the same record-root placement rule between descriptor evaluation and child output capture; allocation and cleanup remain with their respective owners.
14
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts The function neither creates a cache root nor accepts a linked identities entry as evidence of an owned child; failed observations retain the ordinary caller path.
15
+ * @evidence contracts/common.md#meaningful-documentation The comment states the selected-root premise, returned physical path and remaining namespace race without claiming held-directory protection.
16
+ * @evidence contracts/portability.md#os-neutral-implementation Node native path and filesystem APIs observe the actual root and child without assuming case policy or a fixed temporary volume; a junctioned selected root can resolve to its physical directory, while the identities entry itself must be ordinary.
17
+ * @evidence contracts/performance.md#efficient-algorithms A fixed number of native path observations and path-text operations selects the parent without enumeration; lookup depth and path length determine their underlying cost.
18
+ * @evidenceExclude contracts/performance.md#reuse-equivalent-work A record root or identities entry can be replaced between calls, so each allocation needs current observations instead of a remembered parent.
19
+ * @evidenceExclude contracts/performance.md#bound-retention-and-release-resources The selector acquires no handle or directory and retains no state; its returned path is caller-owned text.
20
+ */
21
+ export function selectedRecordIdentityTempParent(
22
+ recordRoot: string | undefined,
23
+ ): string | undefined {
24
+ if (recordRoot === undefined || !path.isAbsolute(recordRoot))
25
+ return undefined;
26
+ try {
27
+ const physicalRoot = fs.realpathSync.native(recordRoot);
28
+ if (!fs.lstatSync(physicalRoot).isDirectory()) return undefined;
29
+ const identities = path.join(recordRoot, "identities");
30
+ const identityStats = fs.lstatSync(identities);
31
+ if (!identityStats.isDirectory() || identityStats.isSymbolicLink())
32
+ return undefined;
33
+ const physicalIdentities = fs.realpathSync.native(identities);
34
+ return path.dirname(physicalIdentities) === physicalRoot
35
+ ? physicalIdentities
36
+ : undefined;
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }