ttsc 0.31.2 → 0.31.4

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 (43) hide show
  1. package/lib/compiler/internal/build/BuildExecution.d.ts +2 -1
  2. package/lib/compiler/internal/build/BuildExecution.js +33 -4
  3. package/lib/compiler/internal/build/BuildExecution.js.map +1 -1
  4. package/lib/compiler/internal/build/RunBuildOptions.d.ts +2 -1
  5. package/lib/compiler/internal/build/runExternalEmitProvenance.d.ts +15 -0
  6. package/lib/compiler/internal/build/runExternalEmitProvenance.js +67 -4
  7. package/lib/compiler/internal/build/runExternalEmitProvenance.js.map +1 -1
  8. package/lib/internal/probeLocalProcess.d.ts +27 -0
  9. package/lib/internal/probeLocalProcess.js +62 -0
  10. package/lib/internal/probeLocalProcess.js.map +1 -0
  11. package/lib/launcher/internal/runTtsc.js +2 -2
  12. package/lib/launcher/internal/runTtsc.js.map +1 -1
  13. package/lib/launcher/internal/runtime/isLocalProcessGone.d.ts +9 -9
  14. package/lib/launcher/internal/runtime/isLocalProcessGone.js +13 -20
  15. package/lib/launcher/internal/runtime/isLocalProcessGone.js.map +1 -1
  16. package/lib/launcher/internal/watch/WatchTopology.d.ts +20 -3
  17. package/lib/launcher/internal/watch/WatchTopology.js +31 -9
  18. package/lib/launcher/internal/watch/WatchTopology.js.map +1 -1
  19. package/lib/plugin/internal/load/loadProjectPlugins.d.ts +5 -2
  20. package/lib/plugin/internal/load/loadProjectPlugins.js +12 -5
  21. package/lib/plugin/internal/load/loadProjectPlugins.js.map +1 -1
  22. package/lib/plugin/internal/source/GoBuildCacheCoordination.js +4 -1
  23. package/lib/plugin/internal/source/GoBuildCacheCoordination.js.map +1 -1
  24. package/lib/plugin/internal/source/PluginBuildLockOwner.d.ts +8 -7
  25. package/lib/plugin/internal/source/PluginBuildLockOwner.js +10 -14
  26. package/lib/plugin/internal/source/PluginBuildLockOwner.js.map +1 -1
  27. package/lib/plugin/internal/source/SourcePluginWorkspace.d.ts +13 -5
  28. package/lib/plugin/internal/source/SourcePluginWorkspace.js +139 -5
  29. package/lib/plugin/internal/source/SourcePluginWorkspace.js.map +1 -1
  30. package/lib/structures/internal/TtscSingleFileEmitOptions.d.ts +5 -1
  31. package/package.json +8 -8
  32. package/src/compiler/internal/build/BuildExecution.ts +35 -7
  33. package/src/compiler/internal/build/RunBuildOptions.ts +2 -1
  34. package/src/compiler/internal/build/runExternalEmitProvenance.ts +84 -4
  35. package/src/internal/probeLocalProcess.ts +56 -0
  36. package/src/launcher/internal/runTtsc.ts +6 -2
  37. package/src/launcher/internal/runtime/isLocalProcessGone.ts +14 -19
  38. package/src/launcher/internal/watch/WatchTopology.ts +38 -9
  39. package/src/plugin/internal/load/loadProjectPlugins.ts +25 -14
  40. package/src/plugin/internal/source/GoBuildCacheCoordination.ts +4 -1
  41. package/src/plugin/internal/source/PluginBuildLockOwner.ts +10 -13
  42. package/src/plugin/internal/source/SourcePluginWorkspace.ts +175 -6
  43. package/src/structures/internal/TtscSingleFileEmitOptions.ts +8 -1
@@ -1,15 +1,17 @@
1
1
  import os from "node:os";
2
2
 
3
+ import { probeLocalProcess } from "../../../internal/probeLocalProcess";
4
+
3
5
  /**
4
6
  * Whether the process an owner record names has certainly exited: it ran on
5
7
  * this host, and no process with its pid is alive.
6
8
  *
7
9
  * A record from another host proves nothing, because its pid names a process
8
10
  * this machine cannot see, so it is never reported gone. An `EPERM` from the
9
- * probe does not establish absence, so it counts as alive. Only `ESRCH` proves
10
- * absence; other probe failures remain unknown. A recycled pid makes the answer
11
- * err toward "alive": a caller that reclaims on "gone" never takes a live
12
- * owner's state.
11
+ * probe does not establish absence, so it counts as alive. Only `ESRCH`, or a
12
+ * Linux zombie that has exited but was never collected, proves absence; other
13
+ * probe failures remain unknown. A recycled pid makes the answer err toward
14
+ * "alive": a caller that reclaims on "gone" never takes a live owner's state.
13
15
  *
14
16
  * Callers supply a validated positive process id, not a process-group selector.
15
17
  * Hostname comparison is a host-label convention, not machine authentication:
@@ -18,13 +20,13 @@ import os from "node:os";
18
20
  * An optional diagnostic receives this same observation without another probe;
19
21
  * a diagnostic failure cannot change the ownership decision.
20
22
  *
21
- * @evidence contracts/common.md#principled-implementation Reclamation requires both the recorded local hostname and ESRCH from the native pid probe; permission, invalid-pid and unknown failures cannot establish that an owner is gone.
22
- * @evidence contracts/common.md#clear-and-simple-design One conservative predicate supplies the same owner-liveness decision to lock recovery and directory sweeping.
23
- * @evidence contracts/common.md#prohibited-implementation-shortcuts Native process existence is queried through process.kill(pid, 0); no fixture pid or broad error-to-dead fallback substitutes for ownership evidence.
24
- * @evidence contracts/common.md#meaningful-documentation The native explanation distinguishes a remote host, a denied or unknown probe and pid recycling, stating why ambiguity protects a live owner's state.
23
+ * @evidence contracts/common.md#principled-implementation Reclamation requires both the recorded local hostname and probeLocalProcess's absence proof, ESRCH or an uncollected Linux zombie; permission, invalid-pid and unknown failures cannot establish that an owner is gone.
24
+ * @evidence contracts/common.md#clear-and-simple-design One conservative predicate supplies the same owner-liveness decision to lock recovery and directory sweeping, and shares its probe with plugin build locks.
25
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts Native process existence is queried through process.kill(pid, 0) and the kernel's /proc state; no fixture pid or broad error-to-dead fallback substitutes for ownership evidence.
26
+ * @evidence contracts/common.md#meaningful-documentation The native explanation distinguishes a remote host, a denied or unknown probe, an uncollected zombie and pid recycling, stating why ambiguity protects a live owner's state.
25
27
  * @evidence contracts/portability.md#os-neutral-implementation Node exposes the host's process-existence probe and portable error codes; hostname comparison scopes pid identity to this host without assuming POSIX signals can terminate Windows processes.
26
28
  * @evidenceExclude contracts/performance.md#bound-retention-and-release-resources This observation retains no handle, historical state or cache; temporary lowercase hostname strings become reclaimable after return.
27
- * @evidence contracts/performance.md#efficient-algorithms Native hostname lookup and two lowercase string constructions/comparison process host-label text before at most one native pid probe; the fixed probe count is not a fixed text or operating-system cost.
29
+ * @evidence contracts/performance.md#efficient-algorithms Native hostname lookup and two lowercase string constructions/comparison process host-label text before at most one native pid probe and, on Linux, one /proc state read; the fixed probe count is not a fixed text or operating-system cost.
28
30
  * @evidenceExclude contracts/performance.md#reuse-equivalent-work Host labels and native process existence are current observations; this predicate coordinates no cross-call cache or reusable liveness proof.
29
31
  */
30
32
  export function isLocalProcessGone(
@@ -54,14 +56,7 @@ export function isLocalProcessGone(
54
56
  report("remote");
55
57
  return false;
56
58
  }
57
- try {
58
- process.kill(owner.pid, 0);
59
- report("present");
60
- return false;
61
- } catch (error) {
62
- const code = (error as NodeJS.ErrnoException | null)?.code;
63
- const absent = code === "ESRCH";
64
- report(absent ? "absent" : "unknown", code);
65
- return absent;
66
- }
59
+ const { result, errorCode } = probeLocalProcess(owner.pid);
60
+ report(result, errorCode);
61
+ return result === "absent";
67
62
  }
@@ -14,9 +14,9 @@ import { type ProjectInputPathIdentityContext } from "../../../internal/pathIden
14
14
  import { createProjectInputPathIdentityContext } from "../../../internal/pathIdentity/createProjectInputPathIdentityContext";
15
15
  import { isProjectInputPathIdentityWithin } from "../../../internal/pathIdentity/isProjectInputPathIdentityWithin";
16
16
  import { resolveProjectInputPath } from "../../../internal/pathIdentity/resolveProjectInputPath";
17
+ import { PluginContentIdentities } from "../../../plugin/internal/source/PluginContentIdentities";
17
18
  import { collectPluginSourceDirectories } from "../../../plugin/internal/source/collectPluginSourceDirectories";
18
19
  import { pluginSourceCovers } from "../../../plugin/internal/source/pluginSourceCovers";
19
- import { pluginSourceDigest } from "../../../plugin/internal/source/pluginSourceDigest";
20
20
  import { prunesPluginSourceDirectory } from "../../../plugin/internal/source/prunesPluginSourceDirectory";
21
21
  import type { ITtscParsedProjectConfig } from "../../../structures/internal/ITtscParsedProjectConfig";
22
22
  import type { ITtscProjectInputSnapshot } from "../../../structures/internal/ITtscProjectInputSnapshot";
@@ -84,6 +84,13 @@ export class WatchTopology {
84
84
  */
85
85
  private pluginInputStates = new Map<string, string>();
86
86
 
87
+ /**
88
+ * The record store of the latest plugin load that reported the inputs, which
89
+ * proves their states from the plugin cache's records (#1728). Absent before
90
+ * a load reports one, or when it opened none; the states are read then.
91
+ */
92
+ private pluginInputIdentities: PluginContentIdentities.Store | undefined;
93
+
87
94
  /**
88
95
  * The plugin inputs notifications named since the last decision, by path key,
89
96
  * each with the locations noted for it (`notePluginNotification`).
@@ -331,17 +338,33 @@ export class WatchTopology {
331
338
  * observation. This does not freeze the source tree or prove every transient
332
339
  * edit was seen.
333
340
  *
341
+ * WARNING (#1728): take each baseline through the reporting load's record
342
+ * store (`identities`), never by reading the sources again. The load proves
343
+ * the same directories from the plugin cache's records a moment later, and a
344
+ * content read here cost every watch session start 0.3-0.6 s for typia's Go
345
+ * module. The store proves only a digest whose metadata still holds and is
346
+ * separable from its reference, so the baseline is the digest a content read
347
+ * would return at this moment.
348
+ *
349
+ * @param inputs Plugin source directories and files the load reported.
350
+ * @param identities The reporting load's record store, if it opened one.
334
351
  * @evidence contracts/common.md#principled-implementation Published plugin inputs define the source corpus, and retaining existing pre-build baselines keeps unreported edits visible.
335
352
  * @evidence contracts/common.md#clear-and-simple-design Deduplication, baseline retention and compiler/watch reconciliation remain ordered phases.
336
353
  * @evidence contracts/common.md#prohibited-implementation-shortcuts The actual plugin source digest applies its owning source-selection and pruning policy; named plugins or fixture output do not replace observed input state.
337
354
  * @evidence contracts/common.md#meaningful-documentation Native prose explains baseline timing and why existing inputs are not restamped following the documentation skill.
338
355
  * @evidence contracts/portability.md#os-neutral-implementation Native path resolution and canonical backend registration govern source trees; lexical keys preserve declarations and unknown names, folding ASCII only under measured insensitive parents rather than an OS-derived rule.
339
- * @evidence contracts/performance.md#efficient-algorithms Input/path keys deduplicate with text and native case/ancestor work; only newly admitted inputs compute initial source digests. Those reads scan the selected pruned corpus, then refresh(false) also pays project/compiler listing, directory traversal and watcher reconciliation; fixed publication count does not bound delegated bytes or backend work.
340
- * @evidence contracts/performance.md#reuse-equivalent-work Unchanged ordered input sets return immediately; existing inputs reuse their recorded digest until actual notification processing establishes changed consumed state.
356
+ * @evidence contracts/performance.md#efficient-algorithms Input/path keys deduplicate with text and native case/ancestor work; only newly admitted inputs compute initial source digests, each proven from the selected files' metadata when the load's records hold and read from the selected pruned corpus otherwise; refresh(false) also pays project/compiler listing, directory traversal and watcher reconciliation; fixed publication count does not bound delegated bytes or backend work.
357
+ * @evidence contracts/performance.md#reuse-equivalent-work Unchanged ordered input sets return immediately; existing inputs reuse their recorded digest until actual notification processing establishes changed consumed state; a new input's digest is the one the load recorded while its metadata holds.
341
358
  * @evidence contracts/performance.md#bound-retention-and-release-resources Publications replace input/digest populations and case-observation caches without a population cap; synchronization attempts obsolete watcher closure. Native/supplied closure has its own lifetime and can throw; owner close does not prove every backend has stopped or every retained snapshot byte was released.
342
359
  */
343
- public setExtraInputs(inputs: readonly string[]): void {
360
+ public setExtraInputs(
361
+ inputs: readonly string[],
362
+ identities?: PluginContentIdentities.Store,
363
+ ): void {
344
364
  this.beginLexicalTransaction();
365
+ // The latest load's store also serves later notifications; its references
366
+ // only grow older, which can make fewer stamps separable but never more.
367
+ this.pluginInputIdentities = identities;
345
368
  const next = uniqueExistingPaths(inputs, this.lexicalIdentities);
346
369
  if (arraysEqual(this.extraInputs, next)) return;
347
370
  this.extraInputs = next;
@@ -354,7 +377,8 @@ export class WatchTopology {
354
377
  const key = this.pathKey(input);
355
378
  states.set(
356
379
  key,
357
- this.pluginInputStates.get(key) ?? pluginInputState(input),
380
+ this.pluginInputStates.get(key) ??
381
+ pluginInputState(input, this.pluginInputIdentities),
358
382
  );
359
383
  }
360
384
  this.pluginInputStates = states;
@@ -1664,7 +1688,7 @@ export class WatchTopology {
1664
1688
  const key = this.pathKey(input);
1665
1689
  const locations = pending.get(key);
1666
1690
  if (locations === undefined) continue;
1667
- const state = pluginInputState(input);
1691
+ const state = pluginInputState(input, this.pluginInputIdentities);
1668
1692
  if (this.pluginInputStates.get(key) === state) continue;
1669
1693
  this.pluginInputStates.set(key, state);
1670
1694
  for (const location of locations) {
@@ -2739,12 +2763,17 @@ function isRecord(value: unknown): value is Record<string, unknown> {
2739
2763
  * What a plugin build keys a plugin input on: the digest of the files its build
2740
2764
  * reads from a source directory (`pluginSourceDigest`), or a file's bytes. An
2741
2765
  * input that cannot be read has a state of its own, so the change that made it
2742
- * unreadable is reported and the build names the failure.
2766
+ * unreadable is reported and the build names the failure. A directory's digest
2767
+ * comes from the load's record store while the selected files' metadata holds
2768
+ * there (#1728), and from the files otherwise.
2743
2769
  */
2744
- function pluginInputState(input: string): string {
2770
+ function pluginInputState(
2771
+ input: string,
2772
+ identities: PluginContentIdentities.Store | undefined,
2773
+ ): string {
2745
2774
  try {
2746
2775
  return WatchPaths.isDirectory(input)
2747
- ? `directory:${pluginSourceDigest(input)}`
2776
+ ? `directory:${PluginContentIdentities.sourceDirectory(identities, input)}`
2748
2777
  : `file:${fingerprintProjectInputFile(input)}`;
2749
2778
  } catch (error) {
2750
2779
  return `unreadable:${error instanceof Error ? error.message : String(error)}`;
@@ -130,8 +130,14 @@ export function loadProjectPlugins(options: {
130
130
  */
131
131
  identities?: PluginContentIdentities.Store;
132
132
 
133
- /** Observe plugin build roots before building, including failed builds. */
134
- onWatchInputs?: (inputs: readonly string[]) => void;
133
+ /**
134
+ * Observe plugin build roots before building, including failed builds, with
135
+ * the record store that proves their states (#1728).
136
+ */
137
+ onWatchInputs?: (
138
+ inputs: readonly string[],
139
+ identities?: PluginContentIdentities.Store,
140
+ ) => void;
135
141
 
136
142
  /** Caller-selected anchor for plugin-owned configuration discovery. */
137
143
  pluginConfigDir?: string;
@@ -425,7 +431,10 @@ export function loadProjectPlugins(options: {
425
431
  ])
426
432
  .filter(reportsPluginSource),
427
433
  );
428
- options.onWatchInputs([...interests].sort());
434
+ // WARNING (#1728): hand the watch owner this load's store with the roots.
435
+ // It baselines each root before any build reads it; without the store it
436
+ // read every source the load proves from records a moment later.
437
+ options.onWatchInputs([...interests].sort(), sourceIdentities);
429
438
  const initialSize = interests.size;
430
439
  for (const candidate of candidates) {
431
440
  // Nested source-only proposals replace the outer root manifests. Do not
@@ -440,7 +449,7 @@ export function loadProjectPlugins(options: {
440
449
  if (reportsPluginSource(directory)) interests.add(directory);
441
450
  }
442
451
  if (interests.size !== initialSize)
443
- options.onWatchInputs([...interests].sort());
452
+ options.onWatchInputs([...interests].sort(), sourceIdentities);
444
453
  }
445
454
  const proposals = NativeSourcePackages.propose(
446
455
  candidates.map((candidate) => ({
@@ -520,16 +529,16 @@ export function loadProjectPlugins(options: {
520
529
  records[candidate.index]!.kind === "executable" &&
521
530
  !proposals[candidate.index]!.ownModule,
522
531
  );
532
+ const buildDirectories = pluginBuildDirectories(
533
+ records,
534
+ effectiveEnv,
535
+ watchReplacements,
536
+ packageReaders,
537
+ );
523
538
  // Admission can now narrow the provisional interests without restamping the
524
539
  // retained package/module baselines. Final host errors still keep these roots.
525
- options.onWatchInputs?.(
526
- pluginBuildDirectories(
527
- records,
528
- effectiveEnv,
529
- watchReplacements,
530
- packageReaders,
531
- ),
532
- );
540
+ options.onWatchInputs?.(buildDirectories, sourceIdentities);
541
+ const keyedDirectories = new Set(buildDirectories);
533
542
  const linkedContributors = records
534
543
  .filter((record) => record.stage === "transform")
535
544
  .flatMap((record) =>
@@ -771,10 +780,12 @@ export function loadProjectPlugins(options: {
771
780
  ? { observationsComplete: false as const }
772
781
  : {}),
773
782
  // The directories the watch inputs named before the builds, as the builds
774
- // read them.
783
+ // read them. Package selection shares these readings and also reads a
784
+ // linked plugin's module root, which no build keys on, so only the build
785
+ // directories are reported.
775
786
  pluginSources: Object.fromEntries(
776
787
  [...sourceDigests]
777
- .filter(([directory]) => reportsPluginSource(directory))
788
+ .filter(([directory]) => keyedDirectories.has(directory))
778
789
  .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
779
790
  .map(([directory, sourceDigest]) => [
780
791
  directory,
@@ -322,6 +322,9 @@ export namespace GoBuildCacheCoordination {
322
322
  // the child process required for `go build`. The fallback inherits only the
323
323
  // low standard descriptors and checks its parent PID. An IPC channel would
324
324
  // allocate another high descriptor and recreate Darwin's spawn EBADF limit.
325
+ // A dead parent that nobody collects stays a zombie and still answers
326
+ // signal zero, but POSIX reparents the heartbeat when its parent exits, so
327
+ // a changed parent id ends it as well; Windows never reparents.
325
328
  const ready = `${file}.heartbeat-${crypto.randomBytes(16).toString("hex")}`;
326
329
  let heartbeatChild: ReturnType<typeof spawn> | undefined;
327
330
  try {
@@ -334,7 +337,7 @@ export namespace GoBuildCacheCoordination {
334
337
  "const parent = Number(process.argv[3]);",
335
338
  "const ready = process.argv[4];",
336
339
  "const timer = setInterval(() => {",
337
- " try { process.kill(parent, 0); } catch { clearInterval(timer); process.exit(0); }",
340
+ " try { if (process.ppid !== parent) throw 0; process.kill(parent, 0); } catch { clearInterval(timer); process.exit(0); }",
338
341
  " try {",
339
342
  ' if (JSON.parse(fs.readFileSync(file, "utf8")).status === "complete") {',
340
343
  " clearInterval(timer); process.exit(0);",
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
+ import { probeLocalProcess } from "../../../internal/probeLocalProcess";
5
6
  import { PluginBuildLockProtocol } from "./PluginBuildLockProtocol";
6
7
 
7
8
  /**
@@ -177,15 +178,16 @@ export namespace PluginBuildLockOwner {
177
178
  * process with its id exists. A holder on another host, or one whose id is
178
179
  * alive (possibly reused), is not proven gone.
179
180
  *
180
- * Only ESRCH proves absence. Permission errors, invalid probe arguments and
181
- * other failures remain inconclusive and must not authorize reclamation.
181
+ * Only ESRCH, or a Linux zombie that exited but was never collected, proves
182
+ * absence. Permission errors, invalid probe arguments and other failures
183
+ * remain inconclusive and must not authorize reclamation.
182
184
  *
183
- * @evidence contracts/common.md#principled-implementation Signal zero checks existence without terminating the process; same-host ESRCH establishes absence, while success or any other error does not.
184
- * @evidence contracts/common.md#clear-and-simple-design Host comparison and one native probe express the proof boundary directly, without a second process-discovery mechanism.
185
+ * @evidence contracts/common.md#principled-implementation probeLocalProcess checks existence without terminating the process; same-host ESRCH or an uncollected zombie establishes absence, while presence or any other error does not.
186
+ * @evidence contracts/common.md#clear-and-simple-design Host comparison and the shared probe express the proof boundary directly, without a second process-discovery mechanism.
185
187
  * @evidence contracts/common.md#prohibited-implementation-shortcuts Unexpected probe failures are not converted to a dead-owner special case or hidden behind retries.
186
- * @evidence contracts/common.md#meaningful-documentation Native paragraphs distinguish PID reuse, remote owners and each inconclusive failure class before the tags.
187
- * @evidence contracts/portability.md#os-neutral-implementation os.hostname and Node's platform-independent signal-zero probe preserve native host scope; only the explicit no-such-process code authorizes absence.
188
- * @evidence contracts/performance.md#efficient-algorithms A hostname comparison and one process-existence syscall avoid enumerating native processes; string comparison costs grow only with hostname length.
188
+ * @evidence contracts/common.md#meaningful-documentation Native paragraphs distinguish PID reuse, remote owners, uncollected zombies and each inconclusive failure class before the tags.
189
+ * @evidence contracts/portability.md#os-neutral-implementation os.hostname and Node's platform-independent signal-zero probe preserve native host scope; only the explicit no-such-process code, or Linux's own zombie state, authorizes absence.
190
+ * @evidence contracts/performance.md#efficient-algorithms A hostname comparison and one process-existence syscall, plus one /proc read on Linux, avoid enumerating native processes; string comparison costs grow only with hostname length.
189
191
  *
190
192
  * @evidenceExclude contracts/performance.md#reuse-equivalent-work Liveness is a time-sensitive observation; a previously absent or present PID cannot be cached as proof for a later reclamation.
191
193
  * @evidenceExclude contracts/performance.md#bound-retention-and-release-resources The query owns no retained handle or running task and returns only its immediate proof decision.
@@ -193,12 +195,7 @@ export namespace PluginBuildLockOwner {
193
195
  export function gone(owner: IRecord): boolean {
194
196
  if (owner.hostname.toLowerCase() !== os.hostname().toLowerCase())
195
197
  return false;
196
- try {
197
- process.kill(owner.pid, 0);
198
- return false;
199
- } catch (error) {
200
- return (error as NodeJS.ErrnoException).code === "ESRCH";
201
- }
198
+ return probeLocalProcess(owner.pid).result === "absent";
202
199
  }
203
200
 
204
201
  /**
@@ -1,12 +1,15 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
 
5
6
  import { OwnedSynchronousProcess } from "../../../internal/OwnedSynchronousProcess";
7
+ import { runtimeExecutableIdentity } from "../../../internal/runtimeExecutableIdentity";
6
8
  import { GoEnvironmentReading } from "./GoEnvironmentReading";
7
9
  import { GoSourceInputs } from "./GoSourceInputs";
8
10
  import { PluginBuildEnvironmentWitness } from "./PluginBuildEnvironmentWitness";
9
- import type { PluginContentIdentities } from "./PluginContentIdentities";
11
+ import { PluginContentIdentities } from "./PluginContentIdentities";
12
+ import { PluginLoadAnswers } from "./PluginLoadAnswers";
10
13
  import { SourcePluginAdmission } from "./SourcePluginAdmission";
11
14
  import { copiesPluginSourceEntry } from "./copiesPluginSourceEntry";
12
15
  import { formatGoWorkPath } from "./formatGoWorkPath";
@@ -239,9 +242,17 @@ export namespace SourcePluginWorkspace {
239
242
  * registry shares native reader state from the first watch query onward; its
240
243
  * owner must keep one fixed effective environment and discard it on retry.
241
244
  * Caller views share that native state and syntax, while each default
242
- * diagnostic label remains with its current caller. An optional record store
243
- * only carries the Go environment-file discovery hint across processes; it
244
- * never carries a parse.
245
+ * diagnostic label remains with its current caller.
246
+ *
247
+ * An optional record store carries the Go environment-file discovery hint
248
+ * across processes, and the parse of identical bytes by the same Go command
249
+ * (#1729): every launch otherwise ran `go mod edit -json` on an unchanged
250
+ * `go.mod`. A recorded parse is keyed by the manifest bytes, the content
251
+ * identities of the launcher and of the `GOROOT/bin/go` the live `go env
252
+ * -json GOROOT` reports, the `GOENV` file's bytes, and every `GO*`/`CGO_*`
253
+ * variable the command receives. It is consulted and written only under the
254
+ * same qualified tool witness that admits in-load sharing, so the live
255
+ * witness query still runs on every load.
245
256
  *
246
257
  * @evidence contracts/common.md#principled-implementation Actual go mod edit JSON determines module/replacement identities; current bytes, selected/dispatched-tool metadata and Go's environment-file witness qualify reuse under the existing metadata-distinguishability premise. Environment values are copied at reader creation. Missing go.mod returns the existing empty module observation, and moving tool/manifest inputs cannot publish a parsed record.
247
258
  * @evidence contracts/common.md#clear-and-simple-design One factory acquires load-owned native reader state by selected tool; a thin caller view forwards its diagnostic label without duplicating the byte-content map. Current reads and post-parse byte/tool comparisons qualify insertion.
@@ -249,7 +260,7 @@ export namespace SourcePluginWorkspace {
249
260
  * @evidence contracts/common.md#meaningful-documentation Native prose states this operation's authority and delegated boundaries; fields retain separate native comments.
250
261
  * @evidence contracts/portability.md#os-neutral-implementation Native path resolution and temporary cwd avoid deep Windows working-directory limits; the selected tool handles manifest syntax.
251
262
  * @evidence contracts/performance.md#efficient-algorithms Reader acquisition performs a selected-tool registry lookup; every nonmissing read qualifies tool/environment metadata and reads current manifest bytes. A cold or stale witness first observes the selected launcher; an unavailable or nonfile launcher skips optional environment acquisition and permits only the actual uncached parse attempt. An observed file lends its same pre-read stat to the witness before acquiring current Go values and GOENV evidence through the common reader, without a GOROOT walk. Only complete native GOENV/GOROOT locations qualify syntax sharing; a dispatched tool is witnessed before parsing. Unavailable native environment acquisition disables syntax sharing while preserving actual parse success or failure. Each distinct byte string that earns stable successful syntax in that tool epoch runs Go mod edit and complete JSON parsing once, then rechecks bytes/tool metadata before caching; copied manifests share that parse. Failed queries remain uncached and report the current caller's diagnostic label.
252
- * @evidence contracts/performance.md#reuse-equivalent-work The caller may share this reader only within one load and the same pinned tool/effective environment; parsed syntax is not cross-load or native containment authority. Caller views share native state while preserving failure attribution; diagnostic labels are not syntax cache inputs.
263
+ * @evidence contracts/performance.md#reuse-equivalent-work The caller may share this reader only within one load and the same pinned tool/effective environment; parsed syntax is never native containment authority. Across loads only a parse recorded under the content identities of the bytes, both Go executables, the GOENV file and the Go variables is reused, and only under a qualified live tool witness. Caller views share native state while preserving failure attribution; diagnostic labels are not syntax cache inputs.
253
264
  * @evidence contracts/performance.md#bound-retention-and-release-resources The map retains distinct manifest bytes and parsed syntax for its caller's load/build lifetime; process/output capture cleanup remains delegated.
254
265
  */
255
266
  export function createGoModReader(
@@ -279,6 +290,9 @@ export namespace SourcePluginWorkspace {
279
290
  const cache = new Map<string, GoModInfo>();
280
291
  const environment = { ...env };
281
292
  let toolWitness: PluginBuildEnvironmentWitness.Record | undefined;
293
+ // The tool part of a recorded parse's identity, formed with each qualified
294
+ // witness; undefined without a store or a provable tool.
295
+ let toolIdentity: unknown;
282
296
  const reader: GoModReader = {
283
297
  read(dir, diagnosticPlugin = pluginName) {
284
298
  OwnedSynchronousProcess.checkpoint();
@@ -343,6 +357,16 @@ export namespace SourcePluginWorkspace {
343
357
  complete && PluginBuildEnvironmentWitness.holds(witness)
344
358
  ? witness
345
359
  : undefined;
360
+ toolIdentity =
361
+ toolWitness === undefined || identities === undefined
362
+ ? undefined
363
+ : goModToolIdentity(
364
+ goBinary,
365
+ observed!.GOROOT as string,
366
+ observed!.GOENV as string,
367
+ environment,
368
+ identities,
369
+ );
346
370
  }
347
371
  // UTF-8 decoding is lossy for malformed input. Only identical bytes
348
372
  // can share Go syntax, even when Go serializes two inputs identically.
@@ -351,6 +375,26 @@ export namespace SourcePluginWorkspace {
351
375
  if (cached !== undefined) {
352
376
  return cached;
353
377
  }
378
+ // WARNING (#1729): a new process asks the record store before running
379
+ // Go on bytes this exact command already parsed. Never key this on a
380
+ // path or on the reader's metadata witness alone.
381
+ const answer =
382
+ toolIdentity === undefined
383
+ ? undefined
384
+ : [
385
+ toolIdentity,
386
+ crypto.createHash("sha256").update(bytes).digest("hex"),
387
+ ];
388
+ const recalled =
389
+ answer === undefined
390
+ ? undefined
391
+ : recallGoModInfo(
392
+ PluginLoadAnswers.read(identities, GO_MOD_SYNTAX, answer),
393
+ );
394
+ if (recalled !== undefined) {
395
+ cache.set(byteKey, recalled);
396
+ return recalled;
397
+ }
354
398
  const info = readGoModInfo(
355
399
  resolved,
356
400
  goBinary,
@@ -370,7 +414,14 @@ export namespace SourcePluginWorkspace {
370
414
  );
371
415
  // An unavailable environment observation can still execute a fresh Go
372
416
  // parse, but it cannot authorize sharing that result on another call.
373
- if (toolWitness !== undefined) cache.set(byteKey, info);
417
+ if (toolWitness !== undefined) {
418
+ cache.set(byteKey, info);
419
+ if (answer !== undefined)
420
+ PluginLoadAnswers.write(identities, GO_MOD_SYNTAX, answer, {
421
+ modulePath: info.modulePath,
422
+ directives: info.directives,
423
+ });
424
+ }
374
425
  return info;
375
426
  },
376
427
  };
@@ -450,6 +501,124 @@ export namespace SourcePluginWorkspace {
450
501
  };
451
502
  }
452
503
 
504
+ /** Answer family of `go mod edit -json` parses (`PluginLoadAnswers`). */
505
+ const GO_MOD_SYNTAX = "go-mod-syntax";
506
+
507
+ /**
508
+ * Everything besides the manifest bytes that decides how `go mod edit -json`
509
+ * parses them, by content: the launcher, the `GOROOT/bin/go` the live query
510
+ * reported, the `GOENV` file that can select another toolchain, and the Go
511
+ * variables the command receives. `undefined` when an executable cannot be
512
+ * identified or the file cannot be read, so the parse runs.
513
+ */
514
+ function goModToolIdentity(
515
+ goBinary: string,
516
+ goRoot: string,
517
+ goEnv: string,
518
+ env: NodeJS.ProcessEnv,
519
+ identities: PluginContentIdentities.Store,
520
+ ): unknown {
521
+ const dispatchedPath = path.join(
522
+ goRoot,
523
+ "bin",
524
+ process.platform === "win32" ? "go.exe" : "go",
525
+ );
526
+ // Only an executable the store can prove from metadata may key the record:
527
+ // streaming both executables on a device without a reference would cost
528
+ // more than the parse it replaces.
529
+ if (
530
+ !provableFromMetadata(goBinary, identities) ||
531
+ !provableFromMetadata(dispatchedPath, identities)
532
+ )
533
+ return undefined;
534
+ const launcher = runtimeExecutableIdentity(goBinary, identities);
535
+ const dispatched = runtimeExecutableIdentity(dispatchedPath, identities);
536
+ if (launcher === undefined || dispatched === undefined) return undefined;
537
+ let goEnvFile: string | null;
538
+ if (goEnv === "off") goEnvFile = "off";
539
+ else
540
+ try {
541
+ goEnvFile = crypto
542
+ .createHash("sha256")
543
+ .update(fs.readFileSync(goEnv))
544
+ .digest("hex");
545
+ } catch (error) {
546
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT")
547
+ return undefined;
548
+ goEnvFile = null;
549
+ }
550
+ return [
551
+ 1,
552
+ launcher,
553
+ goRoot,
554
+ dispatched,
555
+ goEnv,
556
+ goEnvFile,
557
+ Object.entries(GoSourceInputs.goBuildEnv(goBinary, undefined, env))
558
+ .filter(
559
+ (entry): entry is [string, string] =>
560
+ entry[1] !== undefined && /^(GO|CGO_)/i.test(entry[0]),
561
+ )
562
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
563
+ ];
564
+ }
565
+
566
+ /**
567
+ * Whether the file's stamp is separable from the store's reference on its
568
+ * device, the precondition for proving its bytes from a record.
569
+ */
570
+ function provableFromMetadata(
571
+ file: string,
572
+ identities: PluginContentIdentities.Store,
573
+ ): boolean {
574
+ try {
575
+ return PluginContentIdentities.separable(
576
+ identities.references,
577
+ fs.statSync(file, { bigint: true }),
578
+ );
579
+ } catch {
580
+ return false;
581
+ }
582
+ }
583
+
584
+ /**
585
+ * A recorded parse in the shape `readGoModInfo` returns, or `undefined` when
586
+ * the record does not have it.
587
+ */
588
+ function recallGoModInfo(value: unknown): GoModInfo | undefined {
589
+ if (typeof value !== "object" || value === null) return undefined;
590
+ const record = value as { modulePath?: unknown; directives?: unknown };
591
+ if (
592
+ (record.modulePath !== null && typeof record.modulePath !== "string") ||
593
+ !Array.isArray(record.directives) ||
594
+ !record.directives.every(isReplaceDirective)
595
+ )
596
+ return undefined;
597
+ const directives = record.directives as NonNullable<GoModJson["Replace"]>;
598
+ return {
599
+ modulePath: record.modulePath,
600
+ directives,
601
+ replacements: directives
602
+ .map(jsonReplacementToGoModReplacement)
603
+ .filter((replacement) => replacement !== null),
604
+ };
605
+ }
606
+
607
+ function isReplaceDirective(value: unknown): boolean {
608
+ if (typeof value !== "object" || value === null) return false;
609
+ const directive = value as Record<string, unknown>;
610
+ return [directive.Old, directive.New].every(
611
+ (side) =>
612
+ side === undefined ||
613
+ (typeof side === "object" &&
614
+ side !== null &&
615
+ ["Path", "Version"].every((key) => {
616
+ const field = (side as Record<string, unknown>)[key];
617
+ return field === undefined || typeof field === "string";
618
+ })),
619
+ );
620
+ }
621
+
453
622
  function jsonReplacementToGoModReplacement(
454
623
  replacement: NonNullable<GoModJson["Replace"]>[number],
455
624
  ): GoModReplacement | null {
@@ -1,3 +1,4 @@
1
+ import type { PluginContentIdentities } from "../../plugin/internal/source/PluginContentIdentities";
1
2
  import type { ITtscProjectInputSnapshot } from "./ITtscProjectInputSnapshot";
2
3
  import type { TtscCommonOptions } from "./TtscCommonOptions";
3
4
 
@@ -39,6 +40,9 @@ export interface TtscSingleFileEmitOptions extends TtscCommonOptions {
39
40
  * Executable modules, linked source roots, contributors and external local
40
41
  * module replacements can be reported. ttsc's own package sources are omitted;
41
42
  * this notification is not the complete compiler or project-rule input graph.
43
+ * The reporting load also hands over its record store, if it opened one, so the
44
+ * owner proves each root's state from the plugin cache's records instead of
45
+ * reading the sources the load is about to prove (#1728).
42
46
  *
43
47
  * @evidence contracts/common.md#principled-implementation Readonly roots describe the actual native-plugin sources whose later edits need observation.
44
48
  * @evidence contracts/common.md#clear-and-simple-design A void notification leaves watcher construction and lifetime with the invocation owner.
@@ -49,7 +53,10 @@ export interface TtscSingleFileEmitOptions extends TtscCommonOptions {
49
53
  * @evidenceExclude contracts/performance.md#efficient-algorithms A type declaration chooses no processing strategy.
50
54
  * @evidenceExclude contracts/performance.md#reuse-equivalent-work A type declaration computes nothing, so there is no work to share.
51
55
  */
52
- export type TtscPluginSourceInputsHandler = (inputs: readonly string[]) => void;
56
+ export type TtscPluginSourceInputsHandler = (
57
+ inputs: readonly string[],
58
+ identities?: PluginContentIdentities.Store,
59
+ ) => void;
53
60
 
54
61
  /**
55
62
  * Receive the discovered project-rule filesystem topology for the watch owner.